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