diff --git a/modules/tts/tts-panel.js b/modules/tts/tts-panel.js index 93c0f13..bc172a2 100644 --- a/modules/tts/tts-panel.js +++ b/modules/tts/tts-panel.js @@ -1,7 +1,6 @@ // tts-panel.js /** - * TTS 播放器面板 - 极简胶囊版 v4 - * 新增:自动朗读快捷开关,支持双向同步 + * TTS 播放器面板 - 支持楼层按钮和悬浮按钮双模式 */ import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js'; @@ -10,28 +9,41 @@ import { registerToToolbar, removeFromToolbar } from '../../widgets/message-tool // 常量 // ═══════════════════════════════════════════════════════════════════════════ +const FLOAT_POS_KEY = 'xb_tts_float_pos'; const INITIAL_RENDER_LIMIT = 1; // ═══════════════════════════════════════════════════════════════════════════ // 状态 // ═══════════════════════════════════════════════════════════════════════════ -let stylesInjected = false; +// 楼层按钮 const panelMap = new Map(); const pendingCallbacks = new Map(); -let observer = null; +let floorObserver = null; + +// 悬浮按钮 +let floatingEl = null; +let floatingDragState = null; +let $floatingCache = {}; + +// 通用 +let stylesInjected = false; // 配置接口 let getConfigFn = null; let saveConfigFn = null; let openSettingsFn = null; let clearQueueFn = null; +let getLastAIMessageIdFn = null; +let speakMessageFn = null; -export function setPanelConfigHandlers({ getConfig, saveConfig, openSettings, clearQueue }) { - getConfigFn = getConfig; - saveConfigFn = saveConfig; - openSettingsFn = openSettings; - clearQueueFn = clearQueue; +export function setPanelConfigHandlers(handlers) { + getConfigFn = handlers.getConfig; + saveConfigFn = handlers.saveConfig; + openSettingsFn = handlers.openSettings; + clearQueueFn = handlers.clearQueue; + getLastAIMessageIdFn = handlers.getLastAIMessageId; + speakMessageFn = handlers.speakMessage; } export function clearPanelConfigHandlers() { @@ -39,32 +51,27 @@ export function clearPanelConfigHandlers() { saveConfigFn = null; openSettingsFn = null; clearQueueFn = null; + getLastAIMessageIdFn = null; + speakMessageFn = null; } // ═══════════════════════════════════════════════════════════════════════════ // 样式 // ═══════════════════════════════════════════════════════════════════════════ -function injectStyles() { - if (stylesInjected) return; - const css = ` -/* ═══════════════════════════════════════════════════════════════ - TTS 播放器 - 极简胶囊 - ═══════════════════════════════════════════════════════════════ */ - +const STYLES = ` .xb-tts-panel { --h: 34px; --bg: rgba(0, 0, 0, 0.55); + --bg-solid: rgba(24, 24, 28, 0.98); --bg-hover: rgba(0, 0, 0, 0.7); --border: rgba(255, 255, 255, 0.08); - --border-active: rgba(255, 255, 255, 0.2); + --border-hover: 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; @@ -73,10 +80,6 @@ function injectStyles() { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } -/* ═══════════════════════════════════════════════════════════════ - 胶囊主体 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-capsule { display: flex; align-items: center; @@ -94,10 +97,9 @@ function injectStyles() { .xb-tts-panel:hover .xb-tts-capsule { background: var(--bg-hover); - border-color: var(--border-active); + border-color: var(--border-hover); } -/* 自动朗读开启时的边框提示 */ .xb-tts-panel[data-auto="true"] .xb-tts-capsule { border-color: rgba(62, 207, 142, 0.25); } @@ -105,7 +107,6 @@ function injectStyles() { 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); } @@ -113,10 +114,6 @@ function injectStyles() { border-color: var(--error); } -/* ═══════════════════════════════════════════════════════════════ - 按钮 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-btn { width: 28px; height: 28px; @@ -142,7 +139,6 @@ function injectStyles() { transform: scale(0.92); } -/* 播放按钮的自动朗读指示点 */ .xb-tts-auto-dot { position: absolute; top: 4px; @@ -161,7 +157,6 @@ function injectStyles() { transform: scale(1); } -/* 停止按钮 */ .xb-tts-btn.stop-btn { color: var(--text-sub); font-size: 8px; @@ -171,7 +166,6 @@ function injectStyles() { background: rgba(239, 68, 68, 0.1); } -/* 展开按钮 */ .xb-tts-btn.expand-btn { width: 24px; height: 24px; @@ -187,10 +181,6 @@ function injectStyles() { transform: rotate(180deg); } -/* ═══════════════════════════════════════════════════════════════ - 分隔线 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-sep { width: 1px; height: 12px; @@ -199,10 +189,6 @@ function injectStyles() { flex-shrink: 0; } -/* ═══════════════════════════════════════════════════════════════ - 信息区 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-info { display: flex; align-items: center; @@ -224,7 +210,6 @@ function injectStyles() { color: var(--error); } -/* 队列徽标 */ .xb-tts-badge { display: none; align-items: center; @@ -241,10 +226,6 @@ function injectStyles() { display: flex; } -/* ═══════════════════════════════════════════════════════════════ - 波形动画 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-wave { display: none; align-items: center; @@ -273,20 +254,10 @@ function injectStyles() { .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; - } + 0%, 100% { transform: scaleY(0.4); opacity: 0.4; } + 50% { transform: scaleY(1); opacity: 0.85; } } -/* ═══════════════════════════════════════════════════════════════ - 加载动画 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-loading { display: none; width: 12px; @@ -311,10 +282,6 @@ function injectStyles() { to { transform: rotate(360deg); } } -/* ═══════════════════════════════════════════════════════════════ - 底部进度条 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-progress { position: absolute; bottom: 0; @@ -341,10 +308,6 @@ function injectStyles() { border-radius: 1px; } -/* ═══════════════════════════════════════════════════════════════ - 展开菜单 - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-menu { position: absolute; top: calc(100% + 8px); @@ -397,12 +360,8 @@ function injectStyles() { 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-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; @@ -419,10 +378,6 @@ function injectStyles() { font-variant-numeric: tabular-nums; } -/* ═══════════════════════════════════════════════════════════════ - 工具栏(包含自动朗读开关) - ═══════════════════════════════════════════════════════════════ */ - .xb-tts-tools { margin-top: 8px; padding-top: 8px; @@ -439,7 +394,6 @@ function injectStyles() { min-width: 32px; } -/* 自动朗读开关 - flex:1 填满剩余空间 */ .xb-tts-auto-toggle { flex: 1; display: flex; @@ -479,12 +433,8 @@ function injectStyles() { 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-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); @@ -500,9 +450,35 @@ function injectStyles() { background: rgba(255, 255, 255, 0.08); } -/* ═══════════════════════════════════════════════════════════════ - TTS 指令块样式 - ═══════════════════════════════════════════════════════════════ */ +.xb-tts-floating-global { + position: fixed; + z-index: 10000; + user-select: none; + will-change: transform; +} + +.xb-tts-floating-global .xb-tts-capsule { + background: var(--bg-solid); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + touch-action: none; + cursor: grab; +} + +.xb-tts-floating-global .xb-tts-capsule:active { cursor: grabbing; } + +.xb-tts-floating-global .xb-tts-menu { + top: auto; + bottom: calc(100% + 10px); + transform: translateY(6px) scale(0.98); + transform-origin: bottom left; +} + +.xb-tts-floating-global.expanded .xb-tts-menu { + transform: translateY(0) scale(1); +} + +.xb-tts-floating-global .xb-tts-btn.expand-btn { transform: rotate(180deg); } +.xb-tts-floating-global.expanded .xb-tts-btn.expand-btn { transform: rotate(0deg); } .xb-tts-tag { display: inline-flex; @@ -515,336 +491,689 @@ function injectStyles() { 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); -} +.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); + +function injectStyles() { + if (stylesInjected) return; stylesInjected = true; + const el = document.createElement('style'); + el.id = 'xb-tts-panel-styles'; + el.textContent = STYLES; + document.head.appendChild(el); } // ═══════════════════════════════════════════════════════════════════════════ -// 面板创建 +// 通用工具 // ═══════════════════════════════════════════════════════════════════════════ -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 = ` -
-
- - -
-
-
-
-
-
-
- 播放 - 0/0 -
- - - -
- - - -
-
-
-
- -
-
- 音色 - -
-
- 语速 - - ${currentSpeed.toFixed(1)}x -
-
- -字 -
- - 自动朗读 -
- -
-
- `; - - return div; -} - -function buildVoiceOptions(select, config) { +function fillVoiceSelect(selectEl) { + if (!selectEl) return; + const config = getConfigFn?.(); const mySpeakers = config?.volc?.mySpeakers || []; - const current = config?.volc?.defaultSpeaker || ''; - + const currentSpeaker = config?.volc?.defaultSpeaker || ''; + + selectEl.replaceChildren(); + if (mySpeakers.length === 0) { - select.textContent = ''; const opt = document.createElement('option'); opt.value = ''; - opt.disabled = true; opt.textContent = '暂无音色'; - select.appendChild(opt); - select.selectedIndex = -1; + opt.disabled = true; + selectEl.appendChild(opt); return; } - - const isMyVoice = current && mySpeakers.some(s => s.value === current); - - select.textContent = ''; - mySpeakers.forEach((s) => { + + mySpeakers.forEach(s => { const opt = document.createElement('option'); opt.value = s.value; opt.textContent = s.name || s.value; - if (isMyVoice && s.value === current) opt.selected = true; - select.appendChild(opt); + if (s.value === currentSpeaker) opt.selected = true; + selectEl.appendChild(opt); }); - - if (!isMyVoice) { - select.selectedIndex = -1; +} + +function safeGetLastAIMessageId() { + const id = getLastAIMessageIdFn?.(); + return typeof id === 'number' && id >= 0 ? id : -1; +} + +function syncSpeedUI($cache) { + const config = getConfigFn?.(); + const currentSpeed = config?.volc?.speechRate || 1.0; + if ($cache.speedSlider) $cache.speedSlider.value = currentSpeed; + if ($cache.speedVal) $cache.speedVal.textContent = currentSpeed.toFixed(1) + 'x'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DOM 构建(符合 ESLint 规范,不使用 innerHTML) +// ═══════════════════════════════════════════════════════════════════════════ + +function createWaveElement() { + const wave = document.createElement('div'); + wave.className = 'xb-tts-wave'; + for (let i = 0; i < 4; i++) { + const bar = document.createElement('div'); + bar.className = 'xb-tts-bar'; + wave.appendChild(bar); } + return wave; +} + +function createMenuElement(speed, isAuto) { + const menu = document.createElement('div'); + menu.className = 'xb-tts-menu'; + + // 音色行 + const voiceRow = document.createElement('div'); + voiceRow.className = 'xb-tts-row'; + const voiceLabel = document.createElement('span'); + voiceLabel.className = 'xb-tts-label'; + voiceLabel.textContent = '音色'; + voiceRow.appendChild(voiceLabel); + const voiceSelect = document.createElement('select'); + voiceSelect.className = 'xb-tts-select voice-select'; + voiceRow.appendChild(voiceSelect); + menu.appendChild(voiceRow); + + // 语速行 + const speedRow = document.createElement('div'); + speedRow.className = 'xb-tts-row'; + const speedLabel = document.createElement('span'); + speedLabel.className = 'xb-tts-label'; + speedLabel.textContent = '语速'; + speedRow.appendChild(speedLabel); + const speedSlider = document.createElement('input'); + speedSlider.type = 'range'; + speedSlider.className = 'xb-tts-slider speed-slider'; + speedSlider.min = '0.5'; + speedSlider.max = '2.0'; + speedSlider.step = '0.1'; + speedSlider.value = String(speed); + speedRow.appendChild(speedSlider); + const speedVal = document.createElement('span'); + speedVal.className = 'xb-tts-val speed-val'; + speedVal.textContent = speed.toFixed(1) + 'x'; + speedRow.appendChild(speedVal); + menu.appendChild(speedRow); + + // 工具栏 + const tools = document.createElement('div'); + tools.className = 'xb-tts-tools'; + + const usage = document.createElement('span'); + usage.className = 'xb-tts-usage'; + usage.textContent = '-字'; + tools.appendChild(usage); + + const autoToggle = document.createElement('div'); + autoToggle.className = 'xb-tts-auto-toggle' + (isAuto ? ' on' : ''); + autoToggle.title = 'AI回复后自动朗读'; + const autoIndicator = document.createElement('span'); + autoIndicator.className = 'xb-tts-auto-indicator'; + autoToggle.appendChild(autoIndicator); + const autoText = document.createElement('span'); + autoText.className = 'xb-tts-auto-text'; + autoText.textContent = '自动朗读'; + autoToggle.appendChild(autoText); + tools.appendChild(autoToggle); + + const settingsBtn = document.createElement('span'); + settingsBtn.className = 'xb-tts-icon-btn settings-btn'; + settingsBtn.title = 'TTS 设置'; + settingsBtn.textContent = '⚙'; + tools.appendChild(settingsBtn); + + menu.appendChild(tools); + + return menu; +} + +function createCapsuleElement(mode) { + const capsule = document.createElement('div'); + capsule.className = 'xb-tts-capsule'; + + const loading = document.createElement('div'); + loading.className = 'xb-tts-loading'; + capsule.appendChild(loading); + + const playBtn = document.createElement('button'); + playBtn.className = 'xb-tts-btn play-btn'; + playBtn.title = '播放'; + playBtn.textContent = '▶'; + const autoDot = document.createElement('span'); + autoDot.className = 'xb-tts-auto-dot'; + playBtn.appendChild(autoDot); + capsule.appendChild(playBtn); + + const info = document.createElement('div'); + info.className = 'xb-tts-info'; + info.appendChild(createWaveElement()); + const statusText = document.createElement('span'); + statusText.className = 'xb-tts-status'; + statusText.textContent = '播放'; + info.appendChild(statusText); + const badge = document.createElement('span'); + badge.className = 'xb-tts-badge'; + badge.textContent = '0/0'; + info.appendChild(badge); + capsule.appendChild(info); + + const stopBtn = document.createElement('button'); + stopBtn.className = 'xb-tts-btn stop-btn'; + stopBtn.title = '停止'; + stopBtn.textContent = '■'; + stopBtn.style.display = 'none'; + capsule.appendChild(stopBtn); + + const sep = document.createElement('div'); + sep.className = 'xb-tts-sep'; + capsule.appendChild(sep); + + const expandBtn = document.createElement('button'); + expandBtn.className = 'xb-tts-btn expand-btn'; + expandBtn.title = '设置'; + expandBtn.textContent = mode === 'floating' ? '▲' : '▼'; + capsule.appendChild(expandBtn); + + const progress = document.createElement('div'); + progress.className = 'xb-tts-progress'; + const progressInner = document.createElement('div'); + progressInner.className = 'xb-tts-progress-inner'; + progress.appendChild(progressInner); + capsule.appendChild(progress); + + return capsule; +} + +function createPanelElement(speed, isAuto, mode = 'floor') { + const div = document.createElement('div'); + div.className = 'xb-tts-panel'; + div.dataset.status = 'idle'; + div.dataset.hasQueue = 'false'; + div.dataset.auto = isAuto ? 'true' : 'false'; + + const menu = createMenuElement(speed, isAuto); + const capsule = createCapsuleElement(mode); + + if (mode === 'floating') { + div.appendChild(menu); + div.appendChild(capsule); + } else { + div.appendChild(capsule); + div.appendChild(menu); + } + + return div; +} + +function cachePanelDOM(el) { + return { + capsule: el.querySelector('.xb-tts-capsule'), + playBtn: el.querySelector('.play-btn'), + stopBtn: el.querySelector('.stop-btn'), + statusText: el.querySelector('.xb-tts-status'), + badge: el.querySelector('.xb-tts-badge'), + progressInner: el.querySelector('.xb-tts-progress-inner'), + voiceSelect: el.querySelector('.voice-select'), + speedSlider: el.querySelector('.speed-slider'), + speedVal: el.querySelector('.speed-val'), + usageText: el.querySelector('.xb-tts-usage'), + autoToggle: el.querySelector('.xb-tts-auto-toggle'), + expandBtn: el.querySelector('.expand-btn'), + settingsBtn: el.querySelector('.settings-btn'), + }; } // ═══════════════════════════════════════════════════════════════════════════ -// IntersectionObserver 管理 +// 共用事件绑定 // ═══════════════════════════════════════════════════════════════════════════ -function setupObserver() { - if (observer) return; - - observer = new IntersectionObserver((entries) => { - const toMount = []; - - for (const entry of entries) { - if (!entry.isIntersecting) continue; - - const el = entry.target; - const mid = Number(el.getAttribute('mesid')); - const cb = pendingCallbacks.get(mid); - - if (cb) { - toMount.push({ el, mid, cb }); - pendingCallbacks.delete(mid); - observer.unobserve(el); - } +function bindCommonEvents($cache, parentEl = null) { + $cache.autoToggle?.addEventListener('click', async (e) => { + e.stopPropagation(); + const config = getConfigFn?.(); + if (!config) return; + const newValue = config.autoSpeak === false; + config.autoSpeak = newValue; + await saveConfigFn?.({ autoSpeak: newValue }); + updateAutoSpeakAll(); + }); + $cache.voiceSelect?.addEventListener('change', async (e) => { + const config = getConfigFn?.(); + if (config?.volc) { + config.volc.defaultSpeaker = e.target.value; + await saveConfigFn?.({ volc: config.volc }); } - - if (toMount.length > 0) { - requestAnimationFrame(() => { - for (const { el, mid, cb } of toMount) { - mountPanel(el, mid, cb); - } - }); + }); + $cache.speedSlider?.addEventListener('input', (e) => { + if ($cache.speedVal) { + $cache.speedVal.textContent = Number(e.target.value).toFixed(1) + 'x'; } - }, { - rootMargin: '300px', - threshold: 0 + }); + $cache.speedSlider?.addEventListener('change', async (e) => { + const config = getConfigFn?.(); + if (config?.volc) { + config.volc.speechRate = Number(e.target.value); + await saveConfigFn?.({ volc: config.volc }); + updateSpeedAll(); + } + }); + $cache.settingsBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + // ★ 关闭所有菜单 + panelMap.forEach(data => data.root?.classList.remove('expanded')); + floatingEl?.classList.remove('expanded'); + openSettingsFn?.(); }); } // ═══════════════════════════════════════════════════════════════════════════ -// 面板挂载 +// 楼层面板 // ═══════════════════════════════════════════════════════════════════════════ -function mountPanel(messageEl, messageId, onPlay) { - // 已存在且有效 +function createFloorPanel(messageId) { + const config = getConfigFn?.() || {}; + const currentSpeed = config?.volc?.speechRate || 1.0; + const isAutoSpeak = config?.autoSpeak !== false; + + const div = createPanelElement(currentSpeed, isAutoSpeak, 'floor'); + div.dataset.messageId = messageId; + + return div; +} + +function bindFloorPanelEvents(panelData, onPlay) { + const { messageId, root: el, $cache } = panelData; + + $cache.playBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + onPlay(messageId); + }); + + $cache.stopBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + clearQueueFn?.(messageId); + }); + + $cache.expandBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + el.classList.toggle('expanded'); + if (el.classList.contains('expanded')) { + fillVoiceSelect($cache.voiceSelect); + syncSpeedUI($cache); + } + }); + + bindCommonEvents($cache); + + const closeMenu = (e) => { + if (!el.contains(e.target)) { + el.classList.remove('expanded'); + } + }; + document.addEventListener('click', closeMenu, { passive: true }); + + panelData._cleanup = () => { + document.removeEventListener('click', closeMenu); + removeFromToolbar(messageId, el); + }; +} + +function mountFloorPanel(messageEl, messageId, onPlay) { if (panelMap.has(messageId)) { const existing = panelMap.get(messageId); if (existing.root?.isConnected) return existing; existing._cleanup?.(); panelMap.delete(messageId); } - - const panel = createPanel(messageId); - - // 使用工具栏管理器注册 - const success = registerToToolbar(messageId, panel, { + + injectStyles(); + + const panel = createFloorPanel(messageId); + const panelData = { messageId, root: panel, $cache: cachePanelDOM(panel) }; + + const success = registerToToolbar(messageId, panel, { position: 'left', id: `tts-${messageId}` }); - + if (!success) return null; - - 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'), - autoToggle: panel.querySelector('.xb-tts-auto-toggle'), - }; - - // 事件绑定 - 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?.()); - // 同步当前语速 - const config = getConfigFn?.(); - const currentSpeed = config?.volc?.speechRate || 1.0; - ui.speedSlider.value = currentSpeed; - ui.speedVal.textContent = currentSpeed.toFixed(1) + 'x'; + + bindFloorPanelEvents(panelData, onPlay); + panelMap.set(messageId, panelData); + + return panelData; +} + +function setupFloorObserver() { + if (floorObserver) return; + + floorObserver = new IntersectionObserver((entries) => { + const toMount = []; + + for (const entry of entries) { + if (!entry.isIntersecting) continue; + + const el = entry.target; + const mid = Number(el.getAttribute('mesid')); + const cb = pendingCallbacks.get(mid); + + if (cb) { + toMount.push({ el, mid, cb }); + pendingCallbacks.delete(mid); + floorObserver.unobserve(el); + } } - }; - - panel.querySelector('.settings-btn').onclick = (e) => { - e.stopPropagation(); - panel.classList.remove('expanded'); - openSettingsFn?.(); - }; - - // 自动朗读开关 - ui.autoToggle.onclick = async (e) => { - e.stopPropagation(); - const config = getConfigFn?.(); - if (!config) return; - - const newValue = config.autoSpeak === false ? true : false; - config.autoSpeak = newValue; - - // 保存配置 - await saveConfigFn?.({ autoSpeak: newValue }); - - // 更新所有面板的自动朗读状态 - updateAutoSpeakAll(); - }; - - ui.voiceSelect.onchange = async (e) => { - const config = getConfigFn?.(); - if (config?.volc) { - config.volc.defaultSpeaker = e.target.value; - await saveConfigFn?.({ volc: config.volc }); + + if (toMount.length > 0) { + requestAnimationFrame(() => { + for (const { el, mid, cb } of toMount) { + mountFloorPanel(el, mid, cb); + } + }); } - }; - - 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 }); - // 同步所有面板的语速显示 - updateSpeedAll(); - } - }; - - const closeMenu = (e) => { - if (!panel.contains(e.target)) { - panel.classList.remove('expanded'); - } - }; - document.addEventListener('click', closeMenu, { passive: true }); - - ui._cleanup = () => { - document.removeEventListener('click', closeMenu); - removeFromToolbar(messageId, panel); - }; - - panelMap.set(messageId, ui); - return ui; + }, { rootMargin: '300px', threshold: 0 }); } // ═══════════════════════════════════════════════════════════════════════════ -// 全局同步更新 +// 悬浮按钮 +// ═══════════════════════════════════════════════════════════════════════════ + +function getFloatingPosition() { + try { + const raw = localStorage.getItem(FLOAT_POS_KEY); + if (raw) return JSON.parse(raw); + } catch {} + return { left: window.innerWidth - 110, top: window.innerHeight - 80 }; +} + +function saveFloatingPosition() { + if (!floatingEl) return; + const r = floatingEl.getBoundingClientRect(); + try { + localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({ + left: Math.round(r.left), + top: Math.round(r.top) + })); + } catch {} +} + +function applyFloatingPosition() { + if (!floatingEl) return; + const pos = getFloatingPosition(); + const w = floatingEl.offsetWidth || 88; + const h = floatingEl.offsetHeight || 36; + floatingEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`; + floatingEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`; +} + +function onFloatingPointerDown(e) { + if (e.button !== 0) return; + + floatingDragState = { + startX: e.clientX, + startY: e.clientY, + startLeft: floatingEl.getBoundingClientRect().left, + startTop: floatingEl.getBoundingClientRect().top, + pointerId: e.pointerId, + moved: false, + originalTarget: e.target + }; + + try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} + e.preventDefault(); +} + +function onFloatingPointerMove(e) { + if (!floatingDragState || floatingDragState.pointerId !== e.pointerId) return; + + const dx = e.clientX - floatingDragState.startX; + const dy = e.clientY - floatingDragState.startY; + + if (!floatingDragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) { + floatingDragState.moved = true; + } + + if (floatingDragState.moved) { + const w = floatingEl.offsetWidth || 88; + const h = floatingEl.offsetHeight || 36; + floatingEl.style.left = `${Math.max(0, Math.min(floatingDragState.startLeft + dx, window.innerWidth - w))}px`; + floatingEl.style.top = `${Math.max(0, Math.min(floatingDragState.startTop + dy, window.innerHeight - h))}px`; + } + + e.preventDefault(); +} + +function onFloatingPointerUp(e) { + if (!floatingDragState || floatingDragState.pointerId !== e.pointerId) return; + + const { moved, originalTarget } = floatingDragState; + + try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} + floatingDragState = null; + + if (moved) { + saveFloatingPosition(); + } else { + routeFloatingClick(originalTarget); + } +} + +function routeFloatingClick(target) { + if (target.closest('.play-btn')) { + handleFloatingPlayClick(); + } else if (target.closest('.stop-btn')) { + const messageId = safeGetLastAIMessageId(); + if (messageId >= 0) clearQueueFn?.(messageId); + } else if (target.closest('.expand-btn')) { + floatingEl.classList.toggle('expanded'); + if (floatingEl.classList.contains('expanded')) { + fillVoiceSelect($floatingCache.voiceSelect); + syncSpeedUI($floatingCache); + } + } +} + +function handleFloatingPlayClick() { + const messageId = safeGetLastAIMessageId(); + if (messageId < 0) { + if (typeof toastr !== 'undefined') { + toastr.warning('没有可朗读的AI消息'); + } + return; + } + speakMessageFn?.(messageId); +} + +function handleFloatingOutsideClick(e) { + if (floatingEl && !floatingEl.contains(e.target)) { + floatingEl.classList.remove('expanded'); + } +} + +function createFloatingButton() { + if (floatingEl) return; + + const config = getConfigFn?.(); + if (!config || config.showFloatingButton !== true) return; + + injectStyles(); + + const isAutoSpeak = config.autoSpeak !== false; + const currentSpeed = config.volc?.speechRate || 1.0; + + floatingEl = createPanelElement(currentSpeed, isAutoSpeak, 'floating'); + floatingEl.classList.add('xb-tts-floating-global'); + floatingEl.id = 'xb-tts-floating-global'; + + document.body.appendChild(floatingEl); + + $floatingCache = cachePanelDOM(floatingEl); + + applyFloatingPosition(); + + // 拖拽事件 + const capsuleEl = $floatingCache.capsule; + if (capsuleEl) { + capsuleEl.addEventListener('pointerdown', onFloatingPointerDown, { passive: false }); + capsuleEl.addEventListener('pointermove', onFloatingPointerMove, { passive: false }); + capsuleEl.addEventListener('pointerup', onFloatingPointerUp, { passive: false }); + capsuleEl.addEventListener('pointercancel', onFloatingPointerUp, { passive: false }); + } + + bindCommonEvents($floatingCache); + + document.addEventListener('click', handleFloatingOutsideClick, { passive: true }); + window.addEventListener('resize', applyFloatingPosition); +} + +function destroyFloatingButton() { + if (!floatingEl) return; + window.removeEventListener('resize', applyFloatingPosition); + document.removeEventListener('click', handleFloatingOutsideClick); + // ★ 显式移除 pointer 事件 + const capsuleEl = $floatingCache.capsule; + if (capsuleEl) { + capsuleEl.removeEventListener('pointerdown', onFloatingPointerDown); + capsuleEl.removeEventListener('pointermove', onFloatingPointerMove); + capsuleEl.removeEventListener('pointerup', onFloatingPointerUp); + capsuleEl.removeEventListener('pointercancel', onFloatingPointerUp); + } + floatingEl.remove(); + floatingEl = null; + floatingDragState = null; + $floatingCache = {}; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 状态更新 +// ═══════════════════════════════════════════════════════════════════════════ + +function updatePanelStateCore($cache, el, state) { + if (!el || !state) return; + + const status = state.status || 'idle'; + const current = state.currentSegment || 0; + const total = state.totalSegments || 0; + const hasQueue = total > 1; + + el.dataset.status = status; + el.dataset.hasQueue = hasQueue ? 'true' : 'false'; + + let statusText = ''; + let playIcon = '▶'; + let showStop = false; + + switch (status) { + case 'idle': + statusText = '播放'; + break; + case 'sending': + case 'queued': + statusText = hasQueue ? `${current}/${total}` : '准备'; + playIcon = '■'; + showStop = true; + break; + case 'cached': + statusText = hasQueue ? `${current}/${total}` : '缓存'; + break; + case 'playing': + statusText = hasQueue ? `${current}/${total}` : ''; + playIcon = '⏸'; + showStop = true; + break; + case 'paused': + statusText = hasQueue ? `${current}/${total}` : '暂停'; + showStop = true; + break; + case 'ended': + statusText = '完成'; + playIcon = '↻'; + break; + case 'blocked': + statusText = '受阻'; + break; + case 'error': + statusText = (state.error || '失败').slice(0, 8); + playIcon = '↻'; + break; + } + + if ($cache.playBtn) { + const existingDot = $cache.playBtn.querySelector('.xb-tts-auto-dot'); + $cache.playBtn.textContent = playIcon; + if (existingDot) { + $cache.playBtn.appendChild(existingDot); + } else { + const newDot = document.createElement('span'); + newDot.className = 'xb-tts-auto-dot'; + $cache.playBtn.appendChild(newDot); + } + } + + if ($cache.statusText) $cache.statusText.textContent = statusText; + if ($cache.badge && hasQueue && current > 0) $cache.badge.textContent = `${current}/${total}`; + if ($cache.stopBtn) $cache.stopBtn.style.display = showStop ? '' : 'none'; + + if ($cache.progressInner) { + if (hasQueue && total > 0) { + $cache.progressInner.style.width = `${Math.min(100, (current / total) * 100)}%`; + } else if (state.progress && state.duration) { + $cache.progressInner.style.width = `${Math.min(100, (state.progress / state.duration) * 100)}%`; + } else { + $cache.progressInner.style.width = '0%'; + } + } + + if (state.textLength && $cache.usageText) { + $cache.usageText.textContent = `${state.textLength} 字`; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 全局同步 // ═══════════════════════════════════════════════════════════════════════════ -/** - * 更新所有面板的自动朗读状态 - */ export function updateAutoSpeakAll() { const config = getConfigFn?.(); const isAutoSpeak = config?.autoSpeak !== false; - - panelMap.forEach((ui) => { - if (!ui.root) return; - - // 更新 data-auto 属性(控制播放按钮上的绿点) - ui.root.dataset.auto = isAutoSpeak ? 'true' : 'false'; - - // 更新菜单内的开关状态 - if (ui.autoToggle) { - ui.autoToggle.classList.toggle('on', isAutoSpeak); - } + + panelMap.forEach((data) => { + if (!data.root) return; + data.root.dataset.auto = isAutoSpeak ? 'true' : 'false'; + data.$cache?.autoToggle?.classList.toggle('on', isAutoSpeak); }); + + if (floatingEl) { + floatingEl.dataset.auto = isAutoSpeak ? 'true' : 'false'; + $floatingCache.autoToggle?.classList.toggle('on', isAutoSpeak); + } } -/** - * 更新所有面板的语速显示 - */ export function updateSpeedAll() { - const config = getConfigFn?.(); - const currentSpeed = config?.volc?.speechRate || 1.0; - - panelMap.forEach((ui) => { - if (!ui.root) return; - if (ui.speedSlider) ui.speedSlider.value = currentSpeed; - if (ui.speedVal) ui.speedVal.textContent = currentSpeed.toFixed(1) + 'x'; + panelMap.forEach((data) => { + if (!data.root) return; + syncSpeedUI(data.$cache); }); + + if (floatingEl) { + syncSpeedUI($floatingCache); + } } -/** - * 更新所有面板的音色选择 - */ export function updateVoiceAll() { - const config = getConfigFn?.(); - panelMap.forEach((ui) => { - if (!ui.root || !ui.voiceSelect) return; - buildVoiceOptions(ui.voiceSelect, config); + panelMap.forEach((data) => { + if (!data.root || !data.$cache?.voiceSelect) return; + fillVoiceSelect(data.$cache.voiceSelect); }); + + if (floatingEl && $floatingCache.voiceSelect) { + fillVoiceSelect($floatingCache.voiceSelect); + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -855,169 +1184,128 @@ export function initTtsPanelStyles() { injectStyles(); } -function observeForLazyMount(messageEl, messageId, onPlay) { - if (panelMap.has(messageId) && panelMap.get(messageId).root?.isConnected) { - return; - } - - if (pendingCallbacks.has(messageId)) { - return; - } - - setupObserver(); - pendingCallbacks.set(messageId, onPlay); - observer.observe(messageEl); -} - export function ensureTtsPanel(messageEl, messageId, onPlay) { + const config = getConfigFn?.(); + if (config?.showFloorButton === false) return null; + injectStyles(); - + if (panelMap.has(messageId)) { const existing = panelMap.get(messageId); if (existing.root?.isConnected) return existing; existing._cleanup?.(); panelMap.delete(messageId); } - - observeForLazyMount(messageEl, messageId, onPlay); + + const rect = messageEl.getBoundingClientRect(); + if (rect.top < window.innerHeight + 300 && rect.bottom > -300) { + return mountFloorPanel(messageEl, messageId, onPlay); + } + + setupFloorObserver(); + pendingCallbacks.set(messageId, onPlay); + floorObserver.observe(messageEl); + return null; } export function renderPanelsForChat(chat, getMessageElement, onPlay) { + const config = getConfigFn?.(); + if (config?.showFloorButton === false) return; + injectStyles(); - + let immediateCount = 0; - + for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; if (!message || message.is_user) continue; - + const messageEl = getMessageElement(i); if (!messageEl) continue; - + if (panelMap.has(i) && panelMap.get(i).root?.isConnected) { continue; } - + if (immediateCount < INITIAL_RENDER_LIMIT) { - mountPanel(messageEl, i, onPlay); + mountFloorPanel(messageEl, i, onPlay); immediateCount++; } else { - observeForLazyMount(messageEl, i, onPlay); + setupFloorObserver(); + pendingCallbacks.set(i, onPlay); + floorObserver.observe(messageEl); } } } export function updateTtsPanel(messageId, state) { - const ui = panelMap.get(messageId); - if (!ui || !state) return; + const panelData = panelMap.get(messageId); + if (panelData?.root && state) { + updatePanelStateCore(panelData.$cache, panelData.root, state); + } - 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 = '▶'; + if (floatingEl && messageId === safeGetLastAIMessageId()) { + updatePanelStateCore($floatingCache, floatingEl, state); } - - // 更新播放按钮(保留自动朗读指示点) - const playBtnContent = ui.playBtn.querySelector('.xb-tts-auto-dot'); - ui.playBtn.textContent = playIcon; - if (playBtnContent) { - ui.playBtn.appendChild(playBtnContent); - } else { - const dot = document.createElement('span'); - dot.className = 'xb-tts-auto-dot'; - ui.playBtn.appendChild(dot); - } - - 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) { - ui.usageText.textContent = `${state.textLength} 字`; +} + +export function resetFloatingState() { + if (!floatingEl) return; + + floatingEl.dataset.status = 'idle'; + floatingEl.dataset.hasQueue = 'false'; + + if ($floatingCache.statusText) $floatingCache.statusText.textContent = '播放'; + if ($floatingCache.badge) $floatingCache.badge.textContent = '0/0'; + if ($floatingCache.progressInner) $floatingCache.progressInner.style.width = '0%'; + if ($floatingCache.stopBtn) $floatingCache.stopBtn.style.display = 'none'; + if ($floatingCache.usageText) $floatingCache.usageText.textContent = '-字'; + + if ($floatingCache.playBtn) { + const dot = $floatingCache.playBtn.querySelector('.xb-tts-auto-dot'); + $floatingCache.playBtn.textContent = '▶'; + if (dot) $floatingCache.playBtn.appendChild(dot); } } export function removeTtsPanel(messageId) { - const ui = panelMap.get(messageId); - if (ui) { - ui._cleanup?.(); + const data = panelMap.get(messageId); + if (data) { + data._cleanup?.(); panelMap.delete(messageId); } pendingCallbacks.delete(messageId); } export function removeAllTtsPanels() { - panelMap.forEach((ui) => { - ui._cleanup?.(); - }); + panelMap.forEach((data) => data._cleanup?.()); panelMap.clear(); pendingCallbacks.clear(); - - observer?.disconnect(); - observer = null; + + floorObserver?.disconnect(); + floorObserver = null; +} + +export function initFloatingPanel() { + if (!getConfigFn) return; + createFloatingButton(); +} + +export function destroyFloatingPanel() { + destroyFloatingButton(); +} + +export function updateButtonVisibility(showFloor, showFloating) { + if (showFloating && !floatingEl) { + createFloatingButton(); + } else if (!showFloating && floatingEl) { + destroyFloatingButton(); + } + + if (!showFloor) { + removeAllTtsPanels(); + } } export function getPanelMap() {