Files
LittleWhiteBox/modules/tts/tts-panel.js
2026-01-18 23:07:32 +08:00

1314 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tts-panel.js
/**
* TTS 播放器面板 - 支持楼层按钮和悬浮按钮双模式
*/
import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js';
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const FLOAT_POS_KEY = 'xb_tts_float_pos';
const INITIAL_RENDER_LIMIT = 1;
// ═══════════════════════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════════════════════
// 楼层按钮
const panelMap = new Map();
const pendingCallbacks = new Map();
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(handlers) {
getConfigFn = handlers.getConfig;
saveConfigFn = handlers.saveConfig;
openSettingsFn = handlers.openSettings;
clearQueueFn = handlers.clearQueue;
getLastAIMessageIdFn = handlers.getLastAIMessageId;
speakMessageFn = handlers.speakMessage;
}
export function clearPanelConfigHandlers() {
getConfigFn = null;
saveConfigFn = null;
openSettingsFn = null;
clearQueueFn = null;
getLastAIMessageIdFn = null;
speakMessageFn = null;
}
// ═══════════════════════════════════════════════════════════════════════════
// 样式
// ═══════════════════════════════════════════════════════════════════════════
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-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);
--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-hover);
}
.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;
}
.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);
}
.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;
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); }
`;
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 fillVoiceSelect(selectEl) {
if (!selectEl) return;
const config = getConfigFn?.();
const mySpeakers = config?.volc?.mySpeakers || [];
const currentSpeaker = config?.volc?.defaultSpeaker || '';
selectEl.replaceChildren();
if (mySpeakers.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '暂无音色';
opt.disabled = true;
selectEl.appendChild(opt);
return;
}
mySpeakers.forEach(s => {
const opt = document.createElement('option');
opt.value = s.value;
opt.textContent = s.name || s.value;
if (s.value === currentSpeaker) opt.selected = true;
selectEl.appendChild(opt);
});
}
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'),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 共用事件绑定
// ═══════════════════════════════════════════════════════════════════════════
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 });
}
});
$cache.speedSlider?.addEventListener('input', (e) => {
if ($cache.speedVal) {
$cache.speedVal.textContent = Number(e.target.value).toFixed(1) + 'x';
}
});
$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 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);
}
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;
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);
}
}
if (toMount.length > 0) {
requestAnimationFrame(() => {
for (const { el, mid, cb } of toMount) {
mountFloorPanel(el, mid, cb);
}
});
}
}, { 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((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() {
panelMap.forEach((data) => {
if (!data.root) return;
syncSpeedUI(data.$cache);
});
if (floatingEl) {
syncSpeedUI($floatingCache);
}
}
export function updateVoiceAll() {
panelMap.forEach((data) => {
if (!data.root || !data.$cache?.voiceSelect) return;
fillVoiceSelect(data.$cache.voiceSelect);
});
if (floatingEl && $floatingCache.voiceSelect) {
fillVoiceSelect($floatingCache.voiceSelect);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 对外接口
// ═══════════════════════════════════════════════════════════════════════════
export function initTtsPanelStyles() {
injectStyles();
}
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);
}
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) {
mountFloorPanel(messageEl, i, onPlay);
immediateCount++;
} else {
setupFloorObserver();
pendingCallbacks.set(i, onPlay);
floorObserver.observe(messageEl);
}
}
}
export function updateTtsPanel(messageId, state) {
const panelData = panelMap.get(messageId);
if (panelData?.root && state) {
updatePanelStateCore(panelData.$cache, panelData.root, state);
}
if (floatingEl && messageId === safeGetLastAIMessageId()) {
updatePanelStateCore($floatingCache, floatingEl, state);
}
}
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 data = panelMap.get(messageId);
if (data) {
data._cleanup?.();
panelMap.delete(messageId);
}
pendingCallbacks.delete(messageId);
}
export function removeAllTtsPanels() {
panelMap.forEach((data) => data._cleanup?.());
panelMap.clear();
pendingCallbacks.clear();
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() {
return panelMap;
}