// tts-panel.js /** * TTS 播放器面板 - 极简胶囊版 v4 * 新增:自动朗读快捷开关,支持双向同步 */ import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js'; // ═══════════════════════════════════════════════════════════════════════════ // 常量 // ═══════════════════════════════════════════════════════════════════════════ const INITIAL_RENDER_LIMIT = 1; // ═══════════════════════════════════════════════════════════════════════════ // 状态 // ═══════════════════════════════════════════════════════════════════════════ let stylesInjected = false; const panelMap = new Map(); const pendingCallbacks = new Map(); let observer = null; // 配置接口 let getConfigFn = null; let saveConfigFn = null; let openSettingsFn = null; let clearQueueFn = null; export function setPanelConfigHandlers({ getConfig, saveConfig, openSettings, clearQueue }) { getConfigFn = getConfig; saveConfigFn = saveConfig; openSettingsFn = openSettings; clearQueueFn = clearQueue; } export function clearPanelConfigHandlers() { getConfigFn = null; saveConfigFn = null; openSettingsFn = null; clearQueueFn = null; } // ═══════════════════════════════════════════════════════════════════════════ // 样式 // ═══════════════════════════════════════════════════════════════════════════ function injectStyles() { if (stylesInjected) return; const css = ` /* ═══════════════════════════════════════════════════════════════ TTS 播放器 - 极简胶囊 ═══════════════════════════════════════════════════════════════ */ .xb-tts-panel { --h: 34px; --bg: rgba(0, 0, 0, 0.55); --bg-hover: rgba(0, 0, 0, 0.7); --border: rgba(255, 255, 255, 0.08); --border-active: rgba(255, 255, 255, 0.2); --text: rgba(255, 255, 255, 0.85); --text-sub: rgba(255, 255, 255, 0.45); --text-dim: rgba(255, 255, 255, 0.25); --success: rgba(62, 207, 142, 0.9); --success-soft: rgba(62, 207, 142, 0.12); --error: rgba(239, 68, 68, 0.8); position: relative; display: inline-flex; flex-direction: column; z-index: 10; user-select: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } /* ═══════════════════════════════════════════════════════════════ 胶囊主体 ═══════════════════════════════════════════════════════════════ */ .xb-tts-capsule { display: flex; align-items: center; height: var(--h); background: var(--bg); border: 1px solid var(--border); border-radius: 17px; padding: 0 3px; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); width: fit-content; gap: 1px; } .xb-tts-panel:hover .xb-tts-capsule { background: var(--bg-hover); border-color: var(--border-active); } /* 自动朗读开启时的边框提示 */ .xb-tts-panel[data-auto="true"] .xb-tts-capsule { border-color: rgba(62, 207, 142, 0.25); } .xb-tts-panel[data-auto="true"]:hover .xb-tts-capsule { border-color: rgba(62, 207, 142, 0.4); } /* 状态边框 */ .xb-tts-panel[data-status="playing"] .xb-tts-capsule { border-color: rgba(255, 255, 255, 0.25); } .xb-tts-panel[data-status="error"] .xb-tts-capsule { border-color: var(--error); } /* ═══════════════════════════════════════════════════════════════ 按钮 ═══════════════════════════════════════════════════════════════ */ .xb-tts-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--text); cursor: pointer; border-radius: 50%; font-size: 11px; transition: all 0.25s ease; flex-shrink: 0; position: relative; } .xb-tts-btn:hover { background: rgba(255, 255, 255, 0.12); } .xb-tts-btn:active { transform: scale(0.92); } /* 播放按钮的自动朗读指示点 */ .xb-tts-auto-dot { position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; background: var(--success); border-radius: 50%; box-shadow: 0 0 6px rgba(62, 207, 142, 0.6); opacity: 0; transform: scale(0); transition: all 0.25s ease; } .xb-tts-panel[data-auto="true"] .xb-tts-auto-dot { opacity: 1; transform: scale(1); } /* 停止按钮 */ .xb-tts-btn.stop-btn { color: var(--text-sub); font-size: 8px; } .xb-tts-btn.stop-btn:hover { color: var(--error); background: rgba(239, 68, 68, 0.1); } /* 展开按钮 */ .xb-tts-btn.expand-btn { width: 24px; height: 24px; font-size: 8px; color: var(--text-dim); opacity: 0.6; transition: opacity 0.25s, transform 0.25s; } .xb-tts-panel:hover .xb-tts-btn.expand-btn { opacity: 1; } .xb-tts-panel.expanded .xb-tts-btn.expand-btn { transform: rotate(180deg); } /* ═══════════════════════════════════════════════════════════════ 分隔线 ═══════════════════════════════════════════════════════════════ */ .xb-tts-sep { width: 1px; height: 12px; background: var(--border); margin: 0 2px; flex-shrink: 0; } /* ═══════════════════════════════════════════════════════════════ 信息区 ═══════════════════════════════════════════════════════════════ */ .xb-tts-info { display: flex; align-items: center; gap: 6px; padding: 0 6px; min-width: 50px; } .xb-tts-status { font-size: 11px; color: var(--text-sub); white-space: nowrap; transition: color 0.25s; } .xb-tts-panel[data-status="playing"] .xb-tts-status { color: var(--text); } .xb-tts-panel[data-status="error"] .xb-tts-status { color: var(--error); } /* 队列徽标 */ .xb-tts-badge { display: none; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.1); color: var(--text); padding: 2px 6px; border-radius: 8px; font-size: 10px; font-weight: 500; font-variant-numeric: tabular-nums; } .xb-tts-panel[data-has-queue="true"] .xb-tts-badge { display: flex; } /* ═══════════════════════════════════════════════════════════════ 波形动画 ═══════════════════════════════════════════════════════════════ */ .xb-tts-wave { display: none; align-items: center; gap: 2px; height: 14px; padding: 0 4px; } .xb-tts-panel[data-status="playing"] .xb-tts-wave { display: flex; } .xb-tts-panel[data-status="playing"] .xb-tts-status { display: none; } .xb-tts-bar { width: 2px; background: var(--text); border-radius: 1px; animation: xb-tts-wave 1.6s infinite ease-in-out; opacity: 0.7; } .xb-tts-bar:nth-child(1) { height: 4px; animation-delay: 0.0s; } .xb-tts-bar:nth-child(2) { height: 10px; animation-delay: 0.2s; } .xb-tts-bar:nth-child(3) { height: 6px; animation-delay: 0.4s; } .xb-tts-bar:nth-child(4) { height: 8px; animation-delay: 0.6s; } @keyframes xb-tts-wave { 0%, 100% { transform: scaleY(0.4); opacity: 0.4; } 50% { transform: scaleY(1); opacity: 0.85; } } /* ═══════════════════════════════════════════════════════════════ 加载动画 ═══════════════════════════════════════════════════════════════ */ .xb-tts-loading { display: none; width: 12px; height: 12px; border: 1.5px solid rgba(255, 255, 255, 0.15); border-top-color: var(--text); border-radius: 50%; animation: xb-tts-spin 1s linear infinite; margin: 0 4px; } .xb-tts-panel[data-status="sending"] .xb-tts-loading, .xb-tts-panel[data-status="queued"] .xb-tts-loading { display: block; } .xb-tts-panel[data-status="sending"] .play-btn, .xb-tts-panel[data-status="queued"] .play-btn { display: none; } @keyframes xb-tts-spin { to { transform: rotate(360deg); } } /* ═══════════════════════════════════════════════════════════════ 底部进度条 ═══════════════════════════════════════════════════════════════ */ .xb-tts-progress { position: absolute; bottom: 0; left: 8px; right: 8px; height: 2px; background: rgba(255, 255, 255, 0.08); border-radius: 1px; overflow: hidden; opacity: 0; transition: opacity 0.3s; } .xb-tts-panel[data-status="playing"] .xb-tts-progress, .xb-tts-panel[data-has-queue="true"] .xb-tts-progress { opacity: 1; } .xb-tts-progress-inner { height: 100%; background: rgba(255, 255, 255, 0.6); width: 0%; transition: width 0.4s ease-out; border-radius: 1px; } /* ═══════════════════════════════════════════════════════════════ 展开菜单 ═══════════════════════════════════════════════════════════════ */ .xb-tts-menu { position: absolute; top: calc(100% + 8px); left: 0; background: rgba(18, 18, 22, 0.96); border: 1px solid var(--border); border-radius: 12px; padding: 10px; min-width: 220px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); opacity: 0; visibility: hidden; transform: translateY(-6px) scale(0.96); transform-origin: top left; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); z-index: 100; } .xb-tts-panel.expanded .xb-tts-menu { opacity: 1; visibility: visible; transform: translateY(0) scale(1); } .xb-tts-row { display: flex; align-items: center; gap: 10px; padding: 6px 2px; } .xb-tts-label { font-size: 11px; color: var(--text-sub); width: 32px; flex-shrink: 0; } .xb-tts-select { flex: 1; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--border); color: var(--text); font-size: 11px; border-radius: 6px; padding: 6px 8px; outline: none; cursor: pointer; transition: border-color 0.2s; } .xb-tts-select:hover { border-color: rgba(255, 255, 255, 0.2); } .xb-tts-select:focus { border-color: rgba(255, 255, 255, 0.3); } .xb-tts-slider { flex: 1; height: 4px; accent-color: #fff; cursor: pointer; } .xb-tts-val { font-size: 11px; color: var(--text); width: 32px; text-align: right; font-variant-numeric: tabular-nums; } /* ═══════════════════════════════════════════════════════════════ 工具栏(包含自动朗读开关) ═══════════════════════════════════════════════════════════════ */ .xb-tts-tools { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 6px; } .xb-tts-usage { font-size: 10px; color: var(--text-dim); flex-shrink: 0; min-width: 32px; } /* 自动朗读开关 - flex:1 填满剩余空间 */ .xb-tts-auto-toggle { flex: 1; display: flex; align-items: center; gap: 6px; padding: 6px 10px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; transition: all 0.2s ease; } .xb-tts-auto-toggle:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.15); } .xb-tts-auto-toggle.on { background: rgba(62, 207, 142, 0.08); border-color: rgba(62, 207, 142, 0.25); } .xb-tts-auto-indicator { width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.2); transition: all 0.25s ease; flex-shrink: 0; } .xb-tts-auto-toggle.on .xb-tts-auto-indicator { background: var(--success); box-shadow: 0 0 6px rgba(62, 207, 142, 0.5); } .xb-tts-auto-text { font-size: 11px; color: var(--text-sub); transition: color 0.2s; } .xb-tts-auto-toggle:hover .xb-tts-auto-text { color: var(--text); } .xb-tts-auto-toggle.on .xb-tts-auto-text { color: rgba(62, 207, 142, 0.9); } .xb-tts-icon-btn { color: var(--text-sub); cursor: pointer; font-size: 13px; padding: 4px 6px; border-radius: 4px; transition: all 0.2s; flex-shrink: 0; } .xb-tts-icon-btn:hover { color: var(--text); background: rgba(255, 255, 255, 0.08); } /* ═══════════════════════════════════════════════════════════════ TTS 指令块样式 ═══════════════════════════════════════════════════════════════ */ .xb-tts-tag { display: inline-flex; align-items: center; gap: 3px; color: rgba(255, 255, 255, 0.25); font-size: 11px; font-style: italic; vertical-align: baseline; user-select: none; transition: color 0.3s ease; } .xb-tts-tag:hover { color: rgba(255, 255, 255, 0.45); } .xb-tts-tag-icon { font-style: normal; font-size: 10px; opacity: 0.7; } .xb-tts-tag-dot { opacity: 0.4; } .xb-tts-tag[data-has-params="true"] { color: rgba(255, 255, 255, 0.3); } `; const style = document.createElement('style'); style.id = 'xb-tts-panel-styles'; style.textContent = css; document.head.appendChild(style); stylesInjected = true; } // ═══════════════════════════════════════════════════════════════════════════ // 面板创建 // ═══════════════════════════════════════════════════════════════════════════ function createPanel(messageId) { const config = getConfigFn?.() || {}; const currentSpeed = config?.volc?.speechRate || 1.0; const isAutoSpeak = config?.autoSpeak !== false; const div = document.createElement('div'); div.className = 'xb-tts-panel'; div.dataset.messageId = messageId; div.dataset.status = 'idle'; div.dataset.hasQueue = 'false'; div.dataset.auto = isAutoSpeak ? 'true' : 'false'; // Template-only UI markup built locally. // eslint-disable-next-line no-unsanitized/property div.innerHTML = `