/** * TTS 播放器面板 - 极简胶囊版 v2 * 黑白灰配色,舒缓动画 */ 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: 30px; --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(255, 255, 255, 0.9); --error: rgba(239, 68, 68, 0.8); position: relative; display: inline-flex; flex-direction: column; margin: 8px 0; 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: 15px; 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-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: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--text); cursor: pointer; border-radius: 50%; font-size: 10px; transition: all 0.25s ease; flex-shrink: 0; } .xb-tts-btn:hover { background: rgba(255, 255, 255, 0.12); } .xb-tts-btn:active { transform: scale(0.92); } /* 播放按钮 */ .xb-tts-btn.play-btn { font-size: 11px; } /* 停止按钮 - 正方形图标 */ .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: 22px; height: 22px; 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; justify-content: space-between; align-items: center; } .xb-tts-usage { font-size: 10px; color: var(--text-dim); } .xb-tts-icon-btn { color: var(--text-sub); cursor: pointer; font-size: 13px; padding: 4px 6px; border-radius: 4px; transition: all 0.2s; } .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 div = document.createElement('div'); div.className = 'xb-tts-panel'; div.dataset.messageId = messageId; div.dataset.status = 'idle'; div.dataset.hasQueue = 'false'; // Template-only UI markup. // eslint-disable-next-line no-unsanitized/property div.innerHTML = `
播放 0/0
音色
语速 ${currentSpeed.toFixed(1)}x
--
`; return div; } function buildVoiceOptions(select, config) { const mySpeakers = config?.volc?.mySpeakers || []; const current = config?.volc?.defaultSpeaker || ''; if (mySpeakers.length === 0) { // Template-only UI markup. // eslint-disable-next-line no-unsanitized/property select.innerHTML = ''; select.selectedIndex = -1; return; } const isMyVoice = current && mySpeakers.some(s => s.value === current); // UI options from config values only. // eslint-disable-next-line no-unsanitized/property select.innerHTML = mySpeakers.map(s => { const selected = isMyVoice && s.value === current ? ' selected' : ''; return ``; }).join(''); if (!isMyVoice) { select.selectedIndex = -1; } } function mountPanel(messageEl, messageId, onPlay) { if (panelMap.has(messageId)) return panelMap.get(messageId); const nameBlock = messageEl.querySelector('.mes_block > .ch_name') || messageEl.querySelector('.name_text')?.parentElement; if (!nameBlock) return null; const panel = createPanel(messageId); if (nameBlock.nextSibling) { nameBlock.parentNode.insertBefore(panel, nameBlock.nextSibling); } else { nameBlock.parentNode.appendChild(panel); } const ui = { root: panel, playBtn: panel.querySelector('.play-btn'), stopBtn: panel.querySelector('.stop-btn'), statusText: panel.querySelector('.xb-tts-status'), badge: panel.querySelector('.xb-tts-badge'), progressInner: panel.querySelector('.xb-tts-progress-inner'), voiceSelect: panel.querySelector('.voice-select'), speedSlider: panel.querySelector('.speed-slider'), speedVal: panel.querySelector('.speed-val'), usageText: panel.querySelector('.xb-tts-usage'), }; ui.playBtn.onclick = (e) => { e.stopPropagation(); onPlay(messageId); }; ui.stopBtn.onclick = (e) => { e.stopPropagation(); clearQueueFn?.(messageId); }; panel.querySelector('.expand-btn').onclick = (e) => { e.stopPropagation(); panel.classList.toggle('expanded'); if (panel.classList.contains('expanded')) { buildVoiceOptions(ui.voiceSelect, getConfigFn?.()); } }; panel.querySelector('.settings-btn').onclick = (e) => { e.stopPropagation(); panel.classList.remove('expanded'); openSettingsFn?.(); }; ui.voiceSelect.onchange = async (e) => { const config = getConfigFn?.(); if (config?.volc) { config.volc.defaultSpeaker = e.target.value; await saveConfigFn?.({ volc: config.volc }); } }; ui.speedSlider.oninput = (e) => { ui.speedVal.textContent = Number(e.target.value).toFixed(1) + 'x'; }; ui.speedSlider.onchange = async (e) => { const config = getConfigFn?.(); if (config?.volc) { config.volc.speechRate = Number(e.target.value); await saveConfigFn?.({ volc: config.volc }); } }; const closeMenu = (e) => { if (!panel.contains(e.target)) { panel.classList.remove('expanded'); } }; document.addEventListener('click', closeMenu, { passive: true }); ui._cleanup = () => { document.removeEventListener('click', closeMenu); }; panelMap.set(messageId, ui); return ui; } // ============ 对外接口 ============ export function initTtsPanelStyles() { injectStyles(); } export function ensureTtsPanel(messageEl, messageId, onPlay) { injectStyles(); if (panelMap.has(messageId)) { const existingUi = panelMap.get(messageId); if (existingUi.root && existingUi.root.isConnected) { return existingUi; } existingUi._cleanup?.(); panelMap.delete(messageId); } const rect = messageEl.getBoundingClientRect(); if (rect.top < window.innerHeight + 500 && rect.bottom > -500) { return mountPanel(messageEl, messageId, onPlay); } if (!observer) { observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const el = entry.target; const mid = Number(el.getAttribute('mesid')); const cb = pendingCallbacks.get(mid); if (cb) { mountPanel(el, mid, cb); pendingCallbacks.delete(mid); observer.unobserve(el); } } }); }, { rootMargin: '500px' }); } pendingCallbacks.set(messageId, onPlay); observer.observe(messageEl); return null; } export function updateTtsPanel(messageId, state) { const ui = panelMap.get(messageId); if (!ui || !state) return; const status = state.status || 'idle'; const current = state.currentSegment || 0; const total = state.totalSegments || 0; const hasQueue = total > 1; ui.root.dataset.status = status; ui.root.dataset.hasQueue = hasQueue ? 'true' : 'false'; // 状态文本和图标 let statusText = ''; let playIcon = '▶'; let showStop = false; switch (status) { case 'idle': statusText = '播放'; playIcon = '▶'; break; case 'sending': case 'queued': statusText = hasQueue ? `${current}/${total}` : '准备'; playIcon = '■'; showStop = true; break; case 'cached': statusText = hasQueue ? `${current}/${total}` : '缓存'; playIcon = '▶'; break; case 'playing': statusText = hasQueue ? `${current}/${total}` : ''; playIcon = '⏸'; showStop = true; break; case 'paused': statusText = hasQueue ? `${current}/${total}` : '暂停'; playIcon = '▶'; showStop = true; break; case 'ended': statusText = '完成'; playIcon = '↻'; break; case 'blocked': statusText = '受阻'; playIcon = '▶'; break; case 'error': statusText = (state.error || '失败').slice(0, 8); playIcon = '↻'; break; default: statusText = '播放'; playIcon = '▶'; } ui.playBtn.textContent = playIcon; ui.statusText.textContent = statusText; // 队列徽标 if (hasQueue && current > 0) { ui.badge.textContent = `${current}/${total}`; } // 停止按钮显示 ui.stopBtn.style.display = showStop ? '' : 'none'; // 进度条 if (hasQueue && total > 0) { const pct = Math.min(100, (current / total) * 100); ui.progressInner.style.width = `${pct}%`; } else if (state.progress && state.duration) { const pct = Math.min(100, (state.progress / state.duration) * 100); ui.progressInner.style.width = `${pct}%`; } else { ui.progressInner.style.width = '0%'; } // 用量显示 if (state.textLength) { ui.usageText.textContent = `${state.textLength} 字`; } } export function removeAllTtsPanels() { panelMap.forEach(ui => { ui._cleanup?.(); ui.root?.remove(); }); panelMap.clear(); pendingCallbacks.clear(); observer?.disconnect(); observer = null; } export function removeTtsPanel(messageId) { const ui = panelMap.get(messageId); if (ui) { ui._cleanup?.(); ui.root?.remove(); panelMap.delete(messageId); } pendingCallbacks.delete(messageId); }