"use strict"; import { extension_settings } from "../../../../extensions.js"; import { eventSource, event_types } from "../../../../../script.js"; import { SlashCommandParser } from "../../../../slash-commands/SlashCommandParser.js"; import { SlashCommand } from "../../../../slash-commands/SlashCommand.js"; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from "../../../../slash-commands/SlashCommandArgument.js"; const AudioHost = (() => { /** @typedef {{ audio: HTMLAudioElement|null, currentUrl: string }} AudioInstance */ /** @type {Record<'primary'|'secondary', AudioInstance>} */ const instances = { primary: { audio: null, currentUrl: "" }, secondary: { audio: null, currentUrl: "" }, }; /** * @param {('primary'|'secondary')} area * @returns {HTMLAudioElement} */ function getOrCreate(area) { const inst = instances[area] || (instances[area] = { audio: null, currentUrl: "" }); if (!inst.audio) { inst.audio = new Audio(); inst.audio.preload = "auto"; try { inst.audio.crossOrigin = "anonymous"; } catch { } } return inst.audio; } /** * @param {string} url * @param {boolean} loop * @param {('primary'|'secondary')} area * @param {number} volume10 1-10 */ async function playUrl(url, loop = false, area = 'primary', volume10 = 5) { const u = String(url || "").trim(); if (!/^https?:\/\//i.test(u)) throw new Error("仅支持 http/https 链接"); const a = getOrCreate(area); a.loop = !!loop; let v = Number(volume10); if (!Number.isFinite(v)) v = 5; v = Math.max(1, Math.min(10, v)); try { a.volume = v / 10; } catch { } const inst = instances[area]; if (inst.currentUrl && u === inst.currentUrl) { if (a.paused) await a.play(); return `继续播放: ${u}`; } inst.currentUrl = u; if (a.src !== u) { a.src = u; try { await a.play(); } catch (e) { throw new Error("播放失败"); } } else { try { a.currentTime = 0; await a.play(); } catch { } } return `播放: ${u}`; } /** * @param {('primary'|'secondary')} area */ function stop(area = 'primary') { const inst = instances[area]; if (inst?.audio) { try { inst.audio.pause(); } catch { } } return "已停止"; } /** * @param {('primary'|'secondary')} area */ function getCurrentUrl(area = 'primary') { const inst = instances[area]; return inst?.currentUrl || ""; } function reset() { for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { const inst = instances[key]; if (inst.audio) { try { inst.audio.pause(); } catch { } try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } } inst.currentUrl = ""; } } function stopAll() { for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { const inst = instances[key]; if (inst?.audio) { try { inst.audio.pause(); } catch { } } } return "已全部停止"; } /** * 清除指定实例:停止并移除 src,清空 currentUrl * @param {('primary'|'secondary')} area */ function clear(area = 'primary') { const inst = instances[area]; if (inst?.audio) { try { inst.audio.pause(); } catch { } try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } } inst.currentUrl = ""; return "已清除"; } return { playUrl, stop, stopAll, clear, getCurrentUrl, reset }; })(); let registeredCommand = null; let chatChangedHandler = null; let isRegistered = false; let globalStateChangedHandler = null; function registerSlash() { if (isRegistered) return; try { registeredCommand = SlashCommand.fromProps({ name: "xbaudio", callback: async (args, value) => { try { const action = String(args.play || "").toLowerCase(); const mode = String(args.mode || "loop").toLowerCase(); const rawArea = args.area; const hasArea = typeof rawArea !== 'undefined' && rawArea !== null && String(rawArea).trim() !== ''; const area = hasArea && String(rawArea).toLowerCase() === 'secondary' ? 'secondary' : 'primary'; const volumeArg = args.volume; let volume = Number(volumeArg); if (!Number.isFinite(volume)) volume = 5; const url = String(value || "").trim(); const loop = mode === "loop"; if (url.toLowerCase() === "list") { return AudioHost.getCurrentUrl(area) || ""; } if (action === "off") { if (hasArea) { return AudioHost.stop(area); } return AudioHost.stopAll(); } if (action === "clear") { if (hasArea) { return AudioHost.clear(area); } AudioHost.reset(); return "已全部清除"; } if (action === "on" || (!action && url)) { return await AudioHost.playUrl(url, loop, area, volume); } if (!url && !action) { const cur = AudioHost.getCurrentUrl(area); return cur ? `当前播放(${area}): ${cur}` : "未在播放。用法: /xbaudio [play=on] [mode=loop] [area=primary/secondary] [volume=5] URL | /xbaudio list | /xbaudio play=off (未指定 area 将关闭全部)"; } return "用法: /xbaudio play=off | /xbaudio play=off area=primary/secondary | /xbaudio play=clear | /xbaudio play=clear area=primary/secondary | /xbaudio [play=on] [mode=loop/once] [area=primary/secondary] [volume=1-10] URL | /xbaudio list (默认: play=on mode=loop area=primary volume=5;未指定 area 的 play=off 关闭全部;未指定 area 的 play=clear 清除全部)"; } catch (e) { return `错误: ${e.message || e}`; } }, namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: "play", description: "on/off/clear 或留空以默认播放", typeList: [ARGUMENT_TYPE.STRING], enumList: ["on", "off", "clear"] }), SlashCommandNamedArgument.fromProps({ name: "mode", description: "once/loop", typeList: [ARGUMENT_TYPE.STRING], enumList: ["once", "loop"] }), SlashCommandNamedArgument.fromProps({ name: "area", description: "primary/secondary (play=off 未指定 area 关闭全部)", typeList: [ARGUMENT_TYPE.STRING], enumList: ["primary", "secondary"] }), SlashCommandNamedArgument.fromProps({ name: "volume", description: "音量 1-10(默认 5)", typeList: [ARGUMENT_TYPE.NUMBER] }), ], unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: "音频URL (http/https) 或 list", typeList: [ARGUMENT_TYPE.STRING] }), ], helpString: "播放网络音频。示例: /xbaudio https://files.catbox.moe/0ryoa5.mp3 (默认: play=on mode=loop area=primary volume=5) | /xbaudio area=secondary volume=8 https://files.catbox.moe/0ryoa5.mp3 | /xbaudio list | /xbaudio play=off (未指定 area 关闭全部) | /xbaudio play=off area=primary | /xbaudio play=clear (未指定 area 清除全部)", }); SlashCommandParser.addCommandObject(registeredCommand); if (event_types?.CHAT_CHANGED) { chatChangedHandler = () => { try { AudioHost.reset(); } catch { } }; eventSource.on(event_types.CHAT_CHANGED, chatChangedHandler); } isRegistered = true; } catch (e) { console.error("[LittleWhiteBox][audio] 注册斜杠命令失败", e); } } function unregisterSlash() { if (!isRegistered) return; try { if (chatChangedHandler && event_types?.CHAT_CHANGED) { try { eventSource.removeListener(event_types.CHAT_CHANGED, chatChangedHandler); } catch { } } chatChangedHandler = null; try { const map = SlashCommandParser.commands || {}; Object.keys(map).forEach((k) => { if (map[k] === registeredCommand) delete map[k]; }); } catch { } } finally { registeredCommand = null; isRegistered = false; } } function enableFeature() { registerSlash(); } function disableFeature() { try { AudioHost.reset(); } catch { } unregisterSlash(); } export function initControlAudio() { try { try { const enabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); if (enabled) enableFeature(); else disableFeature(); } catch { enableFeature(); } const bind = () => { const cb = document.getElementById('xiaobaix_audio_enabled'); if (!cb) { setTimeout(bind, 200); return; } const applyState = () => { const input = /** @type {HTMLInputElement} */(cb); const enabled = !!(input && input.checked); if (enabled) enableFeature(); else disableFeature(); }; cb.addEventListener('change', applyState); applyState(); }; bind(); // 监听扩展全局开关,关闭时强制停止并清理两个实例 try { if (!globalStateChangedHandler) { globalStateChangedHandler = (e) => { try { const enabled = !!(e && e.detail && e.detail.enabled); if (!enabled) { try { AudioHost.reset(); } catch { } unregisterSlash(); } else { // 重新根据子开关状态应用 const audioEnabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); if (audioEnabled) enableFeature(); else disableFeature(); } } catch { } }; document.addEventListener('xiaobaixEnabledChanged', globalStateChangedHandler); } } catch { } } catch (e) { console.error("[LittleWhiteBox][audio] 初始化失败", e); } }