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.trackStartTime = nil -- GetTime() when current track started playing RJ.pausedElapsed = nil -- elapsed seconds saved when paused (for timer display) 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 -- Delayed sound re-enable state: KillAllSounds() sets MasterSoundEffects="0" -- and schedules a re-enable via the ticker's OnUpdate after a short delay. RJ._pendingReenableAt = nil -- GetTime() when MasterSoundEffects should be re-enabled RJ._pendingReenableCallback = nil -- function to call after re-enable (e.g., play new track) -- Fallback duration used when no metadata is available (seconds). local FALLBACK_DURATION = 180 -- Format seconds as M:SS or MM:SS -- Note: WoW 1.12 uses Lua 5.0 which has no '%' operator; use math.mod() instead. local function FormatTime(seconds) if not seconds or seconds < 0 then seconds = 0 end seconds = math.floor(seconds + 0.5) local m = math.floor(seconds / 60) local s = math.mod(seconds, 60) return string.format("%d:%02d", m, s) end -- Format elapsed/total as "M:SS/M:SS" local function FormatElapsedTime(elapsed, total) return FormatTime(elapsed) .. "/" .. FormatTime(total) end 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 ----------------------------------------------------------------------- -- Sound-CVar snapshot / restore. -- -- Every CVar this addon writes to (MasterSoundEffects, EnableMusic, -- MasterVolume, ...) is snapshotted into SavedVariables the FIRST time -- we change it in a session, and restored on PLAYER_LOGOUT. -- -- Why SavedVariables and not just an in-memory table: -- KillAllSounds() sets MasterSoundEffects="0" and re-enables it ~50ms -- later from the ticker. If the player /reloads, logs out, or the -- client crashes inside that window, "0" is what WoW persists to -- Config.wtf and every sound effect is muted on the next login with -- no obvious cause. Persisting the pre-mute value lets us recover -- on the very next VARIABLES_LOADED even after a hard crash. ----------------------------------------------------------------------- local function SnapshotCVar(name) if type(RelationshipsJukeboxDB) ~= "table" then RelationshipsJukeboxDB = {} end if type(RelationshipsJukeboxDB.savedCVars) ~= "table" then RelationshipsJukeboxDB.savedCVars = {} end if RelationshipsJukeboxDB.savedCVars[name] ~= nil then return -- already snapshotted this session (or from a previous crashed session) end local cv = GetCVar or function() return nil end local ok, val = pcall(cv, name) if ok and val ~= nil then RelationshipsJukeboxDB.savedCVars[name] = tostring(val) end end local function RestoreSavedCVars() if type(RelationshipsJukeboxDB) ~= "table" then return end local saved = RelationshipsJukeboxDB.savedCVars if type(saved) ~= "table" then return end local sc = SetCVar or function() end for name, value in pairs(saved) do pcall(sc, name, value) end RelationshipsJukeboxDB.savedCVars = nil 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 ----------------------------------------------------------------------- -- AUDIO CHANNEL STRATEGY (revised for Turtle WoW / OctoWoW) ----------------------------------------------------------------------- -- WoW 1.12 uses FMOD 3.x which has multiple independent audio channels. -- -- KEY INSIGHT: The Music channel (used by PlayMusic and zone ambient -- music) shares the same FMOD channel. Zone music events REPLACE -- whatever is playing on the Music channel, which restarts jukebox -- tracks. The addon disables EnableMusic on load to prevent zone music -- from overlapping with jukebox playback. -- -- PlaySoundFile(path, "Ambience") routes through the Ambience channel -- on Turtle WoW / OctoWoW, which is separate from both the Music and -- SFX channels. The jukebox volume is controlled by the MasterVolume -- CVar (0.0-1.0), which the in-game Options screen exposes as the -- "Master Volume" slider. The addon provides its own volume slider on -- the main GUI that sets MasterVolume directly, controlling the master -- output volume for all game audio including the jukebox. -- -- Zone changes and loading screens do NOT interrupt sounds on the -- Ambience channel, so no event handler is needed for them. -- -- To STOP a playing track, we use KillAllSounds() which sets -- MasterSoundEffects="0" to kill ALL currently playing sounds, then -- re-enables it on the next frame via the ticker. FMOD needs at least -- one frame to process the "off" state before we can safely re-enable. -- The new PlaySoundFile call that follows (after the re-enable callback) -- then starts fresh. -- -- STOP / SKIP / PREVIOUS: -- • KillAllSounds() sets MasterSoundEffects="0" to kill the current -- sound, then re-enables on the next frame. This creates a -- ~1 frame silence but reliably stops whatever is playing. -- The subsequent PlaySoundFile starts the new track cleanly. -- -- PAUSE: -- • Pause saves elapsed time and stops the track via -- KillAllSounds() (same disable/re-enable mechanism). -- • Resume replays the track from the beginning (PlaySoundFile -- has no seek/offset API). The track restarts, but pause/ -- resume functionality is preserved. -- -- ZONE MUSIC SUPPRESSION: -- • The jukebox now plays through its own sound channel, so zone -- ambient music events no longer interfere with jukebox playback. -- We still set EnableMusic="0" while the jukebox is playing to -- prevent the game engine from playing zone ambient music on the -- Music channel. -- -- KEY BENEFIT: KillAllSounds() / MasterSoundEffects toggle reliably stops ANY -- playing sound regardless of channel, so Stop/Skip/Previous -- always work. -- Check whether a file path uses a format that previously required -- PlayMusic (WAV/OGG). Now ALL tracks use PlaySoundFile regardless -- of format, so this function is only used for format identification, -- not for channel routing decisions. local function IsMusicPath(path) local lower = string.lower(path or "") return string.find(lower, "%.wav$") ~= nil or string.find(lower, "%.ogg$") ~= nil end -- Check whether a file path is an MP3 file. Now ALL tracks use -- PlaySoundFile regardless of format. This function is kept for -- format identification only, not for channel routing decisions. local function IsSfxPath(path) local lower = string.lower(path or "") return string.find(lower, "%.mp3$") ~= nil end ----------------------------------------------------------------------- -- KillAllSounds: Disable all sound via CVar, schedule re-enable next frame ----------------------------------------------------------------------- -- FMOD 3.x needs at least one frame to process the "off" state before -- we can safely re-enable. If we toggle off/on in the same frame, -- FMOD never actually stops the audio. This function: -- 1. Sets MasterSoundEffects="0" to immediately kill ALL playing sounds -- 2. Schedules a re-enable via the ticker's OnUpdate (next frame) -- 3. Also calls StopMusic() as a fallback -- -- The caller can optionally pass a callback to invoke after re-enable -- (e.g., to play a new track immediately after the old one is killed). local function KillAllSounds(afterReenableCallback) local sc = SetCVar or function() end -- Snapshot MasterSoundEffects BEFORE muting, so if the player /reloads -- or crashes before the ticker re-enables it, we can restore it on -- the next login instead of leaving Config.wtf with SFX muted. SnapshotCVar("MasterSoundEffects") -- Kill ALL currently playing sounds by disabling the master sound CVar. pcall(sc, "MasterSoundEffects", "0") -- Also stop any PlayMusic track as a fallback. if StopMusic then StopMusic() end -- Schedule re-enable on the next frame. FMOD needs at least one -- frame to process the "off" before we turn sound back on. -- If a callback was already pending, preserve it unless a new one -- is provided (the new callback supersedes the old one). RJ._pendingReenableAt = GetTime() + 0.05 -- ~50ms delay (1-2 frames at 30fps) if afterReenableCallback then RJ._pendingReenableCallback = afterReenableCallback end end -- Stop the jukebox track. -- PlaySoundFile is fire-and-forget, so StopMusic() does NOT stop it. -- Instead, we use KillAllSounds() which disables MasterSoundEffects (killing -- ALL playing sounds) and re-enables it on the next frame. A safety -- StopMusic() call is also included inside KillAllSounds as a fallback. local function StopJukeboxTrack() KillAllSounds() end ----------------------------------------------------------------------- -- Forward declarations (mutual recursion: PlayNextQueuedTrack <-> PlayTrack) ----------------------------------------------------------------------- local PlayTrack local PlayNextQueuedTrack ----------------------------------------------------------------------- -- Playback button state (forward-declared so PausePlayback/ResumePlayback can call it) ----------------------------------------------------------------------- local RefreshPlaybackButtons ----------------------------------------------------------------------- -- Zone music control (forward-declared so PausePlayback/ResumePlayback/ -- StopPlayback/PlayTrack can call them before their definitions) ----------------------------------------------------------------------- ----------------------------------------------------------------------- -- Format detection helpers -- These identify file formats (MP3 vs WAV/OGG) for display purposes. -- ALL tracks use PlaySoundFile regardless of format, so these are -- only for format identification, not channel routing. ----------------------------------------------------------------------- ----------------------------------------------------------------------- -- 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 -- Save elapsed time for timer display while paused if RJ.trackStartTime then RJ.pausedElapsed = GetTime() - RJ.trackStartTime else RJ.pausedElapsed = nil end RJ.pollingForEnd = false -- Restore zone music CVar (note: since the addon disables EnableMusic -- on load, this will restore it to "0" -- game music stays off even -- while paused, which is the intended behavior). RestoreZoneMusic() -- Stop the jukebox track. StopJukeboxTrack() -- 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 -- Ensure all sound is enabled so PlaySoundFile works. local sc = SetCVar or function() end pcall(sc, "MasterSoundEffects", "1") -- ALL tracks use PlaySoundFile(path, "Ambience"). if PlaySoundFile then PlaySoundFile(RJ.currentPath, "Ambience") RJ.currentCanStop = true end -- Suppress zone ambient music on the Music channel. SuppressZoneMusic() -- 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 -- Adjust trackStartTime so elapsed timer continues from where it was if RJ.pausedElapsed then RJ.trackStartTime = GetTime() - RJ.pausedElapsed RJ.pausedElapsed = nil else RJ.trackStartTime = GetTime() 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.trackStartTime = nil RJ.pausedElapsed = nil RJ.currentDuration = 0 RJ.pollingForEnd = false RJ.pollTimer = 0 RJ.consecutiveNotPlaying = 0 -- Restore zone music CVar (note: since the addon disables EnableMusic -- on load, this will restore it to "0" -- game music stays off even -- when stopped, matching the intended behavior). RestoreZoneMusic() -- Stop the jukebox track. StopJukeboxTrack() 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 ----------------------------------------------------------------------- -- IsMusicPath and IsSfxPath are defined above (before PausePlayback/ -- ResumePlayback) so those functions can reference them. See the -- comments there for the full channel-routing strategy. -- 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 jukebox track. if RJ.currentPath then -- KillAllSounds disables MasterSoundEffects and re-enables it on the -- next frame. We must defer playing the new track until after -- the re-enable, so we pass the play logic as a callback. RJ.isPaused = false RJ.pausedRemaining = nil RJ.currentCanStop = false RJ.pollingForEnd = false RJ.pollTimer = 0 RJ.consecutiveNotPlaying = 0 -- Define what to do after sound is re-enabled: play the new track. local playAfterReenable = function() -- Ensure all sound is enabled so PlaySoundFile works. local sc2 = SetCVar or function() end pcall(sc2, "MasterSoundEffects", "1") if PlaySoundFile then PlaySoundFile(path, "Ambience") ok = 1 RJ.currentCanStop = true -- PlaySoundFile track else ok = nil RJ.currentCanStop = false end if ok then -- Suppress zone ambient music on the Music channel. SuppressZoneMusic() -- 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 RJ.trackStartTime = GetTime() if fromQueue then local queueLen = Count(RJ.playQueue or {}) local posLabel = RJ.playQueuePosition .. "/" .. queueLen if duration and duration > 0 then RJ.trackEndTime = GetTime() + duration SetStatus("Playing " .. posLabel .. ": " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) elseif RJ.currentCanStop and IsPlaying then 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 RJ.trackEndTime = GetTime() + FALLBACK_DURATION RJ.pollingForEnd = false SetStatus("Playing " .. posLabel .. ": " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) end else -- Single track play (not from queue) if duration and duration > 0 then RJ.trackEndTime = GetTime() + duration SetStatus("Playing: " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) elseif RJ.currentCanStop and IsPlaying then RJ.trackEndTime = nil RJ.pollingForEnd = true RJ.pollTimer = GetTime() + RJ.pollStartDelay RJ.consecutiveNotPlaying = 0 SetStatus("Playing: " .. name, 0.2, 1, 0.2) else RJ.trackEndTime = GetTime() + FALLBACK_DURATION RJ.pollingForEnd = false SetStatus("Playing: " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) end end RefreshPlaybackButtons() RefreshMiniPlayer() else SetStatus("Playback error for: " .. (name or "?"), 1, 0.2, 0.2) end end KillAllSounds(playAfterReenable) return ok end RJ.isPaused = false RJ.pausedRemaining = nil RJ.currentCanStop = false RJ.pollingForEnd = false RJ.pollTimer = 0 RJ.consecutiveNotPlaying = 0 -- Play the track using PlaySoundFile(path, "Ambience"). -- ALL tracks use PlaySoundFile for playback. To stop a playing -- track, use KillAllSounds() which disables MasterSoundEffects and -- re-enables it on the next frame. -- Ensure all sound is enabled so PlaySoundFile works. -- Zone events or other addons may have disabled it. local sc = SetCVar or function() end pcall(sc, "MasterSoundEffects", "1") if PlaySoundFile then PlaySoundFile(path, "Ambience") ok = 1 RJ.currentCanStop = true -- PlaySoundFile track else ok = nil RJ.currentCanStop = false end if ok then -- Suppress zone ambient music on the Music channel. SuppressZoneMusic() -- 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 RJ.trackStartTime = GetTime() 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 .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) elseif RJ.currentCanStop and IsPlaying then -- PlaySoundFile 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 -- PlaySoundFile track with no duration and no IsPlaying: use fallback RJ.trackEndTime = GetTime() + FALLBACK_DURATION RJ.pollingForEnd = false SetStatus("Playing " .. posLabel .. ": " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) end else -- Single track play (not from queue) if duration and duration > 0 then RJ.trackEndTime = GetTime() + duration SetStatus("Playing: " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) elseif RJ.currentCanStop and IsPlaying then RJ.trackEndTime = nil RJ.pollingForEnd = true RJ.pollTimer = GetTime() + RJ.pollStartDelay RJ.consecutiveNotPlaying = 0 SetStatus("Playing: " .. name, 0.2, 1, 0.2) else RJ.trackEndTime = GetTime() + FALLBACK_DURATION RJ.pollingForEnd = false SetStatus("Playing: " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 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 -- Stop the jukebox track before advancing. Use KillAllSounds -- with a callback so the next track plays AFTER sound is re-enabled. RJ.isPaused = false RJ.pausedRemaining = nil RJ.currentCanStop = false RJ.trackEndTime = nil RJ.pollingForEnd = false KillAllSounds(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 -- Stop the jukebox track before going back. Use KillAllSounds -- with a callback so the next track plays AFTER sound is re-enabled. RJ.isPaused = false RJ.pausedRemaining = nil RJ.currentCanStop = false RJ.trackEndTime = nil RJ.pollingForEnd = false KillAllSounds(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") local subtitle = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") subtitle:SetPoint("TOP", title, "BOTTOM", 0, -18) subtitle:SetText("Put Your Sound Files In The \"RelationshipsJukeBox\\Media\" Folder Then Run \"GeneratePlaylist.bat\"") 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) + Timer local playbackY = 108 -- playback controls row offset from bottom local pageY = 54 -- 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 — moved down to replace slash text area) 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) -- Volume slider (between Next button and timer, page navigation row) -- Controls the master volume via the "MasterVolume" CVar. -- This affects all game audio including the jukebox. -- The entire block is wrapped in pcall so that if *any* part of slider -- creation fails (missing API, texture, etc.) the rest of CreateUI() -- still runs — timer, reset button, mini player, minimap icon, etc. pcall(function() local sliderFrame = CreateFrame("Frame", nil, f) sliderFrame:SetWidth(120) sliderFrame:SetHeight(17) sliderFrame:SetPoint("LEFT", nextBtn, "RIGHT", 14, 0) if sliderFrame.SetBackdrop then sliderFrame:SetBackdrop({ bgFile = "Interface\\Buttons\\UI-SliderBar-Background", edgeFile = "Interface\\Buttons\\UI-SliderBar-Border", tile = true, tileSize = 8, edgeSize = 8, insets = { left = 3, right = 3, top = 6, bottom = 6 } }) end local slider = CreateFrame("Slider", nil, sliderFrame) RJ.volumeSlider = slider slider:SetAllPoints(sliderFrame) slider:SetOrientation("HORIZONTAL") slider:SetMinMaxValues(0, 100) slider:SetValueStep(5) -- SetObeyStepOnDrag may not exist in 1.12; guard it if slider.SetObeyStepOnDrag then slider:SetObeyStepOnDrag(true) end slider:EnableMouse(true) -- Thumb texture (the draggable knob) slider:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Horizontal") -- Label above the slider local sliderLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") sliderLabel:SetPoint("BOTTOM", sliderFrame, "TOP", 0, 0) sliderLabel:SetText("Vol") -- Low / High labels below the slider local sliderLow = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") sliderLow:SetPoint("TOPLEFT", sliderFrame, "BOTTOMLEFT", 2, 3) sliderLow:SetText("0") local sliderHigh = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") sliderHigh:SetPoint("TOPRIGHT", sliderFrame, "BOTTOMRIGHT", -2, 3) sliderHigh:SetText("100") -- Read initial volume from SavedVariables (falls back to current CVar) local cv = GetCVar or function() return "1" end local ok, val = pcall(cv, "MasterVolume") local initial = RelationshipsJukeboxDB.volume if not initial then initial = (ok and val) and math.floor(tonumber(val) * 100 + 0.5) or 100 end slider:SetValue(initial) slider:SetScript("OnValueChanged", function() local pct = this:GetValue() local sc = SetCVar or function() end -- Snapshot MasterVolume once so we can restore the player's -- original master output level on logout. SnapshotCVar("MasterVolume") -- MasterVolume is 0..1, slider is 0..100 pcall(sc, "MasterVolume", string.format("%.2f", pct / 100)) -- Persist slider position (not applied at login — see VARIABLES_LOADED) RelationshipsJukeboxDB.volume = pct end) end) -- Timer display (right side of page navigation row) RJ.timerText = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") RJ.timerText:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -24, pageY) RJ.timerText:SetJustifyH("RIGHT") RJ.timerText:SetTextColor(0.8, 0.8, 0.8) RJ.timerText:SetText("") -- Reset button (bottom right, just under the timer display) -- Resets the addon to default: stops playback, restores music CVar, -- resets frame position, and re-initializes the database. local resetBtn = MakeButton(nil, f, 60, 20, "Reset") resetBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -24, 33) resetBtn:SetScript("OnClick", function() -- Stop any playing track and restore full audio if RJ.currentPath or RJ.autoPlayEnabled then RestoreZoneMusic() StopJukeboxTrack() end -- Cancel any pending sound re-enable and immediately -- re-enable all sound so the player can hear game audio. -- KillAllSounds() sets MasterSoundEffects="0" and schedules a -- re-enable, but the re-enable callback is cancelled below. -- Without this, the player would be left with no sound at all. RJ._pendingReenableAt = nil RJ._pendingReenableCallback = nil do local sc = SetCVar or function() end pcall(sc, "MasterSoundEffects", "1") pcall(sc, "EnableMusic", "1") end RJ._originalEnableMusic = nil RJ._savedEnableMusic = nil -- Reset all internal state 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.shuffleMode = false RJ.trackEndTime = nil RJ.trackStartTime = nil RJ.pausedElapsed = nil RJ.currentDuration = 0 RJ.pollingForEnd = false RJ.pollTimer = 0 RJ.consecutiveNotPlaying = 0 RJ.currentCanStop = false RJ.currentIndex = nil RJ.currentPath = nil RJ.currentName = nil -- Reset frame position to center of screen f:ClearAllPoints() f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) -- Reset mini player position and show it if RJ.miniFrame then RJ.miniFrame:ClearAllPoints() RJ.miniFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 300, -300) RJ.miniFrame:Show() else CreateMiniPlayer() if RJ.miniFrame then RJ.miniFrame:Show() end end -- Reset minimap icon to center of screen if RJ.minimapButton then RJ.minimapButton:ClearAllPoints() RJ.minimapButton:SetPoint("CENTER", UIParent, "CENTER", 0, 0) RelationshipsJukeboxDB.iconX = nil RelationshipsJukeboxDB.iconY = nil end -- Reset volume slider to default (100%) do local sc = SetCVar or function() end SnapshotCVar("MasterVolume") pcall(sc, "MasterVolume", "1.0") RelationshipsJukeboxDB.volume = 100 if RJ.volumeSlider then RJ.volumeSlider:SetValue(100) end end -- Re-initialize database EnsureDB() -- Reset page RJ.page = 1 RJ.searchFilter = "" RJ.filteredIndices = nil RefreshList() RefreshPlaybackButtons() RefreshMiniPlayer() SetStatus("Addon reset to defaults. All sound re-enabled.", 0.2, 1, 0.2) end) -- 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(68) 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, -12) mf.trackClip:SetPoint("BOTTOMRIGHT", mf, "TOPRIGHT", -8, -30) 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) -- Timer display (between track name and control buttons) mf.timerText = mf:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") mf.timerText:SetPoint("TOPLEFT", mf, "TOPLEFT", 4, -29) mf.timerText:SetPoint("TOPRIGHT", mf, "TOPRIGHT", -8, -29) mf.timerText:SetJustifyH("RIGHT") mf.timerText:SetTextColor(0.8, 0.8, 0.8) mf.timerText:SetText("") -- 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 pcall(function() -- Parent to UIParent so the button can be dragged anywhere on screen, -- not just pinned to the minimap. local btn = CreateFrame("Button", ADDON_NAME .. "MinimapButton", UIParent) RJ.minimapButton = btn btn:SetWidth(32) btn:SetHeight(32) -- Use the built-in paper/note icon (guaranteed to exist in 1.12). btn:SetNormalTexture("Interface\\Icons\\INV_Misc_Note_06") btn:SetPushedTexture("Interface\\Icons\\INV_Misc_Note_06") btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") -- Position: use saved coordinates, or default to a visible spot. local savedX = RelationshipsJukeboxDB.iconX local savedY = RelationshipsJukeboxDB.iconY if savedX and savedY then btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", savedX, -savedY) else -- Default position: top-right area of screen, guaranteed visible local screenWidth = UIParent:GetWidth() or 1024 btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", screenWidth - 80, -30) end -- 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) -- Free dragging: the button can be moved anywhere on screen. -- Save position to SavedVariables on drag stop. btn:SetMovable(true) btn:RegisterForDrag("LeftButton") btn:SetScript("OnDragStart", function() btn:StartMoving() end) btn:SetScript("OnDragStop", function() btn:StopMovingOrSizing() -- Save position to SavedVariables (store positive offset from top-left) local left = btn:GetLeft() or 0 local top = btn:GetTop() or 0 local screenTop = (UIParent:GetTop() or 768) RelationshipsJukeboxDB.iconX = left RelationshipsJukeboxDB.iconY = screenTop - top end) btn:Show() end) end ----------------------------------------------------------------------- -- Zone ambient music suppression ----------------------------------------------------------------------- -- The jukebox now plays through its own sound channel, so zone ambient -- music events no longer interfere with jukebox playback. However, we -- still disable EnableMusic while the jukebox is playing to prevent the -- game engine from playing zone ambient music over the jukebox track on -- the Music channel. When the jukebox stops or pauses, RestoreZoneMusic() -- restores the player's original EnableMusic setting. -- Saved state for EnableMusic CVar restoration RJ._savedEnableMusic = nil -- original EnableMusic value before jukebox took over RJ._originalEnableMusic = nil -- player's EnableMusic value before addon loaded (for Reset) -- Suppress zone ambient music when the jukebox is playing. -- Saves the original EnableMusic value and sets it to "0" so the -- engine does not start zone ambient music on the Music channel. SuppressZoneMusic = function() local cv = GetCVar or function() return "1" end local sc = SetCVar or function() end local ok, val = pcall(cv, "EnableMusic") RJ._savedEnableMusic = (ok and val) or "1" -- Set EnableMusic="0" to suppress zone ambient music. pcall(sc, "EnableMusic", "0") end -- Restore zone ambient music when the jukebox stops or pauses. -- Restores the EnableMusic value saved by SuppressZoneMusic(), -- allowing zone ambient music to resume normally. RestoreZoneMusic = function() local sc = SetCVar or function() end if RJ._savedEnableMusic then pcall(sc, "EnableMusic", RJ._savedEnableMusic) RJ._savedEnableMusic = nil end end -- Loading screen / zone change handler: NOT NEEDED. -- Since the jukebox now uses its own sound channel ("Ambience"), loading -- screens and zone changes do NOT interrupt jukebox playback. The track -- simply continues playing through them. No event handler is required. -- (Previously FMOD was thought to rebuild on PLAYER_ENTERING_WORLD and -- kill all sounds, but on the separate channel the track persists.) ----------------------------------------------------------------------- -- Logout handler: stop jukebox music when the player logs out ----------------------------------------------------------------------- local logoutHandler = CreateFrame("Frame") logoutHandler:RegisterEvent("PLAYER_LOGOUT") logoutHandler:SetScript("OnEvent", function() -- Stop the jukebox track and restore the player's normal audio -- settings so that zone ambient music / SFX / ambience resume -- on the next login or character switch. if RJ.currentPath or RJ.autoPlayEnabled then RestoreZoneMusic() -- Stop the jukebox track. StopJukeboxTrack() RJ.autoPlayEnabled = false RJ.currentPath = nil RJ.currentName = nil RJ.currentIndex = nil RJ.isPaused = false RJ.pausedRemaining = nil RJ.currentCanStop = false RJ.pollingForEnd = false RJ.trackEndTime = nil RJ.trackStartTime = nil RJ.pausedElapsed = nil RJ.currentDuration = 0 end -- Suppress the pending MasterSoundEffects re-enable — we're about to -- restore it explicitly from the snapshot below, and the ticker will -- not fire again this session. RJ._pendingReenableAt = nil RJ._pendingReenableCallback = nil -- Clear legacy in-memory copy (superseded by SavedVariables snapshot). RJ._originalEnableMusic = nil -- Restore every sound CVar the addon ever changed this session -- (EnableMusic, MasterSoundEffects, MasterVolume, ...) back to the -- value the player had before the addon loaded, and clear the -- snapshot so the next login starts clean. RestoreSavedCVars() end) ----------------------------------------------------------------------- -- Main ticker: handles timed advancement and IsPlaying polling ----------------------------------------------------------------------- local ticker = CreateFrame("Frame") ticker:SetScript("OnUpdate", function() -- -1) Pending MasterSoundEffects re-enable: if KillAllSounds() disabled all -- sound, we need to re-enable it after a short delay so FMOD has time -- to actually stop the audio before we turn the master CVar back on. if RJ._pendingReenableAt and GetTime() >= RJ._pendingReenableAt then local sc = SetCVar or function() end pcall(sc, "MasterSoundEffects", "1") RJ._pendingReenableAt = nil -- If a callback was registered (e.g., play new track after killing old one), -- invoke it now that sound is re-enabled. if RJ._pendingReenableCallback then local cb = RJ._pendingReenableCallback RJ._pendingReenableCallback = nil cb() end end -- 0) Loading screen resume: NOT NEEDED. The jukebox plays on its own -- sound channel and continues through loading screens uninterrupted. -- No re-assert is required. -- 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 RJ.currentCanStop = false RJ.pollingForEnd = false KillAllSounds(PlayNextQueuedTrack) else -- Single track mode: stop playback when track finishes StopPlayback(true) SetStatus("Track finished.", 0.2, 1, 0.2) end return end -- 2) IsPlaying polling: detect when PlaySoundFile track finishes for tracks with no duration if 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.autoPlayEnabled then -- Queue mode: advance to next track RJ.currentCanStop = false KillAllSounds(PlayNextQueuedTrack) else -- Single track mode: stop playback when track finishes StopPlayback(true) SetStatus("Track finished.", 0.2, 1, 0.2) end 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 -- 8) Update timer displays (main GUI + mini player) -- Shows elapsed/total in M:SS/M:SS format while a track is playing. -- While paused, the elapsed time freezes at the point of pause. if RJ.currentPath and RJ.trackStartTime then local elapsed if RJ.isPaused then elapsed = RJ.pausedElapsed or 0 else elapsed = GetTime() - RJ.trackStartTime end local total = RJ.currentDuration or 0 local timerStr if total > 0 then timerStr = FormatElapsedTime(elapsed, total) else timerStr = FormatTime(elapsed) end -- Main GUI timer if RJ.timerText then RJ.timerText:SetText(timerStr) end -- Mini player timer if RJ.miniFrame and RJ.miniFrame.timerText then RJ.miniFrame.timerText:SetText(timerStr) end else -- No track playing - clear timers if RJ.timerText then RJ.timerText:SetText("") end if RJ.miniFrame and RJ.miniFrame.timerText then RJ.miniFrame.timerText:SetText("") end 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() -- CRASH-RECOVERY: if a previous session ended (logout, /reload, or crash) -- without our PLAYER_LOGOUT handler firing — for example inside the ~50ms -- window where KillAllSounds() has MasterSoundEffects="0" — restore the -- pre-mute values from SavedVariables FIRST, before we do anything else. -- This guarantees the player logs in with their real sound settings even -- after a hard crash mid-mute. RestoreSavedCVars() -- Save the player's original EnableMusic value and disable game engine -- music so it does not overlap with jukebox playback. Restored on -- PLAYER_LOGOUT via RestoreSavedCVars() (and on Reset). -- -- IMPORTANT: We deliberately do NOT touch MasterVolume here. MasterVolume -- is the global master output CVar and controls ALL game audio (SFX, -- ambience, jukebox, everything). A previous version applied the saved -- jukebox slider value to MasterVolume at login, which muted every game -- sound whenever the slider had been dragged down. The only thing that -- should be silenced when this addon loads is the in-game music ("the -- music box"), which is what EnableMusic="0" does. do local cv = GetCVar or function() return "1" end local sc = SetCVar or function() end SnapshotCVar("EnableMusic") local ok, val = pcall(cv, "EnableMusic") RJ._originalEnableMusic = (ok and val) or "1" pcall(sc, "EnableMusic", "0") end 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