Files
RelationshipsJukeBox/Core.lua
T
2026-07-06 06:49:50 +01:00

2180 lines
73 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.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
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
-- 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
-- MasterVolume is 0..1, slider is 0..100
pcall(sc, "MasterVolume", string.format("%.2f", pct / 100))
-- Persist to SavedVariables
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
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
-- Always restore the player's original EnableMusic on logout,
-- even if no track was playing.
if RJ._originalEnableMusic then
local sc = SetCVar or function() end
pcall(sc, "EnableMusic", RJ._originalEnableMusic)
RJ._originalEnableMusic = nil
end
-- Suppress any remaining pending state
RJ._pendingReenableAt = nil
RJ._pendingReenableCallback = nil
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()
-- Save the player's original EnableMusic value and disable game engine
-- music so it does not overlap with jukebox playback. This is restored
-- on logout or when the Reset button is pressed.
do
local cv = GetCVar or function() return "1" end
local sc = SetCVar or function() end
local ok, val = pcall(cv, "EnableMusic")
RJ._originalEnableMusic = (ok and val) or "1"
pcall(sc, "EnableMusic", "0")
-- Apply saved MasterVolume (master output volume). If the
-- player has never set it, the CVar default (1.0) remains.
if RelationshipsJukeboxDB.volume then
pcall(sc, "MasterVolume",
string.format("%.2f", RelationshipsJukeboxDB.volume / 100))
end
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