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