From 2c6a4291882d867c7499a363886e6afeb422d2af Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Mon, 6 Jul 2026 06:49:50 +0100 Subject: [PATCH] Add files via upload --- Core.lua | 2179 ++++++++++++++++++++++++++++++++++++ GeneratePlaylist.bat | 11 + GeneratePlaylist.ps1 | 2277 ++++++++++++++++++++++++++++++++++++++ RelationshipsJukeBox.toc | 10 + 4 files changed, 4477 insertions(+) create mode 100644 Core.lua create mode 100644 GeneratePlaylist.bat create mode 100644 GeneratePlaylist.ps1 create mode 100644 RelationshipsJukeBox.toc diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..b7be4ba --- /dev/null +++ b/Core.lua @@ -0,0 +1,2179 @@ +local ADDON_NAME = "RelationshipsJukeBox" +local ADDON_MEDIA_PREFIX = "Interface\\AddOns\\RelationshipsJukeBox\\Media\\" + +RelationshipsJukeboxDB = RelationshipsJukeboxDB or {} + +local RJ = {} +RJ.page = 1 +RJ.pageSize = 12 +RJ.rows = {} +RJ.playQueue = nil +RJ.playQueuePosition = 0 +RJ.searchFilter = "" +RJ.filteredIndices = nil -- nil = no filter (show all); table = list of DB indices matching search +RJ.currentIndex = nil +RJ.currentPath = nil +RJ.currentName = nil +RJ.currentCanStop = false +RJ.currentDuration = 0 +RJ.trackStartTime = nil -- GetTime() when current track started playing +RJ.pausedElapsed = nil -- elapsed seconds saved when paused (for timer display) +RJ.trackEndTime = nil +RJ.autoPlayEnabled = false +RJ.shuffleMode = false +RJ.isPaused = false +RJ.pauseCooldownUntil = 0 -- timestamp until which pause/play is blocked (1s cooldown) +RJ.playCooldownUntil = 0 -- timestamp until which the Play button is blocked (1s cooldown) +RJ.skipCooldownUntil = 0 -- timestamp until which Skip is blocked (1s cooldown) +RJ.prevCooldownUntil = 0 -- timestamp until which Previous is blocked (1s cooldown) +RJ.stopCooldownUntil = 0 -- timestamp until which Stop is blocked (1s cooldown) +local COOLDOWN_SECS = 1 -- universal cooldown for all playback buttons + +-- Marquee (scrolling text) state for mini player +RJ.marqueeOffset = 0 -- current pixel offset for scrolling +RJ.marqueeSpeed = 25 -- pixels per second +RJ.marqueeGap = 0 -- legacy gap value; smooth looping now uses a text separator instead +RJ.marqueeSeparator = " • " +RJ.marqueeCycleWidth = 0 -- width of one full repeated cycle +RJ.marqueeLastUpdate = 0 -- last OnUpdate timestamp +RJ.marqueeFullText = "" -- the repeated display text used for looping +RJ.marqueeSingleWidth = 0 -- pixel width of the single-copy display text +RJ.marqueeSingleText = "" -- the single-copy display text (before duplication) + +-- Polling state: when a track has no duration metadata, we poll IsPlaying +-- to detect when it finishes, so autoplay/shuffle can advance. +RJ.pollingForEnd = false +RJ.pollStartDelay = 2 +RJ.pollTimer = 0 +RJ.pollInterval = 0.5 +RJ.consecutiveNotPlaying = 0 +RJ.consecutiveThreshold = 4 + +-- Delayed sound re-enable state: KillAllSounds() sets MasterSoundEffects="0" +-- and schedules a re-enable via the ticker's OnUpdate after a short delay. +RJ._pendingReenableAt = nil -- GetTime() when MasterSoundEffects should be re-enabled +RJ._pendingReenableCallback = nil -- function to call after re-enable (e.g., play new track) + +-- Fallback duration used when no metadata is available (seconds). +local FALLBACK_DURATION = 180 + +-- Format seconds as M:SS or MM:SS +-- Note: WoW 1.12 uses Lua 5.0 which has no '%' operator; use math.mod() instead. +local function FormatTime(seconds) + if not seconds or seconds < 0 then seconds = 0 end + seconds = math.floor(seconds + 0.5) + local m = math.floor(seconds / 60) + local s = math.mod(seconds, 60) + return string.format("%d:%02d", m, s) +end + +-- Format elapsed/total as "M:SS/M:SS" +local function FormatElapsedTime(elapsed, total) + return FormatTime(elapsed) .. "/" .. FormatTime(total) +end + +local function Count(t) + if table.getn then return table.getn(t) end + local n = 0 + while t[n + 1] ~= nil do + n = n + 1 + end + return n +end + +local function Trim(s) + if not s then return "" end + return (string.gsub(s, "^%s*(.-)%s*$", "%1")) +end + +local function NormalizePath(input) + local path = Trim(input) + path = string.gsub(path, "/", "\\") + if path == "" then return "" end + if string.find(string.lower(path), "^interface\\") or string.find(string.lower(path), "^sound\\") or string.find(string.lower(path), "^data\\") then + return path + end + return ADDON_MEDIA_PREFIX .. path +end + +local function CanonicalPath(input) + local path = NormalizePath(input) + return string.lower(path) +end + +local function EnsureDB() + if type(RelationshipsJukeboxDB) ~= "table" then RelationshipsJukeboxDB = {} end + if type(RelationshipsJukeboxDB.tracks) ~= "table" then RelationshipsJukeboxDB.tracks = {} end +end + +local function ExtractFileName(path) + local clean + if not path then return "" end + clean = string.gsub(path, "/", "\\") + clean = string.gsub(clean, "^.*\\", "") + return clean +end + +-- Clean up a file name for display: strip leading numbers, dashes, underscores, +-- and convert remaining underscores to spaces. Also remove the file extension. +-- Examples: +-- "05 - this_song3.mp3" -> "this song" +-- "01. Session.mp3" -> "Session" +-- "06 - System Of A Down - Chop Suey_.mp3" -> "System Of A Down - Chop Suey" +local function CleanDisplayName(name) + if not name or name == "" then return "" end + -- Remove file extension (last .mp3, .wav, .ogg, etc.) + name = string.gsub(name, "%.%w+$", "") + -- Strip leading numbers followed by optional separator (digits, dots, dashes, spaces) + -- e.g. "05 - ", "01. ", "06 - ", "02. ", etc. + name = string.gsub(name, "^[%d%.]+[%s%-_]*", "") + -- Strip any remaining leading dashes or underscores + name = string.gsub(name, "^[%-_]+", "") + -- Strip leading whitespace + name = string.gsub(name, "^%s+", "") + -- Strip trailing numbers, dashes, underscores + name = string.gsub(name, "[%d%-_]+$", "") + -- Strip trailing whitespace + name = string.gsub(name, "%s+$", "") + -- Replace remaining underscores with spaces + name = string.gsub(name, "_", " ") + -- Collapse multiple spaces into one + name = string.gsub(name, " +", " ") + return Trim(name) +end + +local function GetTrackPath(entry) + if type(entry) == "table" then + return NormalizePath(entry.path or entry.file or "") + end + if type(entry) == "string" then + return NormalizePath(entry) + end + return "" +end + +-- GetTrackName returns the raw track name for storage and path resolution. +-- It does NOT apply CleanDisplayName — the stored name must match the actual +-- file so that path/name resolution works correctly across reloads. +local function GetTrackName(entry) + local path = GetTrackPath(entry) + if type(entry) == "table" then + local customName = Trim(entry.name or entry.title or "") + if customName ~= "" then + return customName + end + end + return ExtractFileName(path) +end + +-- GetTrackDisplayName returns a cleaned-up name suitable for UI display only. +-- This strips leading numbers, dashes, underscores, extensions, etc. +-- It must NEVER be used when storing names into SavedVariables or matching +-- entries by name, only for rendering labels and status text. +local function GetTrackDisplayName(entry) + return CleanDisplayName(GetTrackName(entry)) +end + +local function GetTrackDuration(entry) + if type(entry) == "table" then + local duration = tonumber(entry.duration or entry.length or entry.seconds or 0) + if duration and duration > 0 then + return duration + end + end + return 0 +end + +local function CreateTrackEntry(path, name, duration) + local normalized = NormalizePath(path) + if normalized == "" then return nil end + return { + path = normalized, + name = Trim(name or "") ~= "" and Trim(name) or ExtractFileName(normalized), + duration = tonumber(duration) or 0, + } +end + +local function BuildTrackLookup() + local seen = {} + local i + EnsureDB() + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + local path = GetTrackPath(RelationshipsJukeboxDB.tracks[i]) + if path ~= "" then + seen[string.lower(path)] = i + end + end + return seen +end + +local function MergeAutoPlaylist() + local auto, seen, imported, updated, i, entry, path, name, duration, track, existingIndex, existingEntry, currentName, currentDuration, changed + EnsureDB() + auto = RelationshipsJukeboxAutoPlaylist + if type(auto) ~= "table" or type(auto.tracks) ~= "table" then + return 0, 0 + end + seen = BuildTrackLookup() + imported = 0 + updated = 0 + for i = 1, Count(auto.tracks) do + entry = auto.tracks[i] + path = GetTrackPath(entry) + if path ~= "" then + local key = string.lower(path) + name = GetTrackName(entry) + duration = GetTrackDuration(entry) + existingIndex = seen[key] + if existingIndex then + existingEntry = RelationshipsJukeboxDB.tracks[existingIndex] + changed = false + if type(existingEntry) ~= "table" then + track = CreateTrackEntry(path, name, duration) + if track then + RelationshipsJukeboxDB.tracks[existingIndex] = track + updated = updated + 1 + end + else + existingEntry.path = NormalizePath(existingEntry.path or existingEntry.file or path) + currentName = Trim(existingEntry.name or existingEntry.title or "") + if currentName == "" or currentName == ExtractFileName(path) then + existingEntry.name = name + changed = true + end + currentDuration = tonumber(existingEntry.duration or existingEntry.length or existingEntry.seconds or 0) or 0 + if currentDuration <= 0 and duration > 0 then + existingEntry.duration = duration + changed = true + end + if changed then + updated = updated + 1 + end + end + else + track = CreateTrackEntry(path, name, duration) + if track then + table.insert(RelationshipsJukeboxDB.tracks, track) + seen[key] = Count(RelationshipsJukeboxDB.tracks) + imported = imported + 1 + end + end + end + end + return imported, updated +end + +local function SetStatus(msg, r, g, b) + if RJ.statusText then + RJ.statusText:SetText(msg or "") + if RJ.statusText.SetTextColor then + RJ.statusText:SetTextColor(r or 1, g or 0.82, b or 0) + end + end +end + +local function ShuffleQueue(queue) + local i + if not queue then return end + for i = Count(queue), 2, -1 do + local j = math.random(i) + queue[i], queue[j] = queue[j], queue[i] + end +end + +----------------------------------------------------------------------- +-- AUDIO CHANNEL STRATEGY (revised for Turtle WoW / OctoWoW) +----------------------------------------------------------------------- +-- WoW 1.12 uses FMOD 3.x which has multiple independent audio channels. +-- +-- KEY INSIGHT: The Music channel (used by PlayMusic and zone ambient +-- music) shares the same FMOD channel. Zone music events REPLACE +-- whatever is playing on the Music channel, which restarts jukebox +-- tracks. The addon disables EnableMusic on load to prevent zone music +-- from overlapping with jukebox playback. +-- +-- PlaySoundFile(path, "Ambience") routes through the Ambience channel +-- on Turtle WoW / OctoWoW, which is separate from both the Music and +-- SFX channels. The jukebox volume is controlled by the MasterVolume +-- CVar (0.0-1.0), which the in-game Options screen exposes as the +-- "Master Volume" slider. The addon provides its own volume slider on +-- the main GUI that sets MasterVolume directly, controlling the master +-- output volume for all game audio including the jukebox. +-- +-- Zone changes and loading screens do NOT interrupt sounds on the +-- Ambience channel, so no event handler is needed for them. +-- +-- To STOP a playing track, we use KillAllSounds() which sets +-- MasterSoundEffects="0" to kill ALL currently playing sounds, then +-- re-enables it on the next frame via the ticker. FMOD needs at least +-- one frame to process the "off" state before we can safely re-enable. +-- The new PlaySoundFile call that follows (after the re-enable callback) +-- then starts fresh. +-- +-- STOP / SKIP / PREVIOUS: +-- • KillAllSounds() sets MasterSoundEffects="0" to kill the current +-- sound, then re-enables on the next frame. This creates a +-- ~1 frame silence but reliably stops whatever is playing. +-- The subsequent PlaySoundFile starts the new track cleanly. +-- +-- PAUSE: +-- • Pause saves elapsed time and stops the track via +-- KillAllSounds() (same disable/re-enable mechanism). +-- • Resume replays the track from the beginning (PlaySoundFile +-- has no seek/offset API). The track restarts, but pause/ +-- resume functionality is preserved. +-- +-- ZONE MUSIC SUPPRESSION: +-- • The jukebox now plays through its own sound channel, so zone +-- ambient music events no longer interfere with jukebox playback. +-- We still set EnableMusic="0" while the jukebox is playing to +-- prevent the game engine from playing zone ambient music on the +-- Music channel. +-- +-- KEY BENEFIT: KillAllSounds() / MasterSoundEffects toggle reliably stops ANY +-- playing sound regardless of channel, so Stop/Skip/Previous +-- always work. +-- Check whether a file path uses a format that previously required +-- PlayMusic (WAV/OGG). Now ALL tracks use PlaySoundFile regardless +-- of format, so this function is only used for format identification, +-- not for channel routing decisions. +local function IsMusicPath(path) + local lower = string.lower(path or "") + return string.find(lower, "%.wav$") ~= nil + or string.find(lower, "%.ogg$") ~= nil +end + +-- Check whether a file path is an MP3 file. Now ALL tracks use +-- PlaySoundFile regardless of format. This function is kept for +-- format identification only, not for channel routing decisions. +local function IsSfxPath(path) + local lower = string.lower(path or "") + return string.find(lower, "%.mp3$") ~= nil +end + +----------------------------------------------------------------------- +-- KillAllSounds: Disable all sound via CVar, schedule re-enable next frame +----------------------------------------------------------------------- +-- FMOD 3.x needs at least one frame to process the "off" state before +-- we can safely re-enable. If we toggle off/on in the same frame, +-- FMOD never actually stops the audio. This function: +-- 1. Sets MasterSoundEffects="0" to immediately kill ALL playing sounds +-- 2. Schedules a re-enable via the ticker's OnUpdate (next frame) +-- 3. Also calls StopMusic() as a fallback +-- +-- The caller can optionally pass a callback to invoke after re-enable +-- (e.g., to play a new track immediately after the old one is killed). + +local function KillAllSounds(afterReenableCallback) + local sc = SetCVar or function() end + -- Kill ALL currently playing sounds by disabling the master sound CVar. + pcall(sc, "MasterSoundEffects", "0") + -- Also stop any PlayMusic track as a fallback. + if StopMusic then + StopMusic() + end + -- Schedule re-enable on the next frame. FMOD needs at least one + -- frame to process the "off" before we turn sound back on. + -- If a callback was already pending, preserve it unless a new one + -- is provided (the new callback supersedes the old one). + RJ._pendingReenableAt = GetTime() + 0.05 -- ~50ms delay (1-2 frames at 30fps) + if afterReenableCallback then + RJ._pendingReenableCallback = afterReenableCallback + end +end + +-- Stop the jukebox track. +-- PlaySoundFile is fire-and-forget, so StopMusic() does NOT stop it. +-- Instead, we use KillAllSounds() which disables MasterSoundEffects (killing +-- ALL playing sounds) and re-enables it on the next frame. A safety +-- StopMusic() call is also included inside KillAllSounds as a fallback. +local function StopJukeboxTrack() + KillAllSounds() +end + +----------------------------------------------------------------------- +-- Forward declarations (mutual recursion: PlayNextQueuedTrack <-> PlayTrack) +----------------------------------------------------------------------- +local PlayTrack +local PlayNextQueuedTrack + +----------------------------------------------------------------------- +-- Playback button state (forward-declared so PausePlayback/ResumePlayback can call it) +----------------------------------------------------------------------- +local RefreshPlaybackButtons + +----------------------------------------------------------------------- +-- Zone music control (forward-declared so PausePlayback/ResumePlayback/ +-- StopPlayback/PlayTrack can call them before their definitions) +----------------------------------------------------------------------- + + +----------------------------------------------------------------------- +-- Format detection helpers +-- These identify file formats (MP3 vs WAV/OGG) for display purposes. +-- ALL tracks use PlaySoundFile regardless of format, so these are +-- only for format identification, not channel routing. +----------------------------------------------------------------------- + +----------------------------------------------------------------------- +-- Pause / Resume +----------------------------------------------------------------------- +local function PausePlayback() + if not RJ.currentPath or RJ.isPaused then return end + if GetTime() < RJ.pauseCooldownUntil then + SetStatus("Pause cooldown -- please wait...", 1, 1, 0.2) + return + end + RJ.isPaused = true + + -- Save remaining time for timed tracks + if RJ.trackEndTime then + RJ.pausedRemaining = RJ.trackEndTime - GetTime() + RJ.trackEndTime = nil + else + RJ.pausedRemaining = nil + end + -- Save elapsed time for timer display while paused + if RJ.trackStartTime then + RJ.pausedElapsed = GetTime() - RJ.trackStartTime + else + RJ.pausedElapsed = nil + end + RJ.pollingForEnd = false + -- Restore zone music CVar (note: since the addon disables EnableMusic + -- on load, this will restore it to "0" -- game music stays off even + -- while paused, which is the intended behavior). + RestoreZoneMusic() + -- Stop the jukebox track. + StopJukeboxTrack() + -- 1-second cooldown after pause to prevent rapid toggle spam + RJ.pauseCooldownUntil = GetTime() + COOLDOWN_SECS + SetStatus("Paused: " .. (RJ.currentName or ""), 1, 1, 0.2) + RefreshPlaybackButtons() + RefreshMiniPlayer() +end + +local function ResumePlayback() + if not RJ.isPaused then return end + if GetTime() < RJ.pauseCooldownUntil then + SetStatus("Play cooldown -- please wait...", 1, 1, 0.2) + return + end + RJ.isPaused = false + + if RJ.currentPath then + -- Ensure all sound is enabled so PlaySoundFile works. + local sc = SetCVar or function() end + pcall(sc, "MasterSoundEffects", "1") + + -- ALL tracks use PlaySoundFile(path, "Ambience"). + if PlaySoundFile then + PlaySoundFile(RJ.currentPath, "Ambience") + RJ.currentCanStop = true + end + + -- Suppress zone ambient music on the Music channel. + SuppressZoneMusic() + + -- Restore timer or polling + if RJ.pausedRemaining and RJ.pausedRemaining > 0 then + RJ.trackEndTime = GetTime() + RJ.pausedRemaining + RJ.pausedRemaining = nil + elseif RJ.currentCanStop and IsPlaying then + RJ.pollingForEnd = true + RJ.pollTimer = GetTime() + RJ.pollStartDelay + RJ.consecutiveNotPlaying = 0 + RJ.pausedRemaining = nil + end + + -- Adjust trackStartTime so elapsed timer continues from where it was + if RJ.pausedElapsed then + RJ.trackStartTime = GetTime() - RJ.pausedElapsed + RJ.pausedElapsed = nil + else + RJ.trackStartTime = GetTime() + end + end + + -- 1-second cooldown after resume to prevent rapid toggle spam + RJ.pauseCooldownUntil = GetTime() + COOLDOWN_SECS + -- Set the full 1-second play cooldown to prevent spam-playing other tracks + RJ.playCooldownUntil = GetTime() + COOLDOWN_SECS + SetStatus("Resumed: " .. (RJ.currentName or ""), 0.2, 1, 0.2) + RefreshPlaybackButtons() + RefreshMiniPlayer() +end + + +local function TogglePause() + if GetTime() < RJ.pauseCooldownUntil then + SetStatus("Pause/Play cooldown -- please wait...", 1, 1, 0.2) + return + end + if RJ.isPaused then + ResumePlayback() + else + PausePlayback() + end +end + + +local function StopPlayback(silent) + -- 1-second cooldown on Stop to prevent music overlap + if not silent and GetTime() < RJ.stopCooldownUntil then + SetStatus("Stop cooldown -- please wait...", 1, 1, 0.2) + return + end + + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.pauseCooldownUntil = 0 + RJ.playCooldownUntil = 0 + RJ.skipCooldownUntil = 0 + RJ.prevCooldownUntil = 0 + RJ.stopCooldownUntil = 0 + RJ.autoPlayEnabled = false + RJ.playQueue = nil + RJ.playQueuePosition = 0 + RJ.trackEndTime = nil + RJ.trackStartTime = nil + RJ.pausedElapsed = nil + RJ.currentDuration = 0 + RJ.pollingForEnd = false + RJ.pollTimer = 0 + RJ.consecutiveNotPlaying = 0 + + -- Restore zone music CVar (note: since the addon disables EnableMusic + -- on load, this will restore it to "0" -- game music stays off even + -- when stopped, matching the intended behavior). + RestoreZoneMusic() + -- Stop the jukebox track. + StopJukeboxTrack() + + RJ.currentCanStop = false + RJ.currentIndex = nil + RJ.currentPath = nil + RJ.currentName = nil + + if not silent then + -- Set 1-second cooldown after Stop to prevent rapid re-play causing audio overlap + RJ.stopCooldownUntil = GetTime() + COOLDOWN_SECS + SetStatus("Stopped.", 1, 0.82, 0) + end + + RefreshPlaybackButtons() + RefreshMiniPlayer() +end + +----------------------------------------------------------------------- +-- Playback button state +----------------------------------------------------------------------- +RefreshPlaybackButtons = function() + local hasTracks = Count(RelationshipsJukeboxDB.tracks) > 0 + local isPlaying = RJ.currentPath ~= nil or RJ.autoPlayEnabled + local inQueue = RJ.autoPlayEnabled and RJ.playQueue ~= nil + local now = GetTime() + + if RJ.playAllButton then + if hasTracks and not isPlaying and now >= RJ.playCooldownUntil and now >= RJ.stopCooldownUntil then + RJ.playAllButton:Enable() + else + RJ.playAllButton:Disable() + end + end + + if RJ.shuffleButton then + if hasTracks and not isPlaying and now >= RJ.playCooldownUntil and now >= RJ.stopCooldownUntil then + RJ.shuffleButton:Enable() + else + RJ.shuffleButton:Disable() + end + end + + if RJ.stopButton then + if (isPlaying or RJ.isPaused) and now >= RJ.stopCooldownUntil then + RJ.stopButton:Enable() + else + RJ.stopButton:Disable() + end + end + + if RJ.skipButton then + if inQueue and now >= RJ.skipCooldownUntil then + RJ.skipButton:Enable() + else + RJ.skipButton:Disable() + end + end + + if RJ.prevButton then + if inQueue and RJ.playQueuePosition > 1 and now >= RJ.prevCooldownUntil then + RJ.prevButton:Enable() + else + RJ.prevButton:Disable() + end + end + + RefreshMiniPlayer() +end + +----------------------------------------------------------------------- +-- List / page UI +----------------------------------------------------------------------- +local function RefreshList() + EnsureDB() + + -- Determine which indices to display: filtered or all + local indices = RJ.filteredIndices -- nil = no filter (show all) + local total, totalPages + + if indices then + total = Count(indices) + else + total = Count(RelationshipsJukeboxDB.tracks) + end + + totalPages = math.max(1, math.ceil(total / RJ.pageSize)) + if RJ.page > totalPages then RJ.page = totalPages end + if RJ.page < 1 then RJ.page = 1 end + + local startIndex = ((RJ.page - 1) * RJ.pageSize) + 1 + + for i = 1, RJ.pageSize do + local row = RJ.rows[i] + local listPos = startIndex + i - 1 -- position within the display list (1-based) + local idx -- actual DB index + + if indices then + idx = indices[listPos] + else + idx = listPos + end + + local entry = RelationshipsJukeboxDB.tracks[idx] + + if entry then + row.index = idx -- store real DB index so Play/Remove work correctly + row.label:SetText(idx .. ". " .. GetTrackDisplayName(entry)) + -- Disable Play button during 1-second cooldown (play or stop) + if GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil then + row.play:Disable() + else + row.play:Enable() + end + row:Show() + else + row.index = nil + row.label:SetText("") + row:Hide() + end + end + + if RJ.pageText then + local pageText = "Page " .. RJ.page .. "/" .. totalPages .. " Tracks: " .. total + if RJ.filteredIndices then + pageText = pageText .. " (filtered)" + end + RJ.pageText:SetText(pageText) + end + + RefreshPlaybackButtons() +end + +----------------------------------------------------------------------- +-- Track playback core +----------------------------------------------------------------------- +-- IsMusicPath and IsSfxPath are defined above (before PausePlayback/ +-- ResumePlayback) so those functions can reference them. See the +-- comments there for the full channel-routing strategy. + +-- Legacy alias kept for clarity in comments / status messages +local function IsMp3Path(path) + return string.find(string.lower(path or ""), "%.mp3$") ~= nil +end + +PlayNextQueuedTrack = function() + if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then + return + end + + RJ.playQueuePosition = RJ.playQueuePosition + 1 + + if RJ.playQueuePosition > Count(RJ.playQueue) then + StopPlayback(true) + SetStatus("Playlist finished.", 0.2, 1, 0.2) + RefreshPlaybackButtons() + return + end + + local queueEntry = RJ.playQueue[RJ.playQueuePosition] + PlayTrack(queueEntry, true) +end + +PlayTrack = function(indexOrEntry, fromQueue) + EnsureDB() + + -- 1-second cooldown to prevent spam-playing multiple tracks on top of each other + -- Skip cooldown for auto-advancing from queue (fromQueue == true) + if not fromQueue and (GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil) then + SetStatus("Cooldown active -- please wait...", 1, 1, 0.2) + return nil + end + + local entry + if type(indexOrEntry) == "number" then + entry = RelationshipsJukeboxDB.tracks[indexOrEntry] + elseif type(indexOrEntry) == "table" then + entry = indexOrEntry + else + entry = nil + end + + local path = GetTrackPath(entry) + local name = GetTrackDisplayName(entry) + local duration = GetTrackDuration(entry) + local ok + + if path == "" then + SetStatus("Track not found.", 1, 0.2, 0.2) + if fromQueue then + PlayNextQueuedTrack() + end + return nil + end + + -- Stop any currently playing jukebox track. + if RJ.currentPath then + -- KillAllSounds disables MasterSoundEffects and re-enables it on the + -- next frame. We must defer playing the new track until after + -- the re-enable, so we pass the play logic as a callback. + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.currentCanStop = false + RJ.pollingForEnd = false + RJ.pollTimer = 0 + RJ.consecutiveNotPlaying = 0 + + -- Define what to do after sound is re-enabled: play the new track. + local playAfterReenable = function() + -- Ensure all sound is enabled so PlaySoundFile works. + local sc2 = SetCVar or function() end + pcall(sc2, "MasterSoundEffects", "1") + + if PlaySoundFile then + PlaySoundFile(path, "Ambience") + ok = 1 + RJ.currentCanStop = true -- PlaySoundFile track + else + ok = nil + RJ.currentCanStop = false + end + + if ok then + -- Suppress zone ambient music on the Music channel. + SuppressZoneMusic() + + -- Set 1-second play cooldown to prevent spam + RJ.playCooldownUntil = GetTime() + COOLDOWN_SECS + + if type(indexOrEntry) == "number" then + RJ.currentIndex = indexOrEntry + else + RJ.currentIndex = nil + end + RJ.currentPath = path + RJ.currentName = name + RJ.currentDuration = duration + RJ.trackStartTime = GetTime() + + if fromQueue then + local queueLen = Count(RJ.playQueue or {}) + local posLabel = RJ.playQueuePosition .. "/" .. queueLen + + if duration and duration > 0 then + RJ.trackEndTime = GetTime() + duration + SetStatus("Playing " .. posLabel .. ": " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) + elseif RJ.currentCanStop and IsPlaying then + RJ.trackEndTime = nil + RJ.pollingForEnd = true + RJ.pollTimer = GetTime() + RJ.pollStartDelay + RJ.consecutiveNotPlaying = 0 + SetStatus("Playing " .. posLabel .. ": " .. name .. " (auto-advancing when track ends)", 0.2, 1, 0.2) + else + RJ.trackEndTime = GetTime() + FALLBACK_DURATION + RJ.pollingForEnd = false + SetStatus("Playing " .. posLabel .. ": " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) + end + else + -- Single track play (not from queue) + if duration and duration > 0 then + RJ.trackEndTime = GetTime() + duration + SetStatus("Playing: " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) + elseif RJ.currentCanStop and IsPlaying then + RJ.trackEndTime = nil + RJ.pollingForEnd = true + RJ.pollTimer = GetTime() + RJ.pollStartDelay + RJ.consecutiveNotPlaying = 0 + SetStatus("Playing: " .. name, 0.2, 1, 0.2) + else + RJ.trackEndTime = GetTime() + FALLBACK_DURATION + RJ.pollingForEnd = false + SetStatus("Playing: " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) + end + end + RefreshPlaybackButtons() + RefreshMiniPlayer() + else + SetStatus("Playback error for: " .. (name or "?"), 1, 0.2, 0.2) + end + end + + KillAllSounds(playAfterReenable) + return ok + end + + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.currentCanStop = false + RJ.pollingForEnd = false + RJ.pollTimer = 0 + RJ.consecutiveNotPlaying = 0 + + -- Play the track using PlaySoundFile(path, "Ambience"). + -- ALL tracks use PlaySoundFile for playback. To stop a playing + -- track, use KillAllSounds() which disables MasterSoundEffects and + -- re-enables it on the next frame. + -- Ensure all sound is enabled so PlaySoundFile works. + -- Zone events or other addons may have disabled it. + local sc = SetCVar or function() end + pcall(sc, "MasterSoundEffects", "1") + + if PlaySoundFile then + PlaySoundFile(path, "Ambience") + ok = 1 + RJ.currentCanStop = true -- PlaySoundFile track + else + ok = nil + RJ.currentCanStop = false + end + + if ok then + -- Suppress zone ambient music on the Music channel. + SuppressZoneMusic() + + -- Set 1-second play cooldown to prevent spam + RJ.playCooldownUntil = GetTime() + COOLDOWN_SECS + + if type(indexOrEntry) == "number" then + RJ.currentIndex = indexOrEntry + else + RJ.currentIndex = nil + end + RJ.currentPath = path + RJ.currentName = name + RJ.currentDuration = duration + RJ.trackStartTime = GetTime() + + if fromQueue then + local queueLen = Count(RJ.playQueue or {}) + local posLabel = RJ.playQueuePosition .. "/" .. queueLen + + if duration and duration > 0 then + -- Known duration: use timed advancement + RJ.trackEndTime = GetTime() + duration + SetStatus("Playing " .. posLabel .. ": " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) + elseif RJ.currentCanStop and IsPlaying then + -- PlaySoundFile track with no duration: poll IsPlaying() to detect end + RJ.trackEndTime = nil + RJ.pollingForEnd = true + RJ.pollTimer = GetTime() + RJ.pollStartDelay + RJ.consecutiveNotPlaying = 0 + SetStatus("Playing " .. posLabel .. ": " .. name .. " (auto-advancing when track ends)", 0.2, 1, 0.2) + else + -- PlaySoundFile track with no duration and no IsPlaying: use fallback + RJ.trackEndTime = GetTime() + FALLBACK_DURATION + RJ.pollingForEnd = false + SetStatus("Playing " .. posLabel .. ": " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) + end + else + -- Single track play (not from queue) + if duration and duration > 0 then + RJ.trackEndTime = GetTime() + duration + SetStatus("Playing: " .. name .. " (" .. FormatTime(duration) .. ")", 0.2, 1, 0.2) + elseif RJ.currentCanStop and IsPlaying then + RJ.trackEndTime = nil + RJ.pollingForEnd = true + RJ.pollTimer = GetTime() + RJ.pollStartDelay + RJ.consecutiveNotPlaying = 0 + SetStatus("Playing: " .. name, 0.2, 1, 0.2) + else + RJ.trackEndTime = GetTime() + FALLBACK_DURATION + RJ.pollingForEnd = false + SetStatus("Playing: " .. name .. " (~" .. FormatTime(FALLBACK_DURATION) .. " estimated)", 0.2, 1, 0.2) + end + end + else + RJ.trackEndTime = nil + RJ.pollingForEnd = false + SetStatus("Could not play: " .. name .. " (check filename/path)", 1, 0.2, 0.2) + if fromQueue then + -- Skip to next after a brief delay to avoid infinite recursion on bad files + RJ.trackEndTime = GetTime() + 0.5 + RJ.pollingForEnd = false + end + end + + RefreshPlaybackButtons() + return ok +end + +-- Skip to next track in queue +local function SkipTrack() + if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then + return + end + -- 1-second cooldown on Skip to prevent music overlap + if GetTime() < RJ.skipCooldownUntil then + SetStatus("Skip cooldown -- please wait...", 1, 1, 0.2) + return + end + RJ.skipCooldownUntil = GetTime() + COOLDOWN_SECS + RJ.prevCooldownUntil = GetTime() + COOLDOWN_SECS + -- Stop the jukebox track before advancing. Use KillAllSounds + -- with a callback so the next track plays AFTER sound is re-enabled. + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.currentCanStop = false + RJ.trackEndTime = nil + RJ.pollingForEnd = false + KillAllSounds(PlayNextQueuedTrack) +end + + +-- Go back to previous track in queue +local function PreviousTrack() + if not RJ.autoPlayEnabled or type(RJ.playQueue) ~= "table" then + return + end + -- 1-second cooldown on Previous to prevent music overlap + if GetTime() < RJ.prevCooldownUntil then + SetStatus("Previous cooldown -- please wait...", 1, 1, 0.2) + return + end + RJ.prevCooldownUntil = GetTime() + COOLDOWN_SECS + RJ.skipCooldownUntil = GetTime() + COOLDOWN_SECS + if RJ.playQueuePosition <= 1 then + RJ.playQueuePosition = 0 + else + RJ.playQueuePosition = RJ.playQueuePosition - 2 + end + -- Stop the jukebox track before going back. Use KillAllSounds + -- with a callback so the next track plays AFTER sound is re-enabled. + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.currentCanStop = false + RJ.trackEndTime = nil + RJ.pollingForEnd = false + KillAllSounds(PlayNextQueuedTrack) +end + + +local function StartPlaylist(shuffle) + EnsureDB() + + -- 1-second cooldown to prevent spam-starting playlists + if GetTime() < RJ.playCooldownUntil or GetTime() < RJ.stopCooldownUntil then + SetStatus("Cooldown active -- please wait...", 1, 1, 0.2) + return + end + + local total = Count(RelationshipsJukeboxDB.tracks) + if total == 0 then + SetStatus("No tracks found.", 1, 0.2, 0.2) + return + end + + StopPlayback(true) + + RJ.playQueue = {} + RJ.playQueuePosition = 0 + RJ.autoPlayEnabled = true + RJ.shuffleMode = shuffle and true or false + + local i + for i = 1, total do + table.insert(RJ.playQueue, i) + end + + if RJ.shuffleMode then + ShuffleQueue(RJ.playQueue) + end + + PlayNextQueuedTrack() +end + + +local function PerformSearch() + EnsureDB() + local query = RJ.searchInput and RJ.searchInput:GetText() or "" + query = Trim(query) + + if query == "" then + -- Empty query = clear search, show all tracks + RJ.searchFilter = "" + RJ.filteredIndices = nil + RJ.page = 1 + RefreshList() + SetStatus("Search cleared. Showing all tracks.", 0.2, 1, 0.2) + return + end + + RJ.searchFilter = query + RJ.filteredIndices = {} + local lowerQuery = string.lower(query) + + for i = 1, Count(RelationshipsJukeboxDB.tracks) do + local entry = RelationshipsJukeboxDB.tracks[i] + local displayName = string.lower(GetTrackDisplayName(entry)) + if string.find(displayName, lowerQuery, 1, true) then + table.insert(RJ.filteredIndices, i) + end + end + + RJ.page = 1 + RefreshList() + + local matchCount = Count(RJ.filteredIndices) + if matchCount > 0 then + SetStatus("Found " .. matchCount .. " track(s) matching \"" .. query .. "\"", 0.2, 1, 0.2) + else + SetStatus("No tracks found matching \"" .. query .. "\"", 1, 0.2, 0.2) + end +end + +local function ClearSearch() + RJ.searchFilter = "" + RJ.filteredIndices = nil + if RJ.searchInput then RJ.searchInput:SetText("") end + RJ.page = 1 + RefreshList() + SetStatus("Search cleared. Showing all tracks.", 0.2, 1, 0.2) +end + +local function RemoveTrack(index) + EnsureDB() + if RelationshipsJukeboxDB.tracks[index] then + local removed = GetTrackDisplayName(RelationshipsJukeboxDB.tracks[index]) + table.remove(RelationshipsJukeboxDB.tracks, index) + if RJ.currentIndex == index then + StopPlayback(true) + end + -- Re-run search if a filter is active so filteredIndices stays in sync + if RJ.searchFilter and RJ.searchFilter ~= "" then + PerformSearch() + else + RefreshList() + end + SetStatus("Removed: " .. removed, 1, 0.82, 0) + end +end + +----------------------------------------------------------------------- +-- Button helper +----------------------------------------------------------------------- +local function MakeButton(name, parent, width, height, text) + local b = CreateFrame("Button", name, parent, "UIPanelButtonTemplate") + b:SetWidth(width) + b:SetHeight(height) + b:SetText(text) + return b +end + +----------------------------------------------------------------------- +-- Main GUI: track list window +-- Layout (total height 600): +-- TOP: title/subtitle/input (~106 px from top) +-- MIDDLE: track list rows (12/page) (~336 px) +-- Row 1: Playback controls (<< Prev, Skip >>, Play All, Shuffle, Stop) +-- Row 2: Page navigation (Prev, Page X/Y, Next) +-- Slash help text (~20 px) +-- Status bar (~20 px) +-- Bottom padding (~18 px) +----------------------------------------------------------------------- +local function CreateRow(parent, i) + local row = CreateFrame("Frame", nil, parent) + row:SetWidth(680) + row:SetHeight(27) + + -- Give every track its own visible container so each song reads like a + -- separate boxed row instead of one flat list. + -- + -- Some 1.12-based clients are inconsistent about drawing SetBackdrop() + -- borders on anonymous child frames. Use explicit textures instead so the + -- row box always appears on Turtle WoW / OctoWoW. + row.bg = row:CreateTexture(nil, "BACKGROUND") + row.bg:SetPoint("TOPLEFT", row, "TOPLEFT", 1, -1) + row.bg:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -1, 1) + + if math.mod(i, 2) == 1 then + row.bg:SetTexture(0.10, 0.10, 0.10, 0.82) + else + row.bg:SetTexture(0.06, 0.06, 0.06, 0.78) + end + + row.borderTop = row:CreateTexture(nil, "BORDER") + row.borderTop:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + row.borderTop:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0) + row.borderTop:SetHeight(1) + row.borderTop:SetTexture(0.45, 0.45, 0.45, 0.95) + + row.borderBottom = row:CreateTexture(nil, "BORDER") + row.borderBottom:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0) + row.borderBottom:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + row.borderBottom:SetHeight(1) + row.borderBottom:SetTexture(0.45, 0.45, 0.45, 0.95) + + row.borderLeft = row:CreateTexture(nil, "BORDER") + row.borderLeft:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + row.borderLeft:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0) + row.borderLeft:SetWidth(1) + row.borderLeft:SetTexture(0.45, 0.45, 0.45, 0.95) + + row.borderRight = row:CreateTexture(nil, "BORDER") + row.borderRight:SetPoint("TOPRIGHT", row, "TOPRIGHT", 0, 0) + row.borderRight:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + row.borderRight:SetWidth(1) + row.borderRight:SetTexture(0.45, 0.45, 0.45, 0.95) + + row.label = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + row.label:SetPoint("LEFT", row, "LEFT", 10, 0) + row.label:SetWidth(496) + row.label:SetJustifyH("LEFT") + row.label:SetText("") + + row.play = MakeButton(nil, row, 70, 20, "Play") + row.play:SetPoint("RIGHT", row, "RIGHT", -80, 0) + row.play:SetScript("OnClick", function() + if row.index then PlayTrack(row.index, false) end + end) + + row.remove = MakeButton(nil, row, 70, 20, "Remove") + row.remove:SetPoint("RIGHT", row, "RIGHT", -6, 0) + row.remove:SetScript("OnClick", function() + if row.index then RemoveTrack(row.index) end + end) + + return row +end + +local function CreateUI() + if RJ.frame then return end + + local f = CreateFrame("Frame", ADDON_NAME .. "Frame", UIParent) + RJ.frame = f + f:SetWidth(730) + f:SetHeight(600) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForDrag("LeftButton") + f:SetClampedToScreen(true) + f:SetScript("OnDragStart", function() f:StartMoving() end) + f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end) + f:Hide() + + if f.SetBackdrop then + f:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border", + tile = true, tileSize = 32, edgeSize = 32, + insets = { left = 11, right = 12, top = 12, bottom = 11 } + }) + f:SetBackdropColor(0, 0, 0, 0.85) + end + + -- Title bar + local title = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge") + title:SetPoint("TOP", f, "TOP", 0, -16) + title:SetText("Relationships Jukebox") + + local subtitle = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + subtitle:SetPoint("TOP", title, "BOTTOM", 0, -18) + subtitle:SetText("Put Your Sound Files In The \"RelationshipsJukeBox\\Media\" Folder Then Run \"GeneratePlaylist.bat\"") + + local close = MakeButton(nil, f, 28, 22, "X") + close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -14, -14) + close:SetScript("OnClick", function() f:Hide() end) + + -- Search input row + local searchLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") + searchLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 24, -76) + searchLabel:SetText("Search") + + local searchInput = CreateFrame("EditBox", ADDON_NAME .. "SearchInput", f, "InputBoxTemplate") + RJ.searchInput = searchInput + searchInput:SetWidth(290) + searchInput:SetHeight(20) + searchInput:SetPoint("LEFT", searchLabel, "RIGHT", 10, 0) + searchInput:SetAutoFocus(false) + searchInput:SetTextInsets(6, 6, 0, 0) + searchInput:SetText("") + searchInput:SetScript("OnEscapePressed", function() + searchInput:ClearFocus() + end) + searchInput:SetScript("OnEnterPressed", function() + searchInput:ClearFocus() + PerformSearch() + end) + + local searchBtn = MakeButton(nil, f, 70, 22, "Search") + searchBtn:SetPoint("LEFT", searchInput, "RIGHT", 10, 0) + searchBtn:SetScript("OnClick", PerformSearch) + + local clearBtn = MakeButton(nil, f, 60, 22, "Clear") + clearBtn:SetPoint("LEFT", searchBtn, "RIGHT", 8, 0) + clearBtn:SetScript("OnClick", ClearSearch) + + -- Track list (anchored from top, leaves room for buttons at bottom) + local listAnchor = CreateFrame("Frame", nil, f) + listAnchor:SetPoint("TOPLEFT", f, "TOPLEFT", 20, -106) + listAnchor:SetWidth(690) + listAnchor:SetHeight(336) + + for i = 1, RJ.pageSize do + local row = CreateRow(listAnchor, i) + if i == 1 then + row:SetPoint("TOPLEFT", listAnchor, "TOPLEFT", 0, 1) + else + row:SetPoint("TOPLEFT", RJ.rows[i-1], "BOTTOMLEFT", 0, -1) + end + RJ.rows[i] = row + end + + -- Bottom button bar: two rows + -- Row 1 (higher): Playback controls (<< Prev, Skip >>, Play All, Shuffle, Stop) + -- Row 2 (lower): Page navigation (Prev, Page X/Y, Next) + Timer + local playbackY = 108 -- playback controls row offset from bottom + local pageY = 54 -- page navigation row offset from bottom + + -- Playback controls (upper row, left-aligned) + RJ.prevButton = MakeButton(nil, f, 70, 22, "<< Prev") + RJ.prevButton:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, playbackY) + RJ.prevButton:SetScript("OnClick", PreviousTrack) + RJ.prevButton:Disable() + + RJ.skipButton = MakeButton(nil, f, 70, 22, "Skip >>") + RJ.skipButton:SetPoint("LEFT", RJ.prevButton, "RIGHT", 8, 0) + RJ.skipButton:SetScript("OnClick", SkipTrack) + RJ.skipButton:Disable() + + RJ.playAllButton = MakeButton(nil, f, 80, 22, "Play All") + RJ.playAllButton:SetPoint("LEFT", RJ.skipButton, "RIGHT", 8, 0) + RJ.playAllButton:SetScript("OnClick", function() StartPlaylist(false) end) + + RJ.shuffleButton = MakeButton(nil, f, 70, 22, "Shuffle") + RJ.shuffleButton:SetPoint("LEFT", RJ.playAllButton, "RIGHT", 8, 0) + RJ.shuffleButton:SetScript("OnClick", function() StartPlaylist(true) end) + + RJ.stopButton = MakeButton(nil, f, 60, 22, "Stop") + RJ.stopButton:SetPoint("LEFT", RJ.shuffleButton, "RIGHT", 8, 0) + RJ.stopButton:SetScript("OnClick", function() StopPlayback(false) end) + + -- Page navigation (lower row, left-aligned — moved down to replace slash text area) + local prev = MakeButton(nil, f, 70, 22, "Prev") + prev:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, pageY) + prev:SetScript("OnClick", function() + RJ.page = RJ.page - 1 + RefreshList() + end) + + RJ.pageText = f:CreateFontString(nil, "OVERLAY", "GameFontNormal") + RJ.pageText:SetPoint("LEFT", prev, "RIGHT", 14, 0) + RJ.pageText:SetText("Page 1/1") + + local nextBtn = MakeButton(nil, f, 70, 22, "Next") + nextBtn:SetPoint("LEFT", RJ.pageText, "RIGHT", 14, 0) + nextBtn:SetScript("OnClick", function() + RJ.page = RJ.page + 1 + RefreshList() + end) + + -- Volume slider (between Next button and timer, page navigation row) + -- Controls the master volume via the "MasterVolume" CVar. + -- This affects all game audio including the jukebox. + -- The entire block is wrapped in pcall so that if *any* part of slider + -- creation fails (missing API, texture, etc.) the rest of CreateUI() + -- still runs — timer, reset button, mini player, minimap icon, etc. + pcall(function() + local sliderFrame = CreateFrame("Frame", nil, f) + sliderFrame:SetWidth(120) + sliderFrame:SetHeight(17) + sliderFrame:SetPoint("LEFT", nextBtn, "RIGHT", 14, 0) + if sliderFrame.SetBackdrop then + sliderFrame:SetBackdrop({ + bgFile = "Interface\\Buttons\\UI-SliderBar-Background", + edgeFile = "Interface\\Buttons\\UI-SliderBar-Border", + tile = true, tileSize = 8, edgeSize = 8, + insets = { left = 3, right = 3, top = 6, bottom = 6 } + }) + end + local slider = CreateFrame("Slider", nil, sliderFrame) + RJ.volumeSlider = slider + slider:SetAllPoints(sliderFrame) + slider:SetOrientation("HORIZONTAL") + slider:SetMinMaxValues(0, 100) + slider:SetValueStep(5) + -- SetObeyStepOnDrag may not exist in 1.12; guard it + if slider.SetObeyStepOnDrag then + slider:SetObeyStepOnDrag(true) + end + slider:EnableMouse(true) + -- Thumb texture (the draggable knob) + slider:SetThumbTexture("Interface\\Buttons\\UI-SliderBar-Button-Horizontal") + -- Label above the slider + local sliderLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + sliderLabel:SetPoint("BOTTOM", sliderFrame, "TOP", 0, 0) + sliderLabel:SetText("Vol") + -- Low / High labels below the slider + local sliderLow = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + sliderLow:SetPoint("TOPLEFT", sliderFrame, "BOTTOMLEFT", 2, 3) + sliderLow:SetText("0") + local sliderHigh = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + sliderHigh:SetPoint("TOPRIGHT", sliderFrame, "BOTTOMRIGHT", -2, 3) + sliderHigh:SetText("100") + -- Read initial volume from SavedVariables (falls back to current CVar) + local cv = GetCVar or function() return "1" end + local ok, val = pcall(cv, "MasterVolume") + local initial = RelationshipsJukeboxDB.volume + if not initial then + initial = (ok and val) and math.floor(tonumber(val) * 100 + 0.5) or 100 + end + slider:SetValue(initial) + slider:SetScript("OnValueChanged", function() + local pct = this:GetValue() + local sc = SetCVar or function() end + -- MasterVolume is 0..1, slider is 0..100 + pcall(sc, "MasterVolume", string.format("%.2f", pct / 100)) + -- Persist to SavedVariables + RelationshipsJukeboxDB.volume = pct + end) + end) + + -- Timer display (right side of page navigation row) + RJ.timerText = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + RJ.timerText:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -24, pageY) + RJ.timerText:SetJustifyH("RIGHT") + RJ.timerText:SetTextColor(0.8, 0.8, 0.8) + RJ.timerText:SetText("") + + -- Reset button (bottom right, just under the timer display) + -- Resets the addon to default: stops playback, restores music CVar, + -- resets frame position, and re-initializes the database. + local resetBtn = MakeButton(nil, f, 60, 20, "Reset") + resetBtn:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -24, 33) + resetBtn:SetScript("OnClick", function() + -- Stop any playing track and restore full audio + if RJ.currentPath or RJ.autoPlayEnabled then + RestoreZoneMusic() + StopJukeboxTrack() + end + -- Cancel any pending sound re-enable and immediately + -- re-enable all sound so the player can hear game audio. + -- KillAllSounds() sets MasterSoundEffects="0" and schedules a + -- re-enable, but the re-enable callback is cancelled below. + -- Without this, the player would be left with no sound at all. + RJ._pendingReenableAt = nil + RJ._pendingReenableCallback = nil + do + local sc = SetCVar or function() end + pcall(sc, "MasterSoundEffects", "1") + pcall(sc, "EnableMusic", "1") + end + RJ._originalEnableMusic = nil + RJ._savedEnableMusic = nil + -- Reset all internal state + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.pauseCooldownUntil = 0 + RJ.playCooldownUntil = 0 + RJ.skipCooldownUntil = 0 + RJ.prevCooldownUntil = 0 + RJ.stopCooldownUntil = 0 + RJ.autoPlayEnabled = false + RJ.playQueue = nil + RJ.playQueuePosition = 0 + RJ.shuffleMode = false + RJ.trackEndTime = nil + RJ.trackStartTime = nil + RJ.pausedElapsed = nil + RJ.currentDuration = 0 + RJ.pollingForEnd = false + RJ.pollTimer = 0 + RJ.consecutiveNotPlaying = 0 + RJ.currentCanStop = false + RJ.currentIndex = nil + RJ.currentPath = nil + RJ.currentName = nil + -- Reset frame position to center of screen + f:ClearAllPoints() + f:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + -- Reset mini player position and show it + if RJ.miniFrame then + RJ.miniFrame:ClearAllPoints() + RJ.miniFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 300, -300) + RJ.miniFrame:Show() + else + CreateMiniPlayer() + if RJ.miniFrame then + RJ.miniFrame:Show() + end + end + -- Reset minimap icon to center of screen + if RJ.minimapButton then + RJ.minimapButton:ClearAllPoints() + RJ.minimapButton:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + RelationshipsJukeboxDB.iconX = nil + RelationshipsJukeboxDB.iconY = nil + end + -- Reset volume slider to default (100%) + do + local sc = SetCVar or function() end + pcall(sc, "MasterVolume", "1.0") + RelationshipsJukeboxDB.volume = 100 + if RJ.volumeSlider then + RJ.volumeSlider:SetValue(100) + end + end + -- Re-initialize database + EnsureDB() + -- Reset page + RJ.page = 1 + RJ.searchFilter = "" + RJ.filteredIndices = nil + RefreshList() + RefreshPlaybackButtons() + RefreshMiniPlayer() + SetStatus("Addon reset to defaults. All sound re-enabled.", 0.2, 1, 0.2) + end) + + -- Status bar (very bottom) + RJ.statusText = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + RJ.statusText:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 24, 18) + RJ.statusText:SetWidth(680) + RJ.statusText:SetJustifyH("LEFT") + RJ.statusText:SetText("Ready.") + + RefreshList() +end + +----------------------------------------------------------------------- +-- Mini Player: small floating control bar +----------------------------------------------------------------------- +function RefreshMiniPlayer() + if not RJ.miniFrame then return end + local mf = RJ.miniFrame + local inQueue = RJ.autoPlayEnabled and RJ.playQueue ~= nil + local isActive = RJ.currentPath ~= nil + local now = GetTime() + + -- Build the display text for the mini player. The ScrollFrame marquee + -- will lay it out on the next frame; here we only reset state when the + -- visible text actually changes. + local newText + if RJ.currentName and RJ.currentName ~= "" then + if RJ.isPaused then + newText = "|| " .. RJ.currentName + else + newText = RJ.currentName + end + else + newText = "Relationships Jukebox" + end + + if RJ.marqueeSingleText ~= newText then + if mf.trackLabel then + mf.trackLabel:SetText(newText) + end + RJ.marqueeOffset = 0 + RJ.marqueeLastUpdate = 0 + RJ.marqueeCycleWidth = 0 + RJ.marqueeSingleText = "" + RJ.marqueeSingleWidth = 0 + end + + -- Enable/disable buttons with 1-second cooldown checks + if inQueue and now >= RJ.skipCooldownUntil then + mf.skipBtn:Enable() + else + mf.skipBtn:Disable() + end + + if inQueue and RJ.playQueuePosition > 1 and now >= RJ.prevCooldownUntil then + mf.prevBtn:Enable() + else + mf.prevBtn:Disable() + end + + if isActive or RJ.isPaused then + if now >= RJ.stopCooldownUntil then + mf.stopBtn:Enable() + else + mf.stopBtn:Disable() + end + else + mf.stopBtn:Disable() + end +end + + +local function CreateMiniPlayer() + if RJ.miniFrame then return end + + local mf = CreateFrame("Frame", ADDON_NAME .. "MiniFrame", UIParent) + RJ.miniFrame = mf + mf:SetWidth(216) + mf:SetHeight(68) + mf:SetPoint("TOPRIGHT", UIParent, "TOPRIGHT", -60, -200) + mf:SetMovable(true) + mf:EnableMouse(true) + mf:RegisterForDrag("LeftButton") + mf:SetScript("OnDragStart", function() mf:StartMoving() end) + mf:SetScript("OnDragStop", function() mf:StopMovingOrSizing() end) + mf:SetClampedToScreen(true) + mf:SetScript("OnShow", function() + RJ.marqueeLastUpdate = 0 + end) + + if mf.SetBackdrop then + mf:SetBackdrop({ + bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 8, edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 } + }) + mf:SetBackdropColor(0, 0, 0, 0.7) + end + + -- Track name (top row) - marquee scrolling text + -- Use a ScrollFrame viewport plus repeated labels so the text scrolls + -- smoothly at the pixel level without re-slicing strings every frame. + mf.trackClip = CreateFrame("ScrollFrame", nil, mf) + mf.trackClip:SetPoint("TOPLEFT", mf, "TOPLEFT", 8, -12) + mf.trackClip:SetPoint("BOTTOMRIGHT", mf, "TOPRIGHT", -8, -30) + mf.trackClip:SetHorizontalScroll(0) + + mf.trackCanvas = CreateFrame("Frame", nil, mf.trackClip) + mf.trackCanvas:SetWidth(1) + mf.trackCanvas:SetHeight(14) + mf.trackClip:SetScrollChild(mf.trackCanvas) + + mf.trackLabels = {} + for labelIndex = 1, 3 do + local label = mf.trackCanvas:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + label:SetJustifyH("LEFT") + label:SetTextColor(0.4, 1, 0.4) + label:SetText("Relationships Jukebox") + mf.trackLabels[labelIndex] = label + end + + mf.trackLabel = mf.trackLabels[1] + + local function PositionMiniMarquee(offset) + mf.trackCanvas:ClearAllPoints() + + if RJ.marqueeCycleWidth > 0 then + -- Keep the middle copy inside the viewport and slide the whole strip to + -- the left. Wrapping happens by reusing the previous/next copies, so the + -- text never disappears into a blank gap between loops. + mf.trackCanvas:SetPoint("TOPLEFT", mf.trackClip, "TOPLEFT", -RJ.marqueeCycleWidth - offset, 0) + else + mf.trackCanvas:SetPoint("TOPLEFT", mf.trackClip, "TOPLEFT", 0, 0) + end + end + + local function LayoutMiniMarquee(singleText) + if not singleText or singleText == "" then + singleText = "Relationships Jukebox" + end + + local viewWidth = mf.trackClip:GetWidth() + if viewWidth <= 0 then return end + + mf.trackLabels[1]:SetText(singleText) + + local singleWidth + if mf.trackLabels[1].GetStringWidth then + singleWidth = mf.trackLabels[1]:GetStringWidth() + else + singleWidth = string.len(singleText) * 7 + end + + RJ.marqueeSingleText = singleText + RJ.marqueeSingleWidth = singleWidth + + if singleWidth <= viewWidth or singleWidth == 0 then + RJ.marqueeCycleWidth = 0 + RJ.marqueeOffset = 0 + RJ.marqueeFullText = singleText + + mf.trackLabels[1]:ClearAllPoints() + mf.trackLabels[1]:SetPoint("TOPLEFT", mf.trackCanvas, "TOPLEFT", 0, 0) + mf.trackLabels[1]:SetText(singleText) + mf.trackLabels[2]:SetText("") + mf.trackLabels[3]:SetText("") + + mf.trackCanvas:SetWidth(math.max(viewWidth, singleWidth)) + PositionMiniMarquee(0) + return + end + + RJ.marqueeFullText = singleText .. (RJ.marqueeSeparator or " ") + for labelIndex = 1, 3 do + mf.trackLabels[labelIndex]:SetText(RJ.marqueeFullText) + end + + if mf.trackLabels[1].GetStringWidth then + RJ.marqueeCycleWidth = mf.trackLabels[1]:GetStringWidth() + else + RJ.marqueeCycleWidth = string.len(RJ.marqueeFullText) * 7 + end + + for labelIndex = 1, 3 do + mf.trackLabels[labelIndex]:ClearAllPoints() + mf.trackLabels[labelIndex]:SetPoint("TOPLEFT", mf.trackCanvas, "TOPLEFT", (labelIndex - 1) * RJ.marqueeCycleWidth, 0) + end + + mf.trackCanvas:SetWidth(RJ.marqueeCycleWidth * 3) + PositionMiniMarquee(RJ.marqueeOffset) + end + + -- Smooth marquee update fully clipped to the mini player bounds. + mf:SetScript("OnUpdate", function() + local singleText + if RJ.currentName and RJ.currentName ~= "" then + if RJ.isPaused then + singleText = "|| " .. RJ.currentName + else + singleText = RJ.currentName + end + else + singleText = "Relationships Jukebox" + end + + if RJ.marqueeSingleText ~= singleText then + RJ.marqueeOffset = 0 + RJ.marqueeLastUpdate = 0 + LayoutMiniMarquee(singleText) + end + + if not RJ.currentName or RJ.currentName == "" then + RJ.marqueeOffset = 0 + RJ.marqueeLastUpdate = 0 + PositionMiniMarquee(0) + return + end + + if RJ.marqueeCycleWidth <= 0 then + PositionMiniMarquee(0) + return + end + + local now = GetTime() + if RJ.marqueeLastUpdate == 0 then + RJ.marqueeLastUpdate = now + PositionMiniMarquee(RJ.marqueeOffset) + return + end + + local elapsed = now - RJ.marqueeLastUpdate + RJ.marqueeLastUpdate = now + if elapsed > 0.5 then elapsed = 0.5 end + + RJ.marqueeOffset = RJ.marqueeOffset + (RJ.marqueeSpeed * elapsed) + while RJ.marqueeOffset >= RJ.marqueeCycleWidth do + RJ.marqueeOffset = RJ.marqueeOffset - RJ.marqueeCycleWidth + end + + PositionMiniMarquee(RJ.marqueeOffset) + end) + + -- Timer display (between track name and control buttons) + mf.timerText = mf:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + mf.timerText:SetPoint("TOPLEFT", mf, "TOPLEFT", 4, -29) + mf.timerText:SetPoint("TOPRIGHT", mf, "TOPRIGHT", -8, -29) + mf.timerText:SetJustifyH("RIGHT") + mf.timerText:SetTextColor(0.8, 0.8, 0.8) + mf.timerText:SetText("") + + -- Control buttons (bottom row): <<, Stop, >>, +, - + mf.prevBtn = MakeButton(nil, mf, 40, 20, "<<") + mf.prevBtn:SetPoint("BOTTOMLEFT", mf, "BOTTOMLEFT", 6, 6) + mf.prevBtn:SetScript("OnClick", PreviousTrack) + mf.prevBtn:Disable() + + mf.stopBtn = MakeButton(nil, mf, 50, 20, "Stop") + mf.stopBtn:SetPoint("LEFT", mf.prevBtn, "RIGHT", 4, 0) + mf.stopBtn:SetScript("OnClick", function() StopPlayback(false) end) + mf.stopBtn:Disable() + + mf.skipBtn = MakeButton(nil, mf, 40, 20, ">>") + mf.skipBtn:SetPoint("LEFT", mf.stopBtn, "RIGHT", 4, 0) + mf.skipBtn:SetScript("OnClick", SkipTrack) + mf.skipBtn:Disable() + + -- Open full GUI button + mf.openBtn = MakeButton(nil, mf, 28, 20, "+") + mf.openBtn:SetPoint("LEFT", mf.skipBtn, "RIGHT", 4, 0) + mf.openBtn:SetScript("OnClick", function() + if RJ.frame then + RJ.frame:Show() + RefreshList() + end + end) + + -- Minimize button (bottom row, after + button) + mf.minBtn = MakeButton(nil, mf, 28, 20, "-") + mf.minBtn:SetPoint("LEFT", mf.openBtn, "RIGHT", 4, 0) + mf.minBtn:SetScript("OnClick", function() + mf:Hide() + if RJ.minimapButton then + RJ.minimapButton:Show() + end + end) + + mf:Show() + -- Reset marquee timer when mini player first appears + RJ.marqueeLastUpdate = 0 + RefreshMiniPlayer() +end + +----------------------------------------------------------------------- +-- Minimap button +----------------------------------------------------------------------- +local function CreateMinimapButton() + if RJ.minimapButton then return end + + pcall(function() + -- Parent to UIParent so the button can be dragged anywhere on screen, + -- not just pinned to the minimap. + local btn = CreateFrame("Button", ADDON_NAME .. "MinimapButton", UIParent) + RJ.minimapButton = btn + btn:SetWidth(32) + btn:SetHeight(32) + + -- Use the built-in paper/note icon (guaranteed to exist in 1.12). + btn:SetNormalTexture("Interface\\Icons\\INV_Misc_Note_06") + btn:SetPushedTexture("Interface\\Icons\\INV_Misc_Note_06") + btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + + -- Position: use saved coordinates, or default to a visible spot. + local savedX = RelationshipsJukeboxDB.iconX + local savedY = RelationshipsJukeboxDB.iconY + if savedX and savedY then + btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", savedX, -savedY) + else + -- Default position: top-right area of screen, guaranteed visible + local screenWidth = UIParent:GetWidth() or 1024 + btn:SetPoint("TOPLEFT", UIParent, "TOPLEFT", screenWidth - 80, -30) + end + + -- Tooltip + btn:SetScript("OnEnter", function() + GameTooltip:SetOwner(btn, "ANCHOR_LEFT") + GameTooltip:SetText("Relationships Jukebox") + GameTooltip:AddLine("Click: Toggle Mini Player") + GameTooltip:AddLine("Right-click: Toggle Full GUI") + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + -- Left click: toggle mini player + btn:SetScript("OnClick", function() + if RJ.miniFrame then + if RJ.miniFrame:IsShown() then + RJ.miniFrame:Hide() + else + RJ.miniFrame:Show() + RefreshMiniPlayer() + end + else + CreateMiniPlayer() + end + end) + + -- Right click: toggle full GUI + btn:SetScript("OnMouseDown", function() + if arg1 == "RightButton" then + if not RJ.frame then CreateUI() end + if RJ.frame:IsShown() then + RJ.frame:Hide() + else + RJ.frame:Show() + RefreshList() + end + end + end) + + -- Free dragging: the button can be moved anywhere on screen. + -- Save position to SavedVariables on drag stop. + btn:SetMovable(true) + btn:RegisterForDrag("LeftButton") + btn:SetScript("OnDragStart", function() + btn:StartMoving() + end) + btn:SetScript("OnDragStop", function() + btn:StopMovingOrSizing() + -- Save position to SavedVariables (store positive offset from top-left) + local left = btn:GetLeft() or 0 + local top = btn:GetTop() or 0 + local screenTop = (UIParent:GetTop() or 768) + RelationshipsJukeboxDB.iconX = left + RelationshipsJukeboxDB.iconY = screenTop - top + end) + + btn:Show() + end) +end + +----------------------------------------------------------------------- +-- Zone ambient music suppression +----------------------------------------------------------------------- +-- The jukebox now plays through its own sound channel, so zone ambient +-- music events no longer interfere with jukebox playback. However, we +-- still disable EnableMusic while the jukebox is playing to prevent the +-- game engine from playing zone ambient music over the jukebox track on +-- the Music channel. When the jukebox stops or pauses, RestoreZoneMusic() +-- restores the player's original EnableMusic setting. + +-- Saved state for EnableMusic CVar restoration +RJ._savedEnableMusic = nil -- original EnableMusic value before jukebox took over +RJ._originalEnableMusic = nil -- player's EnableMusic value before addon loaded (for Reset) + +-- Suppress zone ambient music when the jukebox is playing. +-- Saves the original EnableMusic value and sets it to "0" so the +-- engine does not start zone ambient music on the Music channel. +SuppressZoneMusic = function() + local cv = GetCVar or function() return "1" end + local sc = SetCVar or function() end + local ok, val = pcall(cv, "EnableMusic") + RJ._savedEnableMusic = (ok and val) or "1" + -- Set EnableMusic="0" to suppress zone ambient music. + pcall(sc, "EnableMusic", "0") +end + +-- Restore zone ambient music when the jukebox stops or pauses. +-- Restores the EnableMusic value saved by SuppressZoneMusic(), +-- allowing zone ambient music to resume normally. +RestoreZoneMusic = function() + local sc = SetCVar or function() end + if RJ._savedEnableMusic then + pcall(sc, "EnableMusic", RJ._savedEnableMusic) + RJ._savedEnableMusic = nil + end +end + + + + + +-- Loading screen / zone change handler: NOT NEEDED. +-- Since the jukebox now uses its own sound channel ("Ambience"), loading +-- screens and zone changes do NOT interrupt jukebox playback. The track +-- simply continues playing through them. No event handler is required. +-- (Previously FMOD was thought to rebuild on PLAYER_ENTERING_WORLD and +-- kill all sounds, but on the separate channel the track persists.) + +----------------------------------------------------------------------- +-- Logout handler: stop jukebox music when the player logs out +----------------------------------------------------------------------- +local logoutHandler = CreateFrame("Frame") +logoutHandler:RegisterEvent("PLAYER_LOGOUT") +logoutHandler:SetScript("OnEvent", function() + -- Stop the jukebox track and restore the player's normal audio + -- settings so that zone ambient music / SFX / ambience resume + -- on the next login or character switch. + if RJ.currentPath or RJ.autoPlayEnabled then + RestoreZoneMusic() + -- Stop the jukebox track. + StopJukeboxTrack() + RJ.autoPlayEnabled = false + RJ.currentPath = nil + RJ.currentName = nil + RJ.currentIndex = nil + RJ.isPaused = false + RJ.pausedRemaining = nil + RJ.currentCanStop = false + RJ.pollingForEnd = false + RJ.trackEndTime = nil + RJ.trackStartTime = nil + RJ.pausedElapsed = nil + RJ.currentDuration = 0 + end + -- Always restore the player's original EnableMusic on logout, + -- even if no track was playing. + if RJ._originalEnableMusic then + local sc = SetCVar or function() end + pcall(sc, "EnableMusic", RJ._originalEnableMusic) + RJ._originalEnableMusic = nil + end + -- Suppress any remaining pending state + RJ._pendingReenableAt = nil + RJ._pendingReenableCallback = nil +end) + +----------------------------------------------------------------------- +-- Main ticker: handles timed advancement and IsPlaying polling +----------------------------------------------------------------------- +local ticker = CreateFrame("Frame") +ticker:SetScript("OnUpdate", function() + -- -1) Pending MasterSoundEffects re-enable: if KillAllSounds() disabled all + -- sound, we need to re-enable it after a short delay so FMOD has time + -- to actually stop the audio before we turn the master CVar back on. + if RJ._pendingReenableAt and GetTime() >= RJ._pendingReenableAt then + local sc = SetCVar or function() end + pcall(sc, "MasterSoundEffects", "1") + RJ._pendingReenableAt = nil + -- If a callback was registered (e.g., play new track after killing old one), + -- invoke it now that sound is re-enabled. + if RJ._pendingReenableCallback then + local cb = RJ._pendingReenableCallback + RJ._pendingReenableCallback = nil + cb() + end + end + + -- 0) Loading screen resume: NOT NEEDED. The jukebox plays on its own + -- sound channel and continues through loading screens uninterrupted. + -- No re-assert is required. + + -- 1) Timed advancement: track with known duration reached end time + if not RJ.isPaused and RJ.trackEndTime and GetTime() >= RJ.trackEndTime then + RJ.trackEndTime = nil + if RJ.autoPlayEnabled then + -- Queue mode: advance to next track + RJ.currentCanStop = false + RJ.pollingForEnd = false + KillAllSounds(PlayNextQueuedTrack) + else + -- Single track mode: stop playback when track finishes + StopPlayback(true) + SetStatus("Track finished.", 0.2, 1, 0.2) + end + return + end + + -- 2) IsPlaying polling: detect when PlaySoundFile track finishes for tracks with no duration + if not RJ.isPaused and RJ.pollingForEnd then + if GetTime() < RJ.pollTimer then + return + end + + local playing = false + if IsPlaying then + playing = IsPlaying() + end + + if not playing then + RJ.consecutiveNotPlaying = RJ.consecutiveNotPlaying + 1 + if RJ.consecutiveNotPlaying >= RJ.consecutiveThreshold then + RJ.pollingForEnd = false + RJ.consecutiveNotPlaying = 0 + if RJ.autoPlayEnabled then + -- Queue mode: advance to next track + RJ.currentCanStop = false + KillAllSounds(PlayNextQueuedTrack) + else + -- Single track mode: stop playback when track finishes + StopPlayback(true) + SetStatus("Track finished.", 0.2, 1, 0.2) + end + else + -- Not sure yet; check again soon + RJ.pollTimer = GetTime() + RJ.pollInterval + end + else + -- Still playing; reset counter, schedule next check + RJ.consecutiveNotPlaying = 0 + RJ.pollTimer = GetTime() + RJ.pollInterval + end + end + + -- 3) Re-enable stop button after cooldown expires (pause button removed from mini-player) + if RJ.pauseCooldownUntil > 0 and GetTime() >= RJ.pauseCooldownUntil then + RJ.pauseCooldownUntil = 0 + RefreshPlaybackButtons() + end + + -- 4) Re-enable Play / Play All / Shuffle buttons after play cooldown expires + if RJ.playCooldownUntil > 0 and GetTime() >= RJ.playCooldownUntil then + RJ.playCooldownUntil = 0 + RefreshPlaybackButtons() + RefreshList() + end + + -- 5) Re-enable Skip button after skip cooldown expires + if RJ.skipCooldownUntil > 0 and GetTime() >= RJ.skipCooldownUntil then + RJ.skipCooldownUntil = 0 + RefreshPlaybackButtons() + end + + -- 6) Re-enable Previous button after prev cooldown expires + if RJ.prevCooldownUntil > 0 and GetTime() >= RJ.prevCooldownUntil then + RJ.prevCooldownUntil = 0 + RefreshPlaybackButtons() + end + + -- 7) Re-enable Stop button after stop cooldown expires + if RJ.stopCooldownUntil > 0 and GetTime() >= RJ.stopCooldownUntil then + RJ.stopCooldownUntil = 0 + RefreshPlaybackButtons() + end + + -- 8) Update timer displays (main GUI + mini player) + -- Shows elapsed/total in M:SS/M:SS format while a track is playing. + -- While paused, the elapsed time freezes at the point of pause. + if RJ.currentPath and RJ.trackStartTime then + local elapsed + if RJ.isPaused then + elapsed = RJ.pausedElapsed or 0 + else + elapsed = GetTime() - RJ.trackStartTime + end + local total = RJ.currentDuration or 0 + local timerStr + if total > 0 then + timerStr = FormatElapsedTime(elapsed, total) + else + timerStr = FormatTime(elapsed) + end + -- Main GUI timer + if RJ.timerText then + RJ.timerText:SetText(timerStr) + end + -- Mini player timer + if RJ.miniFrame and RJ.miniFrame.timerText then + RJ.miniFrame.timerText:SetText(timerStr) + end + else + -- No track playing - clear timers + if RJ.timerText then + RJ.timerText:SetText("") + end + if RJ.miniFrame and RJ.miniFrame.timerText then + RJ.miniFrame.timerText:SetText("") + end + end +end) + +----------------------------------------------------------------------- +-- Auto-start on login (disabled — player chooses when to start music) +----------------------------------------------------------------------- + +local loader = CreateFrame("Frame") +loader:RegisterEvent("VARIABLES_LOADED") +loader:SetScript("OnEvent", function() + local imported, updated + + if time then + math.randomseed(time()) + math.random() + math.random() + math.random() + end + + EnsureDB() + + -- Save the player's original EnableMusic value and disable game engine + -- music so it does not overlap with jukebox playback. This is restored + -- on logout or when the Reset button is pressed. + do + local cv = GetCVar or function() return "1" end + local sc = SetCVar or function() end + local ok, val = pcall(cv, "EnableMusic") + RJ._originalEnableMusic = (ok and val) or "1" + pcall(sc, "EnableMusic", "0") + -- Apply saved MasterVolume (master output volume). If the + -- player has never set it, the CVar default (1.0) remains. + if RelationshipsJukeboxDB.volume then + pcall(sc, "MasterVolume", + string.format("%.2f", RelationshipsJukeboxDB.volume / 100)) + end + end + + imported, updated = MergeAutoPlaylist() + CreateUI() + CreateMiniPlayer() + CreateMinimapButton() + RefreshList() + + if imported > 0 and updated > 0 then + SetStatus("Imported " .. imported .. " and updated " .. updated .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) + elseif imported > 0 then + SetStatus("Imported " .. imported .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) + elseif updated > 0 then + SetStatus("Updated " .. updated .. " track(s) from AutoPlaylist.lua.", 0.2, 1, 0.2) + elseif Count(RelationshipsJukeboxDB.tracks) > 0 then + SetStatus("Loaded " .. Count(RelationshipsJukeboxDB.tracks) .. " track(s). Ready.", 0.2, 1, 0.2) + else + SetStatus("No tracks found. Put files in " .. ADDON_MEDIA_PREFIX .. " then run GeneratePlaylist.bat and restart or /reload.", 1, 0.82, 0) + end + + -- No auto-start on login — player chooses when to start music +end) + +----------------------------------------------------------------------- +-- Slash commands +----------------------------------------------------------------------- +SLASH_RELJUKEBOX1 = "/rjukebox" +SLASH_RELJUKEBOX2 = "/rj" +SlashCmdList["RELJUKEBOX"] = function(msg) + local command = string.lower(Trim(msg or "")) + + if not RJ.frame then CreateUI() end + + if command == "play" then + StartPlaylist(false) + return + elseif command == "shuffle" then + StartPlaylist(true) + return + elseif command == "stop" then + StopPlayback(false) + return + elseif command == "skip" then + SkipTrack() + return + elseif command == "prev" then + PreviousTrack() + return + elseif command == "pause" then + TogglePause() + return + elseif command == "mini" then + if RJ.miniFrame then + if RJ.miniFrame:IsShown() then + RJ.miniFrame:Hide() + else + RJ.miniFrame:Show() + RefreshMiniPlayer() + end + else + CreateMiniPlayer() + end + return + elseif command == "playlast" then + local total = Count(RelationshipsJukeboxDB.tracks) + if total > 0 then PlayTrack(total, false) end + return + end + + -- No command: toggle full GUI + if RJ.frame:IsShown() then + RJ.frame:Hide() + else + RJ.frame:Show() + RefreshList() + end +end diff --git a/GeneratePlaylist.bat b/GeneratePlaylist.bat new file mode 100644 index 0000000..76b36bc --- /dev/null +++ b/GeneratePlaylist.bat @@ -0,0 +1,11 @@ +@echo off +echo ============================================================ +echo Relationships Jukebox - Playlist Generator +echo ============================================================ +echo. + +REM Pass any command-line arguments (e.g. -UseWinRAR) to the PS1 script +powershell -ExecutionPolicy Bypass -File "%~dp0GeneratePlaylist.ps1" %* + +echo. +pause diff --git a/GeneratePlaylist.ps1 b/GeneratePlaylist.ps1 new file mode 100644 index 0000000..d6de9d2 --- /dev/null +++ b/GeneratePlaylist.ps1 @@ -0,0 +1,2277 @@ +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 + +# -- 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. +# +# IMPORTANT: This rename step runs BEFORE the MP3/WAV/OGG compatibility scan +# so that ffmpeg and the binary analysis functions can access files without +# special characters in their paths. Characters like apostrophes and quotes +# can break PowerShell's COM object calls and ffmpeg argument parsing. + +$problematicChars = @("'", '"', '(', ')', '[', ']', '{', '}', '!', '&', '#', '%', '@', '+', '=', ',', ';') +$renamed = 0 +$renameSkipped = 0 +$allMediaFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' -and $_.Name -notmatch '\.sanitize\.tmp\.' }) + +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) { + if (-not (Test-Path -LiteralPath $newPath)) { + # Target doesn't exist - safe to rename + 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): $_" + $renameSkipped++ + } + } else { + # Target already exists - this is likely a duplicate file from a previous + # rename or download. Delete the old problematic-name file since the + # already-renamed file takes precedence. + try { + Remove-Item -LiteralPath $mf.FullName -Force + Write-Host " Removed duplicate (already renamed): $($mf.Name) -> $newName (kept existing)" + $renameSkipped++ + } catch { + Write-Host " WARNING: Could not remove duplicate $($mf.Name): $_" + $renameSkipped++ + } + } + } + } +} + +if ($renamed -gt 0) { + Write-Host "" + Write-Host "Renamed $renamed file(s) -- replaced special characters for WoW 1.12 path compatibility." + Write-Host "" +} +if ($renameSkipped -gt 0) { + Write-Host "" + Write-Host "$renameSkipped file(s) skipped during rename (duplicate with sanitized name already existed)." + Write-Host "" +} + +# --------------------------------------------------------------------------- +# 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' + +# -- ffprobe result cache (MAJOR SPEEDUP) ----------------------------------- +# Instead of calling ffprobe multiple times per file (once for duration, once +# for video stream detection, once after re-encoding, etc.), we call it ONCE +# with -show_format -show_streams and cache the parsed JSON object per file path. +# This eliminates 1-3 redundant ffprobe process launches per file. +$script:ffprobeCache = @{} + +function Get-FfprobeData { + param([string]$FilePath) + if ($script:ffprobeCache.ContainsKey($FilePath)) { + return $script:ffprobeCache[$FilePath] + } + $exe = Find-Ffmpeg -Name 'ffprobe' + if (-not $exe) { + $script:ffprobeCache[$FilePath] = $null + return $null + } + try { + # Use & call operator instead of Start-Process — much faster + # (no process-creation overhead, proper argument quoting built-in) + $raw = & $exe '-v' 'quiet' '-print_format' 'json' '-show_format' '-show_streams' $FilePath 2>$null + if ($LASTEXITCODE -eq 0 -and $raw) { + $json = $raw | ConvertFrom-Json + $script:ffprobeCache[$FilePath] = $json + return $json + } + } catch { + # ffprobe failed — cache null to avoid retrying + } + $script:ffprobeCache[$FilePath] = $null + return $null +} + +# Clear the ffprobe cache (call after a file has been re-encoded in place) +function Clear-FfprobeCache { + param([string]$FilePath) + if ($script:ffprobeCache.ContainsKey($FilePath)) { + $script:ffprobeCache.Remove($FilePath) + } +} + +# -- ffmpeg/ffprobe call helpers using & operator (FASTER than Start-Process) -- +# PowerShell's & (call) operator properly quotes arguments containing spaces, +# so we no longer need Build-QuotedArgs or Start-Process for most calls. +# The & operator also avoids the ~200-400ms process-launch overhead that +# Start-Process imposes on every invocation. + +function Invoke-Ffprobe { + param([string[]]$Arguments) + $exe = Find-Ffmpeg -Name 'ffprobe' + if ($exe) { + return & $exe @Arguments + } + return $null +} + +function Invoke-Ffmpeg { + param([string[]]$Arguments) + $exe = Find-Ffmpeg -Name 'ffmpeg' + if ($exe) { + return & $exe @Arguments + } + return $null +} + +# -- Build a properly-quoted argument string for Start-Process --------------- +# NOTE: Start-Process is now ONLY used for ffmpeg re-encoding where we need +# -RedirectStandardError for diagnostics. All other ffprobe/ffmpeg calls +# use the faster & operator via Invoke-Ffprobe / Invoke-Ffmpeg above. +# +# PowerShell's Start-Process -ArgumentList with an array does NOT automatically +# quote elements containing spaces. When a file path like +# "G:\Music\01 - Pantera - Walk.mp3" is passed as an array element, +# Start-Process splits it at the spaces, and ffmpeg receives multiple broken +# tokens instead of one quoted path. This function builds a single argument +# string where every token that contains spaces is wrapped in double-quotes. +# +# Usage: Start-Process -FilePath $exe -ArgumentList (Build-QuotedArgs @(...)) +function Build-QuotedArgs { + param([string[]]$ArgumentArray) + $parts = @() + foreach ($arg in $ArgumentArray) { + if ($arg -match '\s') { + # Argument contains spaces -- wrap in double-quotes. + # Escape any embedded double-quotes first. + $escaped = $arg.Replace('"', '\"') + $parts += "`"$escaped`"" + } else { + $parts += $arg + } + } + return $parts -join ' ' +} + +# -- Compatibility cache (MAJOR SPEEDUP on re-runs) ------------------------- +# After the first run, each file's compatibility status is cached in a +# .compat_cache file next to the script. On subsequent runs, files whose +# last-modified timestamp and size haven't changed are skipped entirely, +# cutting scan time from minutes to seconds for large libraries. +$compatCachePath = Join-Path $Root 'mp3_compat_cache.txt' +$script:compatCache = @{} + +function Load-CompatCache { + if (Test-Path -LiteralPath $compatCachePath -PathType Leaf) { + try { + $lines = [System.IO.File]::ReadAllLines($compatCachePath) + foreach ($line in $lines) { + # Format: LastWriteTime|Length|Status|Reason + # Status: OK = compatible, FIX = was sanitized + $parts = $line -split '\|' + if ($parts.Count -ge 4) { + $key = "$($parts[0])|$($parts[1])" + $script:compatCache[$key] = @($parts[2], $parts[3]) + } + } + } catch { + $script:compatCache = @{} + } + } +} + +function Save-CompatCache { + try { + $lines = @() + foreach ($key in $script:compatCache.Keys) { + $val = $script:compatCache[$key] + $lines += "$key|$($val[0])|$($val[1])" + } + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllLines($compatCachePath, $lines, $utf8NoBom) + } catch { + # Silently ignore cache save errors + } +} + +function Get-CompatCacheEntry { + param([System.IO.FileInfo]$File) + $key = "$($File.LastWriteTimeUtc.Ticks)|$($File.Length)" + if ($script:compatCache.ContainsKey($key)) { + return $script:compatCache[$key] + } + return $null +} + +function Set-CompatCacheEntry { + param([System.IO.FileInfo]$File, [string]$Status, [string]$Reason) + $key = "$($File.LastWriteTimeUtc.Ticks)|$($File.Length)" + $script:compatCache[$key] = @($Status, $Reason) +} + +# Load cache at startup +Load-CompatCache + +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 ffprobe cache FIRST (avoids redundant process launch) --- + $ffData = Get-FfprobeData -FilePath $File.FullName + if ($ffData -and $ffData.format -and $ffData.format.duration) { + $dur = [double]$ffData.format.duration + if ($dur -gt 0) { return [int][Math]::Ceiling($dur) } + } + + # --- 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 + } + } + + # --- 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. + +# -- Fast-path pre-check for obviously compatible files ---------------------- +# If a file starts with 0xFF 0xFB (MPEG1 Layer3, no CRC, no ID3v2 tag) and +# has no ID3v1 tail tag, it's almost certainly FMOD 3.x-compatible. This +# avoids reading the entire file into memory and scanning 50 MPEG frames. +# Returns: $true if the file is obviously compatible (skip full scan), +# $false if the file needs the full Test-Mp3NeedsReencode check. +function Test-Mp3FastPathCompatible { + param([string]$FilePath) + try { + # Read only the first 4 bytes and last 128 bytes + $fs = [System.IO.File]::OpenRead($FilePath) + try { + if ($fs.Length -lt 4) { return $false } + + # Check first 4 bytes for MPEG1 Layer3 no-CRC sync word + $head = New-Object byte[] 4 + [void]$fs.Read($head, 0, 4) + + # 0xFF 0xFB = MPEG1, Layer3, no CRC protection + # Also accept 0xFF 0xFA (MPEG1 Layer3 with CRC — needs full check) + # Also accept 0xFF 0xE3 etc. (MPEG2 — needs full check) + if ($head[0] -ne 0xFF -or ($head[1] -band 0xE0) -ne 0xE0) { + # Not starting with MPEG sync — might have ID3v2 tag + return $false + } + # Check: must be MPEG1 Layer3 no-CRC (0xFF 0xFBx where x has specific bits) + # Byte 1: bits = [sync1 sync2 sync3 ID1 ID0 Layer1 Layer0 Protection] + # MPEG1=11, Layer3=01, NoCRC=1 => 0xFF 0xFB + if ($head[1] -ne 0xFB) { + # Not MPEG1 Layer3 no-CRC — needs full check + return $false + } + + # Check for ID3v1 tail tag (last 128 bytes start with 'TAG') + if ($fs.Length -ge 128) { + $fs.Seek(-128, [System.IO.SeekOrigin]::End) | Out-Null + $tail = New-Object byte[] 3 + [void]$fs.Read($tail, 0, 3) + if ($tail[0] -eq 0x54 -and $tail[1] -eq 0x41 -and $tail[2] -eq 0x47) { + # Has ID3v1 tag — should strip it, needs full check + return $false + } + } + + # Also check for video streams via ffprobe cache + $ffData = Get-FfprobeData -FilePath $FilePath + if ($ffData -and $ffData.streams) { + foreach ($stream in $ffData.streams) { + if ($stream.codec_type -eq 'video') { + return $false + } + } + } + + # Fast-path: file starts with 0xFF 0xFB, no ID3v1, no video streams + return $true + } finally { + $fs.Close() + } + } catch { + return $false + } +} + +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 + $hasLameTag = $false + $lameEncoder = '' + $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 + # Scan for LAME encoder tag after the Info header fields + # Info header layout: 4 bytes 'Info' + 4 bytes flags + optional fields + LAME tag + # The LAME tag starts 120 bytes after the Info magic (after flags+frames+bytes+TOC+vbr_scale) + # Total Info header with all flags set = 4+4+4+4+100+4 = 120 bytes, then LAME tag + $lameOffset = $xingOffset + 120 + if ($lameOffset + 4 -lt $bytes.Length) { + # Check for common encoder signatures: LAME, Lavc, Lavf, GOGO, etc. + # These appear as ASCII text at the start of the encoder tag field + $encBytes = $bytes[$lameOffset..($lameOffset+8)] + $encText = [System.Text.Encoding]::ASCII.GetString($encBytes).TrimEnd([char]0) + if ($encText -match '^(LAME|Lavc|Lavf|GOGO|Xing|Gogo|lame|REA[LC]|THOMS|QDesign)') { + $hasLameTag = $true + $lameEncoder = $encText + } + } + } + } + + $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 + # Determine actual sample rate from MPEG header for accurate frame size + $sampleRates1 = @(44100, 48000, 32000, 0) # MPEG1 + $sampleRates2 = @(22050, 24000, 16000, 0) # MPEG2 + $sampleRates25 = @(11025, 12000, 8000, 0) # MPEG2.5 + if ($mpegVersion -eq 3) { $curSampleRate = $sampleRates1[$sampleRateIdx] } + elseif ($mpegVersion -eq 2) { $curSampleRate = $sampleRates2[$sampleRateIdx] } + else { $curSampleRate = $sampleRates25[$sampleRateIdx] } + if ($curSampleRate -le 0) { $curSampleRate = 44100 } # fallback + $frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / $curSampleRate)) + $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)' + } + + # Info header with LAME encoder tag + # Many FMOD 3.x implementations (including WoW 1.12) can misparse the LAME + # delay/padding fields in the Info/LAME tag, causing the audio start offset + # to be calculated incorrectly. Re-encoding strips the Info/LAME tag entirely + # and produces a clean MPEG stream that FMOD can parse reliably. + if ($foundInfo -and $hasLameTag) { + return $true, "Info/LAME encoder tag found ($lameEncoder) - FMOD 3.x may misparse delay/padding fields" + } + + # 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 + # Uses the ffprobe cache instead of launching a separate process + $ffData = Get-FfprobeData -FilePath $FilePath + if ($ffData -and $ffData.streams) { + foreach ($stream in $ffData.streams) { + if ($stream.codec_type -eq 'video') { + return $true, "Embedded video/image stream (codec: $($stream.codec_name)) - FMOD 3.x cannot handle" + } + } + } + + # Check for ID3v2 tag that doesn't have embedded art/unsync/extended header + # (those are already caught above). Even a "clean" ID3v2 tag can cause + # FMOD 3.x to add a small seek offset at the start of playback. Stripping + # it ensures the file starts directly with the MPEG sync word. + if ($hasId3v2 -and $audioOffset -gt 0) { + return $true, 'ID3v2 tag present (may cause seek offset in FMOD 3.x)' + } + + return $false, 'Compatible' + } + catch { + return $true, "Error analyzing file: $($_.Exception.Message)" + } +} + +# -- Strip ALL tags from an MP3 file in a SINGLE pass (COMBINED for SPEED) --- +# After ffmpeg re-encodes an MP3 file, libmp3lame writes its own Info/LAME tag +# and ffmpeg may write a minimal ID3v2.4 header. Previously we called +# Strip-LameTag (reads entire file) then Strip-Id3v2Tag (reads entire file again), +# resulting in 2 full file reads + 2 full file writes. This combined function +# does everything in ONE read + ONE write, cutting I/O time in half. +# +# This function modifies the file IN PLACE and is safe to call on files that +# don't have any tags (it will detect and skip them). +# Returns: @($strippedLame, $strippedId3v2) - booleans indicating what was stripped. +function Strip-Mp3Tags { + param([string]$FilePath) + + $strippedLame = $false + $strippedId3v2 = $false + + try { + $bytes = [System.IO.File]::ReadAllBytes($FilePath) + if ($bytes.Length -lt 10) { return @($false, $false) } + + # --- Step 1: Strip ID3v2 tag from the beginning --- + $id3v2TotalSize = 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] + $id3v2TotalSize = $tagSize + 10 # 10-byte header + tag body + if ($id3v2TotalSize -lt $bytes.Length) { + # Remove the ID3v2 tag bytes entirely by shifting the array + $newLen = $bytes.Length - $id3v2TotalSize + $shifted = New-Object byte[] $newLen + [Array]::Copy($bytes, $id3v2TotalSize, $shifted, 0, $newLen) + $bytes = $shifted + $strippedId3v2 = $true + } + } + + # --- Step 2: Strip Info/LAME tag from first MPEG frame --- + if ($bytes.Length -ge 4) { + # Look for the first MPEG frame sync word + $frameStart = -1 + for ($i = 0; $i -lt [Math]::Min($bytes.Length - 4, 4096); $i++) { + if ($bytes[$i] -eq 0xFF -and ($bytes[$i+1] -band 0xE0) -eq 0xE0) { + $frameStart = $i + break + } + } + + if ($frameStart -ge 0) { + # Determine the Xing/Info header offset + $b1 = $bytes[$frameStart + 1] + $mpegVersion = ($b1 -shr 3) -band 0x03 + $layer = ($b1 -shr 1) -band 0x03 + $channelMode = ($bytes[$frameStart + 3] -shr 6) -band 0x03 + + if ($mpegVersion -eq 3 -and $layer -eq 1 -and $channelMode -ne 3) { + $xingOffset = $frameStart + 36 + } elseif ($mpegVersion -eq 3 -and $layer -eq 1 -and $channelMode -eq 3) { + $xingOffset = $frameStart + 21 + } elseif (($mpegVersion -eq 2 -or $mpegVersion -eq 0) -and $layer -eq 1) { + $xingOffset = $frameStart + 21 + } else { + $xingOffset = -1 + } + + if ($xingOffset -ge 0 -and $xingOffset + 4 -lt $bytes.Length) { + $isInfo = ($bytes[$xingOffset] -eq 0x49 -and $bytes[$xingOffset+1] -eq 0x6E -and $bytes[$xingOffset+2] -eq 0x66 -and $bytes[$xingOffset+3] -eq 0x6F) + $isXing = ($bytes[$xingOffset] -eq 0x58 -and $bytes[$xingOffset+1] -eq 0x69 -and $bytes[$xingOffset+2] -eq 0x6E -and $bytes[$xingOffset+3] -eq 0x67) + + if ($isInfo -or $isXing) { + # Calculate Info/Xing header end position + $headerFlags = ($bytes[$xingOffset+4] -shl 24) -bor ($bytes[$xingOffset+5] -shl 16) -bor ($bytes[$xingOffset+6] -shl 8) -bor $bytes[$xingOffset+7] + $headerEnd = $xingOffset + 8 # After magic + flags + if ($headerFlags -band 0x01) { $headerEnd += 4 } # Frames field + if ($headerFlags -band 0x02) { $headerEnd += 4 } # Bytes field + if ($headerFlags -band 0x04) { $headerEnd += 100 } # TOC + if ($headerFlags -band 0x08) { $headerEnd += 4 } # VBR scale + $headerEnd += 24 # LAME tag + + # Zero out the entire Info/Xing + LAME tag area + for ($j = $xingOffset; $j -lt [Math]::Min($headerEnd, $bytes.Length); $j++) { + $bytes[$j] = 0x00 + } + $strippedLame = $true + } + } + } + } + + # --- Step 3: Strip ID3v1 tag from the end --- + $strippedId3v1 = $false + if ($bytes.Length -ge 128 -and $bytes[$bytes.Length - 128] -eq 0x54 -and $bytes[$bytes.Length - 127] -eq 0x41 -and $bytes[$bytes.Length - 126] -eq 0x47) { + $newBytes = New-Object byte[] ($bytes.Length - 128) + [Array]::Copy($bytes, 0, $newBytes, 0, $bytes.Length - 128) + $bytes = $newBytes + $strippedId3v1 = $true + } + + # --- Write only if something was stripped --- + if ($strippedLame -or $strippedId3v2 -or $strippedId3v1) { + [System.IO.File]::WriteAllBytes($FilePath, $bytes) + # Clear ffprobe cache since file changed + Clear-FfprobeCache -FilePath $FilePath + return @($strippedLame, $strippedId3v2) + } + return @($false, $false) + } catch { + return @($false, $false) + } +} + +# -- Legacy wrappers for backward compatibility with any code that calls them -- +# These now delegate to Strip-Mp3Tags for the same single-pass performance. +function Strip-LameTag { + param([string]$FilePath) + $result = Strip-Mp3Tags -FilePath $FilePath + return $result[0] +} + +function Strip-Id3v2Tag { + param([string]$FilePath) + $result = Strip-Mp3Tags -FilePath $FilePath + return $result[1] +} + + +function Sanitize-Mp3File { + param([System.IO.FileInfo]$File) + + # Bitrate table for MPEG1 Layer III (used by the CRC binary-patch fallback) + $bitrateTable1L3 = @(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0) + + # Use a temp file with .mp3 extension so ffmpeg can auto-detect the output format. + # ffmpeg refuses to write to files with non-standard extensions (e.g. .sanitize.tmp). + $tempFile = $File.FullName + '.sanitize.tmp.mp3' + + # ------------------------------------------------------------------------- + # Step 1: Try to re-encode with ffmpeg (primary method). + # Re-encoding produces a clean, FMOD 3.x-compatible MP3 with no CRC, + # no VBR, no Info/LAME tag, CBR 256kbps, simple stereo, no embedded art. + # ------------------------------------------------------------------------- + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + $ffmpegSuccess = $false + + if ($ffmpegExe) { + # Try primary re-encode: 256kbps CBR with error-tolerant input parsing. + try { + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-err_detect', '+ignore_err', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '256k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', '-fflags', '+bitexact', '-flags:a', '+bitexact', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile + + # Save stderr output for diagnostics even on success + $stderrContent = $null + if (Test-Path -LiteralPath $stderrFile -PathType Leaf) { + $stderrContent = Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue + + if ($proc.ExitCode -ne 0) { + Write-Host " ffmpeg (256kbps) exited with code $($proc.ExitCode) for $($File.Name)" + if ($stderrContent -and $stderrContent.Length -gt 0) { + $diag = $stderrContent.TrimEnd() + if ($diag.Length -gt 500) { $diag = $diag.Substring($diag.Length - 500) } + Write-Host " ffmpeg stderr (tail): $diag" + } + Write-Host " Trying fallback re-encode at 192kbps..." + } else { + $ffmpegSuccess = $true + } + } catch { + Write-Host " ffmpeg re-encode (256kbps) threw exception for $($File.Name): $($_.Exception.Message)" + } + + # If primary re-encode failed, try fallback with lower bitrate. + if (-not $ffmpegSuccess -and -not (Test-Path -LiteralPath $tempFile -PathType Leaf)) { + try { + $tempFile = $File.FullName + '.sanitize.tmp.mp3' + $stderrFile2 = [System.IO.Path]::GetTempFileName() + $proc2 = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-err_detect', '+ignore_err', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', '-fflags', '+bitexact', '-flags:a', '+bitexact', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile2 + $stderrContent2 = $null + if (Test-Path -LiteralPath $stderrFile2 -PathType Leaf) { + $stderrContent2 = Get-Content -LiteralPath $stderrFile2 -Raw -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $stderrFile2 -Force -ErrorAction SilentlyContinue + + if ($proc2.ExitCode -ne 0) { + Write-Host " ffmpeg (192kbps fallback) also exited with code $($proc2.ExitCode) for $($File.Name)" + if ($stderrContent2 -and $stderrContent2.Length -gt 0) { + $diag2 = $stderrContent2.TrimEnd() + if ($diag2.Length -gt 500) { $diag2 = $diag2.Substring($diag2.Length - 500) } + Write-Host " ffmpeg stderr (tail): $diag2" + } + } else { + $ffmpegSuccess = $true + } + } catch { + Write-Host " ffmpeg 192kbps fallback threw exception for $($File.Name): $($_.Exception.Message)" + } + } + + # If ffmpeg produced a valid output file, strip tags in ONE pass + if ($ffmpegSuccess -and (Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) { + # Strip Info/LAME + ID3v2/v1 tags in a single file read/write pass + # (previously this was 2 separate reads + 2 separate writes) + $tagResult = Strip-Mp3Tags -FilePath $tempFile + if ($tagResult[0]) { Write-Host " Stripped Info/LAME encoder tag from re-encoded file" } + if ($tagResult[1]) { Write-Host " Stripped ID3v2/v1 tags from re-encoded file" } + + # Quick verify: check that the file starts with 0xFF 0xFB (MPEG1 Layer3 no CRC) + # This is much faster than a full Test-Mp3NeedsReencode scan. + try { + $verifyBytes = [System.IO.File]::ReadAllBytes($tempFile) + if ($verifyBytes.Length -ge 4 -and $verifyBytes[0] -eq 0xFF -and ($verifyBytes[1] -band 0xE0) -eq 0xE0) { + # Has MPEG sync word - good enough after ffmpeg re-encode + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + # Clear ffprobe cache since the file changed + Clear-FfprobeCache -FilePath $File.FullName + Clear-FfprobeCache -FilePath $tempFile + Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)" + return $true + } else { + # Quick verify failed - do full check as fallback + $needsMore, $reasonMore = Test-Mp3NeedsReencode -FilePath $tempFile + if (-not $needsMore) { + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + Clear-FfprobeCache -FilePath $File.FullName + Clear-FfprobeCache -FilePath $tempFile + Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)" + return $true + } + Write-Host " WARNING: Re-encoded file still has issue: $reasonMore" + # Give up on re-encode path, clean up and try other methods + Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue + } + } catch { + # Verify failed - trust ffmpeg and accept the file + Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force + Clear-FfprobeCache -FilePath $File.FullName + Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)" + return $true + } + } else { + # ffmpeg didn't produce output or produced empty file + if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } + Write-Host " ffmpeg did not produce output for $($File.Name), trying binary fallback..." + } + } else { + Write-Host " ffmpeg not found, trying binary fallback methods..." + } + + # ------------------------------------------------------------------------- + # Step 2: Binary patch for CRC-protected files (fallback when ffmpeg fails). + # ------------------------------------------------------------------------- + $needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $File.FullName + if ($needsReencode -and $reason -match 'CRC') { + Write-Host " Attempting CRC removal (binary patch with frame rebuild) for $($File.Name)..." + + # Strip ID3v2/v1 tags BEFORE scanning for MPEG frames. + $tagResult = Strip-Mp3Tags -FilePath $File.FullName + if ($tagResult[1]) { + Write-Host " Stripped ID3v2/v1 tags before CRC patch" + } + + try { + $bytes = [System.IO.File]::ReadAllBytes($File.FullName) + + $audioOffset = 0 + if ($bytes.Length -ge 10 -and $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 + Write-Host " WARNING: ID3v2 tag still present after strip attempt, audio at offset $audioOffset" + } + + $outputList = New-Object System.Collections.Generic.List[byte[]] + $totalOutSize = 0 + $patched = 0 + $i = $audioOffset + + while ($i -lt $bytes.Length - 4) { + if ($bytes[$i] -ne 0xFF -or ($bytes[$i+1] -band 0xE0) -ne 0xE0) { + $oneByte = New-Object byte[] 1 + $oneByte[0] = $bytes[$i] + $outputList.Add($oneByte) + $totalOutSize++ + $i++ + continue + } + + $b1 = $bytes[$i+1] + $b2 = $bytes[$i+2] + $b3 = $bytes[$i+3] + $mpegVersion = ($b1 -shr 3) -band 0x03 + $layer = ($b1 -shr 1) -band 0x03 + $protection = $b1 -band 0x01 + $bitrateIdx = ($b2 -shr 4) -band 0x0F + $sampleRateIdx = ($b2 -shr 2) -band 0x03 + $padding = ($b2 -shr 1) -band 0x01 + + # Determine sample rate from MPEG header + $sampleRates1 = @(44100, 48000, 32000, 0) # MPEG1 + $sampleRates2 = @(22050, 24000, 16000, 0) # MPEG2 + $sampleRates25 = @(11025, 12000, 8000, 0) # MPEG2.5 + if ($mpegVersion -eq 3) { $sampleRate = $sampleRates1[$sampleRateIdx] } + elseif ($mpegVersion -eq 2) { $sampleRate = $sampleRates2[$sampleRateIdx] } + else { $sampleRate = $sampleRates25[$sampleRateIdx] } + + if ($mpegVersion -ne 3 -or $layer -ne 1) { + $skipBytes = New-Object byte[] 4 + [Array]::Copy($bytes, $i, $skipBytes, 0, 4) + $outputList.Add($skipBytes) + $totalOutSize += 4 + $i += 4 + continue + } + + $br = $bitrateTable1L3[$bitrateIdx] + if ($br -le 0) { + $skipBytes = New-Object byte[] 4 + [Array]::Copy($bytes, $i, $skipBytes, 0, 4) + $outputList.Add($skipBytes) + $totalOutSize += 4 + $i += 4 + continue + } + + if ($sampleRate -le 0) { + # Invalid sample rate - skip this candidate + $skipBytes = New-Object byte[] 4 + [Array]::Copy($bytes, $i, $skipBytes, 0, 4) + $outputList.Add($skipBytes) + $totalOutSize += 4 + $i += 4 + continue + } + + $frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / $sampleRate)) + $padding + + if ($i + $frameSize -gt $bytes.Length) { + $remaining = $bytes.Length - $i + $remainBytes = New-Object byte[] $remaining + [Array]::Copy($bytes, $i, $remainBytes, 0, $remaining) + $outputList.Add($remainBytes) + $totalOutSize += $remaining + break + } + + if ($protection -eq 0) { + $newFrameSize = $frameSize - 2 + $newFrame = New-Object byte[] $newFrameSize + [Array]::Copy($bytes, $i, $newFrame, 0, 4) + $newFrame[1] = $newFrame[1] -bor 0x01 + [Array]::Copy($bytes, $i + 4 + 2, $newFrame, 4, $frameSize - 4 - 2) + $outputList.Add($newFrame) + $totalOutSize += $newFrameSize + $patched++ + } else { + $frameBytes = New-Object byte[] $frameSize + [Array]::Copy($bytes, $i, $frameBytes, 0, $frameSize) + $outputList.Add($frameBytes) + $totalOutSize += $frameSize + } + + $i += $frameSize + } + + if ($i -lt $bytes.Length) { + $trailing = $bytes.Length - $i + $trailBytes = New-Object byte[] $trailing + [Array]::Copy($bytes, $i, $trailBytes, 0, $trailing) + $outputList.Add($trailBytes) + $totalOutSize += $trailing + } + + if ($patched -gt 0) { + $outBytes = New-Object byte[] $totalOutSize + $offset = 0 + foreach ($chunk in $outputList) { + [Array]::Copy($chunk, 0, $outBytes, $offset, $chunk.Length) + $offset += $chunk.Length + } + + [System.IO.File]::WriteAllBytes($File.FullName, $outBytes) + Write-Host " Patched (CRC removed from $patched frames, file rebuilt): $($File.Name)" + # Strip LAME + ID3 tags in one pass (instead of 2 separate calls) + $tagResult2 = Strip-Mp3Tags -FilePath $File.FullName + if ($tagResult2[0]) { Write-Host " Stripped Info/LAME encoder tag from $($File.Name)" } + if ($tagResult2[1]) { Write-Host " Stripped ID3v2/v1 tags from $($File.Name)" } + # Quick verify after CRC patch + $stillNeeds, $stillReason = Test-Mp3NeedsReencode -FilePath $File.FullName + if (-not $stillNeeds) { + return $true + } else { + Write-Host " WARNING: CRC-patched file still has issue: $stillReason" + Write-Host " This file may need ffmpeg re-encoding. Install ffmpeg and re-run." + } + } else { + Write-Host " No CRC-protected frames found to patch in $($File.Name)" + } + } catch { + Write-Host " WARNING: CRC binary patch failed for $($File.Name): $($_.Exception.Message)" + } + } + + # ------------------------------------------------------------------------- + # Step 3: If the only issue is a LAME/Info tag or ID3v2 tag (no CRC, no VBR), + # strip it in-place without re-encoding — using the combined single-pass function. + # ------------------------------------------------------------------------- + if ($needsReencode -and ($reason -match 'Info/LAME' -or $reason -match 'ID3v2')) { + $tagResult3 = Strip-Mp3Tags -FilePath $File.FullName + if ($tagResult3[0]) { Write-Host " Stripped Info/LAME encoder tag from $($File.Name)" } + if ($tagResult3[1]) { Write-Host " Stripped ID3v2/v1 tags from $($File.Name)" } + if ($tagResult3[0] -or $tagResult3[1]) { + # Quick verify using fast-path check + if (Test-Mp3FastPathCompatible -FilePath $File.FullName) { + return $true + } + # Full verify as fallback + $stillNeeds, $stillReason = Test-Mp3NeedsReencode -FilePath $File.FullName + if (-not $stillNeeds) { + return $true + } + } + } + + # ------------------------------------------------------------------------- + # Step 4: If the issue is VBR and we don't have ffmpeg, warn the user. + # VBR files CANNOT be fixed with binary patching - they must be re-encoded. + # ------------------------------------------------------------------------- + if ($needsReencode -and $reason -match 'VBR') { + Write-Host " WARNING: $($File.Name) has VBR encoding which requires ffmpeg to fix." + Write-Host " Install ffmpeg and re-run GeneratePlaylist.bat to sanitize this file." + } + + return $false +} + +# Clean up any leftover .sanitize.tmp.* files from a previous interrupted run +$leftoverTmp = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Name -match '\.sanitize\.tmp\.' }) +if ($leftoverTmp.Count -gt 0) { + Write-Host "Cleaning up $($leftoverTmp.Count) leftover temp file(s) from previous run..." + foreach ($tmp in $leftoverTmp) { + Remove-Item -LiteralPath $tmp.FullName -Force -ErrorAction SilentlyContinue + } +} + +# 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$' -and $_.Name -notmatch '\.sanitize\.tmp\.' }) + +$sanitized = 0 +$skipped = 0 +$scanErrors = 0 +$compatOk = 0 +$cacheHits = 0 +$fastPathHits = 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 { + # --- SPEED: Check compatibility cache first --- + # If this file was verified compatible in a previous run and hasn't + # been modified since, skip the entire scan. + $cacheEntry = Get-CompatCacheEntry -File $mp3 + if ($cacheEntry -ne $null) { + if ($cacheEntry[0] -eq 'OK') { + $compatOk++ + $cacheHits++ + continue + } + # If previously marked as 'FIX' (sanitized), re-check since + # the sanitized version may have different timestamp/size + } + + # --- SPEED: Fast-path check for obviously compatible files --- + # Files that start with 0xFF 0xFB (MPEG1 Layer3, no CRC, no ID3v2) + # and have no ID3v1 tail tag are almost certainly FMOD 3.x-compatible. + # This avoids reading the entire file and scanning 50 MPEG frames. + if (Test-Mp3FastPathCompatible -FilePath $mp3.FullName) { + $compatOk++ + $fastPathHits++ + Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason 'FastPath' + continue + } + + # --- Full scan needed --- + $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++ + # Refresh the FileInfo to get the new timestamp/size after sanitization + $mp3 = Get-Item -LiteralPath $mp3.FullName + Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason 'Sanitized' + } else { + $skipped++ + Set-CompatCacheEntry -File $mp3 -Status 'FIX' -Reason 'Failed' + } + } catch { + Write-Host " WARNING: Could not sanitize $($mp3.Name): $($_.Exception.Message)" + $skipped++ + Set-CompatCacheEntry -File $mp3 -Status 'FIX' -Reason 'Error' + } + } else { + $compatOk++ + Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason $reason + } + } 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 ($cacheHits -gt 0) { Write-Host " Cache hits: $cacheHits (skipped full scan)" } + if ($fastPathHits -gt 0) { Write-Host " Fast-path hits: $fastPathHits (quick header check)" } +} + +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 "" +} + +# Save the compatibility cache for faster re-runs +Save-CompatCache + +# -- 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 { + # Use ffprobe cache instead of launching a separate process + $ffJson = Get-FfprobeData -FilePath $FilePath + if ($ffJson -and $ffJson.streams) { + 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)" + } + } + } + } + + # 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 { + # Use ffprobe cache instead of launching a separate process + $ffJson = Get-FfprobeData -FilePath $FilePath + if ($ffJson -and $ffJson.streams) { + 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)" + } + } + } + + return $false, 'Compatible' + } + catch { + return $true, "Error analyzing file: $($_.Exception.Message)" + } +} + +function Sanitize-WavFile { + param([System.IO.FileInfo]$File) + + # Use a temp file with .wav extension so ffmpeg can auto-detect the output format. + $tempFile = $File.FullName + '.sanitize.tmp.wav' + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + try { + if ($ffmpegExe) { + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-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 + Clear-FfprobeCache -FilePath $File.FullName + 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) + + # Use a temp file with .ogg extension so ffmpeg can auto-detect the output format. + $tempFile = $File.FullName + '.sanitize.tmp.ogg' + $ffmpegExe = Find-Ffmpeg -Name 'ffmpeg' + try { + if ($ffmpegExe) { + $stderrFile = [System.IO.Path]::GetTempFileName() + $proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-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 + Clear-FfprobeCache -FilePath $File.FullName + 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$' -and $_.Name -notmatch '\.sanitize\.tmp\.' }) + +$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$' -and $_.Name -notmatch '\.sanitize\.tmp\.' }) + +$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 "" +} + +# -- Scan Media folder ------------------------------------------------------- +$files = @(Get-ChildItem -LiteralPath $Media -Recurse -File | + Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' -and $_.Name -notmatch '\.sanitize\.tmp\.' } | + 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