diff --git a/Core.lua b/Core.lua deleted file mode 100644 index da38d41..0000000 --- a/Core.lua +++ /dev/null @@ -1,1638 +0,0 @@ -local ADDON_NAME = "RelationshipsJukeBox" -local ADDON_MEDIA_PREFIX = "Interface\\AddOns\\RelationshipsJukeBox\\Media\\" - -RelationshipsJukeboxDB = RelationshipsJukeboxDB or {} - -local RJ = {} -RJ.page = 1 -RJ.pageSize = 12 -RJ.rows = {} -RJ.playQueue = nil -RJ.playQueuePosition = 0 -RJ.searchFilter = "" -RJ.filteredIndices = nil -- nil = no filter (show all); table = list of DB indices matching search -RJ.currentIndex = nil -RJ.currentPath = nil -RJ.currentName = nil -RJ.currentCanStop = false -RJ.currentDuration = 0 -RJ.trackEndTime = nil -RJ.autoPlayEnabled = false -RJ.shuffleMode = false -RJ.isPaused = false -RJ.pauseCooldownUntil = 0 -- timestamp until which pause/play is blocked (1s cooldown) -RJ.playCooldownUntil = 0 -- timestamp until which the Play button is blocked (1s cooldown) -RJ.skipCooldownUntil = 0 -- timestamp until which Skip is blocked (1s cooldown) -RJ.prevCooldownUntil = 0 -- timestamp until which Previous is blocked (1s cooldown) -RJ.stopCooldownUntil = 0 -- timestamp until which Stop is blocked (1s cooldown) -local COOLDOWN_SECS = 1 -- universal cooldown for all playback buttons - --- Marquee (scrolling text) state for mini player -RJ.marqueeOffset = 0 -- current pixel offset for scrolling -RJ.marqueeSpeed = 25 -- pixels per second -RJ.marqueeGap = 0 -- legacy gap value; smooth looping now uses a text separator instead -RJ.marqueeSeparator = " • " -RJ.marqueeCycleWidth = 0 -- width of one full repeated cycle -RJ.marqueeLastUpdate = 0 -- last OnUpdate timestamp -RJ.marqueeFullText = "" -- the repeated display text used for looping -RJ.marqueeSingleWidth = 0 -- pixel width of the single-copy display text -RJ.marqueeSingleText = "" -- the single-copy display text (before duplication) - --- Polling state: when a track has no duration metadata, we poll IsPlaying --- to detect when it finishes, so autoplay/shuffle can advance. -RJ.pollingForEnd = false -RJ.pollStartDelay = 2 -RJ.pollTimer = 0 -RJ.pollInterval = 0.5 -RJ.consecutiveNotPlaying = 0 -RJ.consecutiveThreshold = 4 - --- Fallback duration used when no metadata is available (seconds). -local FALLBACK_DURATION = 180 - -local function Count(t) - if table.getn then return table.getn(t) end - local n = 0 - while t[n + 1] ~= nil do - n = n + 1 - end - return n -end - -local function Trim(s) - if not s then return "" end - return (string.gsub(s, "^%s*(.-)%s*$", "%1")) -end - -local function NormalizePath(input) - local path = Trim(input) - path = string.gsub(path, "/", "\\") - if path == "" then return "" end - if string.find(string.lower(path), "^interface\\") or string.find(string.lower(path), "^sound\\") or string.find(string.lower(path), "^data\\") then - return path - end - return ADDON_MEDIA_PREFIX .. path -end - -local function CanonicalPath(input) - local path = NormalizePath(input) - return string.lower(path) -end - -local function EnsureDB() - if type(RelationshipsJukeboxDB) ~= "table" then RelationshipsJukeboxDB = {} end - if type(RelationshipsJukeboxDB.tracks) ~= "table" then RelationshipsJukeboxDB.tracks = {} end -end - -local function ExtractFileName(path) - local clean - if not path then return "" end - clean = string.gsub(path, "/", "\\") - clean = string.gsub(clean, "^.*\\", "") - return clean -end - --- Clean up a file name for display: strip leading numbers, dashes, underscores, --- and convert remaining underscores to spaces. Also remove the file extension. --- Examples: --- "05 - this_song3.mp3" -> "this song" --- "01. Session.mp3" -> "Session" --- "06 - System Of A Down - Chop Suey_.mp3" -> "System Of A Down - Chop Suey" -local function CleanDisplayName(name) - if not name or name == "" then return "" end - -- Remove file extension (last .mp3, .wav, .ogg, etc.) - name = string.gsub(name, "%.%w+$", "") - -- Strip leading numbers followed by optional separator (digits, dots, dashes, spaces) - -- e.g. "05 - ", "01. ", "06 - ", "02. ", etc. - name = string.gsub(name, "^[%d%.]+[%s%-_]*", "") - -- Strip any remaining leading dashes or underscores - name = string.gsub(name, "^[%-_]+", "") - -- Strip leading whitespace - name = string.gsub(name, "^%s+", "") - -- Strip trailing numbers, dashes, underscores - name = string.gsub(name, "[%d%-_]+$", "") - -- Strip trailing whitespace - name = string.gsub(name, "%s+$", "") - -- Replace remaining underscores with spaces - name = string.gsub(name, "_", " ") - -- Collapse multiple spaces into one - name = string.gsub(name, " +", " ") - return Trim(name) -end - -local function GetTrackPath(entry) - if type(entry) == "table" then - return NormalizePath(entry.path or entry.file or "") - end - if type(entry) == "string" then - return NormalizePath(entry) - end - return "" -end - --- GetTrackName returns the raw track name for storage and path resolution. --- It does NOT apply CleanDisplayName — the stored name must match the actual --- file so that path/name resolution works correctly across reloads. -local function GetTrackName(entry) - local path = GetTrackPath(entry) - if type(entry) == "table" then - local customName = Trim(entry.name or entry.title or "") - if customName ~= "" then - return customName - end - end - return ExtractFileName(path) -end - --- GetTrackDisplayName returns a cleaned-up name suitable for UI display only. --- This strips leading numbers, dashes, underscores, extensions, etc. --- It must NEVER be used when storing names into SavedVariables or matching --- entries by name, only for rendering labels and status text. -local function GetTrackDisplayName(entry) - return CleanDisplayName(GetTrackName(entry)) -end - -local function GetTrackDuration(entry) - if type(entry) == "table" then - local duration = tonumber(entry.duration or entry.length or entry.seconds or 0) - if duration and duration > 0 then - return duration - end - end - return 0 -end - -local function CreateTrackEntry(path, name, duration) - 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, - } -end - -local function BuildTrackLookup() - local seen = {} - local i - EnsureDB() - for i = 1, Count(RelationshipsJukeboxDB.tracks) do - local path = GetTrackPath(RelationshipsJukeboxDB.tracks[i]) - if path ~= "" then - seen[string.lower(path)] = i - end - end - return seen -end - -local function MergeAutoPlaylist() - local auto, seen, imported, updated, i, entry, path, name, duration, track, existingIndex, existingEntry, currentName, currentDuration, changed - EnsureDB() - auto = RelationshipsJukeboxAutoPlaylist - if type(auto) ~= "table" or type(auto.tracks) ~= "table" then - return 0, 0 - end - seen = BuildTrackLookup() - imported = 0 - updated = 0 - for i = 1, Count(auto.tracks) do - entry = auto.tracks[i] - path = GetTrackPath(entry) - if path ~= "" then - local key = string.lower(path) - name = GetTrackName(entry) - duration = GetTrackDuration(entry) - existingIndex = seen[key] - if existingIndex then - existingEntry = RelationshipsJukeboxDB.tracks[existingIndex] - changed = false - if type(existingEntry) ~= "table" then - track = CreateTrackEntry(path, name, duration) - if track then - RelationshipsJukeboxDB.tracks[existingIndex] = track - updated = updated + 1 - end - else - existingEntry.path = NormalizePath(existingEntry.path or existingEntry.file or path) - currentName = Trim(existingEntry.name or existingEntry.title or "") - if currentName == "" or currentName == ExtractFileName(path) then - existingEntry.name = name - changed = true - end - currentDuration = tonumber(existingEntry.duration or existingEntry.length or existingEntry.seconds or 0) or 0 - if currentDuration <= 0 and duration > 0 then - existingEntry.duration = duration - changed = true - end - if changed then - updated = updated + 1 - end - end - else - track = CreateTrackEntry(path, name, duration) - if track then - table.insert(RelationshipsJukeboxDB.tracks, track) - seen[key] = Count(RelationshipsJukeboxDB.tracks) - imported = imported + 1 - end - end - end - end - return imported, updated -end - -local function SetStatus(msg, r, g, b) - if RJ.statusText then - RJ.statusText:SetText(msg or "") - if RJ.statusText.SetTextColor then - RJ.statusText:SetTextColor(r or 1, g or 0.82, b or 0) - end - end -end - -local function ShuffleQueue(queue) - local i - if not queue then return end - for i = Count(queue), 2, -1 do - local j = math.random(i) - queue[i], queue[j] = queue[j], queue[i] - end -end - --- Instantly kill all music/sound by toggling the master "Enable Sound" --- checkbox OFF then ON via the MasterSound CVar. This is the ONLY reliable --- way to force WoW 1.12 (Turtle WoW / OctoWoW) to fully stop playback of --- PlayMusic / PlaySoundFile tracks. Simply muting volume CVars does NOT work --- because the engine keeps the audio channel alive; the track continues --- playing silently and can even resume at the wrong position. By unchecking --- the master Enable Sound checkbox the engine tears down the entire audio --- subsystem, and re-checking it immediately rebuilds a clean state so the --- next PlayMusic / PlaySoundFile call starts fresh. --- --- The function also saves and restores the previous enable state so that --- players who intentionally play with sound disabled are not affected. -local function SoundKillToggle() - local sc = SetCVar or function() end - local cv = GetCVar or function() return "1" end - - -- Save whether sound was already enabled so we don't force-enable it - local ok, wasEnabled = pcall(cv, "MasterSound") - RJ._wasMasterSoundEnabled = (ok and wasEnabled) or "1" - - -- Save whether music was already enabled - local okM, wasMusicEnabled = pcall(cv, "EnableMusic") - RJ._wasEnableMusicEnabled = (okM and wasMusicEnabled) or "1" - - -- Turn the master Enable Sound checkbox OFF (unchecks "Enable Sound") - pcall(sc, "MasterSound", "0") - - -- Turn the Enable Music checkbox OFF (unchecks "Enable Music") - pcall(sc, "EnableMusic", "0") - - -- Immediately turn both checkboxes back ON. The engine rebuilds its - -- audio subsystem from scratch, guaranteeing that any previously - -- playing track is fully stopped and will not ghost-resume. - -- The next PlayMusic/PlaySoundFile call starts clean. - pcall(sc, "MasterSound", "1") - pcall(sc, "EnableMusic", "1") -end - --- Restore the master sound enable state to whatever it was before the last --- SoundKillToggle call. This ensures players who intentionally have sound --- disabled are not disrupted. -local function RestoreMasterSoundState() - local sc = SetCVar or function() end - if RJ._wasMasterSoundEnabled then - pcall(sc, "MasterSound", RJ._wasMasterSoundEnabled) - RJ._wasMasterSoundEnabled = nil - end - if RJ._wasEnableMusicEnabled then - pcall(sc, "EnableMusic", RJ._wasEnableMusicEnabled) - RJ._wasEnableMusicEnabled = nil - end -end - --- Instantly kill all music/sound. No fade-out. --- Uses the master Enable Sound checkbox toggle (not volume muting) to --- guarantee that the audio engine fully stops the current track. -local function KillMusicInstant() - if StopMusic then - StopMusic() - StopMusic() - StopMusic() - end - -- Toggle the master Enable Sound checkbox off then on. - -- This is the key step: the engine only truly halts playback when - -- the master sound enable is switched off, which tears down the - -- entire audio subsystem. Switching it back on rebuilds a clean - -- state so the next PlayMusic/PlaySoundFile call starts fresh. - SoundKillToggle() -end - ------------------------------------------------------------------------ --- Forward declarations (mutual recursion: PlayNextQueuedTrack <-> PlayTrack) ------------------------------------------------------------------------ -local PlayTrack -local PlayNextQueuedTrack - ------------------------------------------------------------------------ --- Playback button state (forward-declared so PausePlayback/ResumePlayback can call it) ------------------------------------------------------------------------ -local RefreshPlaybackButtons - ------------------------------------------------------------------------ --- Pause / Resume ------------------------------------------------------------------------ -local function PausePlayback() - if not RJ.currentPath or RJ.isPaused then return end - if GetTime() < RJ.pauseCooldownUntil then - SetStatus("Pause cooldown -- please wait...", 1, 1, 0.2) - return - end - RJ.isPaused = true - -- Save remaining time for timed tracks - if RJ.trackEndTime then - RJ.pausedRemaining = RJ.trackEndTime - GetTime() - RJ.trackEndTime = nil - else - RJ.pausedRemaining = nil - end - RJ.pollingForEnd = false - KillMusicInstant() - -- 1-second cooldown after pause to prevent rapid toggle spam - RJ.pauseCooldownUntil = GetTime() + COOLDOWN_SECS - SetStatus("Paused: " .. (RJ.currentName or ""), 1, 1, 0.2) - RefreshPlaybackButtons() - RefreshMiniPlayer() -end - -local function ResumePlayback() - if not RJ.isPaused then return end - if GetTime() < RJ.pauseCooldownUntil then - SetStatus("Play cooldown -- please wait...", 1, 1, 0.2) - return - end - RJ.isPaused = false - - if RJ.currentPath then - if IsMusicPath(RJ.currentPath) and PlayMusic then - PlayMusic(RJ.currentPath) - RJ.currentCanStop = true - elseif PlaySoundFile then - PlaySoundFile(RJ.currentPath) - RJ.currentCanStop = false - end - - -- Restore timer or polling - if RJ.pausedRemaining and RJ.pausedRemaining > 0 then - RJ.trackEndTime = GetTime() + RJ.pausedRemaining - RJ.pausedRemaining = nil - elseif RJ.currentCanStop and IsPlaying then - RJ.pollingForEnd = true - RJ.pollTimer = GetTime() + RJ.pollStartDelay - RJ.consecutiveNotPlaying = 0 - RJ.pausedRemaining = nil - end - end - - -- 1-second cooldown after resume to prevent rapid toggle spam - RJ.pauseCooldownUntil = GetTime() + COOLDOWN_SECS - -- Set the full 1-second play cooldown to prevent spam-playing other tracks - RJ.playCooldownUntil = GetTime() + COOLDOWN_SECS - SetStatus("Resumed: " .. (RJ.currentName or ""), 0.2, 1, 0.2) - RefreshPlaybackButtons() - RefreshMiniPlayer() -end - -local function TogglePause() - if GetTime() < RJ.pauseCooldownUntil then - SetStatus("Pause/Play cooldown -- please wait...", 1, 1, 0.2) - return - end - if RJ.isPaused then - ResumePlayback() - else - PausePlayback() - end -end - - -local function StopPlayback(silent) - -- 1-second cooldown on Stop to prevent music overlap - if not silent and GetTime() < RJ.stopCooldownUntil then - SetStatus("Stop cooldown -- please wait...", 1, 1, 0.2) - return - end - - RJ.isPaused = false - RJ.pausedRemaining = nil - RJ.pauseCooldownUntil = 0 - RJ.playCooldownUntil = 0 - RJ.skipCooldownUntil = 0 - RJ.prevCooldownUntil = 0 - RJ.stopCooldownUntil = 0 - RJ.autoPlayEnabled = false - RJ.playQueue = nil - RJ.playQueuePosition = 0 - RJ.trackEndTime = nil - RJ.currentDuration = 0 - RJ.pollingForEnd = false - RJ.pollTimer = 0 - RJ.consecutiveNotPlaying = 0 - - KillMusicInstant() - - RJ.currentCanStop = false - RJ.currentIndex = nil - RJ.currentPath = nil - RJ.currentName = nil - - if not silent then - -- Set 1-second cooldown after Stop to prevent rapid re-play causing audio overlap - RJ.stopCooldownUntil = GetTime() + COOLDOWN_SECS - SetStatus("Stopped.", 1, 0.82, 0) - end - - RefreshPlaybackButtons() - RefreshMiniPlayer() -end - ------------------------------------------------------------------------ --- Playback button state ------------------------------------------------------------------------ -RefreshPlaybackButtons = function() - local hasTracks = Count(RelationshipsJukeboxDB.tracks) > 0 - local isPlaying = RJ.currentPath ~= nil or RJ.autoPlayEnabled - local inQueue = RJ.autoPlayEnabled and RJ.playQueue ~= nil - local now = GetTime() - - if RJ.playAllButton then - if hasTracks and not isPlaying and now >= RJ.playCooldownUntil and now >= RJ.stopCooldownUntil then - RJ.playAllButton:Enable() - else - RJ.playAllButton:Disable() - end - end - - if RJ.shuffleButton then - if hasTracks and not isPlaying and now >= RJ.playCooldownUntil and now >= RJ.stopCooldownUntil then - RJ.shuffleButton:Enable() - else - RJ.shuffleButton:Disable() - end - end - - if RJ.stopButton then - if (isPlaying or RJ.isPaused) and now >= RJ.stopCooldownUntil then - RJ.stopButton:Enable() - else - RJ.stopButton:Disable() - end - end - - if RJ.skipButton then - if inQueue and now >= RJ.skipCooldownUntil then - RJ.skipButton:Enable() - else - RJ.skipButton:Disable() - end - end - - if RJ.prevButton then - if inQueue and RJ.playQueuePosition > 1 and now >= RJ.prevCooldownUntil then - RJ.prevButton:Enable() - else - RJ.prevButton:Disable() - end - end - - RefreshMiniPlayer() -end - ------------------------------------------------------------------------ --- List / page UI ------------------------------------------------------------------------ -local function RefreshList() - EnsureDB() - - -- Determine which indices to display: filtered or all - local indices = RJ.filteredIndices -- nil = no filter (show all) - local total, totalPages - - if indices then - total = Count(indices) - else - total = Count(RelationshipsJukeboxDB.tracks) - end - - totalPages = math.max(1, math.ceil(total / RJ.pageSize)) - if RJ.page > totalPages then RJ.page = totalPages end - if RJ.page < 1 then RJ.page = 1 end - - local startIndex = ((RJ.page - 1) * RJ.pageSize) + 1 - - for i = 1, RJ.pageSize do - local row = RJ.rows[i] - local listPos = startIndex + i - 1 -- position within the display list (1-based) - local idx -- actual DB index - - if indices then - idx = indices[listPos] - else - idx = listPos - end - - local entry = RelationshipsJukeboxDB.tracks[idx] - - if entry then - row.index = idx -- store real DB index so Play/Remove work correctly - row.label:SetText(idx .. ". " .. GetTrackDisplayName(entry)) - -- Disable Play button during 1-second cooldown (play or stop) - if GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil then - row.play:Disable() - else - row.play:Enable() - end - row:Show() - else - row.index = nil - row.label:SetText("") - row:Hide() - end - end - - if RJ.pageText then - local pageText = "Page " .. RJ.page .. "/" .. totalPages .. " Tracks: " .. total - if RJ.filteredIndices then - pageText = pageText .. " (filtered)" - end - RJ.pageText:SetText(pageText) - end - - RefreshPlaybackButtons() -end - ------------------------------------------------------------------------ --- Track playback core ------------------------------------------------------------------------ --- Check whether a file path should be played via PlayMusic (WoW 1.12 FMOD 3.x --- requires PlayMusic for .mp3, .wav AND .ogg — PlaySoundFile cannot play any --- of these formats). Only files NOT matching these extensions should use --- PlaySoundFile (e.g. custom SoundKit IDs or Blizzard sound paths). --- --- Uses separate string.find calls per extension instead of a regex-style --- alternation (|) because Lua patterns are NOT regular expressions — the --- pipe character has no special meaning in Lua patterns. -local function IsMusicPath(path) - local lower = string.lower(path or "") - return string.find(lower, "%.mp3$") ~= nil - or string.find(lower, "%.wav$") ~= nil - or string.find(lower, "%.ogg$") ~= nil -end - --- Legacy alias kept for clarity in comments / status messages -local function IsMp3Path(path) - return string.find(string.lower(path or ""), "%.mp3$") ~= nil -end - -PlayNextQueuedTrack = function() - if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then - return - end - - RJ.playQueuePosition = RJ.playQueuePosition + 1 - - if RJ.playQueuePosition > Count(RJ.playQueue) then - StopPlayback(true) - SetStatus("Playlist finished.", 0.2, 1, 0.2) - RefreshPlaybackButtons() - return - end - - local queueEntry = RJ.playQueue[RJ.playQueuePosition] - PlayTrack(queueEntry, true) -end - -PlayTrack = function(indexOrEntry, fromQueue) - EnsureDB() - - -- 1-second cooldown to prevent spam-playing multiple tracks on top of each other - -- Skip cooldown for auto-advancing from queue (fromQueue == true) - if not fromQueue and (GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil) then - SetStatus("Cooldown active -- please wait...", 1, 1, 0.2) - return nil - end - - local entry - if type(indexOrEntry) == "number" then - entry = RelationshipsJukeboxDB.tracks[indexOrEntry] - elseif type(indexOrEntry) == "table" then - entry = indexOrEntry - else - entry = nil - end - - local path = GetTrackPath(entry) - local name = GetTrackDisplayName(entry) - local duration = GetTrackDuration(entry) - local ok - - if path == "" then - SetStatus("Track not found.", 1, 0.2, 0.2) - if fromQueue then - PlayNextQueuedTrack() - end - return nil - end - - -- Stop any currently playing music instantly - if RJ.currentCanStop then - KillMusicInstant() - end - - RJ.isPaused = false - RJ.pausedRemaining = nil - RJ.currentCanStop = false - RJ.pollingForEnd = false - RJ.pollTimer = 0 - RJ.consecutiveNotPlaying = 0 - - -- Play the track - -- WoW 1.12 FMOD 3.x: PlayMusic supports .mp3, .wav and .ogg. - -- PlaySoundFile does NOT play .wav or .ogg (it silently fails), - -- so all three formats must go through PlayMusic. - if IsMusicPath(path) and PlayMusic then - PlayMusic(path) - ok = 1 - RJ.currentCanStop = true - else - ok = PlaySoundFile(path) - RJ.currentCanStop = false - end - - if ok then - -- Set 1-second play cooldown to prevent spam - RJ.playCooldownUntil = GetTime() + COOLDOWN_SECS - - if type(indexOrEntry) == "number" then - RJ.currentIndex = indexOrEntry - else - RJ.currentIndex = nil - end - RJ.currentPath = path - RJ.currentName = name - RJ.currentDuration = duration - - if fromQueue then - local queueLen = Count(RJ.playQueue or {}) - local posLabel = RJ.playQueuePosition .. "/" .. queueLen - - if duration and duration > 0 then - -- Known duration: use timed advancement - RJ.trackEndTime = GetTime() + duration - SetStatus("Playing " .. posLabel .. ": " .. name .. " (" .. duration .. "s)", 0.2, 1, 0.2) - elseif RJ.currentCanStop and IsPlaying then - -- PlayMusic track with no duration: poll IsPlaying() to detect end - RJ.trackEndTime = nil - RJ.pollingForEnd = true - RJ.pollTimer = GetTime() + RJ.pollStartDelay - RJ.consecutiveNotPlaying = 0 - SetStatus("Playing " .. posLabel .. ": " .. name .. " (auto-advancing when track ends)", 0.2, 1, 0.2) - else - -- WAV/OGG with no duration: use fallback - RJ.trackEndTime = GetTime() + FALLBACK_DURATION - RJ.pollingForEnd = false - SetStatus("Playing " .. posLabel .. ": " .. name .. " (~" .. FALLBACK_DURATION .. "s estimated)", 0.2, 1, 0.2) - end - else - -- Single track play (not from queue) - RJ.trackEndTime = nil - if duration and duration > 0 then - SetStatus("Playing: " .. name .. " (" .. duration .. "s)", 0.2, 1, 0.2) - else - SetStatus("Playing: " .. name, 0.2, 1, 0.2) - end - end - else - RJ.trackEndTime = nil - RJ.pollingForEnd = false - SetStatus("Could not play: " .. name .. " (check filename/path)", 1, 0.2, 0.2) - if fromQueue then - -- Skip to next after a brief delay to avoid infinite recursion on bad files - RJ.trackEndTime = GetTime() + 0.5 - RJ.pollingForEnd = false - end - end - - RefreshPlaybackButtons() - return ok -end - --- Skip to next track in queue -local function SkipTrack() - if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then - return - end - -- 1-second cooldown on Skip to prevent music overlap - if GetTime() < RJ.skipCooldownUntil then - SetStatus("Skip cooldown -- please wait...", 1, 1, 0.2) - return - end - RJ.skipCooldownUntil = GetTime() + COOLDOWN_SECS - RJ.prevCooldownUntil = GetTime() + COOLDOWN_SECS - if RJ.currentCanStop then - KillMusicInstant() - end - RJ.isPaused = false - RJ.pausedRemaining = nil - RJ.currentCanStop = false - RJ.trackEndTime = nil - RJ.pollingForEnd = false - PlayNextQueuedTrack() -end - - --- Go back to previous track in queue -local function PreviousTrack() - if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then - return - end - -- 1-second cooldown on Previous to prevent music overlap - if GetTime() < RJ.prevCooldownUntil then - SetStatus("Previous cooldown -- please wait...", 1, 1, 0.2) - return - end - RJ.prevCooldownUntil = GetTime() + COOLDOWN_SECS - RJ.skipCooldownUntil = GetTime() + COOLDOWN_SECS - if RJ.playQueuePosition <= 1 then - RJ.playQueuePosition = 0 - else - RJ.playQueuePosition = RJ.playQueuePosition - 2 - end - if RJ.currentCanStop then - KillMusicInstant() - end - RJ.isPaused = false - RJ.pausedRemaining = nil - RJ.currentCanStop = false - RJ.trackEndTime = nil - RJ.pollingForEnd = false - PlayNextQueuedTrack() -end - - -local function StartPlaylist(shuffle) - EnsureDB() - - -- 1-second cooldown to prevent spam-starting playlists - if GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil then - SetStatus("Cooldown active -- please wait...", 1, 1, 0.2) - return - end - - local total = Count(RelationshipsJukeboxDB.tracks) - if total == 0 then - SetStatus("No tracks found.", 1, 0.2, 0.2) - return - end - - StopPlayback(true) - - RJ.playQueue = {} - RJ.playQueuePosition = 0 - RJ.autoPlayEnabled = true - RJ.shuffleMode = shuffle and true or false - - local i - for i = 1, total do - table.insert(RJ.playQueue, i) - end - - if RJ.shuffleMode then - ShuffleQueue(RJ.playQueue) - end - - PlayNextQueuedTrack() -end - - -local function PerformSearch() - EnsureDB() - local query = RJ.searchInput and RJ.searchInput:GetText() or "" - query = Trim(query) - - if query == "" then - -- Empty query = clear search, show all tracks - RJ.searchFilter = "" - RJ.filteredIndices = nil - RJ.page = 1 - RefreshList() - SetStatus("Search cleared. Showing all tracks.", 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 - RefreshList() - - local matchCount = Count(RJ.filteredIndices) - if matchCount > 0 then - SetStatus("Found " .. matchCount .. " track(s) matching \"" .. query .. "\"", 0.2, 1, 0.2) - else - SetStatus("No tracks found matching \"" .. query .. "\"", 1, 0.2, 0.2) - end -end - -local function ClearSearch() - RJ.searchFilter = "" - RJ.filteredIndices = nil - if RJ.searchInput then RJ.searchInput:SetText("") end - RJ.page = 1 - RefreshList() - SetStatus("Search cleared. Showing all tracks.", 0.2, 1, 0.2) -end - -local function RemoveTrack(index) - EnsureDB() - if RelationshipsJukeboxDB.tracks[index] then - local removed = GetTrackDisplayName(RelationshipsJukeboxDB.tracks[index]) - table.remove(RelationshipsJukeboxDB.tracks, index) - if RJ.currentIndex == index then - StopPlayback(true) - end - -- Re-run search if a filter is active so filteredIndices stays in sync - if RJ.searchFilter and RJ.searchFilter ~= "" then - PerformSearch() - else - RefreshList() - end - SetStatus("Removed: " .. removed, 1, 0.82, 0) - end -end - ------------------------------------------------------------------------ --- Button helper ------------------------------------------------------------------------ -local function MakeButton(name, parent, width, height, text) - local b = CreateFrame("Button", name, parent, "UIPanelButtonTemplate") - b:SetWidth(width) - b:SetHeight(height) - b:SetText(text) - return b -end - ------------------------------------------------------------------------ --- Main GUI: track list window --- Layout (total height 600): --- TOP: title/subtitle/input (~106 px from top) --- MIDDLE: track list rows (12/page) (~336 px) --- Row 1: Playback controls (<< Prev, Skip >>, Play All, Shuffle, Stop) --- Row 2: Page navigation (Prev, Page X/Y, Next) --- Slash help text (~20 px) --- Status bar (~20 px) --- Bottom padding (~18 px) ------------------------------------------------------------------------ -local function CreateRow(parent, i) - local row = CreateFrame("Frame", nil, parent) - row:SetWidth(680) - row:SetHeight(27) - - -- 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. - row.bg = row:CreateTexture(nil, "BACKGROUND") - row.bg:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1) - row.bg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1) - - if math.mod(i, 2) == 1 then - row.bg:SetTexture(0.10, 0.10, 0.10, 0.82) - else - row.bg:SetTexture(0.06, 0.06, 0.06, 0.78) - end - - row.borderTop = row:CreateTexture(nil, "BORDER") - row.borderTop:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) - row.borderTop:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0) - row.borderTop:SetHeight(1) - row.borderTop:SetTexture(0.45, 0.45, 0.45, 0.95) - - row.borderBottom = row:CreateTexture(nil, "BORDER") - row.borderBottom:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0) - row.borderBottom:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) - row.borderBottom:SetHeight(1) - row.borderBottom:SetTexture(0.45, 0.45, 0.45, 0.95) - - row.borderLeft = row:CreateTexture(nil, "BORDER") - row.borderLeft:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) - row.borderLeft:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0) - row.borderLeft:SetWidth(1) - row.borderLeft:SetTexture(0.45, 0.45, 0.45, 0.95) - - row.borderRight = row:CreateTexture(nil, "BORDER") - row.borderRight:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0) - row.borderRight:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) - row.borderRight:SetWidth(1) - row.borderRight:SetTexture(0.45, 0.45, 0.45, 0.95) - - row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - row.label:SetPoint("LEFT", row, "LEFT", 10, 0) - row.label:SetWidth(496) - row.label:SetJustifyH("LEFT") - row.label:SetText("") - - 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) - - 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) - - return row -end - -local function CreateUI() - if RJ.frame then return end - - local f = CreateFrame("Frame", ADDON_NAME .. "Frame", UIParent) - RJ.frame = f - f:SetWidth(730) - f:SetHeight(600) - f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) - f:SetMovable(true) - f:EnableMouse(true) - f:RegisterForDrag("LeftButton") - f:SetClampedToScreen(true) - f:SetScript("OnDragStart", function() f:StartMoving() end) - f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end) - f:Hide() - - if f.SetBackdrop then - f:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", - tile = true, tileSize = 32, edgeSize = 32, - insets = { left = 11, right = 12, top = 12, bottom = 11 } - }) - f:SetBackdropColor(0, 0, 0, 0.85) - end - - -- Title bar - local title = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") - title:SetPoint("TOP", f, "TOP", 0, -16) - title:SetText("Relationships Jukebox - Playlist Player") - - local subtitle = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - subtitle:SetPoint("TOP", title, "BOTTOM", 0, -18) - subtitle:SetText("Put MP3/WAV/OGG files in the RelationshipsJukeBox\\Media folder, run GeneratePlaylist.bat, then /reload") - - local close = MakeButton(nil, f, 28, 22, "X") - close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -14, -14) - close:SetScript("OnClick", function() f:Hide() end) - - -- Search input row - local searchLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") - searchLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, -76) - searchLabel:SetText("Search") - - local searchInput = CreateFrame("EditBox", ADDON_NAME .. "SearchInput", f, "InputBoxTemplate") - RJ.searchInput = searchInput - searchInput:SetWidth(290) - searchInput:SetHeight(20) - searchInput:SetPoint("LEFT", searchLabel, "RIGHT", 10, 0) - searchInput:SetAutoFocus(false) - searchInput:SetTextInsets(6, 6, 0, 0) - searchInput:SetText("") - searchInput:SetScript("OnEscapePressed", function() - searchInput:ClearFocus() - end) - searchInput:SetScript("OnEnterPressed", function() - searchInput:ClearFocus() - PerformSearch() - end) - - local searchBtn = MakeButton(nil, f, 70, 22, "Search") - searchBtn:SetPoint("LEFT", searchInput, "RIGHT", 10, 0) - searchBtn:SetScript("OnClick", PerformSearch) - - local clearBtn = MakeButton(nil, f, 60, 22, "Clear") - clearBtn:SetPoint("LEFT", searchBtn, "RIGHT", 8, 0) - clearBtn:SetScript("OnClick", ClearSearch) - - -- Track list (anchored from top, leaves room for buttons at bottom) - local listAnchor = CreateFrame("Frame", nil, f) - listAnchor:SetPoint("TOPLEFT", f, "TOPLEFT", 20, -106) - listAnchor:SetWidth(690) - listAnchor:SetHeight(336) - - for i = 1, RJ.pageSize do - local row = CreateRow(listAnchor, i) - if i == 1 then - row:SetPoint("TOPLEFT", listAnchor, "TOPLEFT", 0, 1) - else - row:SetPoint("TOPLEFT", RJ.rows[i-1], "BOTTOMLEFT", 0, -1) - end - RJ.rows[i] = row - end - - -- Bottom button bar: two rows - -- Row 1 (higher): Playback controls (<< Prev, Skip >>, Play All, Shuffle, Stop) - -- Row 2 (lower): Page navigation (Prev, Page X/Y, Next) - local playbackY = 108 -- playback controls row offset from bottom - local pageY = 80 -- page navigation row offset from bottom - - -- Playback controls (upper row, left-aligned) - RJ.prevButton = MakeButton(nil, f, 70, 22, "<< Prev") - RJ.prevButton:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, playbackY) - RJ.prevButton:SetScript("OnClick", PreviousTrack) - RJ.prevButton:Disable() - - RJ.skipButton = MakeButton(nil, f, 70, 22, "Skip >>") - RJ.skipButton:SetPoint("LEFT", RJ.prevButton, "RIGHT", 8, 0) - RJ.skipButton:SetScript("OnClick", SkipTrack) - RJ.skipButton:Disable() - - RJ.playAllButton = MakeButton(nil, f, 80, 22, "Play All") - RJ.playAllButton:SetPoint("LEFT", RJ.skipButton, "RIGHT", 8, 0) - RJ.playAllButton:SetScript("OnClick", function() StartPlaylist(false) end) - - RJ.shuffleButton = MakeButton(nil, f, 70, 22, "Shuffle") - RJ.shuffleButton:SetPoint("LEFT", RJ.playAllButton, "RIGHT", 8, 0) - RJ.shuffleButton:SetScript("OnClick", function() StartPlaylist(true) end) - - RJ.stopButton = MakeButton(nil, f, 60, 22, "Stop") - RJ.stopButton:SetPoint("LEFT", RJ.shuffleButton, "RIGHT", 8, 0) - RJ.stopButton:SetScript("OnClick", function() StopPlayback(false) end) - - -- Page navigation (lower row, left-aligned) - local prev = MakeButton(nil, f, 70, 22, "Prev") - prev:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, pageY) - prev:SetScript("OnClick", function() - RJ.page = RJ.page - 1 - RefreshList() - end) - - RJ.pageText = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") - RJ.pageText:SetPoint("LEFT", prev, "RIGHT", 14, 0) - RJ.pageText:SetText("Page 1/1") - - local nextBtn = MakeButton(nil, f, 70, 22, "Next") - nextBtn:SetPoint("LEFT", RJ.pageText, "RIGHT", 14, 0) - nextBtn:SetScript("OnClick", function() - RJ.page = RJ.page + 1 - RefreshList() - end) - - -- Slash help text (above status bar) - local nowLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - nowLabel:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, 54) - nowLabel:SetWidth(680) - nowLabel:SetJustifyH("LEFT") - nowLabel:SetTextColor(0.6, 0.6, 0.6) - nowLabel:SetText("Slash: /rjukebox play | shuffle | stop | skip | prev | pause | mini") - - -- Status bar (very bottom) - RJ.statusText = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - RJ.statusText:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, 18) - RJ.statusText:SetWidth(680) - RJ.statusText:SetJustifyH("LEFT") - RJ.statusText:SetText("Ready.") - - RefreshList() -end - ------------------------------------------------------------------------ --- Mini Player: small floating control bar ------------------------------------------------------------------------ -function RefreshMiniPlayer() - if not RJ.miniFrame then return end - local mf = RJ.miniFrame - local inQueue = RJ.autoPlayEnabled and RJ.playQueue ~= nil - local isActive = RJ.currentPath ~= nil - local now = GetTime() - - -- Build the display text for the mini player. The ScrollFrame marquee - -- will lay it out on the next frame; here we only reset state when the - -- visible text actually changes. - local newText - if RJ.currentName and RJ.currentName ~= "" then - if RJ.isPaused then - newText = "|| " .. RJ.currentName - else - newText = RJ.currentName - end - else - newText = "Relationships Jukebox" - end - - if RJ.marqueeSingleText ~= newText then - if mf.trackLabel then - mf.trackLabel:SetText(newText) - end - RJ.marqueeOffset = 0 - RJ.marqueeLastUpdate = 0 - RJ.marqueeCycleWidth = 0 - RJ.marqueeSingleText = "" - RJ.marqueeSingleWidth = 0 - end - - -- Enable/disable buttons with 1-second cooldown checks - if inQueue and now >= RJ.skipCooldownUntil then - mf.skipBtn:Enable() - else - mf.skipBtn:Disable() - end - - if inQueue and RJ.playQueuePosition > 1 and now >= RJ.prevCooldownUntil then - mf.prevBtn:Enable() - else - mf.prevBtn:Disable() - end - - if isActive or RJ.isPaused then - if now >= RJ.stopCooldownUntil then - mf.stopBtn:Enable() - else - mf.stopBtn:Disable() - end - else - mf.stopBtn:Disable() - end -end - - -local function CreateMiniPlayer() - if RJ.miniFrame then return end - - local mf = CreateFrame("Frame", ADDON_NAME .. "MiniFrame", UIParent) - RJ.miniFrame = mf - mf:SetWidth(216) - mf:SetHeight(56) - mf:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -60, -200) - mf:SetMovable(true) - mf:EnableMouse(true) - mf:RegisterForDrag("LeftButton") - mf:SetScript("OnDragStart", function() mf:StartMoving() end) - mf:SetScript("OnDragStop", function() mf:StopMovingOrSizing() end) - mf:SetClampedToScreen(true) - mf:SetScript("OnShow", function() - RJ.marqueeLastUpdate = 0 - end) - - if mf.SetBackdrop then - mf:SetBackdrop({ - bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", - edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", - tile = true, tileSize = 8, edgeSize = 12, - insets = { left = 3, right = 3, top = 3, bottom = 3 } - }) - mf:SetBackdropColor(0, 0, 0, 0.7) - end - - -- Track name (top row) - marquee scrolling text - -- Use a ScrollFrame viewport plus repeated labels so the text scrolls - -- smoothly at the pixel level without re-slicing strings every frame. - mf.trackClip = CreateFrame("ScrollFrame", nil, mf) - mf.trackClip:SetPoint("TOPLEFT", mf, "TOPLEFT", 8, -9) - mf.trackClip:SetPoint("BOTTOMRIGHT", mf, "TOPRIGHT", -8, -27) - mf.trackClip:SetHorizontalScroll(0) - - mf.trackCanvas = CreateFrame("Frame", nil, mf.trackClip) - mf.trackCanvas:SetWidth(1) - mf.trackCanvas:SetHeight(14) - mf.trackClip:SetScrollChild(mf.trackCanvas) - - mf.trackLabels = {} - for labelIndex = 1, 3 do - local label = mf.trackCanvas:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") - label:SetJustifyH("LEFT") - label:SetTextColor(0.4, 1, 0.4) - label:SetText("Relationships Jukebox") - mf.trackLabels[labelIndex] = label - end - - mf.trackLabel = mf.trackLabels[1] - - local function PositionMiniMarquee(offset) - mf.trackCanvas:ClearAllPoints() - - if RJ.marqueeCycleWidth > 0 then - -- Keep the middle copy inside the viewport and slide the whole strip to - -- the left. Wrapping happens by reusing the previous/next copies, so the - -- text never disappears into a blank gap between loops. - mf.trackCanvas:SetPoint("TOPLEFT", mf.trackClip, "TOPLEFT", -RJ.marqueeCycleWidth - offset, 0) - else - mf.trackCanvas:SetPoint("TOPLEFT", mf.trackClip, "TOPLEFT", 0, 0) - end - end - - local function LayoutMiniMarquee(singleText) - if not singleText or singleText == "" then - singleText = "Relationships Jukebox" - end - - local viewWidth = mf.trackClip:GetWidth() - if viewWidth <= 0 then return end - - mf.trackLabels[1]:SetText(singleText) - - local singleWidth - if mf.trackLabels[1].GetStringWidth then - singleWidth = mf.trackLabels[1]:GetStringWidth() - else - singleWidth = string.len(singleText) * 7 - end - - RJ.marqueeSingleText = singleText - RJ.marqueeSingleWidth = singleWidth - - if singleWidth <= viewWidth or singleWidth == 0 then - RJ.marqueeCycleWidth = 0 - RJ.marqueeOffset = 0 - RJ.marqueeFullText = singleText - - mf.trackLabels[1]:ClearAllPoints() - mf.trackLabels[1]:SetPoint("TOPLEFT", mf.trackCanvas, "TOPLEFT", 0, 0) - mf.trackLabels[1]:SetText(singleText) - mf.trackLabels[2]:SetText("") - mf.trackLabels[3]:SetText("") - - mf.trackCanvas:SetWidth(math.max(viewWidth, singleWidth)) - PositionMiniMarquee(0) - return - end - - RJ.marqueeFullText = singleText .. (RJ.marqueeSeparator or " ") - for labelIndex = 1, 3 do - mf.trackLabels[labelIndex]:SetText(RJ.marqueeFullText) - end - - if mf.trackLabels[1].GetStringWidth then - RJ.marqueeCycleWidth = mf.trackLabels[1]:GetStringWidth() - else - RJ.marqueeCycleWidth = string.len(RJ.marqueeFullText) * 7 - end - - for labelIndex = 1, 3 do - mf.trackLabels[labelIndex]:ClearAllPoints() - mf.trackLabels[labelIndex]:SetPoint("TOPLEFT", mf.trackCanvas, "TOPLEFT", (labelIndex - 1) * RJ.marqueeCycleWidth, 0) - end - - mf.trackCanvas:SetWidth(RJ.marqueeCycleWidth * 3) - PositionMiniMarquee(RJ.marqueeOffset) - end - - -- Smooth marquee update fully clipped to the mini player bounds. - mf:SetScript("OnUpdate", function() - local singleText - if RJ.currentName and RJ.currentName ~= "" then - if RJ.isPaused then - singleText = "|| " .. RJ.currentName - else - singleText = RJ.currentName - end - else - singleText = "Relationships Jukebox" - end - - if RJ.marqueeSingleText ~= singleText then - RJ.marqueeOffset = 0 - RJ.marqueeLastUpdate = 0 - LayoutMiniMarquee(singleText) - end - - if not RJ.currentName or RJ.currentName == "" then - RJ.marqueeOffset = 0 - RJ.marqueeLastUpdate = 0 - PositionMiniMarquee(0) - return - end - - if RJ.marqueeCycleWidth <= 0 then - PositionMiniMarquee(0) - return - end - - local now = GetTime() - if RJ.marqueeLastUpdate == 0 then - RJ.marqueeLastUpdate = now - PositionMiniMarquee(RJ.marqueeOffset) - return - end - - local elapsed = now - RJ.marqueeLastUpdate - RJ.marqueeLastUpdate = now - if elapsed > 0.5 then elapsed = 0.5 end - - RJ.marqueeOffset = RJ.marqueeOffset + (RJ.marqueeSpeed * elapsed) - while RJ.marqueeOffset >= RJ.marqueeCycleWidth do - RJ.marqueeOffset = RJ.marqueeOffset - RJ.marqueeCycleWidth - end - - PositionMiniMarquee(RJ.marqueeOffset) - end) - - -- Control buttons (bottom row): <<, Stop, >>, +, - - mf.prevBtn = MakeButton(nil, mf, 40, 20, "<<") - mf.prevBtn:SetPoint("BOTTOMLEFT", mf, "BOTTOMLEFT", 6, 6) - mf.prevBtn:SetScript("OnClick", PreviousTrack) - mf.prevBtn:Disable() - - mf.stopBtn = MakeButton(nil, mf, 50, 20, "Stop") - mf.stopBtn:SetPoint("LEFT", mf.prevBtn, "RIGHT", 4, 0) - mf.stopBtn:SetScript("OnClick", function() StopPlayback(false) end) - mf.stopBtn:Disable() - - mf.skipBtn = MakeButton(nil, mf, 40, 20, ">>") - mf.skipBtn:SetPoint("LEFT", mf.stopBtn, "RIGHT", 4, 0) - mf.skipBtn:SetScript("OnClick", SkipTrack) - mf.skipBtn:Disable() - - -- Open full GUI button - mf.openBtn = MakeButton(nil, mf, 28, 20, "+") - mf.openBtn:SetPoint("LEFT", mf.skipBtn, "RIGHT", 4, 0) - mf.openBtn:SetScript("OnClick", function() - if RJ.frame then - RJ.frame:Show() - RefreshList() - end - end) - - -- Minimize button (bottom row, after + button) - mf.minBtn = MakeButton(nil, mf, 28, 20, "-") - mf.minBtn:SetPoint("LEFT", mf.openBtn, "RIGHT", 4, 0) - mf.minBtn:SetScript("OnClick", function() - mf:Hide() - if RJ.minimapButton then - RJ.minimapButton:Show() - end - end) - - mf:Show() - -- Reset marquee timer when mini player first appears - RJ.marqueeLastUpdate = 0 - RefreshMiniPlayer() -end - ------------------------------------------------------------------------ --- Minimap button ------------------------------------------------------------------------ -local function CreateMinimapButton() - if RJ.minimapButton then return end - - local btn = CreateFrame("Button", ADDON_NAME .. "MinimapButton", Minimap) - RJ.minimapButton = btn - btn:SetWidth(32) - btn:SetHeight(32) - btn:SetFrameLevel(Minimap:GetFrameLevel() + 4) - btn:SetPoint("TOPLEFT", Minimap, "TOPLEFT", 2, -2) - - -- Use a built-in icon texture (musical note or similar) - -- In WoW 1.12 we use a common built-in texture - btn:SetNormalTexture("Interface\\Icons\\INV_Misc_Note_06") - btn:SetPushedTexture("Interface\\Icons\\INV_Misc_Note_06") - btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") - - -- Tooltip - btn:SetScript("OnEnter", function() - GameTooltip:SetOwner(btn, "ANCHOR_LEFT") - GameTooltip:SetText("Relationships Jukebox") - GameTooltip:AddLine("Click: Toggle Mini Player") - GameTooltip:AddLine("Right-click: Toggle Full GUI") - GameTooltip:Show() - end) - btn:SetScript("OnLeave", function() - GameTooltip:Hide() - end) - - -- Left click: toggle mini player - btn:SetScript("OnClick", function() - if RJ.miniFrame then - if RJ.miniFrame:IsShown() then - RJ.miniFrame:Hide() - else - RJ.miniFrame:Show() - RefreshMiniPlayer() - end - else - CreateMiniPlayer() - end - end) - - -- Right click: toggle full GUI - btn:SetScript("OnMouseDown", function() - if arg1 == "RightButton" then - if not RJ.frame then CreateUI() end - if RJ.frame:IsShown() then - RJ.frame:Hide() - else - RJ.frame:Show() - RefreshList() - end - end - end) - - -- Make minimap button draggable around the minimap - btn:SetMovable(true) - btn:RegisterForDrag("LeftButton") - btn:SetScript("OnDragStart", function() - btn:StartMoving() - end) - btn:SetScript("OnDragStop", function() - btn:StopMovingOrSizing() - end) - - btn:Show() -end - ------------------------------------------------------------------------ --- Main ticker: handles timed advancement and IsPlaying polling ------------------------------------------------------------------------ -local ticker = CreateFrame("Frame") -ticker:SetScript("OnUpdate", function() - -- 1) Timed advancement: track with known duration reached end time - if RJ.autoPlayEnabled and not RJ.isPaused and RJ.trackEndTime and GetTime() >= RJ.trackEndTime then - RJ.trackEndTime = nil - if RJ.currentCanStop then - KillMusicInstant() - end - RJ.currentCanStop = false - RJ.pollingForEnd = false - PlayNextQueuedTrack() - return - end - - -- 2) IsPlaying polling: detect when PlayMusic track finishes for tracks with no duration - if RJ.autoPlayEnabled and not RJ.isPaused and RJ.pollingForEnd then - if GetTime() < RJ.pollTimer then - return - end - - local playing = false - if IsPlaying then - playing = IsPlaying() - end - - if not playing then - RJ.consecutiveNotPlaying = RJ.consecutiveNotPlaying + 1 - if RJ.consecutiveNotPlaying >= RJ.consecutiveThreshold then - RJ.pollingForEnd = false - RJ.consecutiveNotPlaying = 0 - if RJ.currentCanStop then - KillMusicInstant() - end - RJ.currentCanStop = false - PlayNextQueuedTrack() - else - -- Not sure yet; check again soon - RJ.pollTimer = GetTime() + RJ.pollInterval - end - else - -- Still playing; reset counter, schedule next check - RJ.consecutiveNotPlaying = 0 - RJ.pollTimer = GetTime() + RJ.pollInterval - end - end - - -- 3) Re-enable stop button after cooldown expires (pause button removed from mini-player) - if RJ.pauseCooldownUntil > 0 and GetTime() >= RJ.pauseCooldownUntil then - RJ.pauseCooldownUntil = 0 - RefreshPlaybackButtons() - end - - -- 4) Re-enable Play / Play All / Shuffle buttons after play cooldown expires - if RJ.playCooldownUntil > 0 and GetTime() >= RJ.playCooldownUntil then - RJ.playCooldownUntil = 0 - RefreshPlaybackButtons() - RefreshList() - end - - -- 5) Re-enable Skip button after skip cooldown expires - if RJ.skipCooldownUntil > 0 and GetTime() >= RJ.skipCooldownUntil then - RJ.skipCooldownUntil = 0 - RefreshPlaybackButtons() - end - - -- 6) Re-enable Previous button after prev cooldown expires - if RJ.prevCooldownUntil > 0 and GetTime() >= RJ.prevCooldownUntil then - RJ.prevCooldownUntil = 0 - RefreshPlaybackButtons() - end - - -- 7) Re-enable Stop button after stop cooldown expires - if RJ.stopCooldownUntil > 0 and GetTime() >= RJ.stopCooldownUntil then - RJ.stopCooldownUntil = 0 - RefreshPlaybackButtons() - end -end) - ------------------------------------------------------------------------ --- Auto-start on login (disabled — player chooses when to start music) ------------------------------------------------------------------------ - -local loader = CreateFrame("Frame") -loader:RegisterEvent("VARIABLES_LOADED") -loader:SetScript("OnEvent", function() - local imported, updated - - if time then - math.randomseed(time()) - math.random() - math.random() - math.random() - end - - EnsureDB() - imported, updated = MergeAutoPlaylist() - CreateUI() - CreateMiniPlayer() - CreateMinimapButton() - 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 - SetStatus("Imported " .. imported .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) - elseif updated > 0 then - SetStatus("Updated " .. updated .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) - elseif Count(RelationshipsJukeboxDB.tracks) > 0 then - SetStatus("Loaded " .. Count(RelationshipsJukeboxDB.tracks) .. " track(s). Ready.", 0.2, 1, 0.2) - else - SetStatus("No tracks found. Put files in " .. ADDON_MEDIA_PREFIX .. " then run GeneratePlaylist.bat and restart or /reload.", 1, 0.82, 0) - end - - -- No auto-start on login — player chooses when to start music -end) - ------------------------------------------------------------------------ --- Slash commands ------------------------------------------------------------------------ -SLASH_RELJUKEBOX1 = "/rjukebox" -SLASH_RELJUKEBOX2 = "/rj" -SlashCmdList["RELJUKEBOX"] = function(msg) - local command = string.lower(Trim(msg or "")) - - if not RJ.frame then CreateUI() end - - if command == "play" then - StartPlaylist(false) - return - elseif command == "shuffle" then - StartPlaylist(true) - return - elseif command == "stop" then - StopPlayback(false) - return - elseif command == "skip" then - SkipTrack() - return - elseif command == "prev" then - PreviousTrack() - return - elseif command == "pause" then - TogglePause() - return - elseif command == "mini" then - if RJ.miniFrame then - if RJ.miniFrame:IsShown() then - RJ.miniFrame:Hide() - else - RJ.miniFrame:Show() - RefreshMiniPlayer() - end - else - CreateMiniPlayer() - end - return - elseif command == "playlast" then - local total = Count(RelationshipsJukeboxDB.tracks) - if total > 0 then PlayTrack(total, false) end - return - end - - -- No command: toggle full GUI - if RJ.frame:IsShown() then - RJ.frame:Hide() - else - RJ.frame:Show() - RefreshList() - end -end