Song Playlist System

Hello.
I am working on (and have mostly finished) a playlist system. I have no practical implementation for this because why would you play songs from Roblox versus on Spotify or Apple Music or literally anything else?
This is my first OOP script, so I am expecting quite a bit of incorrect practices or little things I missed.
Also, I know getNameFromId() is a horribly slow function when adding songs in bulk. I’m not sure what I will replace it with in the future, but for now I’m just gonna leave it since it works.

Code:

local MPS = game:GetService("MarketplaceService")

local audioFolder = script.Parent.AudioPlayers

local audioPlayer = audioFolder.AudioPlayer
local pitchShifter = audioPlayer.AudioPitchShifter

local function getNameFromId(id: number): string
	local success, result = pcall(function()
		return MPS:GetProductInfo(id, Enum.InfoType.Asset).Name
	end)
	if not success then
		warn(result)
	end
	return success and tostring(result) or "???"
end
local function shuffle(list: {[string]: {}}): {any}
	local t = {}
	
	--convert to list
	local i = 0
	for k, _ in list do
		i += 1
		t[i] = k
	end
	
	--shuffle
	for i = #t, 1, -1 do
		local rng = math.random(1, i)
		t[i], t[rng] = t[rng], t[i]
	end

	return t
end

type song = {
	id: number,
	copyrighted: boolean,
}
type Playlist = {
	songs: {[string]: song},
	name: string,
	songLoop: thread,
	currentSong: string,
	state: string,
	stateChanged: BindableEvent,
	songEndedCon: RBXScriptConnection,
}

local Playlists = {}

Playlists.__index = Playlists

--constructor
function Playlists.New(name: string)
	local self = {} :: Playlist
	setmetatable(self, Playlists)
	
	self.songs = {}
	self.songLoop = nil
	self.currentSong = ""
	self.state = ""
	self.songEndedCon = nil
	self.name = name and tostring(name) or "???"
	self.stateChanged = Instance.new("BindableEvent")
	
	return self
end



--modifiers

--adds a song to the playlist, copyrighted is only for defaultPlaylist, not for user usage... for now ;)
function Playlists:AddSong(id: number, copyrighted: boolean)
	if type(id) ~= "number" then error("Parameter id expected number, got "..type(id)) end
	
	copyrighted = copyrighted or false
	
	self.songs[getNameFromId(id)] = {["id"] = id, ["copyrighted"] = copyrighted}
end
--adds multiple songs to the playlist, copyrighted paramater assumes all songs are copyrighted (true) or not (false)
function Playlists:AddSongs(ids: {number}, copyrighted: boolean)
	if type(ids) ~= "table" then error("Parameter id expected table, got "..type(ids)) end
	
	copyrighted = copyrighted or false
	
	for _, id in ids do
		self:AddSong(id, copyrighted)
	end
end
--removes a song from the playlist, MIGHT CHANGE LATER TO USE NAMES INSTEAD OF IDS TO FIND SONG
function Playlists:RemoveSong(id: number)
	if type(id) ~= "number" then error("Parameter id expected number, got "..type(id)) end

	self.songs[getNameFromId(id)] = nil
end
--removes multiple songs from the playlist
function Playlists:RemoveSongs(ids: {number})
	if type(ids) ~= "table" then error("Parameter id expected table, got "..type(ids)) end

	for _, v in ids do
		self:RemoveSong(v)
	end
end

--playback

--plays the song from <code>TimePosition = 0</code>
function Playlists:PlaySong(name: string, looped: boolean)
	local song = self.songs[name]
	if not song then warn(`Song "{name}" is not part of the playlist`) return end
	
	self.currentSong = name
	self:SetState("Playing")
	
	local id: number = song["id"]
	local copyrighted: boolean = song["copyrighted"]
	
	audioPlayer.Asset = "rbxassetid://"..id
	audioPlayer.TimePosition = 0

	pitchShifter.Bypass = not copyrighted
	
	audioPlayer:Play()
	if looped then
		self.songLoop = coroutine.create(function()
			while self.state == "Playing" do
				coroutine.yield()
				self:Replay()
			end
		end)
		coroutine.resume(self.songLoop)
		self.songEndedCon = audioPlayer.Ended:Connect(function()
			if self.state == "Playing" then
				if self.songLoop and coroutine.status(self.songLoop) == "suspended" then
					coroutine.resume(self.songLoop)
				end
			else
				self.songEndedCon:Disconnect()
			end
		end)
	end
end

--plays all songs, shuffled and/or looped if specified
function Playlists:PlaySongs(shuffled: boolean, looped: boolean)
	local finished: boolean = false
	
	local list = self.songs
	if shuffled then
		list = shuffle(list)
	end
	
	self:SetState("Playing")
	
	self.songLoop = coroutine.create(function()
		while self.state == "Playing" do
			finished = false
			for i, name in list do
				self:PlaySong(type(i) == "string" and i or name) --if it is not shuffled, `i` will be the name, otherwise use `name`
				coroutine.yield()
			end
			finished = true
			if not looped then
				return
			end
		end
	end)
	coroutine.resume(self.songLoop)
	
	self.songEndedCon = audioPlayer.Ended:Connect(function()
		if self.songLoop and coroutine.status(self.songLoop) == "suspended" then
			coroutine.resume(self.songLoop)
		end
		if not looped and finished then
			self:Stop()
			return
		end
	end)
end

--pause current song
function Playlists:Pause()
	audioPlayer:Stop()
	self:SetState("Paused")
end
--resume current song
function Playlists:Resume()
	audioPlayer:Play()
	self:SetState("Playing")
end
--replay current song
function Playlists:Replay()
	audioPlayer.TimePosition = 0
	audioPlayer:Play()
end
--stop playback of songs; exits loop
function Playlists:Stop()
	audioPlayer:Stop()
	audioPlayer.TimePosition = 0
	
	self.currentSong = ""
	self:SetState("")
	
	if self.songLoop and coroutine.status(self.songLoop) ~= "running" then
		pcall(function() coroutine.close(self.songLoop) end)
	end
	self.songLoop = nil
	
	if self.songEndedCon then
		self.songEndedCon:Disconnect()
		self.songEndedCon = nil
	end
end

--playlist setting and getting

--returns the state of the song ("Playing" or "Paused")
function Playlists:GetState()
	return self.state
end
--set the current state of the playlist
function Playlists:SetState(newState: string)
	local oldState = self.state
	if oldState ~= newState then
		self.state = newState
		self.stateChanged:Fire(oldState, newState)
	end
end
--[[fires when the state changes 
	(will not fire if the state changes to the same state is was before, e.g. <code>"Playing" -> "Playing"</code> will not fire this event)]]
function Playlists:onStateChanged(func: (old: string, new: string) -> any)
	self.stateChanged.Event:Connect(func)
end
--returns the currently playing song
function Playlists:GetPlayingSong()
	return self.currentSong
end
--returns the name of the playlist
function Playlists:GetName()
	return self.name
end

--deletion

--[[will delete the playlist, if any function runs on a deleted playlist (including <code>Delete()</code>), the code WILL ERROR
	it is recommended to set the variable you store the playlist with to <code>nil</code> to prevent this from happening]]
function Playlists:Delete()
	if getmetatable(self) == nil then print("Deletion attempted more than once") return end
	self:Stop()
	
	self.stateChanged:Destroy()
	setmetatable(self, nil)
	for k, _ in pairs(self) do
		self[k] = nil
	end
	self = nil
end

--debug; prints all songs in a playlist along with the respective id and copyright status for each element/song
function Playlists:Output()
	for name, t in self.songs do
		print("Name:", name)
		print("ID:", t["id"])
		print("Copyrighted:", t["copyrighted"])
		print("----------------------------")
	end
end
--debug; prints current song loop
function Playlists:SongLoop()
	print(self.songLoop)
end

return Playlists
1 Like