2026-01-18 01:48:30 +08:00
|
|
|
|
// tts-panel.js
|
2026-01-17 16:34:39 +08:00
|
|
|
|
/**
|
2026-01-18 23:07:23 +08:00
|
|
|
|
* TTS 播放器面板 - 支持楼层按钮和悬浮按钮双模式
|
2026-01-17 16:34:39 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-01-18 11:44:14 +08:00
|
|
|
|
import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js';
|
2026-01-18 01:48:30 +08:00
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 常量
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const FLOAT_POS_KEY = 'xb_tts_float_pos';
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const INITIAL_RENDER_LIMIT = 1;
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
// 楼层按钮
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const panelMap = new Map();
|
|
|
|
|
|
const pendingCallbacks = new Map();
|
2026-01-18 23:07:23 +08:00
|
|
|
|
let floorObserver = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 悬浮按钮
|
|
|
|
|
|
let floatingEl = null;
|
|
|
|
|
|
let floatingDragState = null;
|
|
|
|
|
|
let $floatingCache = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 通用
|
|
|
|
|
|
let stylesInjected = false;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 配置接口
|
|
|
|
|
|
let getConfigFn = null;
|
|
|
|
|
|
let saveConfigFn = null;
|
|
|
|
|
|
let openSettingsFn = null;
|
|
|
|
|
|
let clearQueueFn = null;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
let getLastAIMessageIdFn = null;
|
|
|
|
|
|
let speakMessageFn = null;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
export function setPanelConfigHandlers(handlers) {
|
|
|
|
|
|
getConfigFn = handlers.getConfig;
|
|
|
|
|
|
saveConfigFn = handlers.saveConfig;
|
|
|
|
|
|
openSettingsFn = handlers.openSettings;
|
|
|
|
|
|
clearQueueFn = handlers.clearQueue;
|
|
|
|
|
|
getLastAIMessageIdFn = handlers.getLastAIMessageId;
|
|
|
|
|
|
speakMessageFn = handlers.speakMessage;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function clearPanelConfigHandlers() {
|
|
|
|
|
|
getConfigFn = null;
|
|
|
|
|
|
saveConfigFn = null;
|
|
|
|
|
|
openSettingsFn = null;
|
|
|
|
|
|
clearQueueFn = null;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
getLastAIMessageIdFn = null;
|
|
|
|
|
|
speakMessageFn = null;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 样式
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const STYLES = `
|
2026-01-17 16:34:39 +08:00
|
|
|
|
.xb-tts-panel {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--h: 34px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--bg: rgba(0, 0, 0, 0.55);
|
2026-01-18 23:07:23 +08:00
|
|
|
|
--bg-solid: rgba(24, 24, 28, 0.98);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--bg-hover: rgba(0, 0, 0, 0.7);
|
|
|
|
|
|
--border: rgba(255, 255, 255, 0.08);
|
2026-01-18 23:07:23 +08:00
|
|
|
|
--border-hover: rgba(255, 255, 255, 0.2);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--text: rgba(255, 255, 255, 0.85);
|
|
|
|
|
|
--text-sub: rgba(255, 255, 255, 0.45);
|
|
|
|
|
|
--text-dim: rgba(255, 255, 255, 0.25);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
--success: rgba(62, 207, 142, 0.9);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
--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);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
border-radius: 17px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
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);
|
2026-01-18 23:07:23 +08:00
|
|
|
|
border-color: var(--border-hover);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
.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 {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
border-radius: 50%;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
font-size: 11px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
transition: all 0.25s ease;
|
|
|
|
|
|
flex-shrink: 0;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
position: relative;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.xb-tts-btn:hover {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.12);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.xb-tts-btn:active {
|
|
|
|
|
|
transform: scale(0.92);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
.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);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
width: 24px;
|
|
|
|
|
|
height: 24px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
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 {
|
2026-01-18 23:07:23 +08:00
|
|
|
|
0%, 100% { transform: scaleY(0.4); opacity: 0.4; }
|
|
|
|
|
|
50% { transform: scaleY(1); opacity: 0.85; }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
.xb-tts-select:hover { border-color: rgba(255, 255, 255, 0.2); }
|
|
|
|
|
|
.xb-tts-select:focus { border-color: rgba(255, 255, 255, 0.3); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.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;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
gap: 6px;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.xb-tts-usage {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--text-dim);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.xb-tts-icon-btn {
|
|
|
|
|
|
color: var(--text-sub);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
padding: 4px 6px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
transition: all 0.2s;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
flex-shrink: 0;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
.xb-tts-icon-btn:hover {
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
.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); }
|
2026-01-17 16:34:39 +08:00
|
|
|
|
`;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
function injectStyles() {
|
|
|
|
|
|
if (stylesInjected) return;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
stylesInjected = true;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const el = document.createElement('style');
|
|
|
|
|
|
el.id = 'xb-tts-panel-styles';
|
|
|
|
|
|
el.textContent = STYLES;
|
|
|
|
|
|
document.head.appendChild(el);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 23:07:23 +08:00
|
|
|
|
// 通用工具
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
function fillVoiceSelect(selectEl) {
|
|
|
|
|
|
if (!selectEl) return;
|
|
|
|
|
|
const config = getConfigFn?.();
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const mySpeakers = config?.volc?.mySpeakers || [];
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const currentSpeaker = config?.volc?.defaultSpeaker || '';
|
|
|
|
|
|
|
|
|
|
|
|
selectEl.replaceChildren();
|
|
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
if (mySpeakers.length === 0) {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
|
opt.value = '';
|
|
|
|
|
|
opt.textContent = '暂无音色';
|
2026-01-18 23:07:23 +08:00
|
|
|
|
opt.disabled = true;
|
|
|
|
|
|
selectEl.appendChild(opt);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
mySpeakers.forEach(s => {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const opt = document.createElement('option');
|
|
|
|
|
|
opt.value = s.value;
|
|
|
|
|
|
opt.textContent = s.name || s.value;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
if (s.value === currentSpeaker) opt.selected = true;
|
|
|
|
|
|
selectEl.appendChild(opt);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
});
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
function safeGetLastAIMessageId() {
|
|
|
|
|
|
const id = getLastAIMessageIdFn?.();
|
|
|
|
|
|
return typeof id === 'number' && id >= 0 ? id : -1;
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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';
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 23:07:23 +08:00
|
|
|
|
// DOM 构建(符合 ESLint 规范,不使用 innerHTML)
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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'),
|
2026-01-17 16:34:39 +08:00
|
|
|
|
};
|
2026-01-18 23:07:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 共用事件绑定
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
function bindCommonEvents($cache, parentEl = null) {
|
|
|
|
|
|
$cache.autoToggle?.addEventListener('click', async (e) => {
|
2026-01-18 01:48:30 +08:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const config = getConfigFn?.();
|
|
|
|
|
|
if (!config) return;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const newValue = config.autoSpeak === false;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
config.autoSpeak = newValue;
|
|
|
|
|
|
await saveConfigFn?.({ autoSpeak: newValue });
|
|
|
|
|
|
updateAutoSpeakAll();
|
2026-01-18 23:07:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
$cache.voiceSelect?.addEventListener('change', async (e) => {
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const config = getConfigFn?.();
|
|
|
|
|
|
if (config?.volc) {
|
|
|
|
|
|
config.volc.defaultSpeaker = e.target.value;
|
|
|
|
|
|
await saveConfigFn?.({ volc: config.volc });
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
$cache.speedSlider?.addEventListener('input', (e) => {
|
|
|
|
|
|
if ($cache.speedVal) {
|
|
|
|
|
|
$cache.speedVal.textContent = Number(e.target.value).toFixed(1) + 'x';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
$cache.speedSlider?.addEventListener('change', async (e) => {
|
2026-01-17 16:34:39 +08:00
|
|
|
|
const config = getConfigFn?.();
|
|
|
|
|
|
if (config?.volc) {
|
|
|
|
|
|
config.volc.speechRate = Number(e.target.value);
|
|
|
|
|
|
await saveConfigFn?.({ volc: config.volc });
|
2026-01-18 01:48:30 +08:00
|
|
|
|
updateSpeedAll();
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
});
|
|
|
|
|
|
$cache.settingsBtn?.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
// ★ 关闭所有菜单
|
|
|
|
|
|
panelMap.forEach(data => data.root?.classList.remove('expanded'));
|
|
|
|
|
|
floatingEl?.classList.remove('expanded');
|
|
|
|
|
|
openSettingsFn?.();
|
|
|
|
|
|
});
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 23:07:23 +08:00
|
|
|
|
// 楼层面板
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
function createFloorPanel(messageId) {
|
|
|
|
|
|
const config = getConfigFn?.() || {};
|
|
|
|
|
|
const currentSpeed = config?.volc?.speechRate || 1.0;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
const isAutoSpeak = config?.autoSpeak !== false;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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}`
|
2026-01-18 01:48:30 +08:00
|
|
|
|
});
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
if (!success) return null;
|
|
|
|
|
|
|
|
|
|
|
|
bindFloorPanelEvents(panelData, onPlay);
|
|
|
|
|
|
panelMap.set(messageId, panelData);
|
|
|
|
|
|
|
|
|
|
|
|
return panelData;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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 });
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-18 23:07:23 +08:00
|
|
|
|
// 悬浮按钮
|
2026-01-18 01:48:30 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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();
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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`;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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);
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleFloatingPlayClick() {
|
|
|
|
|
|
const messageId = safeGetLastAIMessageId();
|
|
|
|
|
|
if (messageId < 0) {
|
|
|
|
|
|
if (typeof toastr !== 'undefined') {
|
|
|
|
|
|
toastr.warning('没有可朗读的AI消息');
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
return;
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
speakMessageFn?.(messageId);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 23:07:23 +08:00
|
|
|
|
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;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
|
|
|
|
|
|
const status = state.status || 'idle';
|
|
|
|
|
|
const current = state.currentSegment || 0;
|
|
|
|
|
|
const total = state.totalSegments || 0;
|
|
|
|
|
|
const hasQueue = total > 1;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
el.dataset.status = status;
|
|
|
|
|
|
el.dataset.hasQueue = hasQueue ? 'true' : 'false';
|
|
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
let statusText = '';
|
|
|
|
|
|
let playIcon = '▶';
|
|
|
|
|
|
let showStop = false;
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-01-18 01:48:30 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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%';
|
|
|
|
|
|
}
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
if (state.textLength && $cache.usageText) {
|
|
|
|
|
|
$cache.usageText.textContent = `${state.textLength} 字`;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
2026-01-18 23:07:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 全局同步
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
export function removeTtsPanel(messageId) {
|
2026-01-18 23:07:23 +08:00
|
|
|
|
const data = panelMap.get(messageId);
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
data._cleanup?.();
|
2026-01-18 01:48:30 +08:00
|
|
|
|
panelMap.delete(messageId);
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingCallbacks.delete(messageId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 16:34:39 +08:00
|
|
|
|
export function removeAllTtsPanels() {
|
2026-01-18 23:07:23 +08:00
|
|
|
|
panelMap.forEach((data) => data._cleanup?.());
|
2026-01-17 16:34:39 +08:00
|
|
|
|
panelMap.clear();
|
|
|
|
|
|
pendingCallbacks.clear();
|
2026-01-18 23:07:23 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 01:48:30 +08:00
|
|
|
|
export function getPanelMap() {
|
|
|
|
|
|
return panelMap;
|
2026-01-17 16:34:39 +08:00
|
|
|
|
}
|