Files
RelationshipsJukeBox/Core.lua
T
2026-07-05 08:26:06 +01:00

1444 lines
45 KiB
Lua

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
-- 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(24)
if row.SetBackdrop then
row:SetBackdrop({
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 8, edgeSize = 8,
insets = { left = 2, right = 2, top = 2, bottom = 2 }
})
row:SetBackdropColor(0, 0, 0, 0.35)
end
row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
row.label:SetPoint("LEFT", row, "LEFT", 8, 0)
row.label:SetWidth(500)
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 }
})
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, 0)
else
row:SetPoint("TOPLEFT", RJ.rows[i-1], "BOTTOMLEFT", 0, -4)
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()
-- Track name label (shows pause indicator in text since no pause button)
if RJ.currentName and RJ.currentName ~= "" then
if RJ.isPaused then
mf.trackLabel:SetText("|| " .. RJ.currentName)
else
mf.trackLabel:SetText("> " .. RJ.currentName)
end
else
mf.trackLabel:SetText("Relationships Jukebox")
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(176)
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)
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)
mf.trackLabel = mf:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
mf.trackLabel:SetPoint("TOPLEFT", mf, "TOPLEFT", 8, -6)
mf.trackLabel:SetPoint("RIGHT", mf, "RIGHT", -24, 0)
mf.trackLabel:SetJustifyH("LEFT")
mf.trackLabel:SetTextColor(0.4, 1, 0.4)
mf.trackLabel:SetText("Relationships Jukebox")
-- Minimize button (top-right corner)
mf.minBtn = MakeButton(nil, mf, 20, 18, "_")
mf.minBtn:SetPoint("TOPRIGHT", mf, "TOPRIGHT", -4, -2)
mf.minBtn:SetScript("OnClick", function()
mf:Hide()
if RJ.minimapButton then
RJ.minimapButton:Show()
end
end)
-- Control buttons (bottom row)
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 (far right)
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)
mf:Show()
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