diff --git a/Core.lua b/Core.lua index b93f92c..35909d6 100644 --- a/Core.lua +++ b/Core.lua @@ -225,16 +225,46 @@ local function GetTrackDuration(entry) return 0 end -local function CreateTrackEntry(path, name, duration) +local function CreateTrackEntry(path, name, duration, folder, dateAdded, size) local normalized = NormalizePath(path) if normalized == "" then return nil end return { path = normalized, name = Trim(name or "") ~= "" and Trim(name) or ExtractFileName(normalized), duration = tonumber(duration) or 0, + folder = folder or "", + dateAdded = tonumber(dateAdded) or 0, + size = tonumber(size) or 0, } end +-- Return the subfolder (relative to Media\) that a track lives in. Uses the +-- explicit `folder` field written by the PS1 generator when present, and +-- falls back to deriving from the path (everything between ADDON_MEDIA_PREFIX +-- and the file leaf). Empty string == root of Media\. +local function GetTrackFolder(entry) + if type(entry) == "table" and type(entry.folder) == "string" and entry.folder ~= "" then + return entry.folder + end + local path = GetTrackPath(entry) + if path == "" then return "" end + local prefixLen = string.len(ADDON_MEDIA_PREFIX) + if string.lower(string.sub(path, 1, prefixLen)) == string.lower(ADDON_MEDIA_PREFIX) then + local rel = string.sub(path, prefixLen + 1) + local stripped = string.gsub(rel, "\\[^\\]+$", "") + if stripped == rel then return "" end + return stripped + end + return "" +end + +local function GetTrackDateAdded(entry) + if type(entry) == "table" then + return tonumber(entry.dateAdded or entry.added or 0) or 0 + end + return 0 +end + local function BuildTrackLookup() local seen = {} local i @@ -265,12 +295,15 @@ local function MergeAutoPlaylist() local key = string.lower(path) name = GetTrackName(entry) duration = GetTrackDuration(entry) + local folder = (type(entry) == "table" and type(entry.folder) == "string") and entry.folder or "" + local dateAdded = (type(entry) == "table" and tonumber(entry.dateAdded or 0)) or 0 + local size = (type(entry) == "table" and tonumber(entry.size or 0)) or 0 existingIndex = seen[key] if existingIndex then existingEntry = RelationshipsJukeboxDB.tracks[existingIndex] changed = false if type(existingEntry) ~= "table" then - track = CreateTrackEntry(path, name, duration) + track = CreateTrackEntry(path, name, duration, folder, dateAdded, size) if track then RelationshipsJukeboxDB.tracks[existingIndex] = track updated = updated + 1 @@ -287,12 +320,27 @@ local function MergeAutoPlaylist() existingEntry.duration = duration changed = true end + -- Backfill folder / dateAdded / size when the PS1 generator supplies them + -- and the stored entry is missing them. Never overwrite existing values + -- (the user may have older exports that don't include these fields). + if folder ~= "" and (existingEntry.folder == nil or existingEntry.folder == "") then + existingEntry.folder = folder + changed = true + end + if dateAdded > 0 and (not tonumber(existingEntry.dateAdded) or existingEntry.dateAdded == 0) then + existingEntry.dateAdded = dateAdded + changed = true + end + if size > 0 and (not tonumber(existingEntry.size) or existingEntry.size == 0) then + existingEntry.size = size + changed = true + end if changed then updated = updated + 1 end end else - track = CreateTrackEntry(path, name, duration) + track = CreateTrackEntry(path, name, duration, folder, dateAdded, size) if track then table.insert(RelationshipsJukeboxDB.tracks, track) seen[key] = Count(RelationshipsJukeboxDB.tracks) @@ -304,6 +352,272 @@ local function MergeAutoPlaylist() return imported, updated end +----------------------------------------------------------------------- +-- Playlists & Favorites +----------------------------------------------------------------------- +-- Storage shape in SavedVariables: +-- RelationshipsJukeboxDB.playlists = { +-- ["Favorites"] = { paths = { "Interface\\...\\track.mp3", ... }, builtin = true }, +-- ["My Mix"] = { paths = { ... } }, +-- ... +-- } +-- RelationshipsJukeboxDB.activePlaylist = "All" | "Favorites" | "folder:" | "user:" +-- +-- Folder auto-playlists are NOT stored in the .playlists table: they are +-- derived on demand from the .folder field on each track (populated by +-- GeneratePlaylist.ps1) so they always match what's on disk. +----------------------------------------------------------------------- +local function EnsurePlaylists() + EnsureDB() + if type(RelationshipsJukeboxDB.playlists) ~= "table" then + RelationshipsJukeboxDB.playlists = {} + end + local fav = RelationshipsJukeboxDB.playlists["Favorites"] + if type(fav) ~= "table" then + RelationshipsJukeboxDB.playlists["Favorites"] = { paths = {}, builtin = true } + elseif type(fav.paths) ~= "table" then + fav.paths = {} + end + if type(RelationshipsJukeboxDB.activePlaylist) ~= "string" then + RelationshipsJukeboxDB.activePlaylist = "All" + end +end + +local function Playlist_PathSet(name) + EnsurePlaylists() + local pl = RelationshipsJukeboxDB.playlists[name] + local set = {} + if type(pl) ~= "table" or type(pl.paths) ~= "table" then return set end + for i = 1, Count(pl.paths) do + set[string.lower(pl.paths[i] or "")] = true + end + return set +end + +local function Playlist_Contains(name, path) + if not path or path == "" then return false end + return Playlist_PathSet(name)[string.lower(path)] == true +end + +local function Playlist_AddPath(name, path) + EnsurePlaylists() + path = NormalizePath(path or "") + if path == "" or name == nil or name == "" or name == "All" then return false end + if type(RelationshipsJukeboxDB.playlists[name]) ~= "table" then + RelationshipsJukeboxDB.playlists[name] = { paths = {} } + end + local pl = RelationshipsJukeboxDB.playlists[name] + if type(pl.paths) ~= "table" then pl.paths = {} end + local lower = string.lower(path) + for i = 1, Count(pl.paths) do + if string.lower(pl.paths[i]) == lower then return false end + end + table.insert(pl.paths, path) + return true +end + +local function Playlist_RemovePath(name, path) + EnsurePlaylists() + local pl = RelationshipsJukeboxDB.playlists[name] + if type(pl) ~= "table" or type(pl.paths) ~= "table" then return false end + local lower = string.lower(NormalizePath(path or "")) + for i = Count(pl.paths), 1, -1 do + if string.lower(pl.paths[i]) == lower then + table.remove(pl.paths, i) + return true + end + end + return false +end + +local function Playlist_TogglePath(name, path) + if Playlist_Contains(name, path) then + Playlist_RemovePath(name, path) + return false + end + Playlist_AddPath(name, path) + return true +end + +local function ToggleFavorite(path) + return Playlist_TogglePath("Favorites", path) +end + +-- Return sorted list of folder names present in the current track library. +local function GetFolderPlaylists() + EnsureDB() + local folders, seen = {}, {} + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + local f = GetTrackFolder(RelationshipsJukeboxDB.tracks[i]) + if f ~= "" and not seen[f] then + seen[f] = true + table.insert(folders, f) + end + end + table.sort(folders) + return folders +end + +-- Return sorted list of user-defined playlists (excludes the built-in Favorites). +local function GetUserPlaylists() + EnsurePlaylists() + local names = {} + for name, _ in pairs(RelationshipsJukeboxDB.playlists) do + if name ~= "Favorites" then table.insert(names, name) end + end + table.sort(names) + return names +end + +-- Return the DB indices that belong to the active playlist, or nil for "All". +local function ActivePlaylistIndices() + EnsurePlaylists() + local active = RelationshipsJukeboxDB.activePlaylist or "All" + if active == "All" or active == "" then return nil end + EnsureDB() + local out = {} + if active == "Queue" then + -- Show the user queue in playback order. Duplicates & missing tracks + -- are handled: missing paths are skipped, duplicates appear once each + -- position they occur. + if type(RelationshipsJukeboxDB.userQueue) == "table" + and type(RelationshipsJukeboxDB.userQueue.paths) == "table" then + local paths = RelationshipsJukeboxDB.userQueue.paths + for qi = 1, Count(paths) do + local idx = nil + local target = string.lower(paths[qi] or "") + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i])) == target then + idx = i; break + end + end + if idx then table.insert(out, idx) end + end + end + return out + end + if active == "Favorites" then + local set = Playlist_PathSet("Favorites") + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if set[string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i]))] then + table.insert(out, i) + end + end + return out + end + if string.sub(active, 1, 7) == "folder:" then + local folder = string.sub(active, 8) + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if GetTrackFolder(RelationshipsJukeboxDB.tracks[i]) == folder then + table.insert(out, i) + end + end + return out + end + if string.sub(active, 1, 5) == "user:" then + local name = string.sub(active, 6) + local set = Playlist_PathSet(name) + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if set[string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i]))] then + table.insert(out, i) + end + end + return out + end + return nil +end + +-- Friendly label for the active-playlist dropdown text. +local function PlaylistDisplayLabel(value) + if not value or value == "" or value == "All" then return "All Tracks" end + if value == "Favorites" then return "* Favorites" end + if value == "Queue" then return "Up Next (Queue)" end + if string.sub(value, 1, 7) == "folder:" then return "[folder] " .. string.sub(value, 8) end + if string.sub(value, 1, 5) == "user:" then return string.sub(value, 6) end + return value +end + +----------------------------------------------------------------------- +-- User Queue ("Up Next") +-- +-- A user-managed queue of track paths. Independent from the internal +-- RJ.playQueue (which is the shuffled playlist queue built by Play All). +-- The user queue is drained FIRST whenever a track ends and always +-- auto-advances regardless of the autoPlayEnabled toggle. +-- +-- Storage: RelationshipsJukeboxDB.userQueue.paths = { "Interface\\...", ... } +----------------------------------------------------------------------- +local function EnsureUserQueue() + EnsureDB() + if type(RelationshipsJukeboxDB.userQueue) ~= "table" then + RelationshipsJukeboxDB.userQueue = { paths = {} } + elseif type(RelationshipsJukeboxDB.userQueue.paths) ~= "table" then + RelationshipsJukeboxDB.userQueue.paths = {} + end + return RelationshipsJukeboxDB.userQueue +end + +local function Queue_Count() + EnsureUserQueue() + return Count(RelationshipsJukeboxDB.userQueue.paths) +end + +local function Queue_Add(path) + EnsureUserQueue() + path = NormalizePath(path or "") + if path == "" then return false end + table.insert(RelationshipsJukeboxDB.userQueue.paths, path) + return true +end + +local function Queue_PlayNext(path) + EnsureUserQueue() + path = NormalizePath(path or "") + if path == "" then return false end + table.insert(RelationshipsJukeboxDB.userQueue.paths, 1, path) + return true +end + +local function Queue_PopHead() + EnsureUserQueue() + local q = RelationshipsJukeboxDB.userQueue.paths + if Count(q) == 0 then return nil end + local head = q[1] + table.remove(q, 1) + return head +end + +local function Queue_RemoveAt(index) + EnsureUserQueue() + local q = RelationshipsJukeboxDB.userQueue.paths + if index and index >= 1 and index <= Count(q) then + table.remove(q, index) + return true + end + return false +end + +local function Queue_Clear() + EnsureUserQueue() + RelationshipsJukeboxDB.userQueue.paths = {} +end + +-- Look up a DB index for a given track path (case-insensitive on the path). +local function IndexForPath(path) + if not path or path == "" then return nil end + EnsureDB() + local target = string.lower(NormalizePath(path)) + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i])) == target then + return i + end + end + return nil +end + + + + local function SetStatus(msg, r, g, b) if RJ.statusText then RJ.statusText:SetText(msg or "") @@ -699,7 +1013,25 @@ local function RefreshList() if entry then row.index = idx -- store real DB index so Play/Remove work correctly - row.label:SetText(idx .. ". " .. GetTrackDisplayName(entry)) + local isCurrent = (RJ.currentIndex == idx) or + (RJ.currentPath and string.lower(RJ.currentPath) == string.lower(GetTrackPath(entry))) + local prefix = isCurrent and ">" or (idx .. ".") + row.label:SetText(prefix .. " " .. GetTrackDisplayName(entry)) + if isCurrent then + row.label:SetTextColor(0.4, 1, 0.4) + else + row.label:SetTextColor(1, 0.82, 0) + end + -- Favorite star colour + if row.fav and row.fav.text then + if Playlist_Contains("Favorites", GetTrackPath(entry)) then + row.fav.text:SetTextColor(1, 0.82, 0) -- gold + row.fav.text:SetText("*") + else + row.fav.text:SetTextColor(0.35, 0.35, 0.35) -- dim + row.fav.text:SetText("*") + end + end -- Disable Play button during 1-second cooldown (play or stop) if GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil then row.play:Disable() @@ -710,14 +1042,19 @@ local function RefreshList() else row.index = nil row.label:SetText("") + if row.fav and row.fav.text then row.fav.text:SetText("") end row:Hide() end end if RJ.pageText then + local active = RelationshipsJukeboxDB and RelationshipsJukeboxDB.activePlaylist or "All" local pageText = "Page " .. RJ.page .. "/" .. totalPages .. " Tracks: " .. total - if RJ.filteredIndices then - pageText = pageText .. " (filtered)" + if active and active ~= "All" and active ~= "" then + pageText = pageText .. " [" .. PlaylistDisplayLabel(active) .. "]" + end + if RJ.searchFilter and RJ.searchFilter ~= "" then + pageText = pageText .. " (search)" end RJ.pageText:SetText(pageText) end @@ -738,7 +1075,24 @@ local function IsMp3Path(path) end PlayNextQueuedTrack = function() + -- User queue takes priority over the shuffled playlist queue. + -- Also works when autoplay is off, so "Add to Queue" always auto-advances. + if Queue_Count() > 0 then + local nextPath = Queue_PopHead() + local idx = IndexForPath(nextPath) + if idx then + PlayTrack(idx, true) + RecomputeView(); RefreshList() + return + end + -- Path not found in library; fall through to try next + RecomputeView(); RefreshList() + return PlayNextQueuedTrack() + end + if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then + StopPlayback(true) + RefreshPlaybackButtons() return end @@ -755,6 +1109,7 @@ PlayNextQueuedTrack = function() PlayTrack(queueEntry, true) end + PlayTrack = function(indexOrEntry, fromQueue) EnsureDB() @@ -1023,7 +1378,67 @@ local function PreviousTrack() end -local function StartPlaylist(shuffle) +-- Return DB indices for an arbitrary playlist identifier, using the same +-- semantics as ActivePlaylistIndices() but for a value you pass in +-- (rather than reading RelationshipsJukeboxDB.activePlaylist). Returns +-- nil when the value means "everything" ("All" / "" / nil). +local function IndicesForPlaylistValue(value) + EnsurePlaylists(); EnsureDB() + if not value or value == "" or value == "All" then return nil end + local out = {} + if value == "Favorites" then + local set = Playlist_PathSet("Favorites") + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if set[string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i]))] then + table.insert(out, i) + end + end + return out + end + if string.sub(value, 1, 7) == "folder:" then + local folder = string.sub(value, 8) + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if GetTrackFolder(RelationshipsJukeboxDB.tracks[i]) == folder then + table.insert(out, i) + end + end + return out + end + if string.sub(value, 1, 5) == "user:" then + local name = string.sub(value, 6) + local set = Playlist_PathSet(name) + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + if set[string.lower(GetTrackPath(RelationshipsJukeboxDB.tracks[i]))] then + table.insert(out, i) + end + end + return out + end + return nil +end +RJ.IndicesForPlaylistValue = IndicesForPlaylistValue + +-- Append every track in a playlist to the user's Up Next queue. +-- value uses the same identifiers as the playlist dropdown. +local function Queue_AddPlaylist(value) + local indices = IndicesForPlaylistValue(value) + if indices == nil then + -- "All": queue every DB track in order + indices = {} + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + table.insert(indices, i) + end + end + local added = 0 + for i = 1, Count(indices) do + local e = RelationshipsJukeboxDB.tracks[indices[i]] + if e and Queue_Add(GetTrackPath(e)) then added = added + 1 end + end + return added +end +RJ.Queue_AddPlaylist = Queue_AddPlaylist + +local function StartPlaylist(shuffle, playlistValue) EnsureDB() -- 1-second cooldown to prevent spam-starting playlists @@ -1032,9 +1447,25 @@ local function StartPlaylist(shuffle) return end - local total = Count(RelationshipsJukeboxDB.tracks) + -- Resolve which tracks to play: + -- * explicit playlistValue argument (e.g. from "Play all in ") + -- * else the currently active view (ActivePlaylistIndices) + -- * else every DB track ("All") + local indices + if playlistValue ~= nil then + indices = IndicesForPlaylistValue(playlistValue) + else + indices = ActivePlaylistIndices() + end + + local total + if indices == nil then + total = Count(RelationshipsJukeboxDB.tracks) + else + total = Count(indices) + end if total == 0 then - SetStatus("No tracks found.", 1, 0.2, 0.2) + SetStatus("No tracks in this playlist.", 1, 0.2, 0.2) return end @@ -1045,50 +1476,80 @@ local function StartPlaylist(shuffle) RJ.autoPlayEnabled = true RJ.shuffleMode = shuffle and true or false - local i - for i = 1, total do - table.insert(RJ.playQueue, i) + if indices == nil then + for i = 1, total do table.insert(RJ.playQueue, i) end + else + for i = 1, total do table.insert(RJ.playQueue, indices[i]) end end if RJ.shuffleMode then ShuffleQueue(RJ.playQueue) end + if playlistValue and playlistValue ~= "" then + SetStatus("Playing " .. total .. " track(s) from: " .. PlaylistDisplayLabel(playlistValue), 0.2, 1, 0.2) + end + PlayNextQueuedTrack() end +RJ.StartPlaylist = StartPlaylist +-- Recompute the visible track list from (activePlaylist ∩ searchFilter) and +-- store it in RJ.filteredIndices. When both are inactive, RJ.filteredIndices +-- is set to nil, which RefreshList treats as "show every DB track in order". +function RecomputeView() + EnsureDB() + local base = ActivePlaylistIndices() + local query = Trim(RJ.searchFilter or "") + + if query == "" then + RJ.filteredIndices = base + return + end + + local lower = string.lower(query) + local out = {} + if base then + for i = 1, Count(base) do + local e = RelationshipsJukeboxDB.tracks[base[i]] + if e and string.find(string.lower(GetTrackDisplayName(e)), lower, 1, true) then + table.insert(out, base[i]) + end + end + else + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + local e = RelationshipsJukeboxDB.tracks[i] + if e and string.find(string.lower(GetTrackDisplayName(e)), lower, 1, true) then + table.insert(out, i) + end + end + end + RJ.filteredIndices = out +end + local function PerformSearch() EnsureDB() - local query = RJ.searchInput and RJ.searchInput:GetText() or "" - query = Trim(query) + local query = Trim(RJ.searchInput and RJ.searchInput:GetText() or "") if query == "" then - -- Empty query = clear search, show all tracks RJ.searchFilter = "" - RJ.filteredIndices = nil RJ.page = 1 + RecomputeView() RefreshList() - SetStatus("Search cleared. Showing all tracks.", 0.2, 1, 0.2) + SetStatus("Search cleared.", 0.2, 1, 0.2) return end RJ.searchFilter = query - RJ.filteredIndices = {} - local lowerQuery = string.lower(query) - - for i = 1, Count(RelationshipsJukeboxDB.tracks) do - local entry = RelationshipsJukeboxDB.tracks[i] - local displayName = string.lower(GetTrackDisplayName(entry)) - if string.find(displayName, lowerQuery, 1, true) then - table.insert(RJ.filteredIndices, i) - end - end - RJ.page = 1 + RecomputeView() RefreshList() - local matchCount = Count(RJ.filteredIndices) + local matchCount = Count(RJ.filteredIndices or {}) + if RJ.filteredIndices == nil then + matchCount = Count(RelationshipsJukeboxDB.tracks) + end if matchCount > 0 then SetStatus("Found " .. matchCount .. " track(s) matching \"" .. query .. "\"", 0.2, 1, 0.2) else @@ -1098,13 +1559,29 @@ end local function ClearSearch() RJ.searchFilter = "" - RJ.filteredIndices = nil if RJ.searchInput then RJ.searchInput:SetText("") end RJ.page = 1 + RecomputeView() RefreshList() - SetStatus("Search cleared. Showing all tracks.", 0.2, 1, 0.2) + SetStatus("Search cleared.", 0.2, 1, 0.2) end +-- Called by the playlist dropdown when the user picks a new view. +local function SetActivePlaylist(value) + EnsurePlaylists() + RelationshipsJukeboxDB.activePlaylist = value or "All" + RJ.page = 1 + RecomputeView() + RefreshList() + if RJ.playlistDropdownText then + RJ.playlistDropdownText:SetText(PlaylistDisplayLabel(value)) + end + SetStatus("View: " .. PlaylistDisplayLabel(value), 0.6, 0.9, 1) +end +RJ.SetActivePlaylist = SetActivePlaylist + + + local function RemoveTrack(index) EnsureDB() if RelationshipsJukeboxDB.tracks[index] then @@ -1145,17 +1622,423 @@ end -- Status bar (~20 px) -- Bottom padding (~18 px) ----------------------------------------------------------------------- + +----------------------------------------------------------------------- +-- New-playlist StaticPopup +-- +-- When the user picks "New playlist..." from either the header dropdown or a +-- row's right-click menu, we ask for a name. If RJ._pendingAddPath is set +-- when the popup is confirmed, that path is added to the freshly created +-- playlist so the row-context flow "Add to > New playlist..." works in one +-- step. +----------------------------------------------------------------------- +StaticPopupDialogs = StaticPopupDialogs or {} +StaticPopupDialogs["RJ_NEW_PLAYLIST"] = { + text = "New playlist name:", + button1 = "Create", + button2 = "Cancel", + hasEditBox = 1, + maxLetters = 32, + OnShow = function() + -- 'this' is the dialog frame in 1.12 OnShow + local eb = getglobal(this:GetName() .. "EditBox") + if eb then eb:SetText(""); eb:SetFocus() end + end, + OnAccept = function() + -- In 1.12, 'this' inside OnAccept is the OK button, not the dialog. + -- Walk up to the parent dialog to find the edit box. + local dialog = this + if this and this.GetParent then + local p = this:GetParent() + if p and p.GetName and p:GetName() and string.find(p:GetName(), "^StaticPopup%d") then + dialog = p + end + end + local eb = dialog and getglobal(dialog:GetName() .. "EditBox") or nil + local name = eb and Trim(eb:GetText() or "") or "" + if name == "" then + SetStatus("Playlist name cannot be empty.", 1, 0.4, 0.4) + return + end + if name == "All" or name == "Favorites" then + SetStatus("That playlist name is reserved.", 1, 0.4, 0.4) + return + end + EnsurePlaylists() + local created = false + if type(RelationshipsJukeboxDB.playlists[name]) ~= "table" then + RelationshipsJukeboxDB.playlists[name] = { paths = {} } + created = true + end + if RJ._pendingAddPath and RJ._pendingAddPath ~= "" then + Playlist_AddPath(name, RJ._pendingAddPath) + SetStatus("Added track to playlist: " .. name, 0.2, 1, 0.2) + RJ._pendingAddPath = nil + else + if created then + SetStatus("Created playlist: " .. name, 0.2, 1, 0.2) + else + SetStatus("Playlist already exists: " .. name, 1, 0.82, 0) + end + end + -- Switch view to the new playlist so the user sees it immediately. + if RJ.SetActivePlaylist then + RJ.SetActivePlaylist("user:" .. name) + else + RecomputeView(); RefreshList() + end + end, + EditBoxOnEnterPressed = function() + -- 'this' is the edit box; click the Accept button so OnAccept runs + -- with 'this' bound to Button1 (matches the normal click path). + local parent = this:GetParent() + if not parent then return end + local btn = getglobal(parent:GetName() .. "Button1") + if btn then btn:Click() end + end, + EditBoxOnEscapePressed = function() this:GetParent():Hide() end, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, +} + +local function PromptNewPlaylist(pendingPath) + RJ._pendingAddPath = pendingPath + if StaticPopup_Show then StaticPopup_Show("RJ_NEW_PLAYLIST") end +end + +----------------------------------------------------------------------- +-- Row right-click context menu +-- +-- Uses UIDropDownMenu in MENU mode. Level-1 shows Play / favorite toggle / +-- "Add to playlist >" / Remove. Level-2 lists every user playlist plus a +-- "New playlist..." action. 1.12 signals which submenu to populate via the +-- UIDROPDOWNMENU_MENU_LEVEL and UIDROPDOWNMENU_MENU_VALUE globals inside the +-- initialize function -- there is no per-button callback for submenu items. +----------------------------------------------------------------------- +local function EnsureRowContextMenu() + if RJ.rowContextMenu then return RJ.rowContextMenu end + local m = CreateFrame("Frame", "RJRowContextMenu", UIParent, "UIDropDownMenuTemplate") + m.displayMode = "MENU" + RJ.rowContextMenu = m + return m +end + +local function ShowRowContextMenu(row) + if not row or not row.index then return end + local entry = RelationshipsJukeboxDB.tracks[row.index] + if not entry then return end + + RJ._contextPath = GetTrackPath(entry) + RJ._contextIndex = row.index + + local menu = EnsureRowContextMenu() + local initFn = function() + local info + local level = UIDROPDOWNMENU_MENU_LEVEL or 1 + local value = UIDROPDOWNMENU_MENU_VALUE + + if level == 2 and value == "ADDTO" then + local names = GetUserPlaylists() + for i = 1, Count(names) do + local pname = names[i] + info = {} + info.text = pname + info.notCheckable = 1 + info.func = function() + local added = Playlist_AddPath(pname, RJ._contextPath) + if added then + SetStatus("Added to " .. pname, 0.2, 1, 0.2) + else + SetStatus("Already in " .. pname, 1, 0.82, 0) + end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 2) + end + info = {} + info.text = "New playlist..." + info.notCheckable = 1 + info.func = function() + PromptNewPlaylist(RJ._contextPath) + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 2) + return + end + + -- Level 1: primary actions + info = {} + info.text = GetTrackDisplayName(entry) + info.isTitle = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Play" + info.notCheckable = 1 + info.func = function() PlayTrack(RJ._contextIndex, false); if CloseDropDownMenus then CloseDropDownMenus() end end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Play next" + info.notCheckable = 1 + info.func = function() + Queue_PlayNext(RJ._contextPath) + SetStatus("Queued next: " .. GetTrackDisplayName(entry), 0.2, 1, 0.2) + if RelationshipsJukeboxDB.activePlaylist == "Queue" then + RecomputeView(); RefreshList() + end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Add to queue" + info.notCheckable = 1 + info.func = function() + Queue_Add(RJ._contextPath) + SetStatus("Added to queue (" .. Queue_Count() .. "): " .. GetTrackDisplayName(entry), 0.2, 1, 0.2) + if RelationshipsJukeboxDB.activePlaylist == "Queue" then + RecomputeView(); RefreshList() + end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + if Playlist_Contains("Favorites", RJ._contextPath) then + info.text = "Remove from Favorites" + else + info.text = "Add to Favorites" + end + info.notCheckable = 1 + info.func = function() + ToggleFavorite(RJ._contextPath) + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Add to playlist" + info.hasArrow = 1 + info.notCheckable = 1 + info.value = "ADDTO" + UIDropDownMenu_AddButton(info, 1) + + -- Also list currently-playlists-containing-this-track so user can quickly remove + local user = GetUserPlaylists() + local any = false + for i = 1, Count(user) do + if Playlist_Contains(user[i], RJ._contextPath) then + if not any then + info = {} + info.text = "Remove from" + info.isTitle = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + any = true + end + local pname = user[i] + info = {} + info.text = " " .. pname + info.notCheckable = 1 + info.func = function() + Playlist_RemovePath(pname, RJ._contextPath) + SetStatus("Removed from " .. pname, 1, 0.82, 0) + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + end + + info = {} + info.text = "" + info.disabled = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + + -- If viewing the Queue, offer a quick "Remove from queue" for this track. + if RelationshipsJukeboxDB.activePlaylist == "Queue" then + info = {} + info.text = "Remove from queue" + info.notCheckable = 1 + info.func = function() + EnsureUserQueue() + local paths = RelationshipsJukeboxDB.userQueue.paths + local target = string.lower(RJ._contextPath or "") + for i = 1, Count(paths) do + if string.lower(paths[i]) == target then + table.remove(paths, i) + break + end + end + SetStatus("Removed from queue.", 1, 0.82, 0) + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + + info = {} + info.text = "Remove from library" + info.notCheckable = 1 + info.func = function() RemoveTrack(RJ._contextIndex); if CloseDropDownMenus then CloseDropDownMenus() end end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Cancel" + info.notCheckable = 1 + info.func = function() if CloseDropDownMenus then CloseDropDownMenus() end end + UIDropDownMenu_AddButton(info, 1) + end + + UIDropDownMenu_Initialize(menu, initFn, "MENU") + ToggleDropDownMenu(1, nil, menu, "cursor", 0, 0) +end + +----------------------------------------------------------------------- +-- Simple tooltip helper -- attaches title + optional body to any frame. +----------------------------------------------------------------------- +local function AttachTooltip(frame, title, body) + if not frame then return end + frame:SetScript("OnEnter", function() + GameTooltip:SetOwner(frame, "ANCHOR_RIGHT") + GameTooltip:SetText(title, 1, 1, 1) + if body then GameTooltip:AddLine(body, 0.8, 0.8, 0.8, 1) end + GameTooltip:Show() + end) + frame:SetScript("OnLeave", function() GameTooltip:Hide() end) +end +RJ.AttachTooltip = AttachTooltip + +----------------------------------------------------------------------- +-- Row "+" button: dedicated Add-to-playlist popup menu. +-- Anchors under the "+" button, lists every user playlist, plus a +-- "New playlist..." entry that opens the create-playlist popup with +-- this track queued for insertion. +----------------------------------------------------------------------- +local function EnsureAddToPlaylistMenu() + if RJ.addToPlaylistMenu then return RJ.addToPlaylistMenu end + local m = CreateFrame("Frame", "RJAddToPlaylistMenu", UIParent, "UIDropDownMenuTemplate") + m.displayMode = "MENU" + RJ.addToPlaylistMenu = m + return m +end + +function ShowAddToPlaylistMenu(row, anchor) + if not row or not row.index then return end + local entry = RelationshipsJukeboxDB.tracks[row.index] + if not entry then return end + RJ._contextPath = GetTrackPath(entry) + RJ._contextIndex = row.index + + local menu = EnsureAddToPlaylistMenu() + local initFn = function() + local info + info = {} + info.text = "Add to playlist" + info.isTitle = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + + local names = GetUserPlaylists() + if Count(names) == 0 then + info = {} + info.text = "(no playlists yet)" + info.disabled = 1 + info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + else + for i = 1, Count(names) do + local pname = names[i] + local already = Playlist_Contains(pname, RJ._contextPath) + info = {} + info.text = already and (pname .. " (already in)") or pname + info.notCheckable = 1 + info.disabled = already and 1 or nil + info.func = function() + local added = Playlist_AddPath(pname, RJ._contextPath) + if added then + SetStatus("Added to " .. pname, 0.2, 1, 0.2) + else + SetStatus("Already in " .. pname, 1, 0.82, 0) + end + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + end + + info = {}; info.text = ""; info.disabled = 1; info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Play next (queue)" + info.notCheckable = 1 + info.func = function() + Queue_PlayNext(RJ._contextPath) + SetStatus("Queued next.", 0.2, 1, 0.2) + if RelationshipsJukeboxDB.activePlaylist == "Queue" then RecomputeView(); RefreshList() end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Add to queue (" .. Queue_Count() .. ")" + info.notCheckable = 1 + info.func = function() + Queue_Add(RJ._contextPath) + SetStatus("Added to queue (" .. Queue_Count() .. ").", 0.2, 1, 0.2) + if RelationshipsJukeboxDB.activePlaylist == "Queue" then RecomputeView(); RefreshList() end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "* Toggle Favorite" + info.notCheckable = 1 + info.func = function() + ToggleFavorite(RJ._contextPath) + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "New playlist..." + info.notCheckable = 1 + info.func = function() + PromptNewPlaylist(RJ._contextPath) + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + + UIDropDownMenu_Initialize(menu, initFn, "MENU") + ToggleDropDownMenu(1, nil, menu, anchor or "cursor", 0, 0) +end +RJ.ShowAddToPlaylistMenu = ShowAddToPlaylistMenu + + local function CreateRow(parent, i) local row = CreateFrame("Frame", nil, parent) row:SetWidth(680) row:SetHeight(27) + row:EnableMouse(true) - -- Give every track its own visible container so each song reads like a - -- separate boxed row instead of one flat list. - -- - -- Some 1.12-based clients are inconsistent about drawing SetBackdrop() - -- borders on anonymous child frames. Use explicit textures instead so the - -- row box always appears on Turtle WoW / OctoWoW. + -- Right-click anywhere on the row opens the context menu. + row:SetScript("OnMouseUp", function() + if arg1 == "RightButton" then + ShowRowContextMenu(row) + end + end) + + -- Row background (alternating shade) + explicit border textures (some + -- 1.12 clients don't render SetBackdrop borders on anonymous child frames). row.bg = row:CreateTexture(nil, "BACKGROUND") row.bg:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1) row.bg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1) @@ -1165,6 +2048,10 @@ local function CreateRow(parent, i) else row.bg:SetTexture(0.06, 0.06, 0.06, 0.78) end + row.baseColor = { r = math.mod(i, 2) == 1 and 0.10 or 0.06, + g = math.mod(i, 2) == 1 and 0.10 or 0.06, + b = math.mod(i, 2) == 1 and 0.10 or 0.06, + a = math.mod(i, 2) == 1 and 0.82 or 0.78 } row.borderTop = row:CreateTexture(nil, "BORDER") row.borderTop:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) @@ -1190,27 +2077,68 @@ local function CreateRow(parent, i) row.borderRight:SetWidth(1) row.borderRight:SetTexture(0.45, 0.45, 0.45, 0.95) + -- Favorite star. Turtle WoW / 1.12 fonts don't reliably render Unicode + -- stars, so we use an ASCII "*" and colour it gold when the track is in + -- Favorites (grey otherwise). Clicking toggles the favorite state. + row.fav = CreateFrame("Button", nil, row) + row.fav:SetWidth(18) + row.fav:SetHeight(18) + row.fav:SetPoint("LEFT", row, "LEFT", 6, 0) + row.fav.text = row.fav:CreateFontString(nil, "OVERLAY", "GameFontNormal") + row.fav.text:SetAllPoints() + row.fav.text:SetJustifyH("CENTER") + row.fav.text:SetText("*") + row.fav:SetScript("OnClick", function() + if not row.index then return end + local entry = RelationshipsJukeboxDB.tracks[row.index] + if not entry then return end + local nowFav = ToggleFavorite(GetTrackPath(entry)) + if nowFav then + SetStatus("Added to Favorites: " .. GetTrackDisplayName(entry), 1, 0.82, 0) + else + SetStatus("Removed from Favorites: " .. GetTrackDisplayName(entry), 0.7, 0.7, 0.7) + end + RecomputeView() + RefreshList() + end) + AttachTooltip(row.fav, "Favorite", "Click to add/remove from Favorites.") + row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - row.label:SetPoint("LEFT", row, "LEFT", 10, 0) - row.label:SetWidth(496) + row.label:SetPoint("LEFT", row.fav, "RIGHT", 6, 0) + row.label:SetWidth(444) row.label:SetJustifyH("LEFT") row.label:SetText("") + -- "+" button: opens a menu listing every user playlist so the user can + -- add this track without going through the right-click context menu. + row.addTo = MakeButton(nil, row, 22, 20, "+") + row.addTo:SetPoint("RIGHT", row, "RIGHT", -156, 0) + row.addTo:SetScript("OnClick", function() + if not row.index then return end + ShowAddToPlaylistMenu(row, row.addTo) + end) + AttachTooltip(row.addTo, "Add to playlist", + "Add this track to one of your playlists (or create a new one).") + row.play = MakeButton(nil, row, 70, 20, "Play") row.play:SetPoint("RIGHT", row, "RIGHT", -80, 0) row.play:SetScript("OnClick", function() if row.index then PlayTrack(row.index, false) end end) + AttachTooltip(row.play, "Play track", "Play this song immediately.") row.remove = MakeButton(nil, row, 70, 20, "Remove") row.remove:SetPoint("RIGHT", row, "RIGHT", -6, 0) row.remove:SetScript("OnClick", function() if row.index then RemoveTrack(row.index) end end) + AttachTooltip(row.remove, "Remove from library", + "Removes this track from the addon's library. Files on disk are not deleted; re-run GeneratePlaylist.bat to re-import.") return row end + local function CreateUI() if RJ.frame then return end @@ -1257,7 +2185,7 @@ local function CreateUI() local searchInput = CreateFrame("EditBox", ADDON_NAME .. "SearchInput", f, "InputBoxTemplate") RJ.searchInput = searchInput - searchInput:SetWidth(290) + searchInput:SetWidth(210) searchInput:SetHeight(20) searchInput:SetPoint("LEFT", searchLabel, "RIGHT", 10, 0) searchInput:SetAutoFocus(false) @@ -1274,10 +2202,193 @@ local function CreateUI() local searchBtn = MakeButton(nil, f, 70, 22, "Search") searchBtn:SetPoint("LEFT", searchInput, "RIGHT", 10, 0) searchBtn:SetScript("OnClick", PerformSearch) + AttachTooltip(searchBtn, "Search", "Filter the visible tracks by name.") local clearBtn = MakeButton(nil, f, 60, 22, "Clear") clearBtn:SetPoint("LEFT", searchBtn, "RIGHT", 8, 0) clearBtn:SetScript("OnClick", ClearSearch) + AttachTooltip(clearBtn, "Clear search", "Reset the search box and show all matching tracks in the current playlist.") + + -- ----- Header playlist dropdown --------------------------------------- + -- Lets the user switch the visible list between: + -- * All Tracks + -- * Favorites (built-in playlist) + -- * one auto-playlist per subfolder of Media\ + -- * user-defined playlists + -- Also exposes "New playlist..." for creating an empty one on the spot. + local viewLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") + viewLabel:SetPoint("LEFT", clearBtn, "RIGHT", 12, 0) + viewLabel:SetText("View:") + + local dd = CreateFrame("Frame", "RJPlaylistDropDown", f, "UIDropDownMenuTemplate") + RJ.playlistDropdown = dd + dd:SetPoint("LEFT", viewLabel, "RIGHT", -8, -2) + + local function InitPlaylistDropdown() + local info + local level = UIDROPDOWNMENU_MENU_LEVEL or 1 + local active = RelationshipsJukeboxDB.activePlaylist or "All" + + -- Submenu: "Play all in playlist" branch + if level == 2 and (UIDROPDOWNMENU_MENU_VALUE == "PLAYALL" + or UIDROPDOWNMENU_MENU_VALUE == "QUEUEALL") then + local isQueue = (UIDROPDOWNMENU_MENU_VALUE == "QUEUEALL") + local entries = {} + table.insert(entries, { label = "All Tracks", value = "All" }) + table.insert(entries, { label = "* Favorites", value = "Favorites" }) + local folders = GetFolderPlaylists() + for i = 1, Count(folders) do + table.insert(entries, { label = "[folder] " .. folders[i], value = "folder:" .. folders[i] }) + end + local user = GetUserPlaylists() + for i = 1, Count(user) do + table.insert(entries, { label = user[i], value = "user:" .. user[i] }) + end + for i = 1, Count(entries) do + local e = entries[i] + info = {} + info.text = e.label + info.notCheckable = 1 + if isQueue then + info.func = function() + local n = Queue_AddPlaylist(e.value) + SetStatus("Queued " .. n .. " track(s) from: " .. PlaylistDisplayLabel(e.value), 0.2, 1, 0.2) + if RelationshipsJukeboxDB.activePlaylist == "Queue" then + RecomputeView(); RefreshList() + end + if CloseDropDownMenus then CloseDropDownMenus() end + end + else + info.func = function() + StartPlaylist(false, e.value) + if CloseDropDownMenus then CloseDropDownMenus() end + end + end + UIDropDownMenu_AddButton(info, 2) + end + return + end + + -- Submenu: "Delete playlist" branch + if level == 2 and UIDROPDOWNMENU_MENU_VALUE == "DELETE" then + local names = GetUserPlaylists() + for i = 1, Count(names) do + local pname = names[i] + info = {} + info.text = pname + info.notCheckable = 1 + info.func = function() + RelationshipsJukeboxDB.playlists[pname] = nil + if RelationshipsJukeboxDB.activePlaylist == ("user:" .. pname) then + RelationshipsJukeboxDB.activePlaylist = "All" + end + SetStatus("Deleted playlist: " .. pname, 1, 0.82, 0) + RecomputeView(); RefreshList() + if RJ.playlistDropdownText then + RJ.playlistDropdownText:SetText(PlaylistDisplayLabel(RelationshipsJukeboxDB.activePlaylist)) + end + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 2) + end + return + end + + -- Primary level + local function Add(text, value) + info = {} + info.text = text + info.value = value + info.checked = (active == value) and 1 or nil + info.func = function() + SetActivePlaylist(value) + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + + Add("All Tracks", "All") + Add("* Favorites", "Favorites") + Add("Up Next (Queue) [" .. Queue_Count() .. "]", "Queue") + + local folders = GetFolderPlaylists() + if Count(folders) > 0 then + info = {}; info.text = "-- Folders --"; info.isTitle = 1; info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + for i = 1, Count(folders) do + Add("[folder] " .. folders[i], "folder:" .. folders[i]) + end + end + + local user = GetUserPlaylists() + if Count(user) > 0 then + info = {}; info.text = "-- Playlists --"; info.isTitle = 1; info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + for i = 1, Count(user) do + Add(user[i], "user:" .. user[i]) + end + end + + info = {} + info.text = "Play all in playlist" + info.hasArrow = 1 + info.notCheckable = 1 + info.value = "PLAYALL" + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "Queue playlist (add to Up Next)" + info.hasArrow = 1 + info.notCheckable = 1 + info.value = "QUEUEALL" + UIDropDownMenu_AddButton(info, 1) + + info = {}; info.text = ""; info.disabled = 1; info.notCheckable = 1 + UIDropDownMenu_AddButton(info, 1) + + info = {} + info.text = "New playlist..." + info.notCheckable = 1 + info.func = function() + PromptNewPlaylist(nil) + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + + if Queue_Count() > 0 then + info = {} + info.text = "Clear queue (" .. Queue_Count() .. ")" + info.notCheckable = 1 + info.func = function() + Queue_Clear() + SetStatus("Queue cleared.", 1, 0.82, 0) + RecomputeView(); RefreshList() + if CloseDropDownMenus then CloseDropDownMenus() end + end + UIDropDownMenu_AddButton(info, 1) + end + + if Count(user) > 0 then + info = {} + info.text = "Delete playlist" + info.hasArrow = 1 + info.notCheckable = 1 + info.value = "DELETE" + UIDropDownMenu_AddButton(info, 1) + end + end + + RJ.InitPlaylistDropdown = InitPlaylistDropdown + + pcall(UIDropDownMenu_Initialize, dd, InitPlaylistDropdown) + pcall(UIDropDownMenu_SetWidth, 160, dd) + RJ.playlistDropdownText = getglobal("RJPlaylistDropDownText") + if RJ.playlistDropdownText then + RJ.playlistDropdownText:SetText(PlaylistDisplayLabel(RelationshipsJukeboxDB.activePlaylist or "All")) + end + AttachTooltip(dd, "Playlist view", "Switch which list of tracks is shown -- All / Favorites / one auto-playlist per Media subfolder / your own playlists.") + + -- Track list (anchored from top, leaves room for buttons at bottom) local listAnchor = CreateFrame("Frame", nil, f) @@ -2002,8 +3113,8 @@ ticker:SetScript("OnUpdate", function() -- 1) Timed advancement: track with known duration reached end time if not RJ.isPaused and RJ.trackEndTime and GetTime() >= RJ.trackEndTime then RJ.trackEndTime = nil - if RJ.autoPlayEnabled then - -- Queue mode: advance to next track + if RJ.autoPlayEnabled or Queue_Count() > 0 then + -- Queue/autoplay mode: advance to next track (user queue drained first) RJ.currentCanStop = false RJ.pollingForEnd = false KillAllSounds(PlayNextQueuedTrack) @@ -2031,8 +3142,8 @@ ticker:SetScript("OnUpdate", function() if RJ.consecutiveNotPlaying >= RJ.consecutiveThreshold then RJ.pollingForEnd = false RJ.consecutiveNotPlaying = 0 - if RJ.autoPlayEnabled then - -- Queue mode: advance to next track + if RJ.autoPlayEnabled or Queue_Count() > 0 then + -- Queue/autoplay mode: advance (user queue drained first) RJ.currentCanStop = false KillAllSounds(PlayNextQueuedTrack) else @@ -2135,6 +3246,9 @@ loader:SetScript("OnEvent", function() end EnsureDB() + EnsurePlaylists() + + -- CRASH-RECOVERY: if a previous session ended (logout, /reload, or crash) -- without our PLAYER_LOGOUT handler firing — for example inside the ~50ms @@ -2168,8 +3282,19 @@ loader:SetScript("OnEvent", function() CreateUI() CreateMiniPlayer() CreateMinimapButton() + + -- Rebuild the dropdown now that folder-playlists (derived from track data) + -- and user playlists (from SavedVariables) are both known. + if RJ.playlistDropdown and RJ.InitPlaylistDropdown then + pcall(UIDropDownMenu_Initialize, RJ.playlistDropdown, RJ.InitPlaylistDropdown) + end + if RJ.playlistDropdownText then + RJ.playlistDropdownText:SetText(PlaylistDisplayLabel(RelationshipsJukeboxDB.activePlaylist or "All")) + end + RecomputeView() RefreshList() + if imported > 0 and updated > 0 then SetStatus("Imported " .. imported .. " and updated " .. updated .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) elseif imported > 0 then @@ -2229,6 +3354,63 @@ SlashCmdList["RELJUKEBOX"] = function(msg) local total = Count(RelationshipsJukeboxDB.tracks) if total > 0 then PlayTrack(total, false) end return + elseif command == "fav" then + -- Toggle Favorites for the currently playing track. + if RJ.currentPath then + local nowFav = ToggleFavorite(RJ.currentPath) + if nowFav then + SetStatus("Added current track to Favorites.", 1, 0.82, 0) + else + SetStatus("Removed current track from Favorites.", 0.7, 0.7, 0.7) + end + RecomputeView(); RefreshList() + else + SetStatus("No track is playing.", 1, 0.4, 0.4) + end + return + elseif string.sub(command, 1, 9) == "playlist " then + -- /rj playlist -- switch view. Special values: all, favorites, + -- folder:, user:. Plain names are treated as user playlists. + local arg = Trim(string.sub(command, 10)) + if arg == "" or arg == "all" then + SetActivePlaylist("All") + elseif arg == "favorites" or arg == "favourites" or arg == "fav" then + SetActivePlaylist("Favorites") + elseif string.sub(arg, 1, 7) == "folder:" or string.sub(arg, 1, 5) == "user:" then + SetActivePlaylist(arg) + else + -- match against user-playlist names (case insensitive), then folders + local lower = string.lower(arg) + local matched = nil + local user = GetUserPlaylists() + for i = 1, Count(user) do + if string.lower(user[i]) == lower then matched = "user:" .. user[i]; break end + end + if not matched then + local folders = GetFolderPlaylists() + for i = 1, Count(folders) do + if string.lower(folders[i]) == lower then matched = "folder:" .. folders[i]; break end + end + end + if matched then + SetActivePlaylist(matched) + else + SetStatus("Unknown playlist: " .. arg, 1, 0.4, 0.4) + end + end + if RJ.playlistDropdown and RJ.InitPlaylistDropdown then + pcall(UIDropDownMenu_Initialize, RJ.playlistDropdown, RJ.InitPlaylistDropdown) + end + return + elseif command == "help" or command == "?" then + DEFAULT_CHAT_FRAME:AddMessage("|cffffd200Relationships Jukebox commands:|r") + DEFAULT_CHAT_FRAME:AddMessage(" /rj - toggle main GUI") + DEFAULT_CHAT_FRAME:AddMessage(" /rj play | shuffle - start playlist") + DEFAULT_CHAT_FRAME:AddMessage(" /rj stop | skip | prev | pause") + DEFAULT_CHAT_FRAME:AddMessage(" /rj mini - toggle mini player") + DEFAULT_CHAT_FRAME:AddMessage(" /rj fav - toggle Favorites for current track") + DEFAULT_CHAT_FRAME:AddMessage(" /rj playlist - switch view (all / favorites / / )") + return end -- No command: toggle full GUI diff --git a/GeneratePlaylist.ps1 b/GeneratePlaylist.ps1 index 6e303d6..af6938e 100644 --- a/GeneratePlaylist.ps1 +++ b/GeneratePlaylist.ps1 @@ -2345,12 +2345,30 @@ foreach ($file in $files) { $name = Escape-LuaString($file.BaseName) $duration = Get-TrackDurationSeconds -File $file + # Derive the subfolder (path under Media\ minus the file leaf). Empty + # string means the track lives directly under Media\. This is what the + # addon uses to build folder auto-playlists in the header dropdown. + $folderRaw = '' + $lastSlash = $relative.LastIndexOf('\') + if ($lastSlash -ge 0) { + $folderRaw = $relative.Substring(0, $lastSlash) + } + $folder = Escape-LuaString($folderRaw) + + # dateAdded: seconds since Unix epoch based on file creation time (UTC). + # Falls back to LastWriteTime if CreationTime is zero for some reason. + $ctime = $file.CreationTimeUtc + if ($ctime.Year -lt 1971) { $ctime = $file.LastWriteTimeUtc } + $epoch = [int][Math]::Floor(($ctime - (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc))).TotalSeconds) + + $size = [int64]$file.Length + if ($duration -eq $DEFAULT_DURATION) { $defaultCount++ } - $fmtLuaEntry = ' {{ path = "{0}", name = "{1}", duration = {2} }},' - $lines.Add(($fmtLuaEntry -f $luaPath, $name, $duration)) | Out-Null + $fmtLuaEntry = ' {{ path = "{0}", name = "{1}", duration = {2}, folder = "{3}", dateAdded = {4}, size = {5} }},' + $lines.Add(($fmtLuaEntry -f $luaPath, $name, $duration, $folder, $epoch, $size)) | Out-Null } $lines.Add('}') | Out-Null diff --git a/RelationshipsJukeBox.toc b/RelationshipsJukeBox.toc index a5995ef..3ae8474 100644 --- a/RelationshipsJukeBox.toc +++ b/RelationshipsJukeBox.toc @@ -2,7 +2,7 @@ ## Title: Relationships Jukebox ## Notes: Simple in-game MP3/WAV/OGG player with GUI. ## Author: Relationship -## Version: 1.4 +## Version: 1.5 ## SavedVariables: RelationshipsJukeboxDB ## X-Website: https://github.com/Relationship-OctoWoW/RelationshipsJukeBox ## X-GitHub: https://github.com/Relationship-OctoWoW/RelationshipsJukeBox