diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..da38d41 --- /dev/null +++ b/Core.lua @@ -0,0 +1,1638 @@ +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 diff --git a/GeneratePlaylist.bat b/GeneratePlaylist.bat new file mode 100644 index 0000000..c456957 --- /dev/null +++ b/GeneratePlaylist.bat @@ -0,0 +1,122 @@ +@echo off +setlocal + +REM -- Strip trailing backslash from %~dp0 to avoid "Illegal characters in path" -- +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" + +REM -- If a local ffmpeg folder exists inside the addon, prepend it to PATH -- +REM This ensures the PS1 script's Find-Ffmpeg can discover it even if +REM it isn't on the system PATH yet. +if exist "%ROOT%\ffmpeg\ffmpeg.exe" ( + set "PATH=%ROOT%\ffmpeg;%PATH%" +) + +REM -- Check for -UseWinRAR flag -- +set "PS_ARGS=-NoProfile -ExecutionPolicy Bypass -File "%ROOT%\GeneratePlaylist.ps1" -RootPath "%ROOT%"" +if "%1"=="-UseWinRAR" ( + set "PS_ARGS=-NoProfile -ExecutionPolicy Bypass -File "%ROOT%\GeneratePlaylist.ps1" -RootPath "%ROOT%" -UseWinRAR" +) +if "%1"=="-usewinrar" ( + set "PS_ARGS=-NoProfile -ExecutionPolicy Bypass -File "%ROOT%\GeneratePlaylist.ps1" -RootPath "%ROOT%" -UseWinRAR" +) + +powershell %PS_ARGS% +set "ERR=%ERRORLEVEL%" + +if not "%ERR%"=="0" ( + echo. + echo GeneratePlaylist failed with exit code %ERR%. + echo. + echo Trying fallback download method... + echo. + + REM -- Fallback: Use bitsadmin or certutil to download ffmpeg if needed -- + REM -- This provides at least some progress output when PowerShell fails -- + + set "FFMPEG_DIR=%ROOT%\ffmpeg" + if not exist "%FFMPEG_DIR%\ffmpeg.exe" ( + echo ffmpeg not found. Attempting fallback download... + echo. + + set "DL_DIR=%ROOT%\ffmpeg_download" + if not exist "%DL_DIR%" mkdir "%DL_DIR%" + + REM -- Try bitsadmin (available on most Windows systems) -- + where bitsadmin >nul 2>nul + if %ERRORLEVEL%==0 ( + echo Using bitsadmin to download ffmpeg... + echo This may take several minutes depending on your connection. + echo. + bitsadmin /transfer "ffmpeg_download" /priority normal "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" "%DL_DIR%\ffmpeg.zip" + if exist "%DL_DIR%\ffmpeg.zip" ( + echo. + echo Download complete. Extracting ffmpeg.zip with PowerShell... + powershell -NoProfile -Command "Expand-Archive -LiteralPath '%DL_DIR%\ffmpeg.zip' -DestinationPath '%DL_DIR%\extracted' -Force; $bin = Get-ChildItem '%DL_DIR%\extracted' -Recurse -Directory -Filter 'bin' | Select-Object -First 1; if ($bin) { if (-not (Test-Path '%FFMPEG_DIR%')) { New-Item -ItemType Directory -Path '%FFMPEG_DIR%' -Force | Out-Null }; Copy-Item (Join-Path $bin.FullName 'ffmpeg.exe') '%FFMPEG_DIR%\' -Force -ErrorAction SilentlyContinue; Copy-Item (Join-Path $bin.FullName 'ffprobe.exe') '%FFMPEG_DIR%\' -Force -ErrorAction SilentlyContinue }; Remove-Item '%DL_DIR%\extracted' -Recurse -Force -ErrorAction SilentlyContinue; Remove-Item '%DL_DIR%\ffmpeg.zip' -Force -ErrorAction SilentlyContinue" + if exist "%FFMPEG_DIR%\ffmpeg.exe" ( + echo ffmpeg installed successfully via fallback. + ) else ( + echo WARNING: Fallback extraction may not have completed successfully. + ) + ) else ( + echo bitsadmin download failed. + ) + ) else ( + echo bitsadmin not available on this system. + echo. + echo Please install ffmpeg manually: + echo 1. Download from https://www.gyan.dev/ffmpeg/builds/ + echo 2. Extract ffmpeg.exe and ffprobe.exe + echo 3. Place them in: %FFMPEG_DIR% + ) + ) + + echo. + echo Trying to generate playlist without PowerShell... + echo. + + REM -- Minimal fallback: just scan Media folder and write AutoPlaylist.lua -- + REM -- This works without ffmpeg but uses estimated durations -- + if exist "%ROOT%\Media" ( + echo Scanning Media folder for audio files... + set "OUTFILE=%ROOT%\AutoPlaylist.lua" + echo RelationshipsJukeboxAutoPlaylist = {} > "%OUTFILE%" + echo RelationshipsJukeboxAutoPlaylist.tracks = { >> "%OUTFILE%" + for /r "%ROOT%\Media" %%F in (*.mp3 *.wav *.ogg) do ( + setlocal enabledelayedexpansion + REM Get the full path of the file + set "FULLFILE=%%F" + REM Strip the ROOT prefix to get a relative path like \Media\song.mp3 + set "RELPATH=!FULLFILE:%ROOT%=!" + REM Build the WoW-relative path: Interface\AddOns\RelationshipsJukeBox\Media\song.mp3 + REM RELPATH starts with \ (from the remaining path after stripping ROOT) + set "FULLPATH=Interface\AddOns\RelationshipsJukeBox!RELPATH!" + REM Double all backslashes for Lua string escaping (\\ in source = \ in Lua value) + set "FULLPATH=!FULLPATH:\=\\!" + set "BASENAME=%%~nF" + echo { path = "!FULLPATH!", name = "!BASENAME!", duration = 180 }, >> "%OUTFILE%" + endlocal + echo Found: %%~nF + ) + echo } >> "%OUTFILE%" + echo. + echo Playlist generated (using estimated 180s durations). + echo Install ffmpeg for accurate durations and MP3 sanitization. + ) else ( + echo Media folder not found: %ROOT%\Media + ) + + echo. + echo Usage: GeneratePlaylist.bat [-UseWinRAR] + echo -UseWinRAR Prefer WinRAR over 7-Zip for archive extraction + pause + exit /b %ERR% +) + +echo. +echo Playlist entries now include names and durations for Play All / Shuffle. +echo Autoplay and Shuffle work even without metadata - estimated durations are used. +echo. +echo Tip: Run with -UseWinRAR flag to use WinRAR for archive extraction: +echo GeneratePlaylist.bat -UseWinRAR +pause diff --git a/GeneratePlaylist.ps1 b/GeneratePlaylist.ps1 new file mode 100644 index 0000000..7aeffe7 --- /dev/null +++ b/GeneratePlaylist.ps1 @@ -0,0 +1,1692 @@ +param( + [string]$RootPath = '', + [switch]$UseWinRAR = $false +) + +$ErrorActionPreference = 'Continue' + +# --------------------------------------------------------------------------- +# Download with progress bar +# --------------------------------------------------------------------------- +# Uses System.Net.WebClient for chunk-based downloading with a real-time +# console progress bar showing percentage, speed, and ETA. Falls back to +# chunked HttpWebRequest if the WebClient approach fails for any reason. +# --------------------------------------------------------------------------- + +function Download-FileWithProgress { + param( + [Parameter(Mandatory=$true)] + [string]$Url, + + [Parameter(Mandatory=$true)] + [string]$OutFile + ) + + # Ensure TLS 1.2 for modern HTTPS endpoints + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Pre-define format strings as variables to avoid Windows PowerShell 5.1 + # parser errors caused by curly braces inside inline -f expressions. + $fmtSizeMB = ' Downloading {0:N1} MB...' + $fmtBar = ' [{0}] {1,3}% {2:N1}/{3:N1} MB {4:N1} MB/s ETA {5}' + $fmtCarriage = "`r{0}" + $fmtEtaMin = '{0}m {1}s' + $fmtEtaSec = '{0}s' + $fmtUnknownDl = "`r Downloaded {0:N1} MB {1:N1} MB/s " + $fmtUnknownFin = ' Download complete ({0:N1} MB).' + + # Try the chunked WebClient approach with progress bar + $useWebClient = $true + + try { + $webClient = New-Object System.Net.WebClient + + # First, get the file size via a HEAD request so we can show percentage + $totalBytes = 0L + try { + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.Method = 'HEAD' + $req.Timeout = 15000 + $req.AllowAutoRedirect = $true + $resp = $req.GetResponse() + $totalBytes = $resp.ContentLength + $resp.Close() + } catch { + # HEAD not supported or failed -- we'll still download, just without total size + $totalBytes = 0L + } + + if ($totalBytes -gt 0) { + # --- We know the total size: show a percentage progress bar --- + $sizeMB = $totalBytes / 1MB + Write-Host ($fmtSizeMB -f $sizeMB) + + $chunkSize = 256KB + $downloaded = 0L + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + # Open the remote stream + $remoteStream = $webClient.OpenRead($Url) + # Create the local file stream + $fileStream = [System.IO.File]::Create($OutFile) + + $buffer = New-Object byte[] $chunkSize + $lastPct = -1 + + while ($true) { + $read = $remoteStream.Read($buffer, 0, $buffer.Length) + if ($read -le 0) { break } + $fileStream.Write($buffer, 0, $read) + $downloaded += $read + + $pct = [int](($downloaded * 100) / $totalBytes) + if ($pct -ne $lastPct) { + $lastPct = $pct + + # Build the visual progress bar using string multiplication + # NOTE: PowerShell 5.1 does NOT support [char] * [int], so + # we use string constructor instead (String([char], count)). + $barWidth = 30 + $filled = [int]($barWidth * $pct / 100) + $empty = $barWidth - $filled + $blockChar = [string][char]0x2588 # full block + $lightChar = [string][char]0x2591 # light shade + $barStr = ($blockChar * $filled) + ($lightChar * $empty) + + # Calculate speed and ETA + $elapsed = $sw.Elapsed.TotalSeconds + $speedMB = 0 + if ($elapsed -gt 0) { $speedMB = ($downloaded / 1MB) / $elapsed } + $etaSec = 0 + if ($speedMB -gt 0) { $etaSec = (($totalBytes - $downloaded) / 1MB) / $speedMB } + + # Format ETA + $etaStr = '' + if ($etaSec -ge 60) { + $etaMin = [int]($etaSec / 60) + $etaRem = [int]($etaSec % 60) + $etaStr = $fmtEtaMin -f $etaMin, $etaRem + } else { + $etaStr = $fmtEtaSec -f [int]$etaSec + } + + # Downloaded so far in MB + $dlMB = $downloaded / 1MB + $totalMB = $totalBytes / 1MB + + # Write the progress line (overwrites itself using carriage return) + $progressLine = $fmtBar -f $barStr, $pct, $dlMB, $totalMB, $speedMB, $etaStr + Write-Host -NoNewline ($fmtCarriage -f $progressLine) + } + } + + Write-Host '' # newline after the progress bar + + $fileStream.Close() + $remoteStream.Close() + $sw.Stop() + + # Verify file was written + if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) { + Write-Host ' Download complete.' + return $true + } else { + return $false + } + + } else { + # --- Unknown total size: show bytes downloaded with speed --- + Write-Host ' Downloading (unknown size)...' + + $chunkSize = 256KB + $downloaded = 0L + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + $remoteStream = $webClient.OpenRead($Url) + $fileStream = [System.IO.File]::Create($OutFile) + + $buffer = New-Object byte[] $chunkSize + $lastPrint = [DateTime]::MinValue + + while ($true) { + $read = $remoteStream.Read($buffer, 0, $buffer.Length) + if ($read -le 0) { break } + $fileStream.Write($buffer, 0, $read) + $downloaded += $read + + # Update progress every 0.5 seconds + $now = [DateTime]::UtcNow + if (($now - $lastPrint).TotalSeconds -ge 0.5) { + $lastPrint = $now + $elapsed = $sw.Elapsed.TotalSeconds + $speedMB = 0 + if ($elapsed -gt 0) { $speedMB = ($downloaded / 1MB) / $elapsed } + $dlMB = $downloaded / 1MB + Write-Host -NoNewline ($fmtUnknownDl -f $dlMB, $speedMB) + } + } + + Write-Host '' # newline after the progress line + + $fileStream.Close() + $remoteStream.Close() + $sw.Stop() + + if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) { + $finalMB = [Math]::Round($downloaded / 1MB, 1) + Write-Host ($fmtUnknownFin -f $finalMB) + return $true + } else { + return $false + } + } + + } catch { + # WebClient approach failed -- fall back to chunked HttpWebRequest download + Write-Host ' WebClient download failed, falling back to chunked HTTP download...' + Write-Host " Error: $($_.Exception.Message)" + $useWebClient = $false + } + + # Fallback: use manual chunked HTTP download with byte/MB progress display + if (-not $useWebClient) { + try { + Write-Host ' Downloading (fallback mode - showing bytes downloaded)...' + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Use HttpWebRequest directly so we get the response stream and can + # read chunks while showing progress (Invoke-WebRequest has no chunk API) + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.Method = 'GET' + $req.Timeout = 120000 + $req.AllowAutoRedirect = $true + $resp = $req.GetResponse() + + $totalBytes = $resp.ContentLength + $respStream = $resp.GetResponseStream() + $fileStream = [System.IO.File]::Create($OutFile) + + $buffer = New-Object byte[] 65536 + $totalDownloaded = 0L + $sw = [System.Diagnostics.Stopwatch]::StartNew() + $lastPrint = [DateTime]::MinValue + + if ($totalBytes -gt 0) { + # Known size - show percentage-based progress + $sizeMB = $totalBytes / 1MB + Write-Host " File size: $([Math]::Round($sizeMB, 1)) MB" + + $blockChar = [string][char]0x2588 + $lightChar = [string][char]0x2591 + $lastPct = -1 + + while ($true) { + $read = $respStream.Read($buffer, 0, $buffer.Length) + if ($read -le 0) { break } + $fileStream.Write($buffer, 0, $read) + $totalDownloaded += $read + + $pct = [int](($totalDownloaded * 100) / $totalBytes) + if ($pct -ne $lastPct) { + $lastPct = $pct + $barWidth = 30 + $filled = [int]($barWidth * $pct / 100) + $empty = $barWidth - $filled + $barStr = ($blockChar * $filled) + ($lightChar * $empty) + + $elapsed = $sw.Elapsed.TotalSeconds + $speedMB = 0 + if ($elapsed -gt 0) { $speedMB = ($totalDownloaded / 1MB) / $elapsed } + $etaSec = 0 + if ($speedMB -gt 0) { $etaSec = (($totalBytes - $totalDownloaded) / 1MB) / $speedMB } + + $etaStr = '' + if ($etaSec -ge 60) { + $etaStr = '{0}m {1}s' -f [int]($etaSec / 60), [int]($etaSec % 60) + } else { + $etaStr = '{0}s' -f [int]$etaSec + } + + $dlMB = $totalDownloaded / 1MB + $totalMB = $totalBytes / 1MB + $progressLine = ' [{0}] {1,3}% {2:N1}/{3:N1} MB {4:N1} MB/s ETA {5}' -f $barStr, $pct, $dlMB, $totalMB, $speedMB, $etaStr + Write-Host -NoNewline ("`r{0}" -f $progressLine) + } + } + } else { + # Unknown size - show MB/bytes downloaded with speed + while ($true) { + $read = $respStream.Read($buffer, 0, $buffer.Length) + if ($read -le 0) { break } + $fileStream.Write($buffer, 0, $read) + $totalDownloaded += $read + + $now = [DateTime]::UtcNow + if (($now - $lastPrint).TotalSeconds -ge 0.5) { + $lastPrint = $now + $elapsed = $sw.Elapsed.TotalSeconds + $speedMB = 0 + if ($elapsed -gt 0) { $speedMB = ($totalDownloaded / 1MB) / $elapsed } + $dlMB = $totalDownloaded / 1MB + Write-Host -NoNewline ("`r Downloaded {0:N1} MB {1:N1} MB/s " -f $dlMB, $speedMB) + } + } + } + + Write-Host '' # newline after the progress line + + $fileStream.Close() + $respStream.Close() + $resp.Close() + $sw.Stop() + + if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) { + $finalMB = [Math]::Round($totalDownloaded / 1MB, 1) + Write-Host " Download complete ($finalMB MB)." + return $true + } + } catch { + Write-Host " Download failed: $($_.Exception.Message)" + } + } + + return $false +} + +# -- Resolve the addon root directory ---------------------------------------- +# $PSScriptRoot can be empty when invoked via powershell -File from a .bat +if ($RootPath -eq '') { + $RootPath = $PSScriptRoot +} +if ($RootPath -eq '') { + $RootPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +} +if ($RootPath -eq '') { + # Last resort: use the current working directory + $RootPath = (Get-Location -PSProvider FileSystem).ProviderPath +} + +# Strip any trailing slashes or quotes that cause "Illegal characters in path" +$RootPath = $RootPath.TrimEnd('\', '/').Trim('"', "'") + +# Normalize to a full absolute path - use GetFullPath on the cleaned string +$Root = [System.IO.Path]::GetFullPath($RootPath) + +$Media = Join-Path $Root 'Media' +$Out = Join-Path $Root 'AutoPlaylist.lua' + +# WoW reads paths with backslashes; inside Lua strings a single backslash +# must be written as \\ (two characters in the source file). Escape-LuaString +# will double every \ it sees, so we store the prefix with SINGLE backslashes +# here -- after escaping they become \\ in the Lua source, which Lua reads as \. +$AddonPrefix = 'Interface\AddOns\RelationshipsJukeBox\' + +# --------------------------------------------------------------------------- +# Auto-download ffmpeg into the RelationshipsJukeBox folder if not found +# --------------------------------------------------------------------------- +# We look for ffmpeg/ffprobe on PATH first, then in a local "ffmpeg" subfolder +# inside the addon directory. If neither exists, we download a static build +# from gyan.dev (the official ffmpeg.org-linked Windows builds) or from +# BtbN/FFmpeg-Builds on GitHub, extract it, and place ffmpeg.exe + ffprobe.exe +# into a "ffmpeg" folder next to this script. +# +# IMPORTANT: gyan.dev distributes .7z archives (smaller). We need 7-Zip or +# WinRAR to extract them. If neither is available, we fall back to .zip +# archives from gyan.dev or GitHub (BtbN) which PowerShell can extract natively. +# --------------------------------------------------------------------------- + +$FfmpegDir = Join-Path $Root 'ffmpeg' + +function Find-Ffmpeg { + param([string]$Name) + # 1) On PATH already? + $found = Get-Command $Name -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + # 2) In our local ffmpeg subfolder? + $local = Join-Path $FfmpegDir "$Name.exe" + if (Test-Path -LiteralPath $local -PathType Leaf) { return $local } + return $null +} + +function Find-SevenZip { + # 1) On PATH? + $found = Get-Command '7z' -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + # 2) Default install location (7-Zip installs here on 64-bit) + $default64 = Join-Path $env:ProgramFiles '7-Zip\7z.exe' + if (Test-Path -LiteralPath $default64 -PathType Leaf) { return $default64 } + # 3) 32-bit on 64-bit system + $default32 = Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe' + if (Test-Path -LiteralPath $default32 -PathType Leaf) { return $default32 } + return $null +} + +function Find-WinRAR { + # 1) On PATH? + $found = Get-Command 'winrar' -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + # 2) On PATH as rar.exe (command-line variant)? + $foundRar = Get-Command 'rar' -ErrorAction SilentlyContinue + if ($foundRar) { + # rar.exe is typically in the WinRAR install folder alongside WinRAR.exe + $rarDir = Split-Path -Parent $foundRar.Source + $winrarExe = Join-Path $rarDir 'WinRAR.exe' + if (Test-Path -LiteralPath $winrarExe -PathType Leaf) { return $winrarExe } + # unrar.exe can also extract + return $foundRar.Source + } + # 3) On PATH as unrar.exe (extraction-only variant)? + $foundUnrar = Get-Command 'unrar' -ErrorAction SilentlyContinue + if ($foundUnrar) { return $foundUnrar.Source } + # 4) Default 64-bit install location + $default64 = Join-Path $env:ProgramFiles 'WinRAR\WinRAR.exe' + if (Test-Path -LiteralPath $default64 -PathType Leaf) { return $default64 } + # 5) Default 32-bit install on 64-bit system + $default32 = Join-Path ${env:ProgramFiles(x86)} 'WinRAR\WinRAR.exe' + if (Test-Path -LiteralPath $default32 -PathType Leaf) { return $default32 } + # 6) Try common non-ProgramFiles locations + $commonPaths = @( + 'C:\WinRAR\WinRAR.exe', + 'D:\WinRAR\WinRAR.exe', + (Join-Path $env:ProgramFiles 'RAR\WinRAR.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'RAR\WinRAR.exe') + ) + foreach ($p in $commonPaths) { + if (Test-Path -LiteralPath $p -PathType Leaf) { return $p } + } + # 7) Try to find unrar.exe in WinRAR folder as fallback + $unrar64 = Join-Path $env:ProgramFiles 'WinRAR\UnRAR.exe' + if (Test-Path -LiteralPath $unrar64 -PathType Leaf) { return $unrar64 } + $unrar32 = Join-Path ${env:ProgramFiles(x86)} 'WinRAR\UnRAR.exe' + if (Test-Path -LiteralPath $unrar32 -PathType Leaf) { return $unrar32 } + return $null +} + +# -UseWinRAR flag is set via the script parameter above + +function Ensure-FfmpegInstalled { + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + $ffprobeExe = Find-Ffmpeg -Name 'ffprobe' + + if ($ffmpegExe -and $ffprobeExe) { + Write-Host "ffmpeg found: $ffmpegExe" + Write-Host "ffprobe found: $ffprobeExe" + return + } + + Write-Host "" + Write-Host "============================================" + Write-Host " ffmpeg not found -- auto-downloading..." + Write-Host "============================================" + Write-Host "" + + # Check if we have 7-Zip and/or WinRAR available for .7z extraction + $sevenZipExe = Find-SevenZip + $winRarExe = Find-WinRAR + + # Determine extraction preference order based on -UseWinRAR flag + # When -UseWinRAR is specified, WinRAR is preferred over 7-Zip for all formats + $preferWinRAR = $UseWinRAR -and $winRarExe + + $downloadDir = Join-Path $Root 'ffmpeg_download' + if (-not (Test-Path -LiteralPath $downloadDir -PathType Container)) { + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null + } + + $downloaded = $false + $downloadedFile = $null + + # We can extract .7z if we have 7-Zip OR WinRAR + $canExtract7z = ($sevenZipExe -ne $null) -or ($winRarExe -ne $null) + + if ($canExtract7z) { + # We have an extraction tool -- download the smaller .7z release + if ($preferWinRAR) { + Write-Host "WinRAR found (preferred): $winRarExe" + } elseif ($sevenZipExe) { + Write-Host "7-Zip found: $sevenZipExe" + } + if ($winRarExe -and -not $preferWinRAR) { + Write-Host "WinRAR also found: $winRarExe" + } + Write-Host "Downloading ffmpeg .7z release (smaller archive)..." + + # gyan.dev URL scheme (as of 2026-07): + # - Short alias URLs that 303-redirect to dated packages under /packages/ + # - ffmpeg-release-essentials.7z -> /packages/ffmpeg-X.Y.Z-essentials_build.7z + # - ffmpeg-git-full.7z -> /packages/ffmpeg-YYYY-MM-DD-git-HASH-full_build.7z + # - ffmpeg-release-full.7z -> /packages/ffmpeg-X.Y.Z-full_build.7z + # - ffmpeg-git-essentials.7z -> /packages/ffmpeg-YYYY-MM-DD-git-HASH-essentials_build.7z + # NOTE: ffmpeg-release-full.7z exists but there is no .zip for the full build. + # There is also NO /packages/ffmpeg-release-full.7z direct URL (404). + # Use the short alias URLs -- they redirect (303) to the correct dated file. + $urls7z = @( + # Release essentials (smaller, ~32 MB) -- has everything we need + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z", + # Git master essentials (latest dev build) + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-essentials.7z", + # Release full build (more codecs, ~70 MB) + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z", + # Git master full build (latest dev build, full) + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z" + ) + + foreach ($tryUrl in $urls7z) { + $archName = [System.IO.Path]::GetFileName($tryUrl) + $savePath = Join-Path $downloadDir $archName + Write-Host "Attempting: $tryUrl" + try { + $dlOk = Download-FileWithProgress -Url $tryUrl -OutFile $savePath + if ($dlOk) { + $downloaded = $true + $downloadedFile = $savePath + Write-Host "Download complete ($archName)." + break + } + } catch { + Write-Host "Failed: $($_.Exception.Message)" + if (Test-Path -LiteralPath $savePath) { + Remove-Item -LiteralPath $savePath -Force -ErrorAction SilentlyContinue + } + } + } + } + + if (-not $downloaded) { + # .7z download failed or no extraction tool -- try .zip archives + # PowerShell can extract .zip natively with Expand-Archive + Write-Host "" + if (-not $canExtract7z) { + Write-Host "Neither 7-Zip nor WinRAR found -- cannot extract .7z archives." + Write-Host "Downloading .zip release instead (larger but no extra tools needed)..." + } else { + Write-Host ".7z downloads failed. Trying .zip archives..." + } + + # gyan.dev .zip files: + # - ffmpeg-release-essentials.zip (~104 MB, PowerShell-native extraction) + # - ffmpeg-8.1.2-essentials_build.zip (versioned, under /packages/) + # NOTE: There is NO ffmpeg-release-full.zip on gyan.dev (404). + # So we also try BtbN/FFmpeg-Builds on GitHub which provides .zip archives. + $urlsZip = @( + # gyan.dev release essentials .zip (native PS extraction, ~104 MB) + "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", + # BtbN GitHub - GPL shared build (has ffmpeg.exe + ffprobe.exe + DLLs, ~80 MB) + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip", + # BtbN GitHub - GPL static build (single ffmpeg.exe, no DLLs, ~100 MB) + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" + ) + + foreach ($tryUrl in $urlsZip) { + $archName = [System.IO.Path]::GetFileName($tryUrl) + $savePath = Join-Path $downloadDir $archName + Write-Host "Attempting: $tryUrl" + try { + $dlOk = Download-FileWithProgress -Url $tryUrl -OutFile $savePath + if ($dlOk) { + $downloaded = $true + $downloadedFile = $savePath + Write-Host "Download complete ($archName)." + break + } + } catch { + Write-Host "Failed: $($_.Exception.Message)" + if (Test-Path -LiteralPath $savePath) { + Remove-Item -LiteralPath $savePath -Force -ErrorAction SilentlyContinue + } + } + } + } + + if (-not $downloaded) { + Write-Host "" + Write-Host "============================================================" + Write-Host " AUTOMATIC DOWNLOAD FAILED" + Write-Host "============================================================" + Write-Host "" + Write-Host "Please install ffmpeg manually using ONE of these methods:" + Write-Host "" + Write-Host " Option 1 - Download and extract into the addon folder:" + Write-Host " 1. Go to https://www.gyan.dev/ffmpeg/builds/" + Write-Host " 2. Download 'ffmpeg-release-essentials.zip' (or .7z if you have 7-Zip)" + Write-Host " 3. Extract the archive" + Write-Host " 4. Find ffmpeg.exe and ffprobe.exe inside the bin/ folder" + Write-Host " 5. Create a folder called 'ffmpeg' next to this script" + Write-Host " 6. Copy ffmpeg.exe and ffprobe.exe into that 'ffmpeg' folder" + Write-Host "" + Write-Host " Option 2 - Download from GitHub BtbN/FFmpeg-Builds:" + Write-Host " 1. Go to https://github.com/BtbN/FFmpeg-Builds/releases" + Write-Host " 2. Download 'ffmpeg-master-latest-win64-gpl-shared.zip'" + Write-Host " 3. Extract the archive" + Write-Host " 4. Find ffmpeg.exe and ffprobe.exe inside the bin/ folder" + Write-Host " 5. Create a folder called 'ffmpeg' next to this script" + Write-Host " 6. Copy ffmpeg.exe and ffprobe.exe into that 'ffmpeg' folder" + Write-Host "" + Write-Host " Option 3 - Install via Chocolatey (run as Admin):" + Write-Host " choco install ffmpeg" + Write-Host "" + Write-Host " Option 4 - Install via Winget:" + Write-Host " winget install ffmpeg" + Write-Host "" + Write-Host " Option 5 - Install via Scoop:" + Write-Host " scoop install ffmpeg-essentials" + Write-Host "" + Write-Host " Option 6 - Add ffmpeg to your system PATH:" + Write-Host " Download from https://ffmpeg.org/download.html" + Write-Host " Extract and add the bin folder to your PATH environment variable" + Write-Host "" + Write-Host "NOTE: gyan.dev distributes ffmpeg as .7z files (smaller)." + Write-Host "Install 7-Zip from https://www.7-zip.org/ for .7z support." + Write-Host "WinRAR from https://www.win-rar.com/ can also extract .7z files." + Write-Host "You can also run this script with -UseWinRAR to prefer WinRAR over 7-Zip." + Write-Host "A .zip release is also available from gyan.dev or GitHub but is larger." + Write-Host "" + Write-Host "The script will continue WITHOUT ffmpeg -- MP3 sanitization" + Write-Host "and accurate duration detection will be skipped." + Write-Host "============================================================" + Write-Host "" + return + } + + # Extract the archive + Write-Host "Extracting ffmpeg..." + try { + # Create ffmpeg output directory + if (-not (Test-Path -LiteralPath $FfmpegDir -PathType Container)) { + New-Item -ItemType Directory -Path $FfmpegDir -Force | Out-Null + } + + $extractBase = Join-Path $downloadDir 'extracted' + if (Test-Path -LiteralPath $extractBase) { + Remove-Item -LiteralPath $extractBase -Recurse -Force + } + + $is7z = $downloadedFile.EndsWith('.7z') + + if ($is7z) { + # Extract .7z -- prefer WinRAR if -UseWinRAR was specified, then 7-Zip, then WinRAR as fallback + if ($preferWinRAR -and $winRarExe) { + Write-Host "Using WinRAR to extract .7z archive..." + & $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null + } elseif ($sevenZipExe) { + Write-Host "Using 7-Zip to extract .7z archive..." + & $sevenZipExe x $downloadedFile -o"$extractBase" -y 2>$null | Out-Null + } elseif ($winRarExe) { + Write-Host "Using WinRAR to extract .7z archive..." + & $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null + } else { + Write-Host "ERROR: No tool available to extract .7z archive (need 7-Zip or WinRAR)." + throw "Cannot extract .7z file -- install 7-Zip or WinRAR, or re-run without .7z download" + } + } else { + # Extract .zip -- prefer WinRAR if -UseWinRAR, then 7-Zip, then PowerShell native + if ($preferWinRAR -and $winRarExe) { + Write-Host "Using WinRAR to extract .zip archive..." + & $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null + } elseif ($sevenZipExe) { + Write-Host "Using 7-Zip to extract .zip archive..." + & $sevenZipExe x $downloadedFile -o"$extractBase" -y 2>$null | Out-Null + } else { + Write-Host "Using PowerShell to extract .zip archive..." + Expand-Archive -LiteralPath $downloadedFile -DestinationPath $extractBase -Force + } + } + + # Find the bin folder inside the extracted directory + # gyan.dev archives contain a folder like ffmpeg-8.1.2-essentials_build/ or + # ffmpeg-2026-06-29-git-de6bcf5c05-full_build/ with a bin/ subfolder inside. + # BtbN GitHub archives contain a folder like ffmpeg-master-latest-win64-gpl-shared/ + # with a bin/ subfolder inside. + $binDir = Get-ChildItem -LiteralPath $extractBase -Recurse -Directory -Filter 'bin' | + Select-Object -First 1 + + if ($binDir) { + # Copy ffmpeg.exe and ffprobe.exe to our local ffmpeg folder + foreach ($exe in @('ffmpeg.exe', 'ffprobe.exe')) { + $src = Join-Path $binDir.FullName $exe + $dst = Join-Path $FfmpegDir $exe + if (Test-Path -LiteralPath $src -PathType Leaf) { + Copy-Item -LiteralPath $src -Destination $dst -Force + Write-Host "Installed: $exe -> $dst" + } + } + } else { + # Try to find ffmpeg.exe directly in extracted folder + $ffmpegFiles = Get-ChildItem -LiteralPath $extractBase -Recurse -File -Filter 'ffmpeg.exe' + $ffprobeFiles = Get-ChildItem -LiteralPath $extractBase -Recurse -File -Filter 'ffprobe.exe' + foreach ($f in $ffmpegFiles) { + Copy-Item -LiteralPath $f.FullName -Destination (Join-Path $FfmpegDir 'ffmpeg.exe') -Force + Write-Host "Installed: ffmpeg.exe" + } + foreach ($f in $ffprobeFiles) { + Copy-Item -LiteralPath $f.FullName -Destination (Join-Path $FfmpegDir 'ffprobe.exe') -Force + Write-Host "Installed: ffprobe.exe" + } + } + + # Clean up download artifacts + Remove-Item -LiteralPath $extractBase -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $downloadedFile -Force -ErrorAction SilentlyContinue + # Remove download dir if empty + $remaining = Get-ChildItem -LiteralPath $downloadDir -ErrorAction SilentlyContinue + if (-not $remaining) { + Remove-Item -LiteralPath $downloadDir -Force -ErrorAction SilentlyContinue + } + + # Verify installation + $ffTest = Join-Path $FfmpegDir 'ffmpeg.exe' + $fpTest = Join-Path $FfmpegDir 'ffprobe.exe' + if ((Test-Path -LiteralPath $ffTest) -and (Test-Path -LiteralPath $fpTest)) { + Write-Host "" + Write-Host "ffmpeg installed successfully in: $FfmpegDir" + Write-Host "" + } else { + Write-Host "" + Write-Host "WARNING: ffmpeg/ffprobe not found after extraction." + Write-Host "Please install manually (see instructions below)." + Write-Host "" + } + + } catch { + Write-Host "ERROR: Failed to extract ffmpeg: $($_.Exception.Message)" + Write-Host "Please install ffmpeg manually (see instructions in the script output)." + } +} + +# Run the auto-installer +Ensure-FfmpegInstalled + +# --------------------------------------------------------------------------- +# Now that ffmpeg may be available, set up a helper to call it from the +# local ffmpeg folder when the system PATH doesn't have it. +# --------------------------------------------------------------------------- + +# Override ffprobe/ffmpeg calls to use the local copy if needed +$localFfmpeg = Join-Path $FfmpegDir 'ffmpeg.exe' +$localFfprobe = Join-Path $FfmpegDir 'ffprobe.exe' + +# We can't easily override the & operator, but we can define helper functions +function Invoke-LocalFfprobe { + param([string[]]$Arguments) + $exe = Find-Ffmpeg -Name 'ffprobe' + if ($exe) { + return & $exe @Arguments + } + return $null +} + +function Invoke-LocalFfmpeg { + param([string[]]$Arguments) + $exe = Find-Ffmpeg -Name 'ffmpeg' + if ($exe) { + return & $exe @Arguments + } + return $null +} + +if (-not (Test-Path -LiteralPath $Media -PathType Container)) { + throw "Media folder not found: $Media" +} + +# -- Lua string escaper ------------------------------------------------------ +function Escape-LuaString { + param([AllowNull()][string]$Value) + + if ($null -eq $Value) { return '' } + + $escaped = $Value.Replace('\', '\\') + $escaped = $escaped.Replace('"', '\"') + $escaped = $escaped.Replace("`n", '\n') + $escaped = $escaped.Replace("`r", '\r') + return $escaped +} + +# -- Duration helpers (optional -- graceful fallback) ------------------------- +$shell = $null +$wmp = $null +try { $shell = New-Object -ComObject Shell.Application } catch { $shell = $null } +try { $wmp = New-Object -ComObject WMPlayer.OCX.7 } catch { $wmp = $null } + +# Default duration assigned when we cannot read metadata (seconds). +# This lets autoplay/shuffle work without metadata -- the Lua side uses +# this as the wait time before advancing to the next track. +$DEFAULT_DURATION = 180 # 3 minutes -- reasonable for most songs + +function Get-TrackDurationSeconds { + param([System.IO.FileInfo]$File) + + # --- Try Shell.Application (works for mp3 with Windows property handlers) --- + if ($shell) { + try { + $folder = $shell.Namespace($File.DirectoryName) + if ($folder) { + $item = $folder.ParseName($File.Name) + if ($item) { + # 27 = Duration column in Shell folder view (HH:MM:SS string) + $raw = $item.ExtendedProperty('System.Media.Duration') + $ticks = 0L + if ($raw -is [ValueType]) { + $ticks = [int64]$raw + } elseif ($raw) { + [void][int64]::TryParse([string]$raw, [ref]$ticks) + } + + if ($ticks -gt 0) { + return [int][Math]::Ceiling($ticks / 10000000.0) + } + + # Fallback: try the Duration column (index 27) as a string + $durStr = $folder.GetDetailsOf($item, 27) + if ($durStr -and $durStr.Trim() -ne '') { + # Parse HH:MM:SS or MM:SS + $parts = $durStr.Trim() -split ':' + $secs = 0 + if ($parts.Count -eq 3) { + $secs = [int]$parts[0]*3600 + [int]$parts[1]*60 + [int]$parts[2] + } elseif ($parts.Count -eq 2) { + $secs = [int]$parts[0]*60 + [int]$parts[1] + } + if ($secs -gt 0) { return $secs } + } + } + } + } catch { + # Silently skip -- special characters in filename can break COM + } + } + + # --- Try Windows Media Player COM --- + if ($wmp) { + try { + $mediaItem = $wmp.newMedia($File.FullName) + if ($mediaItem) { + $duration = [double]$mediaItem.duration + if ($duration -gt 0) { + return [int][Math]::Ceiling($duration) + } + } + } catch { + # Silently skip -- special characters in filename can break COM + } + } + + # --- Try ffprobe if available --- + try { + $ffprobeExe = Find-Ffmpeg -Name 'ffprobe' + if ($ffprobeExe) { + # Use Start-Process to avoid crashes from ffprobe stderr/non-zero exit + $ffOutFile = [System.IO.Path]::GetTempFileName() + $ffErrFile = [System.IO.Path]::GetTempFileName() + $ffProc = Start-Process -FilePath $ffprobeExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_format', $File.FullName) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile + Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue + if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) { + $ffOut = Get-Content -LiteralPath $ffOutFile -Raw + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + if ($ffOut) { + $ffJson = $ffOut | ConvertFrom-Json + if ($ffJson.format.duration) { + $dur = [double]$ffJson.format.duration + if ($dur -gt 0) { return [int][Math]::Ceiling($dur) } + } + } + } else { + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + } + } + } catch { + # ffprobe not installed or failed, skip + } + + # --- Fallback: estimate from file size (very rough) --- + # For mp3 @ ~128kbps: ~1KB per second. For WAV: ~172KB per second (16bit/44.1k/stereo) + if ($File.Extension -match '^(?i)\.mp3$') { + $estimate = [int][Math]::Ceiling($File.Length / 16000.0) # ~128kbps + if ($estimate -gt 10) { return $estimate } + } elseif ($File.Extension -match '^(?i)\.wav$') { + $estimate = [int][Math]::Ceiling($File.Length / 176000.0) # ~16bit/44.1k/stereo + if ($estimate -gt 10) { return $estimate } + } elseif ($File.Extension -match '^(?i)\.ogg$') { + $estimate = [int][Math]::Ceiling($File.Length / 12000.0) # ~112kbps vorbis + if ($estimate -gt 10) { return $estimate } + } + + # --- Ultimate fallback: use default duration --- + return $DEFAULT_DURATION +} + +# -- Sanitize MP3 files for WoW 1.12 compatibility --------------------------- +# WoW 1.12's FMOD 3.x engine is very picky about MP3 files. Many tracks that +# play fine in modern players will silently fail in-game. Known issues include: +# +# 1. CRC protection bit set (protection_bit = 0 in MPEG frame header) +# 2. Embedded album art / ID3v2 APIC frames (FMOD may misparse these as video) +# 3. Variable bitrate (VBR) encoding with Xing/Info headers +# 4. Non-standard MPEG version/layer combinations (MPEG 2.0/2.5, Layer I/II) +# 5. Unusual channel modes (dual channel, joint stereo variants) +# 6. Very high bitrates (>256kbps) that FMOD 3.x sometimes chokes on +# 7. LAME-encoded files with non-standard delay/padding info tags +# 8. ID3v2 extended headers or unsynchronization that confuse FMOD +# 9. Files with large ID3v2 tags where FMOD fails to find the audio start +# 10. Files encoded with encoders that produce MPEG frame padding inconsistencies +# +# The ONLY reliable fix is to re-encode with ffmpeg using settings known to +# produce FMOD 3.x-compatible output. This script checks each MP3 for any +# of these issues and re-encodes only those that need it. If ffmpeg is not +# available, it tries a binary patch to at least fix CRC protection. +# +# Re-encoded files use: CBR 256kbps, MPEG1 Layer3, Simple Stereo, no CRC, +# no embedded art, stripped tags, standard LAME encoding. + +function Test-Mp3NeedsReencode { + param([string]$FilePath) + + try { + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + if ($bytes.Length -lt 10) { return $false, 'File too small' } + + # --- Check for ID3v2 tag and find audio start --- + $audioOffset = 0 + $hasId3v2 = $false + $hasExtendedHeader = $false + $hasUnsynchronization = $false + $id3v2Size = 0 + + if ($bytes[0] -eq 0x49 -and $bytes[1] -eq 0x44 -and $bytes[2] -eq 0x33) { + # ID3v2 tag present + $hasId3v2 = $true + $id3v2Flags = $bytes[5] + $hasUnsynchronization = ($id3v2Flags -band 0x80) -ne 0 + $hasExtendedHeader = ($id3v2Flags -band 0x40) -ne 0 + $id3v2Size = ($bytes[6] -shl 21) -bor ($bytes[7] -shl 14) -bor ($bytes[8] -shl 7) -bor $bytes[9] + $audioOffset = $id3v2Size + 10 + + # Check for embedded album art (APIC frame) in ID3v2 + # Scan the ID3v2 tag for 'APIC' frame ID + $tagEnd = [Math]::Min($id3v2Size + 10, $bytes.Length) + for ($j = 10; $j -lt $tagEnd - 4; $j++) { + if ($bytes[$j] -eq 0x41 -and $bytes[$j+1] -eq 0x50 -and $bytes[$j+2] -eq 0x49 -and $bytes[$j+3] -eq 0x43) { + # APIC frame found = embedded album art + return $true, 'Embedded album art (APIC frame in ID3v2 tag)' + } + # Also check for 'PIC ' (ID3v2.2 picture frame) + if ($bytes[$j] -eq 0x50 -and $bytes[$j+1] -eq 0x49 -and $bytes[$j+2] -eq 0x43 -and $bytes[$j+3] -eq 0x20) { + return $true, 'Embedded album art (PIC frame in ID3v2 tag)' + } + } + } + + if ($hasUnsynchronization) { + return $true, 'ID3v2 unsynchronization flag set (can confuse FMOD 3.x)' + } + + if ($hasExtendedHeader) { + return $true, 'ID3v2 extended header present (can confuse FMOD 3.x)' + } + + # --- Scan MPEG audio frames --- + if ($audioOffset -ge $bytes.Length - 4) { return $false, 'No audio data found' } + + $frameCount = 0 + $maxFramesToCheck = 50 + $foundXing = $false + $foundInfo = $false + $hasCrc = $false + $hasVbr = $false + $hasHighBitrate = $false + $hasNonStandardMode = $false + $hasMpeg2 = $false + $prevBitrateIdx = -1 + $bitrateChanges = 0 + + # Bitrate table for MPEG1 Layer III (index 0 = free, 15 = bad) + $bitrateTable1L3 = @(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0) + + for ($i = $audioOffset; $i -lt [Math]::Min($bytes.Length - 4, $audioOffset + 65536); $i++) { + # Look for MPEG sync word + if ($bytes[$i] -ne 0xFF -or ($bytes[$i+1] -band 0xE0) -ne 0xE0) { + continue + } + + $b1 = $bytes[$i+1] + $b2 = $bytes[$i+2] + + # Decode MPEG header + $mpegVersion = ($b1 -shr 3) -band 0x03 # 0=2.5, 2=2.0, 3=1.0 + $layer = ($b1 -shr 1) -band 0x03 # 1=Layer3, 2=Layer2, 3=Layer1 + $protection = $b1 -band 0x01 # 0=CRC present, 1=no CRC + $bitrateIdx = ($b2 -shr 4) -band 0x0F + $sampleRateIdx = ($b2 -shr 2) -band 0x03 + $channelMode = ($bytes[$i+3] -shr 6) -band 0x03 # 0=Stereo, 1=Joint, 2=Dual, 3=Mono + + # Check for MPEG 2.0 or 2.5 (WoW 1.12 FMOD may not handle these) + if ($mpegVersion -eq 0 -or $mpegVersion -eq 2) { + $hasMpeg2 = $true + } + + # Check for non-Layer3 (WoW 1.12 FMOD may not handle Layer I/II) + if ($layer -ne 1) { + $hasNonStandardMode = $true + } + + # Check CRC protection + if ($protection -eq 0) { + $hasCrc = $true + } + + # Check bitrate + if ($mpegVersion -eq 3 -and $layer -eq 1) { + # MPEG1 Layer3 + $br = $bitrateTable1L3[$bitrateIdx] + if ($br -ge 320) { + $hasHighBitrate = $true # 320kbps sometimes causes FMOD issues + } + # Track bitrate changes for VBR detection + if ($prevBitrateIdx -ge 0 -and $bitrateIdx -ne $prevBitrateIdx) { + $bitrateChanges++ + } + $prevBitrateIdx = $bitrateIdx + } + + # Check for Xing/Info header (VBR indicator) - usually at offset 36 from frame start + # for MPEG1 Layer3 stereo: after 32-byte side information + if ($i + 72 -lt $bytes.Length) { + $xingOffset = $i + 36 + if ($bytes[$xingOffset] -eq 0x58 -and $bytes[$xingOffset+1] -eq 0x69 -and $bytes[$xingOffset+2] -eq 0x6E -and $bytes[$xingOffset+3] -eq 0x67) { + $foundXing = $true + $hasVbr = $true + } + if ($bytes[$xingOffset] -eq 0x49 -and $bytes[$xingOffset+1] -eq 0x6E -and $bytes[$xingOffset+2] -eq 0x66 -and $bytes[$xingOffset+3] -eq 0x6F) { + $foundInfo = $true + # Info header can indicate CBR but still has LAME tag that may cause issues + } + } + + $frameCount++ + if ($frameCount -ge $maxFramesToCheck) { break } + + # Calculate frame size to skip to next frame + if ($mpegVersion -eq 3 -and $layer -eq 1) { + $br = $bitrateTable1L3[$bitrateIdx] + if ($br -gt 0) { + $padding = ($b2 -shr 1) -band 0x01 + $frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / 44100)) + $padding + $i += $frameSize - 1 # -1 because the for loop does $i++ + } else { + $i += 417 + } + } else { + # Non-standard frame, skip forward conservatively + $i += 417 + } + } + + # --- Return findings --- + # VBR detection: Xing header or multiple bitrate changes + if ($foundXing) { + return $true, 'VBR encoding (Xing header found - FMOD 3.x often fails on VBR)' + } + if ($bitrateChanges -ge 3) { + return $true, 'VBR encoding (bitrate changes detected across frames)' + } + + # CRC protection + if ($hasCrc) { + return $true, 'CRC protection bit set (FMOD 3.x cannot play CRC-protected MP3)' + } + + # Embedded art was already checked above, but also check via file size heuristic + # If there's an ID3v2 tag larger than 100KB, it likely has embedded art + if ($hasId3v2 -and $id3v2Size -gt 102400) { + return $true, "Large ID3v2 tag ($([int]($id3v2Size/1024))KB - likely contains embedded art)" + } + + # High bitrate (320kbps) with CRC or other issues + if ($hasHighBitrate -and $hasCrc) { + return $true, '320kbps with CRC protection (known to fail in FMOD 3.x)' + } + + # Non-standard MPEG version/layer + if ($hasMpeg2) { + return $true, 'MPEG 2.0/2.5 audio detected (FMOD 3.x may not support)' + } + if ($hasNonStandardMode) { + return $true, 'Non-Layer3 MPEG audio detected (FMOD 3.x may not support)' + } + + # Dual channel mode (rare, sometimes causes FMOD issues) + if ($channelMode -eq 2 -and $frameCount -gt 0) { + return $true, 'Dual channel mode detected (FMOD 3.x may not handle correctly)' + } + + # Check for video/image streams embedded in the MP3 file + # Some MP3 files have embedded JPEG/PNG album art as a separate "stream" + # FMOD may try to play these as video and fail + try { + $ffprobeExe = Find-Ffmpeg -Name 'ffprobe' + if ($ffprobeExe) { + $ffOutFile2 = [System.IO.Path]::GetTempFileName() + $ffErrFile2 = [System.IO.Path]::GetTempFileName() + $ffProc2 = Start-Process -FilePath $ffprobeExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile2 -RedirectStandardError $ffErrFile2 + Remove-Item -LiteralPath $ffErrFile2 -Force -ErrorAction SilentlyContinue + if ($ffProc2.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile2 -PathType Leaf)) { + $ffProbeOut = Get-Content -LiteralPath $ffOutFile2 -Raw + Remove-Item -LiteralPath $ffOutFile2 -Force -ErrorAction SilentlyContinue + if ($ffProbeOut) { + $ffJson = $ffProbeOut | ConvertFrom-Json + foreach ($stream in $ffJson.streams) { + if ($stream.codec_type -eq 'video') { + return $true, "Embedded video/image stream (codec: $($stream.codec_name)) - FMOD 3.x cannot handle" + } + } + } + } else { + Remove-Item -LiteralPath $ffOutFile2 -Force -ErrorAction SilentlyContinue + } + } + } catch { + # ffprobe not available or failed, skip this check + } + + return $false, 'Compatible' + } + catch { + return $true, "Error analyzing file: $($_.Exception.Message)" + } +} + +function Sanitize-Mp3File { + param([System.IO.FileInfo]$File) + + $tempFile = $File.FullName + '.sanitize.tmp' + + # Re-encode with ffmpeg: strip video/art, no CRC, CBR 256kbps, simple stereo, + # no tags (stripped), no VBR, standard MPEG1 Layer3 + # Using 256kbps instead of 320kbps because FMOD 3.x is more reliable at this bitrate + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + $result = $null + try { + if ($ffmpegExe) { + # Capture stderr to prevent it from interfering with the console + # and check the exit code manually rather than relying on ErrorActionPreference + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '256k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile + Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue + if ($proc.ExitCode -ne 0) { + Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..." + } + } + } catch { + # ffmpeg failed -- cannot sanitize, continue to next method + Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)" + } + + if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) { + # Verify the sanitized file passes all checks + $needsMore, $reason = Test-Mp3NeedsReencode -FilePath $tempFile + if (-not $needsMore) { + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)" + return $true + } else { + # Even after re-encode, still has issues - try one more time with different settings + Remove-Item -LiteralPath $tempFile -Force + $tempFile2 = $File.FullName + '.sanitize2.tmp' + try { + $ffmpegExe2 = Find-Ffmpeg -Name 'ffmpeg' + if ($ffmpegExe2) { + $stderrFile2 = [System.IO.Path]::GetTempFileName() + $proc2 = Start-Process -FilePath $ffmpegExe2 -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', $tempFile2) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile2 + Remove-Item -LiteralPath $stderrFile2 -Force -ErrorAction SilentlyContinue + } + if ((Test-Path -LiteralPath $tempFile2 -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile2).Length -gt 0)) { + Move-Item -LiteralPath $tempFile2 -Destination $File.FullName -Force + Write-Host " Sanitized (re-encoded at 192kbps for WoW 1.12): $($File.Name)" + return $true + } + } catch {} + if (Test-Path -LiteralPath $tempFile2) { Remove-Item -LiteralPath $tempFile2 -Force } + } + } elseif (Test-Path -LiteralPath $tempFile) { + Remove-Item -LiteralPath $tempFile -Force + } + + # If ffmpeg is not available, try binary patch for CRC-only fix + $needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $File.FullName + if ($needsReencode -and $reason -match 'CRC') { + # Binary patch: flip the protection bit from 0 to 1 in each MPEG frame + try { + $bytes = [System.IO.File]::ReadAllBytes($File.FullName) + $patched = 0 + + # Find ID3v2 tag end + $audioOffset = 0 + if ($bytes[0] -eq 0x49 -and $bytes[1] -eq 0x44 -and $bytes[2] -eq 0x33) { + $tagSize = ($bytes[6] -shl 21) -bor ($bytes[7] -shl 14) -bor ($bytes[8] -shl 7) -bor $bytes[9] + $audioOffset = $tagSize + 10 + } + + for ($i = $audioOffset; $i -lt $bytes.Length - 4; $i++) { + if ($bytes[$i] -eq 0xFF -and ($bytes[$i+1] -band 0xE0) -eq 0xE0) { + if (($bytes[$i+1] -band 0x01) -eq 0) { + # CRC bit is 0, flip it to 1 (no CRC) + $bytes[$i+1] = $bytes[$i+1] -bor 0x01 + $patched++ + } + # Skip past this frame + $b2 = $bytes[$i+2] + $bitrateIdx = ($b2 -shr 4) -band 0x0F + $br = $bitrateTable1L3[$bitrateIdx] + if ($br -gt 0) { + $padding = ($b2 -shr 1) -band 0x01 + # Approximate frame skip (note: if CRC was present, frame had 2 extra bytes) + $frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / 44100)) + $padding + # After removing CRC, each frame is 2 bytes shorter, but we're patching in-place + # so we just skip the original frame size minus the CRC + $i += $frameSize - 1 + } else { + $i += 417 + } + } + } + + if ($patched -gt 0) { + [System.IO.File]::WriteAllBytes($File.FullName, $bytes) + Write-Host " Patched (CRC flag removed in $patched frames): $($File.Name)" + return $true + } + } catch { + # Binary patch failed + } + } + + return $false +} + +# Check and sanitize MP3 files for WoW 1.12 compatibility +Write-Host "" +Write-Host "Scanning MP3 files for WoW 1.12 compatibility..." +$mp3Files = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.mp3$' }) + +$sanitized = 0 +$skipped = 0 +$scanErrors = 0 +$compatOk = 0 + +if ($mp3Files.Count -eq 0) { + Write-Host "No MP3 files found in Media folder." +} else { + Write-Host "Found $($mp3Files.Count) MP3 file(s) to check..." + foreach ($mp3 in $mp3Files) { + try { + $needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $mp3.FullName + if ($needsReencode) { + Write-Host " Issue found: $($mp3.Name) - $reason" + try { + $fixed = Sanitize-Mp3File -File $mp3 + if ($fixed) { $sanitized++ } else { $skipped++ } + } catch { + Write-Host " WARNING: Could not sanitize $($mp3.Name): $($_.Exception.Message)" + $skipped++ + } + } else { + $compatOk++ + } + } catch { + Write-Host " WARNING: Could not scan $($mp3.Name): $($_.Exception.Message)" + $scanErrors++ + } + } + Write-Host "" + Write-Host " Compatible: $compatOk Sanitized: $sanitized Skipped: $skipped Errors: $scanErrors" +} + +if ($sanitized -gt 0) { + Write-Host "" + Write-Host "Sanitized $sanitized MP3 file(s) for WoW 1.12 compatibility." + Write-Host "These files were re-encoded to work with the in-game FMOD 3.x music engine." + Write-Host "" +} +if ($skipped -gt 0) { + Write-Host "" + Write-Host "WARNING: $skipped MP3 file(s) could not be sanitized (ffmpeg not available?)." + Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run." + Write-Host "" +} +if ($sanitized -eq 0 -and $skipped -eq 0 -and $mp3Files.Count -gt 0) { + Write-Host "" + Write-Host "All $($mp3Files.Count) MP3 file(s) passed compatibility check." + Write-Host "" +} + +# -- Sanitize WAV/OGG files for WoW 1.12 compatibility ----------------------- +# WoW 1.12's FMOD 3.x engine is very picky about WAV and OGG files too. +# Many WAV/OGG files that play fine in modern players will silently fail in-game. +# +# Known WAV issues: +# 1. 24-bit or 32-bit float samples (FMOD 3.x only supports 8-bit and 16-bit PCM) +# 2. Sample rates other than 44100 Hz or 22050 Hz +# 3. More than 2 channels (surround sound WAVs) +# 4. ADPCM or other compressed WAV formats +# 5. RIFF LIST/INFO chunks that can confuse FMOD +# 6. Very large WAV files that exceed FMOD's buffer +# 7. IEEE float format (format code 3) instead of PCM (format code 1) +# +# Known OGG issues: +# 1. Vorbis encoding at unusual bitrates or quality levels +# 2. OGG files with video streams or metadata that FMOD cannot parse +# 3. OGG files encoded with very high quality settings (-q10) that FMOD 3.x rejects +# 4. Non-standard channel configurations +# 5. Opus codec inside an OGG container (FMOD 3.x only supports Vorbis in OGG) +# +# The ONLY reliable fix is to re-encode with ffmpeg using settings known to +# produce FMOD 3.x-compatible output. WAV is re-encoded to 16-bit PCM at +# 44100 Hz stereo; OGG is re-encoded to Vorbis at quality 6 (~192kbps). + +function Test-WavNeedsReencode { + param([string]$FilePath) + + try { + $ffmpegExe = Find-Ffmpeg -Name 'ffprobe' + if ($ffmpegExe) { + $ffOutFile = [System.IO.Path]::GetTempFileName() + $ffErrFile = [System.IO.Path]::GetTempFileName() + $ffProc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile + Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue + if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) { + $ffOut = Get-Content -LiteralPath $ffOutFile -Raw + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + if ($ffOut) { + $ffJson = $ffOut | ConvertFrom-Json + foreach ($stream in $ffJson.streams) { + if ($stream.codec_type -eq 'audio') { + # Check sample format - must be s16 (signed 16-bit PCM) + if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) { + return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x requires 44100 or 22050 Hz)" + } + if ($stream.channels -and [int]$stream.channels -gt 2) { + return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)" + } + # Check codec - must be PCM (not ADPCM, float, etc.) + if ($stream.codec_name -and $stream.codec_name -ne 'pcm_s16le' -and $stream.codec_name -ne 'pcm_s16be' -and $stream.codec_name -ne 'pcm_u8' -and $stream.codec_name -ne 'pcm_s24le' -and $stream.codec_name -ne 'pcm_f32le' -and $stream.codec_name -ne 'adpcm_ms' -and $stream.codec_name -ne 'adpcm_ima_wav') { + # Not a standard PCM codec + } elseif ($stream.codec_name -eq 'pcm_s24le') { + return $true, '24-bit PCM (FMOD 3.x only supports 8-bit and 16-bit)' + } elseif ($stream.codec_name -eq 'pcm_f32le' -or $stream.codec_name -eq 'pcm_f64le') { + return $true, 'Float PCM format (FMOD 3.x only supports integer PCM)' + } elseif ($stream.codec_name -match 'adpcm') { + return $true, 'ADPCM compressed WAV (FMOD 3.x may not support this codec)' + } + # Check bit depth via bits_per_sample if available + if ($stream.bits_per_sample -and [int]$stream.bits_per_sample -gt 16 -and [int]$stream.bits_per_sample -ne 0) { + return $true, "$($stream.bits_per_sample)-bit audio (FMOD 3.x only supports 8-bit and 16-bit)" + } + } + } + } + } else { + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + } + } + + # Fallback: check WAV header bytes manually + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + if ($bytes.Length -lt 44) { return $false, 'File too small' } + + # Check RIFF header + if ($bytes[0] -ne 0x52 -or $bytes[1] -ne 0x49 -or $bytes[2] -ne 0x46 -or $bytes[3] -ne 0x46) { + return $false, 'Not a valid RIFF/WAV file' + } + + # Find the 'fmt ' chunk + $fmtOffset = -1 + for ($i = 12; $i -lt [Math]::Min($bytes.Length - 24, 4096); $i++) { + if ($bytes[$i] -eq 0x66 -and $bytes[$i+1] -eq 0x6D -and $bytes[$i+2] -eq 0x74 -and $bytes[$i+3] -eq 0x20) { + $fmtOffset = $i + 8 # Skip past 'fmt ' and chunk size + break + } + } + + if ($fmtOffset -ge 0 -and $fmtOffset + 16 -le $bytes.Length) { + # Parse format fields (little-endian) + $audioFormat = $bytes[$fmtOffset] -bor ($bytes[$fmtOffset+1] -shl 8) + $numChannels = $bytes[$fmtOffset+2] -bor ($bytes[$fmtOffset+3] -shl 8) + $sampleRate = $bytes[$fmtOffset+4] -bor ($bytes[$fmtOffset+5] -shl 8) -bor ($bytes[$fmtOffset+6] -shl 16) -bor ($bytes[$fmtOffset+7] -shl 24) + $bitsPerSample = $bytes[$fmtOffset+14] -bor ($bytes[$fmtOffset+15] -shl 8) + + if ($audioFormat -ne 1) { + return $true, "Non-PCM format code $audioFormat (FMOD 3.x only supports PCM format code 1)" + } + if ($bitsPerSample -gt 16) { + return $true, "$bitsPerSample-bit audio (FMOD 3.x only supports 8-bit and 16-bit)" + } + if ($numChannels -gt 2) { + return $true, "$numChannels channels (FMOD 3.x supports max 2 channels)" + } + if ($sampleRate -ne 44100 -and $sampleRate -ne 22050 -and $sampleRate -ne 11025) { + return $true, "Sample rate $sampleRate Hz (FMOD 3.x requires 11025/22050/44100 Hz)" + } + } + + return $false, 'Compatible' + } + catch { + return $true, "Error analyzing file: $($_.Exception.Message)" + } +} + +function Test-OggNeedsReencode { + param([string]$FilePath) + + try { + $ffmpegExe = Find-Ffmpeg -Name 'ffprobe' + if ($ffmpegExe) { + $ffOutFile = [System.IO.Path]::GetTempFileName() + $ffErrFile = [System.IO.Path]::GetTempFileName() + $ffProc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile + Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue + if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) { + $ffOut = Get-Content -LiteralPath $ffOutFile -Raw + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + if ($ffOut) { + $ffJson = $ffOut | ConvertFrom-Json + foreach ($stream in $ffJson.streams) { + if ($stream.codec_type -eq 'audio') { + # OGG must use Vorbis codec (not Opus, FLAC, etc.) + if ($stream.codec_name -and $stream.codec_name -ne 'vorbis') { + return $true, "Non-Vorbis codec '$($stream.codec_name)' in OGG container (FMOD 3.x only supports Vorbis)" + } + if ($stream.channels -and [int]$stream.channels -gt 2) { + return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)" + } + if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) { + return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x prefers 44100 or 22050 Hz)" + } + } + if ($stream.codec_type -eq 'video') { + return $true, "Embedded video stream in OGG (FMOD 3.x cannot handle)" + } + } + } + } else { + Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue + } + } + + return $false, 'Compatible' + } + catch { + return $true, "Error analyzing file: $($_.Exception.Message)" + } +} + +function Sanitize-WavFile { + param([System.IO.FileInfo]$File) + + $tempFile = $File.FullName + '.sanitize.tmp' + + # Re-encode with ffmpeg: 16-bit PCM, 44100 Hz, stereo, strip metadata + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + try { + if ($ffmpegExe) { + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'pcm_s16le', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile + Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue + if ($proc.ExitCode -ne 0) { + Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..." + } + } + } catch { + Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)" + } + + if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) { + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + Write-Host " Sanitized (re-encoded to 16-bit PCM 44100Hz stereo for WoW 1.12): $($File.Name)" + return $true + } elseif (Test-Path -LiteralPath $tempFile) { + Remove-Item -LiteralPath $tempFile -Force + } + + return $false +} + +function Sanitize-OggFile { + param([System.IO.FileInfo]$File) + + $tempFile = $File.FullName + '.sanitize.tmp' + + # Re-encode with ffmpeg: Vorbis codec, quality 6 (~192kbps), 44100 Hz, stereo + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + try { + if ($ffmpegExe) { + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libvorbis', '-q:a', '6', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile + Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue + if ($proc.ExitCode -ne 0) { + Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..." + } + } + } catch { + Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)" + } + + if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) { + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + Write-Host " Sanitized (re-encoded to Vorbis q6 44100Hz stereo for WoW 1.12): $($File.Name)" + return $true + } elseif (Test-Path -LiteralPath $tempFile) { + Remove-Item -LiteralPath $tempFile -Force + } + + return $false +} + +# Check and sanitize WAV files for WoW 1.12 compatibility +Write-Host "" +Write-Host "Scanning WAV files for WoW 1.12 compatibility..." +$wavFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.wav$' }) + +$wavSanitized = 0 +$wavSkipped = 0 +$wavCompatOk = 0 + +if ($wavFiles.Count -eq 0) { + Write-Host "No WAV files found in Media folder." +} else { + Write-Host "Found $($wavFiles.Count) WAV file(s) to check..." + foreach ($wav in $wavFiles) { + try { + $needsReencode, $reason = Test-WavNeedsReencode -FilePath $wav.FullName + if ($needsReencode) { + Write-Host " Issue found: $($wav.Name) - $reason" + try { + $fixed = Sanitize-WavFile -File $wav + if ($fixed) { $wavSanitized++ } else { $wavSkipped++ } + } catch { + Write-Host " WARNING: Could not sanitize $($wav.Name): $($_.Exception.Message)" + $wavSkipped++ + } + } else { + $wavCompatOk++ + } + } catch { + Write-Host " WARNING: Could not scan $($wav.Name): $($_.Exception.Message)" + $wavSkipped++ + } + } + Write-Host "" + Write-Host " WAV Compatible: $wavCompatOk Sanitized: $wavSanitized Skipped: $wavSkipped" +} + +if ($wavSanitized -gt 0) { + Write-Host "" + Write-Host "Sanitized $wavSanitized WAV file(s) for WoW 1.12 compatibility." + Write-Host "These files were re-encoded to 16-bit PCM 44100Hz stereo." + Write-Host "" +} +if ($wavSkipped -gt 0) { + Write-Host "" + Write-Host "WARNING: $wavSkipped WAV file(s) could not be sanitized (ffmpeg not available?)." + Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run." + Write-Host "" +} +if ($wavSanitized -eq 0 -and $wavSkipped -eq 0 -and $wavFiles.Count -gt 0) { + Write-Host "" + Write-Host "All $($wavFiles.Count) WAV file(s) passed compatibility check." + Write-Host "" +} + +# Check and sanitize OGG files for WoW 1.12 compatibility +Write-Host "" +Write-Host "Scanning OGG files for WoW 1.12 compatibility..." +$oggFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.ogg$' }) + +$oggSanitized = 0 +$oggSkipped = 0 +$oggCompatOk = 0 + +if ($oggFiles.Count -eq 0) { + Write-Host "No OGG files found in Media folder." +} else { + Write-Host "Found $($oggFiles.Count) OGG file(s) to check..." + foreach ($ogg in $oggFiles) { + try { + $needsReencode, $reason = Test-OggNeedsReencode -FilePath $ogg.FullName + if ($needsReencode) { + Write-Host " Issue found: $($ogg.Name) - $reason" + try { + $fixed = Sanitize-OggFile -File $ogg + if ($fixed) { $oggSanitized++ } else { $oggSkipped++ } + } catch { + Write-Host " WARNING: Could not sanitize $($ogg.Name): $($_.Exception.Message)" + $oggSkipped++ + } + } else { + $oggCompatOk++ + } + } catch { + Write-Host " WARNING: Could not scan $($ogg.Name): $($_.Exception.Message)" + $oggSkipped++ + } + } + Write-Host "" + Write-Host " OGG Compatible: $oggCompatOk Sanitized: $oggSanitized Skipped: $oggSkipped" +} + +if ($oggSanitized -gt 0) { + Write-Host "" + Write-Host "Sanitized $oggSanitized OGG file(s) for WoW 1.12 compatibility." + Write-Host "These files were re-encoded to Vorbis q6 44100Hz stereo." + Write-Host "" +} +if ($oggSkipped -gt 0) { + Write-Host "" + Write-Host "WARNING: $oggSkipped OGG file(s) could not be sanitized (ffmpeg not available?)." + Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run." + Write-Host "" +} +if ($oggSanitized -eq 0 -and $oggSkipped -eq 0 -and $oggFiles.Count -gt 0) { + Write-Host "" + Write-Host "All $($oggFiles.Count) OGG file(s) passed compatibility check." + Write-Host "" +} + +# -- Rename files with special characters for WoW 1.12 compatibility ---------- +# WoW 1.12's FMOD engine may fail to play files whose names contain characters +# like apostrophes ('), parentheses, or other special symbols. This step +# renames such files by replacing problematic characters with underscores. +# Only the physical filename is changed; the track name in the playlist +# preserves the original display name. + +$problematicChars = @("'", '"', '(', ')', '[', ']', '{', '}', '!', '&', '#', '%', '@', '+', '=', ',', ';') +$renamed = 0 +$allMediaFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' }) + +foreach ($mf in $allMediaFiles) { + $needsRename = $false + foreach ($ch in $problematicChars) { + if ($mf.Name.Contains($ch)) { + $needsRename = $true + break + } + } + + if ($needsRename) { + $newName = $mf.Name + foreach ($ch in $problematicChars) { + $newName = $newName.Replace($ch, '_') + } + # Remove double underscores and trailing/leading underscores + while ($newName.Contains('__')) { $newName = $newName.Replace('__', '_') } + + $newPath = Join-Path $mf.DirectoryName $newName + if ($newPath -ne $mf.FullName -and -not (Test-Path -LiteralPath $newPath)) { + try { + Rename-Item -LiteralPath $mf.FullName -NewName $newName -Force + Write-Host " Renamed: $($mf.Name) -> $newName" + $renamed++ + } catch { + Write-Host " WARNING: Could not rename $($mf.Name): $_" + } + } + } +} + +if ($renamed -gt 0) { + Write-Host "" + Write-Host "Renamed $renamed file(s) -- replaced special characters for WoW 1.12 path compatibility." + Write-Host "" +} + +# -- Scan Media folder ------------------------------------------------------- +$files = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' } | + Sort-Object -Property Name) + +$lines = New-Object System.Collections.Generic.List[string] +$lines.Add('RelationshipsJukeboxAutoPlaylist = {}') | Out-Null +$lines.Add('RelationshipsJukeboxAutoPlaylist.tracks = {') | Out-Null + +$defaultCount = 0 +foreach ($file in $files) { + # Build the WoW-relative path (always backslashes for consistency) + $relative = $file.FullName.Substring($Root.Length).TrimStart('\', '/') + $relative = $relative.Replace('/', '\') + $luaPath = Escape-LuaString(($AddonPrefix + $relative)) + $name = Escape-LuaString($file.BaseName) + $duration = Get-TrackDurationSeconds -File $file + + if ($duration -eq $DEFAULT_DURATION) { + $defaultCount++ + } + + $fmtLuaEntry = ' {{ path = "{0}", name = "{1}", duration = {2} }},' + $lines.Add(($fmtLuaEntry -f $luaPath, $name, $duration)) | Out-Null +} + +$lines.Add('}') | Out-Null + +# -- Write AutoPlaylist.lua (UTF-8 no BOM) ----------------------------------- +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllLines($Out, $lines, $utf8NoBom) + +# -- Summary ----------------------------------------------------------------- +Write-Host "Generated: $Out" +Write-Host "Tracks: $($files.Count)" +if ($defaultCount -gt 0) { + Write-Host "" + Write-Host "$defaultCount track(s) using estimated/default duration $($DEFAULT_DURATION)s." + Write-Host "Autoplay and Shuffle will still work -- the addon advances after the estimated time." +} else { + Write-Host 'All track durations were captured from metadata successfully.' +} +Write-Host '' +Write-Host 'Next step: type /reload in game or restart the client.' + +# -- Cleanup COM objects ----------------------------------------------------- +if ($wmp) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($wmp) } catch {} } +if ($shell) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } catch {} } diff --git a/RelationshipsJukeBox.toc b/RelationshipsJukeBox.toc new file mode 100644 index 0000000..5032f43 --- /dev/null +++ b/RelationshipsJukeBox.toc @@ -0,0 +1,10 @@ +## Interface: 11200 +## Title: Relationships Jukebox +## Notes: Simple in-game MP3/WAV/OGG player with GUI. +## Author: Relationship +## Version: 1.3 +## SavedVariables: RelationshipsJukeboxDB +## X-Website: https://github.com/Relationship-OctoWoW/RelationshipsJukeBox +## X-GitHub: https://github.com/Relationship-OctoWoW/RelationshipsJukeBox +AutoPlaylist.lua +Core.lua