diff --git a/.gitignore b/.gitignore index 6fd0a37..eb41a05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Wago updater +.wago + # Compiled Lua sources luac.out diff --git a/CHANGELOG.md b/CHANGELOG.md index a61f5e1..75e788f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,24 @@ +2026-Feb-25 r1.0.11 + New: Sound Browser module + - Browse SoundKit IDs by category (auto-read from SOUNDKIT global, grouped by prefix) + - FileID range explorer with async playability scanning + - Live capture tab hooks PlaySound, PlaySoundFile, and PlayMusic calls + - Play/stop controls with single-active-sound management and auto-reset polling + - Favorites and recent lists persisted to SavedVariables + - Context menu: Copy ID, Copy PlaySound/PlaySoundFile/PlayMusic code, Insert to editor + - Search across all sources (kits, file IDs, live captures, favorites) + Improved: API Browser detail view + - Security labels (callable vs protected) instead of raw internal values + - Default values shown on arguments and fields + - Mixin type annotations on arguments (e.g. ItemLocationMixin) + - InnerType shown for table returns (e.g. table) + - MayReturnNothing flag with explanation + - Event LiteralName shown as copyable RegisterEvent string + - Synchronous event timing indicator + - Enumeration range info (count, min, max) + - Per-field documentation on structures and enum values + - Namespace environment and documentation + - Insert Call now uses documented default values for argument placeholders + Fixed: API Browser Documentation field crash when value was a table instead of string + 2026-Feb-3 r1.0.0 Initial stable public release \ No newline at end of file diff --git a/DevForge/DevForge.toc b/DevForge/DevForge.toc index c03dcac..07bcd18 100644 --- a/DevForge/DevForge.toc +++ b/DevForge/DevForge.toc @@ -1,9 +1,9 @@ ## Interface: 120000, 120001 ## Title: DevForge -## Notes: In-game dev toolkit: inspector, editor, console, events, errors, APIs, textures, performance +## Notes: In-game dev toolkit: inspector, editor, console, events, errors, APIs, textures, sounds, performance ## Author: hatdragon ## Contributors: -## Version: r1.0.10 +## Version: r1.0.11 ## SavedVariables: DevForgeDB ## IconTexture: Interface\Icons\INV_Gizmo_02 @@ -133,3 +133,10 @@ Modules\TextureBrowser\TextureIconData.lua Modules\TextureBrowser\TextureRuntime.lua Modules\TextureBrowser\TextureIndex.lua Modules\TextureBrowser\TextureBrowser.lua + +# Sound Browser Module +Modules\SoundBrowser\SoundKitData.lua +Modules\SoundBrowser\SoundFileData.lua +Modules\SoundBrowser\SoundRuntime.lua +Modules\SoundBrowser\SoundIndex.lua +Modules\SoundBrowser\SoundBrowser.lua diff --git a/DevForge/Modules/APIBrowser/APIBrowser.lua b/DevForge/Modules/APIBrowser/APIBrowser.lua index fe792a3..33f4ae9 100644 --- a/DevForge/Modules/APIBrowser/APIBrowser.lua +++ b/DevForge/Modules/APIBrowser/APIBrowser.lua @@ -81,20 +81,35 @@ DF.ModuleSystem:Register("APIBrowser", function(sidebarParent, editorParent) -- Build arguments with safe placeholder values local args = {} - if doc.Arguments then - for _, arg in ipairs(doc.Arguments) do - local argType = arg.Type or "any" - if argType == "string" or argType == "cstring" then - args[#args + 1] = '"' .. (arg.Name or "str") .. '"' - elseif argType == "number" or argType == "luaIndex" or argType == "uiMapID" then - args[#args + 1] = "0" - elseif argType == "bool" then - args[#args + 1] = "true" + local function ArgPlaceholder(arg) + local argType = arg.Type or "any" + local argName = arg.Name + -- Use the documented default value when available + if arg.Default ~= nil then + local def = tostring(arg.Default) + if argType == "bool" then + return def + elseif argType == "string" or argType == "cstring" then + return '"' .. def .. '"' else - -- Use nil with a type hint comment so the code is runnable - args[#args + 1] = "nil --[[" .. (arg.Name or "arg") .. ": " .. argType .. "]]" + return def .. " --[[" .. argName .. "]]" end end + if argType == "string" or argType == "cstring" then + return '"' .. (argName or "str") .. '"' + elseif argType == "number" or argType == "luaIndex" or argType == "uiMapID" then + return "0" + elseif argType == "bool" then + return "true" + else + return "nil --[[" .. (argName or "arg") .. ": " .. argType .. "]]" + end + end + + if doc.Arguments then + for _, arg in ipairs(doc.Arguments) do + args[#args + 1] = ArgPlaceholder(arg) + end end -- Build return values diff --git a/DevForge/Modules/APIBrowser/APIBrowserDetail.lua b/DevForge/Modules/APIBrowser/APIBrowserDetail.lua index f293dc6..61ef016 100644 --- a/DevForge/Modules/APIBrowser/APIBrowserDetail.lua +++ b/DevForge/Modules/APIBrowser/APIBrowserDetail.lua @@ -4,6 +4,61 @@ DF.APIBrowserDetail = {} local Detail = DF.APIBrowserDetail +-- Helper: safely get a Documentation field as a string +local function DocString(doc) + if not doc then return nil end + if type(doc) == "table" then return table.concat(doc, " ") end + return tostring(doc) +end + +-- Helper: format a single field/argument/return line with all available metadata +local function FormatField(field) + local typeStr = field.Type or "any" + local parts = {} + + -- Name : Type + parts[#parts + 1] = " " .. DF.Colors.text .. field.Name .. "|r" + .. " : " .. DF.Colors.tableRef .. typeStr .. "|r" + + -- InnerType for table types (e.g. table) + if field.InnerType then + parts[#parts + 1] = DF.Colors.dim .. "<" .. DF.Colors.tableRef .. field.InnerType .. "|r" .. DF.Colors.dim .. ">|r" + end + + -- Mixin — the Lua mixin class this argument expects + if field.Mixin then + parts[#parts + 1] = DF.Colors.dim .. " mixin:" .. DF.Colors.func .. field.Mixin .. "|r" + end + + -- Nilable + if field.Nilable then + parts[#parts + 1] = DF.Colors.dim .. " [nilable]|r" + end + + -- Default value + if field.Default ~= nil then + parts[#parts + 1] = DF.Colors.dim .. " default:" .. DF.Colors.number .. tostring(field.Default) .. "|r" + end + + -- EnumValue (for enum fields) + if field.EnumValue ~= nil then + parts[#parts + 1] = " = " .. DF.Colors.number .. tostring(field.EnumValue) .. "|r" + end + + -- StrideIndex — position within repeating argument groups + if field.StrideIndex then + parts[#parts + 1] = DF.Colors.dim .. " stride:" .. tostring(field.StrideIndex) .. "|r" + end + + -- Documentation + local doc = DocString(field.Documentation) + if doc then + parts[#parts + 1] = " - " .. DF.Colors.comment .. doc .. "|r" + end + + return table.concat(parts) +end + function Detail:Create(parent) local pane = DF.Widgets:CreateScrollPane(parent, true) @@ -56,16 +111,36 @@ function Detail:Create(parent) end function detail:FormatFunction(lines, system, doc) - -- Function signature + -- Function header lines[#lines + 1] = DF.Colors.func .. (system or "") .. "." .. (doc.Name or "?") .. "|r" + + -- Security and flags + if doc.SecretArguments then + local secLabel, secColor + if doc.SecretArguments == "AllowedWhenUntainted" then + secLabel = "Callable from addon code (untainted execution only)" + secColor = DF.Colors.dim + elseif doc.SecretArguments == "NotAllowed" then + secLabel = "Protected — cannot be called from addon code" + secColor = DF.Colors.error + else + secLabel = doc.SecretArguments + secColor = DF.Colors.dim + end + lines[#lines + 1] = DF.Colors.text .. "Security: |r" .. secColor .. secLabel .. "|r" + end + if doc.MayReturnNothing then + lines[#lines + 1] = DF.Colors.text .. "Returns: |r" .. DF.Colors.dim .. "may return nothing (check for nil)|r" + end lines[#lines + 1] = "" -- Build signature string local params = {} if doc.Arguments then for _, arg in ipairs(doc.Arguments) do - local nilable = arg.Nilable and " [optional]" or "" - params[#params + 1] = arg.Name .. nilable + local p = arg.Name + if arg.Nilable then p = p .. " [optional]" end + params[#params + 1] = p end end @@ -81,12 +156,18 @@ function Detail:Create(parent) sig = table.concat(returns, ", ") .. " = " end sig = sig .. (system or "") .. "." .. (doc.Name or "?") .. "(" .. table.concat(params, ", ") .. ")" + + if doc.MayReturnNothing then + sig = sig .. DF.Colors.dim .. " -- may return nothing|r" + end + lines[#lines + 1] = DF.Colors.text .. sig .. "|r" lines[#lines + 1] = "" -- Documentation - if doc.Documentation then - lines[#lines + 1] = DF.Colors.comment .. "-- " .. doc.Documentation .. "|r" + local docText = DocString(doc.Documentation) + if docText then + lines[#lines + 1] = DF.Colors.comment .. "-- " .. docText .. "|r" lines[#lines + 1] = "" end @@ -94,15 +175,7 @@ function Detail:Create(parent) if doc.Arguments and #doc.Arguments > 0 then lines[#lines + 1] = DF.Colors.keyword .. "Parameters:|r" for _, arg in ipairs(doc.Arguments) do - local typeStr = arg.Type or "any" - local nilable = arg.Nilable and (DF.Colors.dim .. " [nilable]|r") or "" - local argLine = " " .. DF.Colors.text .. arg.Name .. "|r" - .. " : " .. DF.Colors.tableRef .. typeStr .. "|r" - .. nilable - if arg.Documentation then - argLine = argLine .. " - " .. DF.Colors.comment .. arg.Documentation .. "|r" - end - lines[#lines + 1] = argLine + lines[#lines + 1] = FormatField(arg) end lines[#lines + 1] = "" end @@ -111,15 +184,7 @@ function Detail:Create(parent) if doc.Returns and #doc.Returns > 0 then lines[#lines + 1] = DF.Colors.keyword .. "Returns:|r" for _, ret in ipairs(doc.Returns) do - local typeStr = ret.Type or "any" - local nilable = ret.Nilable and (DF.Colors.dim .. " [nilable]|r") or "" - local retLine = " " .. DF.Colors.text .. ret.Name .. "|r" - .. " : " .. DF.Colors.tableRef .. typeStr .. "|r" - .. nilable - if ret.Documentation then - retLine = retLine .. " - " .. DF.Colors.comment .. ret.Documentation .. "|r" - end - lines[#lines + 1] = retLine + lines[#lines + 1] = FormatField(ret) end end end @@ -129,50 +194,88 @@ function Detail:Create(parent) lines[#lines + 1] = DF.Colors.dim .. "Event in " .. (system or "?") .. "|r" lines[#lines + 1] = "" - if doc.Documentation then - lines[#lines + 1] = DF.Colors.comment .. "-- " .. doc.Documentation .. "|r" + -- LiteralName — the actual event string for RegisterEvent + if doc.LiteralName then + lines[#lines + 1] = DF.Colors.text .. "RegisterEvent:|r " + .. DF.Colors.string .. "\"" .. doc.LiteralName .. "\"|r" lines[#lines + 1] = "" end + -- Synchronous flag + if doc.SynchronousEvent then + lines[#lines + 1] = DF.Colors.text .. "Timing: |r" .. DF.Colors.dim .. "Fires synchronously (before frame rendering)|r" + lines[#lines + 1] = "" + end + + -- Documentation + local docText = DocString(doc.Documentation) + if docText then + lines[#lines + 1] = DF.Colors.comment .. "-- " .. docText .. "|r" + lines[#lines + 1] = "" + end + + -- Payload if doc.Payload and #doc.Payload > 0 then lines[#lines + 1] = DF.Colors.keyword .. "Payload:|r" for _, arg in ipairs(doc.Payload) do - local typeStr = arg.Type or "any" - local nilable = arg.Nilable and (DF.Colors.dim .. " [nilable]|r") or "" - lines[#lines + 1] = " " .. DF.Colors.text .. arg.Name .. "|r" - .. " : " .. DF.Colors.tableRef .. typeStr .. "|r" - .. nilable + lines[#lines + 1] = FormatField(arg) end end end function detail:FormatTable(lines, system, doc) + local typeLabel = doc.Type or "Table" lines[#lines + 1] = DF.Colors.tableRef .. (doc.Name or "?") .. "|r" - lines[#lines + 1] = DF.Colors.dim .. "Table/Enum in " .. (system or "?") .. "|r" + lines[#lines + 1] = DF.Colors.dim .. typeLabel .. " in " .. (system or "?") .. "|r" lines[#lines + 1] = "" + -- Documentation + local docText = DocString(doc.Documentation) + if docText then + lines[#lines + 1] = DF.Colors.comment .. "-- " .. docText .. "|r" + lines[#lines + 1] = "" + end + + -- Enumeration range info + if doc.Type == "Enumeration" then + local rangeParts = {} + if doc.NumValues then + rangeParts[#rangeParts + 1] = DF.Colors.number .. doc.NumValues .. "|r" .. DF.Colors.text .. " values|r" + end + if doc.MinValue then + rangeParts[#rangeParts + 1] = DF.Colors.text .. "min " .. DF.Colors.number .. doc.MinValue .. "|r" + end + if doc.MaxValue then + rangeParts[#rangeParts + 1] = DF.Colors.text .. "max " .. DF.Colors.number .. doc.MaxValue .. "|r" + end + if #rangeParts > 0 then + lines[#lines + 1] = table.concat(rangeParts, DF.Colors.dim .. " | |r") + lines[#lines + 1] = "" + end + end + if doc.Type == "Enumeration" and doc.Fields then lines[#lines + 1] = DF.Colors.keyword .. "Values:|r" for _, field in ipairs(doc.Fields) do local valStr = "" - if field.EnumValue then + if field.EnumValue ~= nil then valStr = " = " .. DF.Colors.number .. tostring(field.EnumValue) .. "|r" end + local docStr = DocString(field.Documentation) + if docStr then + valStr = valStr .. " - " .. DF.Colors.comment .. docStr .. "|r" + end lines[#lines + 1] = " " .. DF.Colors.text .. field.Name .. "|r" .. valStr end elseif doc.Type == "Structure" and doc.Fields then lines[#lines + 1] = DF.Colors.keyword .. "Fields:|r" for _, field in ipairs(doc.Fields) do - local typeStr = field.Type or "any" - local nilable = field.Nilable and (DF.Colors.dim .. " [nilable]|r") or "" - lines[#lines + 1] = " " .. DF.Colors.text .. field.Name .. "|r" - .. " : " .. DF.Colors.tableRef .. typeStr .. "|r" - .. nilable + lines[#lines + 1] = FormatField(field) end elseif doc.Fields then lines[#lines + 1] = DF.Colors.keyword .. "Fields:|r" for _, field in ipairs(doc.Fields) do - lines[#lines + 1] = " " .. DF.Colors.text .. (field.Name or "?") .. "|r" + lines[#lines + 1] = FormatField(field) end end end @@ -184,6 +287,11 @@ function Detail:Create(parent) local sys = DF.APIBrowserData:GetSystem(system) if sys then + -- Environment + if sys.Environment then + lines[#lines + 1] = DF.Colors.text .. "Environment: " .. DF.Colors.dim .. sys.Environment .. "|r" + end + local funcCount = sys.Functions and #sys.Functions or 0 local eventCount = sys.Events and #sys.Events or 0 local tableCount = sys.Tables and #sys.Tables or 0 @@ -191,6 +299,13 @@ function Detail:Create(parent) lines[#lines + 1] = DF.Colors.text .. "Functions: " .. DF.Colors.number .. funcCount .. "|r" lines[#lines + 1] = DF.Colors.text .. "Events: " .. DF.Colors.number .. eventCount .. "|r" lines[#lines + 1] = DF.Colors.text .. "Tables: " .. DF.Colors.number .. tableCount .. "|r" + + -- Documentation + local docText = DocString(sys.Documentation) + if docText then + lines[#lines + 1] = "" + lines[#lines + 1] = DF.Colors.comment .. "-- " .. docText .. "|r" + end end lines[#lines + 1] = "" diff --git a/DevForge/Modules/SoundBrowser/SoundBrowser.lua b/DevForge/Modules/SoundBrowser/SoundBrowser.lua new file mode 100644 index 0000000..a69950b --- /dev/null +++ b/DevForge/Modules/SoundBrowser/SoundBrowser.lua @@ -0,0 +1,930 @@ +local _, DF = ... + +-- Register the Sound Browser module with sidebar + editor split +DF.ModuleSystem:Register("SoundBrowser", function(sidebarParent, editorParent) + editorParent = editorParent or DF.ModuleSystem:GetContentParent() + if not editorParent then + error("No content parent available") + end + + local browser = {} + + --------------------------------------------------------------------------- + -- State + --------------------------------------------------------------------------- + local activeTab = "kits" + local currentResults = {} + local lastSelectedNode = nil + local listItems = {} + local MAX_LIST_ITEMS = 500 + local ROW_HEIGHT = 28 + local unloadTimer = nil + local contextMenu = nil + local ShowContextMenu + local ShowSounds + local SwitchTab + + -- Sound playback state: only one sound at a time + local activeHandle = nil + local activeItemIndex = nil + + local function StopActiveSound() + if pollTicker then pollTicker:Cancel(); pollTicker = nil end + if activeHandle then + if activeHandle == "music" then + pcall(StopMusic) + else + pcall(StopSound, activeHandle, 0) + end + activeHandle = nil + end + if activeItemIndex and listItems[activeItemIndex] then + listItems[activeItemIndex].playIcon:SetAtlas("common-dropdown-icon-play") + listItems[activeItemIndex].playIcon:SetVertexColor(0.7, 0.7, 0.7, 1) + end + activeItemIndex = nil + end + + -- Poll C_Sound.IsPlaying to auto-reset play button when sound ends + local pollTicker = nil + local function StartPlayPoll() + if pollTicker then return end + pollTicker = C_Timer.NewTicker(0.3, function() + if not activeHandle then + if pollTicker then pollTicker:Cancel(); pollTicker = nil end + return + end + if activeHandle == "music" then return end + local stillPlaying = C_Sound and C_Sound.IsPlaying and C_Sound.IsPlaying(activeHandle) + if not stillPlaying then + if activeItemIndex and listItems[activeItemIndex] then + listItems[activeItemIndex].playIcon:SetAtlas("common-dropdown-icon-play") + listItems[activeItemIndex].playIcon:SetVertexColor(0.7, 0.7, 0.7, 1) + end + activeHandle = nil + activeItemIndex = nil + pollTicker:Cancel() + pollTicker = nil + end + end) + end + + --------------------------------------------------------------------------- + -- Sidebar: tab buttons + tree view + --------------------------------------------------------------------------- + local sidebarFrame = CreateFrame("Frame", nil, sidebarParent or editorParent) + if sidebarParent then + sidebarFrame:SetAllPoints(sidebarParent) + end + + local TAB_DEFS = { + { id = "kits", label = "Kits" }, + { id = "fileid", label = "FileID" }, + { id = "live", label = "Live" }, + { id = "favs", label = "Favs" }, + } + + local tabBtnHeight = 20 + local tabBtnRow = CreateFrame("Frame", nil, sidebarFrame) + tabBtnRow:SetHeight(tabBtnHeight) + tabBtnRow:SetPoint("TOPLEFT", sidebarFrame, "TOPLEFT", 0, 0) + tabBtnRow:SetPoint("TOPRIGHT", sidebarFrame, "TOPRIGHT", 0, 0) + + local tabButtons = {} + + local function UpdateTabHighlights() + for _, tb in ipairs(tabButtons) do + if tb.id == activeTab then + tb.btn:SetBackdropColor(unpack(DF.Colors.tabActive)) + else + tb.btn:SetBackdropColor(unpack(DF.Colors.tabInactive)) + end + end + end + + for i, def in ipairs(TAB_DEFS) do + local btn = CreateFrame("Button", nil, tabBtnRow, "BackdropTemplate") + btn:RegisterForClicks("LeftButtonUp") + btn:SetHeight(tabBtnHeight) + btn:SetBackdrop({ + bgFile = "Interface\\ChatFrame\\ChatFrameBackground", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, tileSize = 16, edgeSize = 10, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + + local label = btn:CreateFontString(nil, "OVERLAY") + label:SetFontObject(DF.Theme:UIFont()) + label:SetPoint("CENTER", 0, 0) + label:SetText(def.label) + label:SetTextColor(0.83, 0.83, 0.83, 1) + + btn:SetScript("OnEnter", function(self) + if def.id ~= activeTab then + self:SetBackdropColor(unpack(DF.Colors.tabHover)) + end + end) + btn:SetScript("OnLeave", function(self) + if def.id ~= activeTab then + self:SetBackdropColor(unpack(DF.Colors.tabInactive)) + end + end) + + tabButtons[i] = { id = def.id, btn = btn, label = label } + end + + tabBtnRow:SetScript("OnSizeChanged", function(self) + local w = self:GetWidth() + local count = #tabButtons + local bw = math.floor(w / count) + for i, tb in ipairs(tabButtons) do + tb.btn:ClearAllPoints() + tb.btn:SetSize(bw, tabBtnHeight) + tb.btn:SetPoint("TOPLEFT", self, "TOPLEFT", (i - 1) * bw, 0) + end + end) + + local tree = DF.Widgets:CreateTreeView(sidebarFrame) + tree.frame:SetPoint("TOPLEFT", tabBtnRow, "BOTTOMLEFT", 0, -2) + tree.frame:SetPoint("BOTTOMRIGHT", sidebarFrame, "BOTTOMRIGHT", 0, 0) + + --------------------------------------------------------------------------- + -- Editor: toolbar + info + scrollable list + --------------------------------------------------------------------------- + local editorFrame = CreateFrame("Frame", nil, editorParent) + editorFrame:SetAllPoints(editorParent) + + -- Toolbar + local toolbar = CreateFrame("Frame", nil, editorFrame) + toolbar:SetHeight(DF.Layout.buttonHeight + 4) + toolbar:SetPoint("TOPLEFT", 0, 0) + toolbar:SetPoint("TOPRIGHT", 0, 0) + + local stopAllBtn = DF.Widgets:CreateButton(toolbar, "Stop All", 60) + stopAllBtn:SetPoint("RIGHT", -4, 0) + stopAllBtn:SetScript("OnClick", function() StopActiveSound() end) + + local copyBtn = DF.Widgets:CreateButton(toolbar, "Copy", 46) + copyBtn:SetPoint("RIGHT", stopAllBtn, "LEFT", -2, 0) + + -- Back button (hidden until cross-module navigation) + local backBtn = DF.Widgets:CreateButton(toolbar, "< Back", 55) + backBtn:SetPoint("LEFT", 2, 0) + backBtn:Hide() + backBtn._sourceModule = nil + + local searchInput = CreateFrame("EditBox", nil, toolbar, "BackdropTemplate") + searchInput:SetPoint("LEFT", 2, 0) + searchInput:SetPoint("RIGHT", copyBtn, "LEFT", -8, 0) + searchInput:SetHeight(20) + searchInput:SetAutoFocus(false) + searchInput:SetFontObject(DF.Theme:CodeFont()) + searchInput:SetTextColor(0.83, 0.83, 0.83, 1) + searchInput:SetMaxLetters(200) + DF.Theme:ApplyInputStyle(searchInput) + + local searchPlaceholder = searchInput:CreateFontString(nil, "OVERLAY") + searchPlaceholder:SetFontObject(DF.Theme:UIFont()) + searchPlaceholder:SetPoint("LEFT", 6, 0) + searchPlaceholder:SetText("Search sounds by name or ID...") + searchPlaceholder:SetTextColor(0.4, 0.4, 0.4, 1) + + local function ShowBackButton(sourceModule) + if sourceModule then + backBtn._sourceModule = sourceModule + local label = DF.ModuleSystem:GetTabLabel(sourceModule) or sourceModule + backBtn:SetLabel("< " .. label) + backBtn:Show() + searchInput:SetPoint("LEFT", backBtn, "RIGHT", 4, 0) + else + backBtn:Hide() + backBtn._sourceModule = nil + searchInput:SetPoint("LEFT", toolbar, "LEFT", 2, 0) + end + end + + backBtn:SetScript("OnClick", function() + local target = backBtn._sourceModule + ShowBackButton(nil) + if target then + DF.ModuleSystem:Activate(target) + end + end) + + searchInput:SetScript("OnTextChanged", function(self, userInput) + local text = self:GetText() + if text and text ~= "" then + searchPlaceholder:Hide() + else + searchPlaceholder:Show() + end + end) + + -- Info label + local infoLabel = editorFrame:CreateFontString(nil, "OVERLAY") + infoLabel:SetFontObject(DF.Theme:UIFont()) + infoLabel:SetPoint("TOPLEFT", toolbar, "BOTTOMLEFT", 4, -2) + infoLabel:SetPoint("TOPRIGHT", toolbar, "BOTTOMRIGHT", -4, -2) + infoLabel:SetHeight(16) + infoLabel:SetJustifyH("LEFT") + infoLabel:SetTextColor(0.5, 0.5, 0.5, 1) + infoLabel:SetText("") + + local listPane = DF.Widgets:CreateScrollPane(editorFrame, true) + listPane.frame:SetPoint("TOPLEFT", toolbar, "BOTTOMLEFT", 0, -20) + listPane.frame:SetPoint("BOTTOMRIGHT", editorFrame, "BOTTOMRIGHT", 0, 0) + + --------------------------------------------------------------------------- + -- List item pool + --------------------------------------------------------------------------- + local function GetListItem(index) + if listItems[index] then + listItems[index].frame:Show() + return listItems[index] + end + + local item = {} + item.frame = CreateFrame("Button", nil, listPane:GetContent()) + item.frame:SetHeight(ROW_HEIGHT) + item.frame:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + -- Alternating row background + local bg = item.frame:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints() + bg:SetColorTexture(0.1, 0.1, 0.12, (index % 2 == 0) and 0.6 or 0.3) + item.bg = bg + + -- Play/Stop button + local playBtn = CreateFrame("Button", nil, item.frame) + playBtn:RegisterForClicks("LeftButtonUp") + playBtn:SetSize(20, 20) + playBtn:SetPoint("LEFT", 6, 0) + local playIcon = playBtn:CreateTexture(nil, "OVERLAY") + playIcon:SetAllPoints() + playIcon:SetAtlas("common-dropdown-icon-play") + playIcon:SetVertexColor(0.7, 0.7, 0.7, 1) + item.playBtn = playBtn + item.playIcon = playIcon + + playBtn:SetScript("OnEnter", function() playIcon:SetVertexColor(1, 1, 1, 1) end) + playBtn:SetScript("OnLeave", function() + if activeItemIndex == index then + playIcon:SetVertexColor(0.3, 1, 0.3, 1) + else + playIcon:SetVertexColor(0.7, 0.7, 0.7, 1) + end + end) + + playBtn:SetScript("OnClick", function() + if activeItemIndex == index then + StopActiveSound() + return + end + StopActiveSound() + if not item.soundId then return end + local willPlay, handle + if item.sourceType == "music" then + PlayMusic(tostring(item.soundId)) + willPlay, handle = true, "music" + elseif item.sourceType == "file" then + willPlay, handle = PlaySoundFile(item.soundId, "Master") + else + willPlay, handle = PlaySound(item.soundId, "Master") + end + if willPlay and handle then + activeHandle = handle + activeItemIndex = index + playIcon:SetAtlas("common-dropdown-icon-stop") + playIcon:SetVertexColor(0.3, 1, 0.3, 1) + StartPlayPoll() + -- Add to recent + DF.SoundIndex:AddRecent({ + id = item.soundId, + name = item.displayName or tostring(item.soundId), + sourceType = item.sourceType or "kit", + }) + end + end) + + -- Sound name/ID label + local nameLabel = item.frame:CreateFontString(nil, "OVERLAY") + nameLabel:SetFontObject(DF.Theme:CodeFont()) + nameLabel:SetPoint("LEFT", playBtn, "RIGHT", 8, 0) + nameLabel:SetPoint("RIGHT", item.frame, "RIGHT", -30, 0) + nameLabel:SetJustifyH("LEFT") + nameLabel:SetWordWrap(false) + nameLabel:SetTextColor(0.83, 0.83, 0.83, 1) + item.nameLabel = nameLabel + + -- Highlight + local hl = item.frame:CreateTexture(nil, "HIGHLIGHT") + hl:SetAllPoints() + hl:SetColorTexture(0.3, 0.5, 0.8, 0.2) + + -- Favorite star + local star = CreateFrame("Button", nil, item.frame) + star:RegisterForClicks("LeftButtonUp") + star:SetSize(14, 14) + star:SetPoint("RIGHT", item.frame, "RIGHT", -8, 0) + star:SetFrameLevel(item.frame:GetFrameLevel() + 2) + local starTex = star:CreateTexture(nil, "OVERLAY") + starTex:SetAllPoints() + starTex:SetAtlas("auctionhouse-icon-favorite") + starTex:SetDesaturated(true) + starTex:SetVertexColor(0.6, 0.6, 0.6, 0.7) + star.tex = starTex + item.star = star + item.starTex = starTex + + star:SetScript("OnEnter", function(self) self.tex:SetVertexColor(1, 0.85, 0, 1) end) + star:SetScript("OnLeave", function(self) + if item.soundId and DF.SoundIndex:IsFavorite(item.soundId, item.sourceType) then + self.tex:SetDesaturated(false) + self.tex:SetVertexColor(1, 0.85, 0, 1) + else + self.tex:SetDesaturated(true) + self.tex:SetVertexColor(0.6, 0.6, 0.6, 0.7) + end + end) + star:SetScript("OnClick", function() + if not item.soundId then return end + if DF.SoundIndex:IsFavorite(item.soundId, item.sourceType) then + DF.SoundIndex:RemoveFavorite(item.soundId, item.sourceType) + starTex:SetDesaturated(true) + starTex:SetVertexColor(0.6, 0.6, 0.6, 0.7) + else + DF.SoundIndex:AddFavorite({ + id = item.soundId, + name = item.displayName or tostring(item.soundId), + sourceType = item.sourceType or "kit", + }) + starTex:SetDesaturated(false) + starTex:SetVertexColor(1, 0.85, 0, 1) + end + end) + + -- Row click + item.frame:SetScript("OnClick", function(self, button) + if not item.soundId then return end + if button == "RightButton" then + ShowContextMenu(item) + else + searchInput:SetText(tostring(item.soundId)) + searchInput:SetFocus() + searchInput:HighlightText() + end + end) + + item.frame:SetScript("OnEnter", function(self) + if not item.soundId then return end + GameTooltip:SetOwner(self, "ANCHOR_CURSOR") + if item.sourceType == "file" then + GameTooltip:SetText("FileID: " .. tostring(item.soundId), 1, 1, 1) + else + GameTooltip:SetText("SoundKit: " .. tostring(item.soundId), 1, 1, 1) + end + if item.displayName then + GameTooltip:AddLine(item.displayName, 0.6, 0.8, 1) + end + GameTooltip:AddLine("Left-click to select | Right-click for options", 0.5, 0.8, 1) + GameTooltip:AddLine("Play button to preview", 0.5, 0.8, 1) + GameTooltip:Show() + end) + item.frame:SetScript("OnLeave", function() GameTooltip:Hide() end) + + listItems[index] = item + return item + end + + local function HideAllListItems() + for _, item in ipairs(listItems) do item.frame:Hide() end + end + + local function UpdateStarState(item) + if item.soundId and DF.SoundIndex:IsFavorite(item.soundId, item.sourceType) then + item.starTex:SetDesaturated(false) + item.starTex:SetVertexColor(1, 0.85, 0, 1) + else + item.starTex:SetDesaturated(true) + item.starTex:SetVertexColor(0.6, 0.6, 0.6, 0.7) + end + end + + --------------------------------------------------------------------------- + -- Show sounds in the list + --------------------------------------------------------------------------- + ShowSounds = function(results) + HideAllListItems() + StopActiveSound() + currentResults = results + local totalCount = #results + local displayCount = math.min(totalCount, MAX_LIST_ITEMS) + local contentW = listPane:GetContent():GetWidth() + if contentW < 1 then contentW = listPane.scrollFrame:GetWidth() end + + for i = 1, displayCount do + local result = results[i] + local item = GetListItem(i) + item.frame:ClearAllPoints() + item.frame:SetPoint("TOPLEFT", listPane:GetContent(), "TOPLEFT", 0, -((i - 1) * ROW_HEIGHT)) + item.frame:SetPoint("RIGHT", listPane:GetContent(), "RIGHT", 0, 0) + + item.soundId = result.id + item.displayName = result.name + item.sourceType = result.sourceType or "kit" + + -- Format display text + local label + if item.sourceType == "file" then + label = "FileID:" .. tostring(result.id) + if result.name and result.name ~= ("FileID:" .. tostring(result.id)) then + label = result.name .. " | " .. label + end + else + label = tostring(result.id) .. " — " .. (result.name or "?") + end + item.nameLabel:SetText(label) + + -- Reset play icon + item.playIcon:SetAtlas("common-dropdown-icon-play") + item.playIcon:SetVertexColor(0.7, 0.7, 0.7, 1) + + -- Update alternating background + item.bg:SetColorTexture(0.1, 0.1, 0.12, (i % 2 == 0) and 0.6 or 0.3) + + UpdateStarState(item) + end + + listPane:SetContentHeight(displayCount * ROW_HEIGHT + 4) + listPane:ScrollToTop() + + if totalCount > displayCount then + infoLabel:SetText(displayCount .. " of " .. totalCount .. " sounds (search to narrow)") + else + infoLabel:SetText(totalCount .. " sounds") + end + end + + --------------------------------------------------------------------------- + -- Context menu + --------------------------------------------------------------------------- + ShowContextMenu = function(item) + if not contextMenu then contextMenu = DF.Widgets:CreateDropDown() end + local items = {} + + items[#items + 1] = { + text = "Copy ID", + func = function() DF.Widgets:ShowCopyDialog(tostring(item.soundId)) end, + } + + if item.sourceType == "music" then + local code = 'PlayMusic("' .. tostring(item.soundId) .. '")' + items[#items + 1] = { + text = "Copy PlayMusic Code", + func = function() DF.Widgets:ShowCopyDialog(code) end, + } + items[#items + 1] = { + text = "Insert PlayMusic Code", + func = function() + DF.EventBus:Fire("DF_INSERT_TO_EDITOR", { text = code }) + end, + } + elseif item.sourceType == "file" then + local code = "PlaySoundFile(" .. tostring(item.soundId) .. ', "Master")' + items[#items + 1] = { + text = "Copy PlaySoundFile Code", + func = function() DF.Widgets:ShowCopyDialog(code) end, + } + items[#items + 1] = { + text = "Insert PlaySoundFile Code", + func = function() + DF.EventBus:Fire("DF_INSERT_TO_EDITOR", { text = code }) + end, + } + else + local code = "PlaySound(" .. tostring(item.soundId) .. ', "Master")' + items[#items + 1] = { + text = "Copy PlaySound Code", + func = function() DF.Widgets:ShowCopyDialog(code) end, + } + items[#items + 1] = { + text = "Insert PlaySound Code", + func = function() + DF.EventBus:Fire("DF_INSERT_TO_EDITOR", { text = code }) + end, + } + end + + items[#items + 1] = { isSeparator = true } + + if DF.SoundIndex:IsFavorite(item.soundId, item.sourceType) then + items[#items + 1] = { + text = "Remove from Favorites", + func = function() + DF.SoundIndex:RemoveFavorite(item.soundId, item.sourceType) + UpdateStarState(item) + end, + } + else + items[#items + 1] = { + text = "Add to Favorites", + func = function() + DF.SoundIndex:AddFavorite({ + id = item.soundId, + name = item.displayName or tostring(item.soundId), + sourceType = item.sourceType or "kit", + }) + UpdateStarState(item) + end, + } + end + + contextMenu:Show(nil, items) + end + + --------------------------------------------------------------------------- + -- Tree builders + --------------------------------------------------------------------------- + local function BuildKitsTree() + local categories = DF.SoundKitData:GetCategories() + local nodes = {} + for _, cat in ipairs(categories) do + local sounds = DF.SoundKitData:GetSounds(cat.id) + nodes[#nodes + 1] = { + id = "kitcat_" .. cat.id, + text = cat.name .. " (" .. #sounds .. ")", + data = { categoryType = "soundkit", categoryId = cat.id }, + } + end + return nodes + end + + -- FileID range explorer state + local fileidStart = 1 + local FILEID_PAGE_SIZE = DF.SoundFileData:GetDefaultRangeSize() + + local function BuildFileIdTree() + return { + { id = "fileid_browse", text = "Browse from " .. fileidStart, data = { categoryType = "fileid_browse" } }, + { id = "fileid_prev", text = "< Previous " .. FILEID_PAGE_SIZE, data = { categoryType = "fileid_prev" } }, + { id = "fileid_next", text = "Next " .. FILEID_PAGE_SIZE .. " >", data = { categoryType = "fileid_next" } }, + } + end + + local fileidScanTicker = nil + + local function CancelFileIdScan() + if fileidScanTicker then fileidScanTicker:Cancel(); fileidScanTicker = nil end + end + + local function LoadFileIdPage() + CancelFileIdScan() + HideAllListItems() + currentResults = {} + local rangeEnd = fileidStart + FILEID_PAGE_SIZE - 1 + infoLabel:SetText("Scanning FileID " .. fileidStart .. " - " .. rangeEnd .. " ...") + if activeTab == "fileid" then tree:SetNodes(BuildFileIdTree()) end + + local results = {} + local current = fileidStart + local BATCH = 50 + + fileidScanTicker = C_Timer.NewTicker(0, function(ticker) + if current > rangeEnd then + ticker:Cancel() + fileidScanTicker = nil + ShowSounds(results) + infoLabel:SetText("FileID " .. fileidStart .. " - " .. rangeEnd .. " | " .. #results .. " playable") + return + end + + local batchEnd = math.min(current + BATCH - 1, rangeEnd) + for fileId = current, batchEnd do + local willPlay, handle = PlaySoundFile(fileId, "Master") + if willPlay and handle then + pcall(StopSound, handle, 0) + results[#results + 1] = { + id = fileId, + name = "FileID:" .. fileId, + sourceType = "file", + } + end + end + current = batchEnd + 1 + infoLabel:SetText("Scanning FileID " .. fileidStart .. " - " .. rangeEnd .. " | " .. (current - fileidStart) .. "/" .. FILEID_PAGE_SIZE .. " checked, " .. #results .. " found") + end) + end + + local function BuildLiveTree() + local nodes = {} + local isListening = DF.SoundRuntime:IsListening() + nodes[1] = { + id = "live_toggle", + text = isListening and "Stop Listening" or "Start Listening", + data = { categoryType = "live_toggle" }, + } + local runtimeResults = DF.SoundRuntime:GetResults() + if #runtimeResults > 0 then + nodes[#nodes + 1] = { + id = "live_all", + text = "All Captured (" .. #runtimeResults .. ")", + data = { categoryType = "live_all" }, + } + -- Group by source type + local kitCount, fileCount, musicCount = 0, 0, 0 + for _, item in ipairs(runtimeResults) do + if item.sourceType == "music" then + musicCount = musicCount + 1 + elseif item.sourceType == "file" then + fileCount = fileCount + 1 + else + kitCount = kitCount + 1 + end + end + if kitCount > 0 then + nodes[#nodes + 1] = { + id = "live_kits", + text = "SoundKit (" .. kitCount .. ")", + data = { categoryType = "live_kits" }, + } + end + if fileCount > 0 then + nodes[#nodes + 1] = { + id = "live_files", + text = "SoundFile (" .. fileCount .. ")", + data = { categoryType = "live_files" }, + } + end + if musicCount > 0 then + nodes[#nodes + 1] = { + id = "live_music", + text = "Music (" .. musicCount .. ")", + data = { categoryType = "live_music" }, + } + end + nodes[#nodes + 1] = { + id = "live_clear", + text = "Clear Results", + data = { categoryType = "live_clear" }, + } + end + return nodes + end + + local function BuildFavsTree() + local favs = DF.SoundIndex:GetFavorites() + local recent = DF.SoundIndex:GetRecent() + return { + { id = "favorites_root", text = "Favorites (" .. #favs .. ")", data = { categoryType = "favorites" } }, + { id = "recent_root", text = "Recent (" .. #recent .. ")", data = { categoryType = "recent" } }, + } + end + + --------------------------------------------------------------------------- + -- Tab switching + --------------------------------------------------------------------------- + SwitchTab = function(tabId) + CancelFileIdScan() + activeTab = tabId + UpdateTabHighlights() + local nodes + if tabId == "kits" then nodes = BuildKitsTree() + elseif tabId == "fileid" then nodes = BuildFileIdTree() + elseif tabId == "live" then nodes = BuildLiveTree() + elseif tabId == "favs" then nodes = BuildFavsTree() + end + tree:SetNodes(nodes or {}) + HideAllListItems() + StopActiveSound() + currentResults = {} + infoLabel:SetText("") + end + + for _, tb in ipairs(tabButtons) do + tb.btn:SetScript("OnClick", function() SwitchTab(tb.id) end) + end + + --------------------------------------------------------------------------- + -- Tree selection handler + --------------------------------------------------------------------------- + tree:SetOnSelect(function(node) + if not node or not node.data then return end + lastSelectedNode = node + local d = node.data + + -- SoundKit category + if d.categoryType == "soundkit" and d.categoryId then + local sounds = DF.SoundKitData:GetSounds(d.categoryId) + local results = {} + for _, entry in ipairs(sounds) do + results[#results + 1] = { + id = entry.id, + name = entry.name, + sourceType = "kit", + } + end + ShowSounds(results) + return + end + + -- FileID range explorer + if d.categoryType == "fileid_browse" then + local text = searchInput:GetText() + local asNum = tonumber(text) + if asNum and asNum > 0 then + fileidStart = math.floor(asNum) + end + LoadFileIdPage() + return + end + if d.categoryType == "fileid_prev" then + fileidStart = math.max(1, fileidStart - FILEID_PAGE_SIZE) + LoadFileIdPage() + return + end + if d.categoryType == "fileid_next" then + fileidStart = fileidStart + FILEID_PAGE_SIZE + LoadFileIdPage() + return + end + + -- Live toggle + if d.categoryType == "live_toggle" then + if DF.SoundRuntime:IsListening() then + DF.SoundRuntime:StopListening() + infoLabel:SetText("Listener stopped — " .. DF.SoundRuntime:GetCount() .. " sounds captured") + else + DF.SoundRuntime:StartListening() + infoLabel:SetText("Listening for sounds... play sounds in-game to capture them") + end + C_Timer.After(0, function() + if activeTab == "live" then tree:SetNodes(BuildLiveTree()) end + end) + return + end + + -- Live: all results + if d.categoryType == "live_all" then + local runtimeResults = DF.SoundRuntime:GetResults() + local results = {} + for _, item in ipairs(runtimeResults) do + results[#results + 1] = { + id = item.id, + name = item.name, + sourceType = item.sourceType, + } + end + ShowSounds(results) + return + end + + -- Live: filtered by type + if d.categoryType == "live_kits" or d.categoryType == "live_files" or d.categoryType == "live_music" then + local filterType = (d.categoryType == "live_kits" and "kit") or (d.categoryType == "live_music" and "music") or "file" + local runtimeResults = DF.SoundRuntime:GetResults() + local results = {} + for _, item in ipairs(runtimeResults) do + if item.sourceType == filterType then + results[#results + 1] = { + id = item.id, + name = item.name, + sourceType = item.sourceType, + } + end + end + ShowSounds(results) + return + end + + -- Live: clear + if d.categoryType == "live_clear" then + DF.SoundRuntime:Clear() + infoLabel:SetText("Captured sounds cleared") + HideAllListItems() + currentResults = {} + C_Timer.After(0, function() + if activeTab == "live" then tree:SetNodes(BuildLiveTree()) end + end) + return + end + + -- Favorites + if d.categoryType == "favorites" then + local favs = DF.SoundIndex:GetFavorites() + local results = {} + for _, fav in ipairs(favs) do + results[#results + 1] = { + id = fav.id, + name = fav.name, + sourceType = fav.sourceType or "kit", + } + end + ShowSounds(results) + return + end + + -- Recent + if d.categoryType == "recent" then + local recent = DF.SoundIndex:GetRecent() + local results = {} + for _, rec in ipairs(recent) do + results[#results + 1] = { + id = rec.id, + name = rec.name, + sourceType = rec.sourceType or "kit", + } + end + ShowSounds(results) + return + end + end) + + --------------------------------------------------------------------------- + -- Search input + --------------------------------------------------------------------------- + searchInput:SetScript("OnEnterPressed", function(self) + local text = self:GetText() + if text and text ~= "" then + local asNum = tonumber(text) + if asNum then + if activeTab == "fileid" then + -- On FileID tab, jump to that range + fileidStart = math.max(1, math.floor(asNum)) + LoadFileIdPage() + else + -- Try as both SoundKit and FileID + ShowSounds({ + { id = asNum, name = "SoundKit:" .. asNum, sourceType = "kit" }, + { id = asNum, name = "FileID:" .. asNum, sourceType = "file" }, + }) + infoLabel:SetText("Showing ID " .. asNum .. " as SoundKit and FileID") + end + else + local results = DF.SoundIndex:Search(text) + ShowSounds(results) + infoLabel:SetText(#results .. " search results for \"" .. text .. "\"") + end + end + self:ClearFocus() + end) + searchInput:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + + copyBtn:SetScript("OnClick", function() + local text = searchInput:GetText() + if text and text ~= "" then DF.Widgets:ShowCopyDialog(text) end + end) + + listPane.frame:SetScript("OnSizeChanged", function() + listPane:GetContent():SetWidth(listPane.scrollFrame:GetWidth()) + if #currentResults > 0 then ShowSounds(currentResults) end + end) + + SwitchTab("kits") + + --------------------------------------------------------------------------- + -- Cross-module navigation: show a specific sound + --------------------------------------------------------------------------- + function browser:ShowSound(soundId, sourceType, sourceModule) + if not soundId then return end + ShowBackButton(sourceModule) + searchInput:SetText(tostring(soundId)) + local name = (sourceType == "file") and ("FileID:" .. soundId) or ("SoundKit:" .. soundId) + ShowSounds({ { id = soundId, name = name, sourceType = sourceType or "kit" } }) + infoLabel:SetText(name) + end + + --------------------------------------------------------------------------- + -- Lifecycle + --------------------------------------------------------------------------- + function browser:OnActivate() + if unloadTimer then unloadTimer:Cancel(); unloadTimer = nil end + end + + function browser:OnDeactivate() + ShowBackButton(nil) + CancelFileIdScan() + StopActiveSound() + unloadTimer = C_Timer.After(30, function() + unloadTimer = nil + HideAllListItems() + currentResults = {} + infoLabel:SetText("") + end) + end + + browser.sidebar = sidebarFrame + browser.editor = editorFrame + return browser +end, "Sounds") + +-- Cross-module event: navigate to SoundBrowser with a specific sound. +-- Registered at file-load time so it works even before the module is first opened. +DF.EventBus:On("DF_SHOW_IN_SOUND_BROWSER", function(data) + if not data or not data.id then return end + local sourceModule = DF.ModuleSystem:GetActive() + DF.ModuleSystem:Activate("SoundBrowser") + local instance = DF.ModuleSystem:GetInstance("SoundBrowser") + if instance and instance.ShowSound then + instance:ShowSound(data.id, data.sourceType, sourceModule) + end +end) diff --git a/DevForge/Modules/SoundBrowser/SoundFileData.lua b/DevForge/Modules/SoundBrowser/SoundFileData.lua new file mode 100644 index 0000000..c885955 --- /dev/null +++ b/DevForge/Modules/SoundBrowser/SoundFileData.lua @@ -0,0 +1,24 @@ +local _, DF = ... + +DF.SoundFileData = {} + +local SFD = DF.SoundFileData + +local RANGE_SIZE = 500 + +--------------------------------------------------------------------------- +-- Generate a range of FileIDs to explore from a given start point. +-- Not all IDs will be playable — users try each one to discover sounds. +--------------------------------------------------------------------------- +function SFD:GetRange(startId, count) + count = count or RANGE_SIZE + local ids = {} + for i = startId, startId + count - 1 do + ids[#ids + 1] = i + end + return ids +end + +function SFD:GetDefaultRangeSize() + return RANGE_SIZE +end diff --git a/DevForge/Modules/SoundBrowser/SoundIndex.lua b/DevForge/Modules/SoundBrowser/SoundIndex.lua new file mode 100644 index 0000000..79acb77 --- /dev/null +++ b/DevForge/Modules/SoundBrowser/SoundIndex.lua @@ -0,0 +1,165 @@ +local _, DF = ... + +DF.SoundIndex = {} + +local Index = DF.SoundIndex +local MAX_SEARCH_RESULTS = 200 +local MAX_RECENT = 50 + +--------------------------------------------------------------------------- +-- Search: unified across all data sources +--------------------------------------------------------------------------- +function Index:Search(query) + if not query or query == "" then return {} end + local queryLower = query:lower() + local results = {} + local count = 0 + local seen = {} + + -- Search SoundKit entries + local allSounds = DF.SoundKitData:GetAllSounds() + for _, entry in ipairs(allSounds) do + if count >= MAX_SEARCH_RESULTS then break end + if entry.name:lower():find(queryLower, 1, true) or + tostring(entry.id):find(queryLower, 1, true) then + local key = "kit:" .. entry.id + if not seen[key] then + seen[key] = true + count = count + 1 + results[count] = { + id = entry.id, + name = entry.name, + category = "SoundKit", + sourceType = "kit", + } + end + end + end + + -- Search runtime results + if count < MAX_SEARCH_RESULTS then + local runtimeResults = DF.SoundRuntime:GetResults() + for _, item in ipairs(runtimeResults) do + if count >= MAX_SEARCH_RESULTS then break end + if item.name:lower():find(queryLower, 1, true) or + tostring(item.id):find(queryLower, 1, true) then + local key = item.sourceType .. ":" .. tostring(item.id) + if not seen[key] then + seen[key] = true + count = count + 1 + results[count] = { + id = item.id, + name = item.name, + category = "Runtime", + sourceType = item.sourceType, + } + end + end + end + end + + -- Search favorites + if count < MAX_SEARCH_RESULTS then + local favs = self:GetFavorites() + for _, fav in ipairs(favs) do + if count >= MAX_SEARCH_RESULTS then break end + if fav.name:lower():find(queryLower, 1, true) or + tostring(fav.id):find(queryLower, 1, true) then + local key = (fav.sourceType or "kit") .. ":" .. tostring(fav.id) + if not seen[key] then + seen[key] = true + count = count + 1 + results[count] = { + id = fav.id, + name = fav.name, + category = "Favorite", + sourceType = fav.sourceType or "kit", + } + end + end + end + end + + return results +end + +--------------------------------------------------------------------------- +-- Favorites (persisted in DevForgeDB.soundFavorites) +--------------------------------------------------------------------------- +function Index:GetFavorites() + if not DevForgeDB then return {} end + return DevForgeDB.soundFavorites or {} +end + +function Index:AddFavorite(entry) + if not DevForgeDB then return end + if not DevForgeDB.soundFavorites then + DevForgeDB.soundFavorites = {} + end + local idStr = tostring(entry.id) + for _, fav in ipairs(DevForgeDB.soundFavorites) do + if tostring(fav.id) == idStr and (fav.sourceType or "kit") == (entry.sourceType or "kit") then + return + end + end + DevForgeDB.soundFavorites[#DevForgeDB.soundFavorites + 1] = { + id = entry.id, + name = entry.name, + sourceType = entry.sourceType or "kit", + } +end + +function Index:RemoveFavorite(id, sourceType) + if not DevForgeDB or not DevForgeDB.soundFavorites then return end + local idStr = tostring(id) + sourceType = sourceType or "kit" + for i, fav in ipairs(DevForgeDB.soundFavorites) do + if tostring(fav.id) == idStr and (fav.sourceType or "kit") == sourceType then + table.remove(DevForgeDB.soundFavorites, i) + return + end + end +end + +function Index:IsFavorite(id, sourceType) + if not DevForgeDB or not DevForgeDB.soundFavorites then return false end + local idStr = tostring(id) + sourceType = sourceType or "kit" + for _, fav in ipairs(DevForgeDB.soundFavorites) do + if tostring(fav.id) == idStr and (fav.sourceType or "kit") == sourceType then + return true + end + end + return false +end + +--------------------------------------------------------------------------- +-- Recent history (persisted in DevForgeDB.soundRecent, newest first) +--------------------------------------------------------------------------- +function Index:GetRecent() + if not DevForgeDB then return {} end + return DevForgeDB.soundRecent or {} +end + +function Index:AddRecent(entry) + if not DevForgeDB then return end + if not DevForgeDB.soundRecent then + DevForgeDB.soundRecent = {} + end + local idStr = tostring(entry.id) + local st = entry.sourceType or "kit" + for i, rec in ipairs(DevForgeDB.soundRecent) do + if tostring(rec.id) == idStr and (rec.sourceType or "kit") == st then + table.remove(DevForgeDB.soundRecent, i) + break + end + end + table.insert(DevForgeDB.soundRecent, 1, { + id = entry.id, + name = entry.name, + sourceType = st, + }) + while #DevForgeDB.soundRecent > MAX_RECENT do + DevForgeDB.soundRecent[#DevForgeDB.soundRecent] = nil + end +end diff --git a/DevForge/Modules/SoundBrowser/SoundKitData.lua b/DevForge/Modules/SoundBrowser/SoundKitData.lua new file mode 100644 index 0000000..a369a11 --- /dev/null +++ b/DevForge/Modules/SoundBrowser/SoundKitData.lua @@ -0,0 +1,127 @@ +local _, DF = ... + +DF.SoundKitData = {} + +local SKD = DF.SoundKitData + +--------------------------------------------------------------------------- +-- Category definitions: prefix patterns matched against SOUNDKIT key names +-- Order matters — first match wins. +--------------------------------------------------------------------------- +local CATEGORY_RULES = { + { id = "ig", name = "Interface (IG)", pattern = "^IG_" }, + { id = "gs", name = "Glue Screens (GS)", pattern = "^GS_" }, + { id = "gluescreen", name = "Glue Screen Ambience", pattern = "^AMB_GLUESCREEN_" }, + { id = "ui", name = "UI General", pattern = "^UI_" }, + { id = "interface", name = "Interface", pattern = "^INTERFACE_" }, + { id = "account", name = "Account Store", pattern = "^ACCOUNT_STORE_" }, + { id = "alarm", name = "Alarms", pattern = "^ALARM_" }, + { id = "barbershop", name = "Barbershop", pattern = "^BARBERSHOP_" }, + { id = "catalog", name = "Catalog Shop", pattern = "^CATALOG_SHOP_" }, + { id = "housing", name = "Housing", pattern = "^HOUSING_" }, + { id = "lfg", name = "LFG / Group Finder", pattern = "^LFG_" }, + { id = "music", name = "Music", pattern = "^MUS_" }, + { id = "quest", name = "Quests", pattern = "^QUEST_" }, + { id = "tradingpost", name = "Trading Post", pattern = "^TRADING_POST_" }, + { id = "soulbinds", name = "Soulbinds", pattern = "^SOULBINDS_" }, + { id = "pvp", name = "PvP", pattern = "^PVP" }, + { id = "raid", name = "Raid & Dungeon", pattern = "^RAID" }, + { id = "loot", name = "Loot & Items", pattern = "^LOOT" }, + { id = "map", name = "Map & Navigation", pattern = "^MAP" }, + { id = "achievement", name = "Achievements", pattern = "^ACHIEVEMENT" }, + { id = "spell", name = "Spells & Auras", pattern = "^SPELL" }, + { id = "pet", name = "Pets & Mounts", pattern = "^PET" }, + { id = "garrison", name = "Garrison & Missions", pattern = "^GARRISON" }, + { id = "other", name = "Other", pattern = "." }, +} + +--------------------------------------------------------------------------- +-- Build data from the global SOUNDKIT table at runtime +--------------------------------------------------------------------------- +local categories = nil +local soundData = nil +local allSoundsCache = nil + +local function EnsureBuilt() + if categories then return end + + categories = {} + soundData = {} + + -- Initialize buckets + for _, rule in ipairs(CATEGORY_RULES) do + soundData[rule.id] = {} + end + + -- Read the global SOUNDKIT table (Blizzard-provided) + if not SOUNDKIT then + -- SOUNDKIT not available; create empty categories + for _, rule in ipairs(CATEGORY_RULES) do + categories[#categories + 1] = { id = rule.id, name = rule.name } + end + return + end + + -- Sort keys alphabetically for stable ordering + local keys = {} + for name, id in pairs(SOUNDKIT) do + if type(name) == "string" and type(id) == "number" then + keys[#keys + 1] = name + end + end + table.sort(keys) + + -- Categorize each entry + for _, name in ipairs(keys) do + local id = SOUNDKIT[name] + local placed = false + for _, rule in ipairs(CATEGORY_RULES) do + if name:match(rule.pattern) then + local bucket = soundData[rule.id] + bucket[#bucket + 1] = { id = id, name = name } + placed = true + break + end + end + end + + -- Build category list with counts, skip empty ones (except "other") + for _, rule in ipairs(CATEGORY_RULES) do + local bucket = soundData[rule.id] + if #bucket > 0 then + categories[#categories + 1] = { id = rule.id, name = rule.name } + end + end +end + +--------------------------------------------------------------------------- +-- API +--------------------------------------------------------------------------- +function SKD:GetCategories() + EnsureBuilt() + return categories +end + +function SKD:GetSounds(categoryId) + EnsureBuilt() + return soundData[categoryId] or {} +end + +function SKD:GetAllSounds() + EnsureBuilt() + if allSoundsCache then return allSoundsCache end + allSoundsCache = {} + for _, cat in ipairs(categories) do + local sounds = soundData[cat.id] + if sounds then + for _, entry in ipairs(sounds) do + allSoundsCache[#allSoundsCache + 1] = { + id = entry.id, + name = entry.name, + categoryId = cat.id, + } + end + end + end + return allSoundsCache +end diff --git a/DevForge/Modules/SoundBrowser/SoundRuntime.lua b/DevForge/Modules/SoundBrowser/SoundRuntime.lua new file mode 100644 index 0000000..bf52017 --- /dev/null +++ b/DevForge/Modules/SoundBrowser/SoundRuntime.lua @@ -0,0 +1,101 @@ +local _, DF = ... + +DF.SoundRuntime = {} + +local SR = DF.SoundRuntime + +local results = {} +local resultCount = 0 +local listening = false +local seenIds = {} + +--------------------------------------------------------------------------- +-- Hooks: intercept PlaySound / PlaySoundFile calls to capture IDs +--------------------------------------------------------------------------- +local origPlaySound +local origPlaySoundFile + +local function OnSoundPlayed(soundId, sourceType, displayName) + if not listening then return end + if not soundId then return end + local key = sourceType .. ":" .. tostring(soundId) + if seenIds[key] then + seenIds[key].time = GetTime() + return + end + local name + if displayName then + name = displayName + elseif sourceType == "kit" then + name = "SoundKit:" .. soundId + elseif sourceType == "music" then + name = tostring(soundId):match("([^/\\]+)$") or tostring(soundId) + else + name = "FileID:" .. tostring(soundId) + end + local entry = { + id = soundId, + name = name, + sourceType = sourceType, + time = GetTime(), + } + seenIds[key] = entry + resultCount = resultCount + 1 + results[resultCount] = entry +end + +-- Hook PlaySound to capture SoundKit IDs +if PlaySound then + hooksecurefunc("PlaySound", function(soundKitID, channel, forceNoDuplicates) + if soundKitID then + OnSoundPlayed(soundKitID, "kit") + end + end) +end + +-- Hook PlaySoundFile to capture file-based sound IDs +if PlaySoundFile then + hooksecurefunc("PlaySoundFile", function(soundFileOrID, channel) + if soundFileOrID then + OnSoundPlayed(soundFileOrID, "file") + end + end) +end + +-- Hook PlayMusic to capture music paths +if PlayMusic then + hooksecurefunc("PlayMusic", function(musicPath) + if musicPath then + OnSoundPlayed(musicPath, "music") + end + end) +end + +--------------------------------------------------------------------------- +-- API +--------------------------------------------------------------------------- +function SR:StartListening() + listening = true +end + +function SR:StopListening() + listening = false +end + +function SR:IsListening() + return listening +end + +function SR:GetResults() + return results +end + +function SR:GetCount() + return resultCount +end + +function SR:Clear() + results = {} + resultCount = 0 + seenIds = {} +end diff --git a/DevForge/SavedVariables/Schema.lua b/DevForge/SavedVariables/Schema.lua index e8c2423..0dc6925 100644 --- a/DevForge/SavedVariables/Schema.lua +++ b/DevForge/SavedVariables/Schema.lua @@ -5,7 +5,7 @@ DF.Schema = {} local Schema = DF.Schema local DEFAULTS = { - dbVersion = 3, + dbVersion = 4, windowX = nil, windowY = nil, windowW = nil, @@ -25,6 +25,8 @@ local DEFAULTS = { lastMacroIndex = nil, textureFavorites = {}, textureRecent = {}, + soundFavorites = {}, + soundRecent = {}, -- IDE layout state sidebarWidth = 220, sidebarCollapsed = false, @@ -80,6 +82,13 @@ function Schema:Migrate(db) if db.snippetSidebarTab == nil then db.snippetSidebarTab = "snippets" end db.dbVersion = 3 end + + if version < 4 then + -- Sound Browser favorites/recent + if db.soundFavorites == nil then db.soundFavorites = {} end + if db.soundRecent == nil then db.soundRecent = {} end + db.dbVersion = 4 + end end function Schema:GetDefault(key) diff --git a/DevForge/UI/ActivityBar.lua b/DevForge/UI/ActivityBar.lua index 041f742..3c2be2d 100644 --- a/DevForge/UI/ActivityBar.lua +++ b/DevForge/UI/ActivityBar.lua @@ -17,6 +17,7 @@ local MODULE_ICONS = { { name = "Inspector", atlas = "Crosshair_Inspect_32", group = "inspect" }, { name = "TableViewer", atlasOn = "common-icon-visual", atlasOff = "common-icon-visual-disabled", group = "inspect" }, { name = "TextureBrowser", atlas = "Crosshair_Transmogrify_32", group = "inspect" }, + { name = "SoundBrowser", atlas = "common-icon-sound-pressed", group = "inspect" }, -- Reference: browsing data { name = "APIBrowser", atlas = "crosshair_speak_32", group = "reference" }, { name = "CVarViewer", atlas = "Adventure-Mission-Silver-Dragon", group = "reference" },