From 0a58b11876fb1c759dacaa3fb7833abd70d3d176 Mon Sep 17 00:00:00 2001 From: RT15548 Date: Sun, 18 Jan 2026 23:26:51 +0800 Subject: [PATCH] Add files via upload --- modules/tts/tts-overlay.html | 4842 +++++++++++++++++----------------- modules/tts/tts-panel.js | 2338 +++++++++------- modules/tts/tts.js | 2702 +++++++++---------- 3 files changed, 5132 insertions(+), 4750 deletions(-) diff --git a/modules/tts/tts-overlay.html b/modules/tts/tts-overlay.html index 65f6fab..6f18542 100644 --- a/modules/tts/tts-overlay.html +++ b/modules/tts/tts-overlay.html @@ -1,2407 +1,2467 @@ - - - - - - - -TTS 语音设置 - - - - - - -
- -
- -
-
试用
-
鉴权
-
+ margin: 0; +} + +.header-badge { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 12px; + font-size: 10px; + color: var(--text-muted); + transition: all 0.2s; +} +.header-badge i { + font-size: 5px; + opacity: 0.5; +} +.header-badge.active { + color: var(--text-secondary); + border-color: var(--border-light); +} +.header-badge.active i { + color: var(--accent); + opacity: 1; +} + +.header-spacer { flex: 1; min-width: 10px; } + +.header-close { + width: 36px; height: 36px; min-width: 36px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s; +} +.header-close:hover { + background: rgba(255,255,255,0.05); + color: var(--text-primary); + border-color: var(--border-light); +} + +/* ═══════════════════════════════════════════════════════════════ + Layout + ═══════════════════════════════════════════════════════════════ */ + +.app-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.app-sidebar { + width: 200px; + min-width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + padding: 16px 8px; + padding-left: calc(8px + var(--safe-area-left)); + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + flex-shrink: 0; +} + +.app-main { + flex: 1; + padding: 24px; + padding-right: calc(24px + var(--safe-area-right)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +/* ═══════════════════════════════════════════════════════════════ + Navigation + ═══════════════════════════════════════════════════════════════ */ + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + font-size: 13px; +} +.nav-item:hover { + background: rgba(255,255,255,0.03); + color: var(--text-secondary); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} +.nav-item i { width: 18px; text-align: center; } + +.nav-divider { + height: 1px; + background: var(--border); + margin: 8px 0; +} + +/* ═══════════════════════════════════════════════════════════════ + Views + ═══════════════════════════════════════════════════════════════ */ + +.view { + display: none; + max-width: 800px; + margin: 0 auto; + padding-bottom: 24px; +} +.view.active { + display: block; + animation: viewIn 0.2s ease; +} +@keyframes viewIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; } +} + +.view-header { margin-bottom: 20px; } +.view-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 4px; + color: var(--text-primary); +} +.view-desc { + font-size: 13px; + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════════ + Cards + ═══════════════════════════════════════════════════════════════ */ + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.card-title { + font-size: 11px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ═══════════════════════════════════════════════════════════════ + Forms + ═══════════════════════════════════════════════════════════════ */ + +.form-group { margin-bottom: 16px; } +.form-group:last-child { margin-bottom: 0; } + +.form-label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 500; +} + +.form-hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.input { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + transition: all 0.15s; +} +.input:focus { + outline: none; + border-color: var(--border-focus); + background: rgba(255,255,255,0.06); +} +.input::placeholder { color: var(--text-dim); } +textarea.input { + min-height: 80px; + resize: vertical; + font-family: inherit; +} +select.input { cursor: pointer; } + +.input-row { display: flex; gap: 8px; } +.input-row .input { flex: 1; min-width: 0; } + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} +.checkbox-row label { + font-size: 13px; + cursor: pointer; + color: var(--text-secondary); +} + +/* ═══════════════════════════════════════════════════════════════ + Buttons + ═══════════════════════════════════════════════════════════════ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + min-height: 40px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.btn:hover { + background: var(--bg-elevated); + color: var(--text-primary); + border-color: var(--border-light); +} +.btn:active { transform: scale(0.98); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-primary { + background: var(--accent-soft); + border-color: rgba(140, 200, 255, 0.2); + color: var(--accent); + font-weight: 500; +} +.btn-primary:hover { + background: rgba(140, 200, 255, 0.15); + border-color: rgba(140, 200, 255, 0.3); +} + +.btn-danger { + color: var(--error); + border-color: rgba(224, 128, 128, 0.2); +} +.btn-danger:hover { + background: var(--error-soft); +} + +.btn-icon { width: 40px; padding: 0; } +.btn-group { display: flex; gap: 8px; flex-wrap: wrap; } +.btn-sm { padding: 6px 10px; min-height: 32px; font-size: 12px; } +.btn-xs { padding: 4px 8px; min-height: 28px; font-size: 11px; } + +.btn.saving { pointer-events: none; opacity: 0.7; } +.btn.save-success { + background: var(--success-soft) !important; + border-color: rgba(144, 212, 160, 0.3) !important; + color: var(--success) !important; + pointer-events: none; +} + +/* ═══════════════════════════════════════════════════════════════ + Slider + ═══════════════════════════════════════════════════════════════ */ + +.slider-row { display: flex; align-items: center; gap: 12px; } +.slider-row input[type="range"] { + flex: 1; + height: 4px; + accent-color: var(--accent); + cursor: pointer; + opacity: 0.8; +} +.slider-row input[type="range"]:hover { opacity: 1; } +.slider-row .slider-val { + min-width: 50px; + text-align: right; + font-size: 13px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* ═══════════════════════════════════════════════════════════════ + Rules Editor + ═══════════════════════════════════════════════════════════════ */ + +.rules-editor { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.rules-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} +.rules-header-title { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.rules-list { max-height: 200px; overflow-y: auto; } + +.rules-empty { + padding: 20px; + text-align: center; + color: var(--text-dim); + font-size: 12px; +} + +.rule-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.rule-item:last-child { border-bottom: none; } +.rule-item:hover { background: rgba(255,255,255,0.02); } + +.rule-input { + flex: 1; + padding: 6px 10px; + min-width: 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 12px; +} +.rule-input:focus { + outline: none; + border-color: var(--border-focus); +} +.rule-input::placeholder { color: var(--text-dim); } + +.rule-arrow { + color: var(--text-dim); + font-size: 11px; + flex-shrink: 0; +} + +.rule-delete { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.rule-delete:hover { + background: var(--error-soft); + color: var(--error); +} + +/* ═══════════════════════════════════════════════════════════════ + Current Voice Card - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.current-voice-card { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; + transition: all 0.2s; +} +.current-voice-card:hover { + border-color: var(--border-light); +} + +.current-voice-label { + font-size: 10px; + color: var(--text-dim); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.current-voice-display { + display: flex; + align-items: center; + gap: 14px; +} + +.current-voice-icon { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; +} +.current-voice-card:hover .current-voice-icon { + color: var(--accent); + border-color: rgba(140, 200, 255, 0.2); +} + +.current-voice-info { flex: 1; } + +.current-voice-name { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.current-voice-source { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ═══════════════════════════════════════════════════════════════ + Source Badge - 极简黑白版 + ═══════════════════════════════════════════════════════════════ */ + +.source-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* 试用 - 白色/浅色调 */ +.source-badge.trial { + background: var(--tag-free-bg); + color: var(--tag-free); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* 鉴权 - 蓝色点缀 */ +.source-badge.auth { + background: var(--tag-auth-bg); + color: var(--tag-auth); + border: 1px solid rgba(140, 200, 255, 0.15); +} + +/* ═══════════════════════════════════════════════════════════════ + Voice Tabs - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.voice-tabs { + display: flex; + gap: 2px; + margin-bottom: 16px; + background: var(--bg-tertiary); + padding: 3px; + border-radius: 10px; + border: 1px solid var(--border); +} + +.voice-tab { + flex: 1; + padding: 10px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.voice-tab:hover { + color: var(--text-secondary); +} +.voice-tab.active { + background: var(--bg-elevated); + color: var(--text-primary); + font-weight: 500; +} + +.voice-tab-count { + background: var(--bg-input); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--text-dim); +} +.voice-tab.active .voice-tab-count { + background: var(--accent-soft); + color: var(--accent); +} + +.voice-panel { display: none; } +.voice-panel.active { display: block; } + +/* ═══════════════════════════════════════════════════════════════ + Test Voice Box + ═══════════════════════════════════════════════════════════════ */ + +.test-voice-box { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + margin-bottom: 16px; +} + +.test-voice-row { + display: flex; + gap: 8px; + align-items: center; +} +.test-voice-row .input { flex: 1; } + +.test-voice-status { + font-size: 11px; + color: var(--text-dim); + margin-top: 6px; + min-height: 16px; +} +.test-voice-status.playing { color: var(--accent); } +.test-voice-status.error { color: var(--error); } + +/* ═══════════════════════════════════════════════════════════════ + Voice Filters + ═══════════════════════════════════════════════════════════════ */ + +.voice-filters { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.voice-filters select { + flex: 1; + min-width: 80px; + padding: 8px 10px; + font-size: 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); +} +.voice-filters select:focus { + outline: none; + border-color: var(--border-focus); +} + +.voice-search { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.voice-search .input { flex: 1; } + +/* ═══════════════════════════════════════════════════════════════ + Voice List - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.voice-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 320px; + overflow-y: auto; +} + +.voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} +.voice-item:hover { + border-color: var(--border-light); + background: var(--bg-elevated); +} +.voice-item.selected { + border-color: var(--accent); + background: var(--accent-soft); +} +.voice-item.in-my-list { + opacity: 0.5; +} +.voice-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} +.voice-item.disabled:hover { + border-color: var(--border); + background: var(--bg-tertiary); +} + +.voice-item-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--text-dim); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.voice-item:hover .voice-item-radio { + border-color: var(--text-muted); +} +.voice-item.selected .voice-item-radio { + border-color: var(--accent); + background: var(--accent); +} +.voice-item.selected .voice-item-radio::after { + content: '✓'; + color: var(--bg-primary); + font-size: 9px; + font-weight: bold; +} + +.voice-item-info { flex: 1; min-width: 0; } + +.voice-item-name { + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: var(--text-primary); +} + +.voice-item-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.voice-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Editing State */ +.voice-item.editing { + background: var(--accent-soft); + border-color: var(--accent); +} +.voice-item-edit-form { + display: none; + width: 100%; + margin-top: 8px; +} +.voice-item.editing .voice-item-edit-form { + display: flex; + gap: 8px; +} +.voice-item.editing .voice-item-info > *:not(.voice-item-edit-form) { + display: none; +} +.voice-item-edit-form input { + flex: 1; + padding: 6px 10px; + font-size: 12px; +} + +/* Add Form */ +.voice-add-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} +.voice-add-row { + display: flex; + gap: 8px; + align-items: flex-end; +} +.voice-add-row .form-group { + flex: 1; + margin-bottom: 0; +} +.voice-add-row .form-label { + font-size: 11px; + margin-bottom: 4px; +} + +.preset-save-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 12px; +} +.preset-save-row input { + flex: 1; + padding: 10px 12px; + font-size: 13px; +} + +/* ═══════════════════════════════════════════════════════════════ + API Status Box + ═══════════════════════════════════════════════════════════════ */ + +.api-status-box { + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 16px; +} + +.api-status-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} + +.api-status-box.configured .api-status-icon { + color: var(--accent); + border-color: rgba(140, 200, 255, 0.2); +} +.api-status-box.not-configured .api-status-icon { + color: var(--text-dim); +} + +.api-status-info { flex: 1; } +.api-status-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.api-status-desc { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ═══════════════════════════════════════════════════════════════ + Stats Card + ═══════════════════════════════════════════════════════════════ */ + +.stats-card { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; +} + +.stats-group { + display: flex; + gap: 32px; +} + +.stats-item { text-align: center; } + +.stats-value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.stats-label { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ═══════════════════════════════════════════════════════════════ + Tip Box - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.tip-box { + display: flex; + gap: 10px; + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} +.tip-box i { + color: var(--accent); + flex-shrink: 0; + margin-top: 2px; + opacity: 0.7; +} + +.tip-box.warning { + border-left: 3px solid var(--accent); +} +.tip-box.warning i { + color: var(--accent); +} + +/* ═══════════════════════════════════════════════════════════════ + Guide Box + ═══════════════════════════════════════════════════════════════ */ + +.guide-box { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; +} + +.guide-box h3 { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.guide-box h3 i { + color: var(--accent); + opacity: 0.8; +} + +.guide-box p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 10px; + line-height: 1.6; +} + +.guide-box ol, .guide-box ul { + margin-left: 18px; + font-size: 13px; + color: var(--text-secondary); +} +.guide-box li { + margin-bottom: 8px; + line-height: 1.6; +} + +.guide-box a { color: var(--accent); } + +.guide-box code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + color: var(--accent); + font-family: monospace; + border: 1px solid var(--border); +} + +.guide-box pre { + background: var(--bg-primary); + padding: 12px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + font-family: monospace; + overflow-x: auto; + margin: 10px 0; + line-height: 1.5; + border: 1px solid var(--border); +} + +.guide-image { + margin-top: 12px; + width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: 8px; + display: block; + opacity: 0.9; +} +.guide-image:hover { opacity: 1; } + +.guide-link { + display: block; + padding: 10px 12px; + margin: 10px 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + word-break: break-all; +} +.guide-link a { color: var(--accent); } + +/* ═══════════════════════════════════════════════════════════════ + Mobile Navigation + ═══════════════════════════════════════════════════════════════ */ + +.mobile-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + var(--safe-area-bottom)); + padding-bottom: var(--safe-area-bottom); + background: var(--bg-secondary); + border-top: 1px solid var(--border); + z-index: 100; +} + +.mobile-nav-inner { + display: flex; + height: 60px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); +} +.mobile-nav-inner::-webkit-scrollbar { display: none; } + +.mobile-nav-item { + flex: 1; + min-width: 60px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--text-dim); + font-size: 10px; + cursor: pointer; + padding: 8px 4px; + transition: color 0.15s; +} +.mobile-nav-item i { font-size: 18px; } +.mobile-nav-item:hover { color: var(--text-muted); } +.mobile-nav-item.active { color: var(--accent); } + +/* ═══════════════════════════════════════════════════════════════ + Responsive + ═══════════════════════════════════════════════════════════════ */ + +@media (max-width: 768px) { + .app-sidebar { display: none; } + .mobile-nav { display: block; } + + .app-body { + padding-bottom: calc(60px + var(--safe-area-bottom)); + } + + .app-main { + padding: 16px; + padding-left: calc(16px + var(--safe-area-left)); + padding-right: calc(16px + var(--safe-area-right)); + padding-bottom: calc(80px + var(--safe-area-bottom)); + } + + .view { padding-bottom: 20px; } + + .app-header { + padding: 10px 12px; + padding-top: calc(10px + var(--safe-area-top)); + padding-left: calc(12px + var(--safe-area-left)); + padding-right: calc(12px + var(--safe-area-right)); + gap: 8px; + } + .header-logo span { display: none; } + .header-badge span { display: none; } + .header-close { width: 32px; height: 32px; min-width: 32px; font-size: 14px; } + .view-title { font-size: 18px; } + .card { padding: 16px; } + .form-row { grid-template-columns: 1fr; } + .voice-filters select { min-width: calc(50% - 4px); } + .stats-card { flex-direction: column; align-items: stretch; } + .stats-group { justify-content: space-around; } + .voice-add-row { flex-wrap: wrap; } + .voice-add-row .form-group { min-width: calc(50% - 4px); } + .preset-save-row { flex-wrap: wrap; } + .preset-save-row input { min-width: 100%; margin-bottom: 8px; } + .voice-tabs { flex-wrap: wrap; } + .voice-tab { min-width: calc(33% - 3px); font-size: 11px; padding: 8px 6px; } +} + +@media (max-width: 400px) { + .app-header { padding: 8px 10px; } + .mobile-nav-item { min-width: 48px; font-size: 9px; } + .mobile-nav-item i { font-size: 16px; } +} + +@media (hover: none) and (pointer: coarse) { + .btn { min-height: 44px; } + .input { min-height: 44px; padding: 12px; } + .nav-item { min-height: 44px; } + .header-close { width: 44px; height: 44px; min-width: 44px; } + .mobile-nav-item { min-height: 44px; } +} + +/* ═══════════════════════════════════════════════════════════════ + Scrollbar + ═══════════════════════════════════════════════════════════════ */ + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.08); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.15); +} + +.hidden { display: none !important; } + + + + +
+ +
+ +
+
试用
+
鉴权
+
+
+ + +
-
- -
- - -
- - -
-
-

基础配置

-

TTS 服务连接与朗读设置

-
- -
- -
- 试用音色 — 无需配置,使用插件服务器(11个音色)
- 鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻) -
-
- -
-
鉴权配置(可选)
-
-
-
-
未配置
-
配置后可使用预设音色库和复刻音色
-
-
-
- - -
-
- -
- - -
-

获取方式见「使用说明」页

-
-
- -
-
朗读设置
-
- - -
-
- -
- - 1.0x -
-
-
- -
-
文本过滤
-
- -

遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。

-
-
- 当前规则 - -
-
-
-
-
-
- - -
-

起始或结束可单独留空。

-
-
- 只读规则 - -
-
-
-
-
- - -
- - -
-
-

音色管理

-

将喜欢的音色重命名加入【我的音色】

-
- -
-
当前默认音色
-
-
-
-
未选择
-
请在下方选择音色
-
-
-
- -
- - - -
- - -
-
-
-
- - -
-
-
- -

- 点击选中设为默认。试用 无需配置,鉴权 需配置 API -

-
-
- - 暂无音色,请从「试用」或「预设库」添加 -
- -
-
手动添加复刻音色 鉴权
-
-
- - -
-
- - -
- -
-
-
-
- - -
-
-
-
- - -
-
-
- -
- -
- - -
-
-
- - -
-
-
- -
使用预设音色库需要先配置鉴权 API,请前往「基础配置」页面设置。
-
- -
-
- - -
-
-
- - -
- - - - -
- -
- -
- - -
-
-
- - -
- - -
-
-

高级设置

-

计费、缓存与过滤选项(鉴权模式)

-
- -
-
计费与缓存
-
- - -
-
- - -
-
- -
-
过滤与识别
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- - -
-
-

缓存管理

-

本地音频缓存统计与清理

-
- -
-
-
-
-
0
-
缓存条数
-
-
-
0 MB
-
占用空间
-
-
-
0
-
命中
-
-
-
0
-
未命中
-
-
-
-
- -
-
缓存配置
-
-
- - -
-
- - -
-
- - -
-
-
- -
- - - - -
-
- - -
-
-

使用说明

-

配音指令与开通流程

-
- -
-

配音指令

-

格式:[tts:speaker=音色名;emotion=情绪;context=语气提示] 放在正文前一行

-

speaker、emotion、context 三个参数可任意组合、任意顺序,用分号分隔

-

每遇到一个新 [tts:...] 块会分段朗读,按顺序播放

-

未写 speaker= 的块使用当前选中的默认音色

- -

音色(speaker)

-

只能指定"我的音色"中保存的名称。例如保存了名为"小白"的音色,则可用 speaker=小白

- -

情感(emotion)可用值:

-
中文:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰、鼓励、咆哮、焦急、温柔、讲故事、自然讲述、情感电台、磁性、广告营销、气泡音、低语、新闻播报、娱乐八卦、方言、对话、闲聊、温暖、深情、权威
-
-英文:happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, radio, magnetic, advertising, vocal-fry, asmr, news, entertainment, dialect, chat, warm, affectionate, authoritative
- -

语气提示(context)仅对 seed-tts-2.0 生效:

-

例如:"用更委屈的语气"、"放慢一点,压低音量"

-
- -
-

复刻音色使用

-
    -
  1. 在火山官网复刻音色
  2. -
  3. 获取音色ID(格式 S_xxxxxxxx
  4. -
  5. 在"音色管理" → "我的音色"中添加
  6. -
-
- -
- -
以下是鉴权模式的开通教程,试用音色无需配置。
-
- -
-

开启 CORS 代理

-
    -
  1. 打开酒馆目录的 config.yaml
  2. -
  3. 将 enableCorsProxy 改为 true 并保存
  4. -
  5. 重启酒馆(重启容器/进程,不是 F5 刷新)
  6. -
-
- -
-

开通服务(推荐一次性开通全部)

- - 开通管理 -
- -
-

获取 Access Token / AppID

- - 获取ID和KEY -
- -
-

声音复刻入口(复刻后去音色库拿ID)

- - 声音复刻 -
-
- -
-
- - - -
- - - - - \ No newline at end of file +
+ +
+ + +
+ + +
+
+

基础配置

+

TTS 服务连接与朗读设置

+
+ +
+ +
+ 试用音色 — 无需配置,使用插件服务器(11个音色)
+ 鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻) +
+
+ +
+
鉴权配置(可选)
+
+
+
+
未配置
+
配置后可使用预设音色库和复刻音色
+
+
+
+ + +
+
+ +
+ + +
+

获取方式见「使用说明」页

+
+
+ +
+
朗读设置
+
+ + +
+
+ +
+ + 1.0x +
+
+
+ +
+
文本过滤
+
+ +

遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。

+
+
+ 当前规则 + +
+
+
+
+
+
+ + +
+

起始或结束可单独留空。

+
+
+ 只读规则 + +
+
+
+
+
+ + +
+ + +
+
+

音色管理

+

将喜欢的音色重命名加入【我的音色】

+
+ +
+
当前默认音色
+
+
+
+
未选择
+
请在下方选择音色
+
+
+
+ +
+ + + +
+ + +
+
+
+
+ + +
+
+
+ +

+ 点击选中设为默认。试用 无需配置,鉴权 需配置 API +

+
+
+ + 暂无音色,请从「试用」或「预设库」添加 +
+ +
+
手动添加复刻音色 鉴权
+
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+ + +
+
+
+ + +
+
+
+ +
使用预设音色库需要先配置鉴权 API,请前往「基础配置」页面设置。
+
+ +
+
+ + +
+
+
+ + +
+ + + + +
+ +
+ +
+ + +
+
+
+ + +
+ + +
+
+

高级设置

+

计费、缓存与过滤选项(鉴权模式)

+
+ +
+
计费与缓存
+
+ + +
+
+ + +
+
+ +
+
过滤与识别
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+

缓存管理

+

本地音频缓存统计与清理

+
+ +
+
+
+
+
0
+
缓存条数
+
+
+
0 MB
+
占用空间
+
+
+
0
+
命中
+
+
+
0
+
未命中
+
+
+
+
+ +
+
缓存配置
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + +
+
+ + +
+
+

使用说明

+

配音指令与开通流程

+
+ +
+

配音指令

+

格式:[tts:speaker=音色名;emotion=情绪;context=语气提示] 放在正文前一行

+

speaker、emotion、context 三个参数可任意组合、任意顺序,用分号分隔

+

每遇到一个新 [tts:...] 块会分段朗读,按顺序播放

+

未写 speaker= 的块使用当前选中的默认音色

+ +

音色(speaker)

+

只能指定"我的音色"中保存的名称。例如保存了名为"小白"的音色,则可用 speaker=小白

+ +

情感(emotion)可用值:

+
中文:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰、鼓励、咆哮、焦急、温柔、讲故事、自然讲述、情感电台、磁性、广告营销、气泡音、低语、新闻播报、娱乐八卦、方言、对话、闲聊、温暖、深情、权威
+
+英文:happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, radio, magnetic, advertising, vocal-fry, asmr, news, entertainment, dialect, chat, warm, affectionate, authoritative
+ +

语气提示(context)仅对 seed-tts-2.0 生效:

+

例如:"用更委屈的语气"、"放慢一点,压低音量"

+
+ +
+

复刻音色使用

+
    +
  1. 在火山官网复刻音色
  2. +
  3. 获取音色ID(格式 S_xxxxxxxx
  4. +
  5. 在"音色管理" → "我的音色"中添加
  6. +
+
+ +
+ +
以下是鉴权模式的开通教程,试用音色无需配置。
+
+ +
+

开启 CORS 代理

+
    +
  1. 打开酒馆目录的 config.yaml
  2. +
  3. 将 enableCorsProxy 改为 true 并保存
  4. +
  5. 重启酒馆(重启容器/进程,不是 F5 刷新)
  6. +
+
+ +
+

开通服务(推荐一次性开通全部)

+ + 开通管理 +
+ +
+

获取 Access Token / AppID

+ + 获取ID和KEY +
+ +
+

声音复刻入口(复刻后去音色库拿ID)

+ + 声音复刻 +
+
+ +
+
+ + + +
+ + + + + diff --git a/modules/tts/tts-panel.js b/modules/tts/tts-panel.js index 93c0f13..1fa3b50 100644 --- a/modules/tts/tts-panel.js +++ b/modules/tts/tts-panel.js @@ -1,1025 +1,1313 @@ -// tts-panel.js -/** - * TTS 播放器面板 - 极简胶囊版 v4 - * 新增:自动朗读快捷开关,支持双向同步 - */ - -import { registerToToolbar, removeFromToolbar } from '../../widgets/message-toolbar.js'; - -// ═══════════════════════════════════════════════════════════════════════════ -// 常量 -// ═══════════════════════════════════════════════════════════════════════════ - -const INITIAL_RENDER_LIMIT = 1; - -// ═══════════════════════════════════════════════════════════════════════════ -// 状态 -// ═══════════════════════════════════════════════════════════════════════════ - -let stylesInjected = false; -const panelMap = new Map(); -const pendingCallbacks = new Map(); -let observer = null; - -// 配置接口 -let getConfigFn = null; -let saveConfigFn = null; -let openSettingsFn = null; -let clearQueueFn = null; - -export function setPanelConfigHandlers({ getConfig, saveConfig, openSettings, clearQueue }) { - getConfigFn = getConfig; - saveConfigFn = saveConfig; - openSettingsFn = openSettings; - clearQueueFn = clearQueue; -} - -export function clearPanelConfigHandlers() { - getConfigFn = null; - saveConfigFn = null; - openSettingsFn = null; - clearQueueFn = null; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 样式 -// ═══════════════════════════════════════════════════════════════════════════ - -function injectStyles() { - if (stylesInjected) return; - const css = ` -/* ═══════════════════════════════════════════════════════════════ - TTS 播放器 - 极简胶囊 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-panel { - --h: 34px; - --bg: rgba(0, 0, 0, 0.55); - --bg-hover: rgba(0, 0, 0, 0.7); - --border: rgba(255, 255, 255, 0.08); - --border-active: rgba(255, 255, 255, 0.2); - --text: rgba(255, 255, 255, 0.85); - --text-sub: rgba(255, 255, 255, 0.45); - --text-dim: rgba(255, 255, 255, 0.25); - --success: rgba(62, 207, 142, 0.9); - --success-soft: rgba(62, 207, 142, 0.12); - --error: rgba(239, 68, 68, 0.8); - - position: relative; - display: inline-flex; - flex-direction: column; - z-index: 10; - user-select: none; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -} - -/* ═══════════════════════════════════════════════════════════════ - 胶囊主体 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-capsule { - display: flex; - align-items: center; - height: var(--h); - background: var(--bg); - border: 1px solid var(--border); - border-radius: 17px; - padding: 0 3px; - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); - width: fit-content; - gap: 1px; -} - -.xb-tts-panel:hover .xb-tts-capsule { - background: var(--bg-hover); - border-color: var(--border-active); -} - -/* 自动朗读开启时的边框提示 */ -.xb-tts-panel[data-auto="true"] .xb-tts-capsule { - border-color: rgba(62, 207, 142, 0.25); -} -.xb-tts-panel[data-auto="true"]:hover .xb-tts-capsule { - border-color: rgba(62, 207, 142, 0.4); -} - -/* 状态边框 */ -.xb-tts-panel[data-status="playing"] .xb-tts-capsule { - border-color: rgba(255, 255, 255, 0.25); -} -.xb-tts-panel[data-status="error"] .xb-tts-capsule { - border-color: var(--error); -} - -/* ═══════════════════════════════════════════════════════════════ - 按钮 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-btn { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border: none; - background: transparent; - color: var(--text); - cursor: pointer; - border-radius: 50%; - font-size: 11px; - transition: all 0.25s ease; - flex-shrink: 0; - position: relative; -} - -.xb-tts-btn:hover { - background: rgba(255, 255, 255, 0.12); -} - -.xb-tts-btn:active { - transform: scale(0.92); -} - -/* 播放按钮的自动朗读指示点 */ -.xb-tts-auto-dot { - position: absolute; - top: 4px; - right: 4px; - width: 6px; - height: 6px; - background: var(--success); - border-radius: 50%; - box-shadow: 0 0 6px rgba(62, 207, 142, 0.6); - opacity: 0; - transform: scale(0); - transition: all 0.25s ease; -} -.xb-tts-panel[data-auto="true"] .xb-tts-auto-dot { - opacity: 1; - transform: scale(1); -} - -/* 停止按钮 */ -.xb-tts-btn.stop-btn { - color: var(--text-sub); - font-size: 8px; -} -.xb-tts-btn.stop-btn:hover { - color: var(--error); - background: rgba(239, 68, 68, 0.1); -} - -/* 展开按钮 */ -.xb-tts-btn.expand-btn { - width: 24px; - height: 24px; - font-size: 8px; - color: var(--text-dim); - opacity: 0.6; - transition: opacity 0.25s, transform 0.25s; -} -.xb-tts-panel:hover .xb-tts-btn.expand-btn { - opacity: 1; -} -.xb-tts-panel.expanded .xb-tts-btn.expand-btn { - transform: rotate(180deg); -} - -/* ═══════════════════════════════════════════════════════════════ - 分隔线 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-sep { - width: 1px; - height: 12px; - background: var(--border); - margin: 0 2px; - flex-shrink: 0; -} - -/* ═══════════════════════════════════════════════════════════════ - 信息区 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-info { - display: flex; - align-items: center; - gap: 6px; - padding: 0 6px; - min-width: 50px; -} - -.xb-tts-status { - font-size: 11px; - color: var(--text-sub); - white-space: nowrap; - transition: color 0.25s; -} -.xb-tts-panel[data-status="playing"] .xb-tts-status { - color: var(--text); -} -.xb-tts-panel[data-status="error"] .xb-tts-status { - color: var(--error); -} - -/* 队列徽标 */ -.xb-tts-badge { - display: none; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.1); - color: var(--text); - padding: 2px 6px; - border-radius: 8px; - font-size: 10px; - font-weight: 500; - font-variant-numeric: tabular-nums; -} -.xb-tts-panel[data-has-queue="true"] .xb-tts-badge { - display: flex; -} - -/* ═══════════════════════════════════════════════════════════════ - 波形动画 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-wave { - display: none; - align-items: center; - gap: 2px; - height: 14px; - padding: 0 4px; -} - -.xb-tts-panel[data-status="playing"] .xb-tts-wave { - display: flex; -} -.xb-tts-panel[data-status="playing"] .xb-tts-status { - display: none; -} - -.xb-tts-bar { - width: 2px; - background: var(--text); - border-radius: 1px; - animation: xb-tts-wave 1.6s infinite ease-in-out; - opacity: 0.7; -} -.xb-tts-bar:nth-child(1) { height: 4px; animation-delay: 0.0s; } -.xb-tts-bar:nth-child(2) { height: 10px; animation-delay: 0.2s; } -.xb-tts-bar:nth-child(3) { height: 6px; animation-delay: 0.4s; } -.xb-tts-bar:nth-child(4) { height: 8px; animation-delay: 0.6s; } - -@keyframes xb-tts-wave { - 0%, 100% { - transform: scaleY(0.4); - opacity: 0.4; - } - 50% { - transform: scaleY(1); - opacity: 0.85; - } -} - -/* ═══════════════════════════════════════════════════════════════ - 加载动画 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-loading { - display: none; - width: 12px; - height: 12px; - border: 1.5px solid rgba(255, 255, 255, 0.15); - border-top-color: var(--text); - border-radius: 50%; - animation: xb-tts-spin 1s linear infinite; - margin: 0 4px; -} - -.xb-tts-panel[data-status="sending"] .xb-tts-loading, -.xb-tts-panel[data-status="queued"] .xb-tts-loading { - display: block; -} -.xb-tts-panel[data-status="sending"] .play-btn, -.xb-tts-panel[data-status="queued"] .play-btn { - display: none; -} - -@keyframes xb-tts-spin { - to { transform: rotate(360deg); } -} - -/* ═══════════════════════════════════════════════════════════════ - 底部进度条 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-progress { - position: absolute; - bottom: 0; - left: 8px; - right: 8px; - height: 2px; - background: rgba(255, 255, 255, 0.08); - border-radius: 1px; - overflow: hidden; - opacity: 0; - transition: opacity 0.3s; -} - -.xb-tts-panel[data-status="playing"] .xb-tts-progress, -.xb-tts-panel[data-has-queue="true"] .xb-tts-progress { - opacity: 1; -} - -.xb-tts-progress-inner { - height: 100%; - background: rgba(255, 255, 255, 0.6); - width: 0%; - transition: width 0.4s ease-out; - border-radius: 1px; -} - -/* ═══════════════════════════════════════════════════════════════ - 展开菜单 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-menu { - position: absolute; - top: calc(100% + 8px); - left: 0; - background: rgba(18, 18, 22, 0.96); - border: 1px solid var(--border); - border-radius: 12px; - padding: 10px; - min-width: 220px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - opacity: 0; - visibility: hidden; - transform: translateY(-6px) scale(0.96); - transform-origin: top left; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 100; -} - -.xb-tts-panel.expanded .xb-tts-menu { - opacity: 1; - visibility: visible; - transform: translateY(0) scale(1); -} - -.xb-tts-row { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 2px; -} - -.xb-tts-label { - font-size: 11px; - color: var(--text-sub); - width: 32px; - flex-shrink: 0; -} - -.xb-tts-select { - flex: 1; - background: rgba(255, 255, 255, 0.06); - border: 1px solid var(--border); - color: var(--text); - font-size: 11px; - border-radius: 6px; - padding: 6px 8px; - outline: none; - cursor: pointer; - transition: border-color 0.2s; -} -.xb-tts-select:hover { - border-color: rgba(255, 255, 255, 0.2); -} -.xb-tts-select:focus { - border-color: rgba(255, 255, 255, 0.3); -} - -.xb-tts-slider { - flex: 1; - height: 4px; - accent-color: #fff; - cursor: pointer; -} - -.xb-tts-val { - font-size: 11px; - color: var(--text); - width: 32px; - text-align: right; - font-variant-numeric: tabular-nums; -} - -/* ═══════════════════════════════════════════════════════════════ - 工具栏(包含自动朗读开关) - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-tools { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border); - display: flex; - align-items: center; - gap: 6px; -} - -.xb-tts-usage { - font-size: 10px; - color: var(--text-dim); - flex-shrink: 0; - min-width: 32px; -} - -/* 自动朗读开关 - flex:1 填满剩余空间 */ -.xb-tts-auto-toggle { - flex: 1; - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; -} -.xb-tts-auto-toggle:hover { - background: rgba(255, 255, 255, 0.06); - border-color: rgba(255, 255, 255, 0.15); -} -.xb-tts-auto-toggle.on { - background: rgba(62, 207, 142, 0.08); - border-color: rgba(62, 207, 142, 0.25); -} - -.xb-tts-auto-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.2); - transition: all 0.25s ease; - flex-shrink: 0; -} -.xb-tts-auto-toggle.on .xb-tts-auto-indicator { - background: var(--success); - box-shadow: 0 0 6px rgba(62, 207, 142, 0.5); -} - -.xb-tts-auto-text { - font-size: 11px; - color: var(--text-sub); - transition: color 0.2s; -} -.xb-tts-auto-toggle:hover .xb-tts-auto-text { - color: var(--text); -} -.xb-tts-auto-toggle.on .xb-tts-auto-text { - color: rgba(62, 207, 142, 0.9); -} - -.xb-tts-icon-btn { - color: var(--text-sub); - cursor: pointer; - font-size: 13px; - padding: 4px 6px; - border-radius: 4px; - transition: all 0.2s; - flex-shrink: 0; -} -.xb-tts-icon-btn:hover { - color: var(--text); - background: rgba(255, 255, 255, 0.08); -} - -/* ═══════════════════════════════════════════════════════════════ - TTS 指令块样式 - ═══════════════════════════════════════════════════════════════ */ - -.xb-tts-tag { - display: inline-flex; - align-items: center; - gap: 3px; - color: rgba(255, 255, 255, 0.25); - font-size: 11px; - font-style: italic; - vertical-align: baseline; - user-select: none; - transition: color 0.3s ease; -} -.xb-tts-tag:hover { - color: rgba(255, 255, 255, 0.45); -} -.xb-tts-tag-icon { - font-style: normal; - font-size: 10px; - opacity: 0.7; -} -.xb-tts-tag-dot { - opacity: 0.4; -} -.xb-tts-tag[data-has-params="true"] { - color: rgba(255, 255, 255, 0.3); -} -`; - const style = document.createElement('style'); - style.id = 'xb-tts-panel-styles'; - style.textContent = css; - document.head.appendChild(style); - stylesInjected = true; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 面板创建 -// ═══════════════════════════════════════════════════════════════════════════ - -function createPanel(messageId) { - const config = getConfigFn?.() || {}; - const currentSpeed = config?.volc?.speechRate || 1.0; - const isAutoSpeak = config?.autoSpeak !== false; - - const div = document.createElement('div'); - div.className = 'xb-tts-panel'; - div.dataset.messageId = messageId; - div.dataset.status = 'idle'; - div.dataset.hasQueue = 'false'; - div.dataset.auto = isAutoSpeak ? 'true' : 'false'; - - // Template-only UI markup built locally. - // eslint-disable-next-line no-unsanitized/property - div.innerHTML = ` -
-
- - -
-
-
-
-
-
-
- 播放 - 0/0 -
- - - -
- - - -
-
-
-
- -
-
- 音色 - -
-
- 语速 - - ${currentSpeed.toFixed(1)}x -
-
- -字 -
- - 自动朗读 -
- -
-
- `; - - return div; -} - -function buildVoiceOptions(select, config) { - const mySpeakers = config?.volc?.mySpeakers || []; - const current = config?.volc?.defaultSpeaker || ''; - - if (mySpeakers.length === 0) { - select.textContent = ''; - const opt = document.createElement('option'); - opt.value = ''; - opt.disabled = true; - opt.textContent = '暂无音色'; - select.appendChild(opt); - select.selectedIndex = -1; - return; - } - - const isMyVoice = current && mySpeakers.some(s => s.value === current); - - select.textContent = ''; - 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 (!isMyVoice) { - select.selectedIndex = -1; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 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); - } - } - - if (toMount.length > 0) { - requestAnimationFrame(() => { - for (const { el, mid, cb } of toMount) { - mountPanel(el, mid, cb); - } - }); - } - }, { - rootMargin: '300px', - threshold: 0 - }); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 面板挂载 -// ═══════════════════════════════════════════════════════════════════════════ - -function mountPanel(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, { - 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'; - } - }; - - 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 }); - } - }; - - 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; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 全局同步更新 -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * 更新所有面板的自动朗读状态 - */ -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); - } - }); -} - -/** - * 更新所有面板的语速显示 - */ -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'; - }); -} - -/** - * 更新所有面板的音色选择 - */ -export function updateVoiceAll() { - const config = getConfigFn?.(); - panelMap.forEach((ui) => { - if (!ui.root || !ui.voiceSelect) return; - buildVoiceOptions(ui.voiceSelect, config); - }); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 对外接口 -// ═══════════════════════════════════════════════════════════════════════════ - -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) { - 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); - return null; -} - -export function renderPanelsForChat(chat, getMessageElement, onPlay) { - 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); - immediateCount++; - } else { - observeForLazyMount(messageEl, i, onPlay); - } - } -} - -export function updateTtsPanel(messageId, state) { - const ui = panelMap.get(messageId); - if (!ui || !state) return; - - const status = state.status || 'idle'; - const current = state.currentSegment || 0; - const total = state.totalSegments || 0; - const hasQueue = total > 1; - - ui.root.dataset.status = status; - ui.root.dataset.hasQueue = hasQueue ? 'true' : 'false'; - - let statusText = ''; - let playIcon = '▶'; - let showStop = false; - - switch (status) { - case 'idle': - statusText = '播放'; - playIcon = '▶'; - break; - case 'sending': - case 'queued': - statusText = hasQueue ? `${current}/${total}` : '准备'; - playIcon = '■'; - showStop = true; - break; - case 'cached': - statusText = hasQueue ? `${current}/${total}` : '缓存'; - playIcon = '▶'; - break; - case 'playing': - statusText = hasQueue ? `${current}/${total}` : ''; - playIcon = '⏸'; - showStop = true; - break; - case 'paused': - statusText = hasQueue ? `${current}/${total}` : '暂停'; - playIcon = '▶'; - showStop = true; - break; - case 'ended': - statusText = '完成'; - playIcon = '↻'; - break; - case 'blocked': - statusText = '受阻'; - playIcon = '▶'; - break; - case 'error': - statusText = (state.error || '失败').slice(0, 8); - playIcon = '↻'; - break; - default: - statusText = '播放'; - playIcon = '▶'; - } - - // 更新播放按钮(保留自动朗读指示点) - 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 removeTtsPanel(messageId) { - const ui = panelMap.get(messageId); - if (ui) { - ui._cleanup?.(); - panelMap.delete(messageId); - } - pendingCallbacks.delete(messageId); -} - -export function removeAllTtsPanels() { - panelMap.forEach((ui) => { - ui._cleanup?.(); - }); - panelMap.clear(); - pendingCallbacks.clear(); - - observer?.disconnect(); - observer = null; -} - -export function getPanelMap() { - return panelMap; -} +// 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; +} diff --git a/modules/tts/tts.js b/modules/tts/tts.js index 8c1d26a..7c6be7c 100644 --- a/modules/tts/tts.js +++ b/modules/tts/tts.js @@ -1,1334 +1,1368 @@ -// ============ 导入 ============ - -import { event_types } from "../../../../../../script.js"; -import { extension_settings, getContext } from "../../../../../extensions.js"; -import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; -import { createModuleEvents } from "../../core/event-manager.js"; -import { TtsStorage } from "../../core/server-storage.js"; -import { extractSpeakText, parseTtsSegments, DEFAULT_SKIP_TAGS, normalizeEmotion, splitTtsSegmentsForFree } from "./tts-text.js"; -import { TtsPlayer } from "./tts-player.js"; -import { synthesizeV3, FREE_DEFAULT_VOICE } from "./tts-api.js"; -import { - ensureTtsPanel, - updateTtsPanel, - removeAllTtsPanels, - initTtsPanelStyles, - setPanelConfigHandlers, - updateAutoSpeakAll, - updateSpeedAll, - updateVoiceAll -} from "./tts-panel.js"; -import { getCacheEntry, setCacheEntry, getCacheStats, clearExpiredCache, clearAllCache, pruneCache } from './tts-cache.js'; -import { speakMessageFree, clearAllFreeQueues, clearFreeQueueForMessage } from './tts-free-provider.js'; -import { - speakMessageAuth, - speakSegmentAuth, - inferResourceIdBySpeaker, - buildV3Headers, - speedToV3SpeechRate -} from './tts-auth-provider.js'; -import { postToIframe, isTrustedIframeEvent } from "../../core/iframe-messaging.js"; - -// ============ 常量 ============ - -const MODULE_ID = 'tts'; -const OVERLAY_ID = 'xiaobaix-tts-overlay'; -const HTML_PATH = `${extensionFolderPath}/modules/tts/tts-overlay.html`; -const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi; - -const FREE_VOICE_KEYS = new Set([ - 'female_1', 'female_2', 'female_3', 'female_4', 'female_5', 'female_6', 'female_7', - 'male_1', 'male_2', 'male_3', 'male_4' -]); - -// ============ NovelDraw 兼容 ============ - -let ndImageObserver = null; -let ndRenderPending = new Set(); -let ndRenderTimer = null; - -function scheduleNdRerender(mesText) { - ndRenderPending.add(mesText); - if (ndRenderTimer) return; - - ndRenderTimer = setTimeout(() => { - ndRenderTimer = null; - const pending = Array.from(ndRenderPending); - ndRenderPending.clear(); - - if (!isModuleEnabled()) return; - - for (const el of pending) { - if (!el.isConnected) continue; - TTS_DIRECTIVE_REGEX.lastIndex = 0; - // Tests existing message HTML only. - // eslint-disable-next-line no-unsanitized/property - if (TTS_DIRECTIVE_REGEX.test(el.innerHTML)) { - enhanceTtsDirectives(el); - } - } - }, 50); -} - -function setupNovelDrawObserver() { - if (ndImageObserver) return; - - const chatEl = document.getElementById('chat'); - if (!chatEl) return; - - ndImageObserver = new MutationObserver((mutations) => { - if (!isModuleEnabled()) return; - - for (const mutation of mutations) { - for (const node of mutation.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - - const isNdImg = node.classList?.contains('xb-nd-img'); - const hasNdImg = isNdImg || node.querySelector?.('.xb-nd-img'); - if (!hasNdImg) continue; - - const mesText = node.closest('.mes_text'); - if (mesText) { - scheduleNdRerender(mesText); - } - } - } - }); - - ndImageObserver.observe(chatEl, { childList: true, subtree: true }); -} - -function cleanupNovelDrawObserver() { - ndImageObserver?.disconnect(); - ndImageObserver = null; - if (ndRenderTimer) { - clearTimeout(ndRenderTimer); - ndRenderTimer = null; - } - ndRenderPending.clear(); -} - -// ============ 状态 ============ - -let player = null; -let moduleInitialized = false; -let overlay = null; -let config = null; -const messageStateMap = new Map(); -const cacheCounters = { hits: 0, misses: 0 }; - -const events = createModuleEvents(MODULE_ID); - -// ============ 指令块懒加载 ============ - -let directiveObserver = null; -const processedDirectives = new WeakSet(); - -function setupDirectiveObserver() { - if (directiveObserver) return; - - directiveObserver = new IntersectionObserver((entries) => { - if (!isModuleEnabled()) return; - - for (const entry of entries) { - if (!entry.isIntersecting) continue; - - const mesText = entry.target; - if (processedDirectives.has(mesText)) { - directiveObserver.unobserve(mesText); - continue; - } - - TTS_DIRECTIVE_REGEX.lastIndex = 0; - // Tests existing message HTML only. - // eslint-disable-next-line no-unsanitized/property - if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) { - enhanceTtsDirectives(mesText); - } - processedDirectives.add(mesText); - directiveObserver.unobserve(mesText); - } - }, { rootMargin: '300px' }); -} - -function observeDirective(mesText) { - if (!mesText || processedDirectives.has(mesText)) return; - - setupDirectiveObserver(); - - // 已在视口附近,立即处理 - const rect = mesText.getBoundingClientRect(); - if (rect.top < window.innerHeight + 300 && rect.bottom > -300) { - TTS_DIRECTIVE_REGEX.lastIndex = 0; - // Tests existing message HTML only. - // eslint-disable-next-line no-unsanitized/property - if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) { - enhanceTtsDirectives(mesText); - } - processedDirectives.add(mesText); - return; - } - - // 不在视口,加入观察队列 - directiveObserver.observe(mesText); -} - -function cleanupDirectiveObserver() { - directiveObserver?.disconnect(); - directiveObserver = null; -} - -// ============ 模块状态检查 ============ - -function isModuleEnabled() { - if (!moduleInitialized) return false; - try { - const settings = extension_settings[EXT_ID]; - if (!settings?.enabled) return false; - if (!settings?.tts?.enabled) return false; - return true; - } catch { - return false; - } -} - -// ============ 工具函数 ============ - -function hashString(input) { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i++) { - hash ^= input.charCodeAt(i); - hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); - } - return (hash >>> 0).toString(16); -} - -function generateBatchId() { - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function normalizeSpeed(value) { - const num = Number.isFinite(value) ? value : 1.0; - if (num >= 0.5 && num <= 2.0) return num; - return Math.min(2.0, Math.max(0.5, 1 + num / 100)); -} - -function escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -// ============ 音色来源判断 ============ - -function getVoiceSource(value) { - if (!value) return 'free'; - if (FREE_VOICE_KEYS.has(value)) return 'free'; - return 'auth'; -} - -function isAuthConfigured() { - return !!(config?.volc?.appId && config?.volc?.accessKey); -} - -function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) { - const list = Array.isArray(mySpeakers) ? mySpeakers : []; - - // ★ 调试日志 - if (speakerName) { - console.log('[TTS Debug] resolveSpeaker:', { - 查找的名称: speakerName, - mySpeakers: list.map(s => ({ name: s.name, value: s.value, source: s.source })), - 默认音色: defaultSpeaker - }); - } - - if (!speakerName) { - const defaultItem = list.find(s => s.value === defaultSpeaker); - return { - value: defaultSpeaker, - source: defaultItem?.source || getVoiceSource(defaultSpeaker) - }; - } - - const byName = list.find(s => s.name === speakerName); - console.log('[TTS Debug] byName 查找结果:', byName); // ★ 调试 - - if (byName?.value) { - return { - value: byName.value, - source: byName.source || getVoiceSource(byName.value) - }; - } - - const byValue = list.find(s => s.value === speakerName); - console.log('[TTS Debug] byValue 查找结果:', byValue); // ★ 调试 - - if (byValue?.value) { - return { - value: byValue.value, - source: byValue.source || getVoiceSource(byValue.value) - }; - } - - if (FREE_VOICE_KEYS.has(speakerName)) { - return { value: speakerName, source: 'free' }; - } - - // ★ 回退到默认,这是问题发生的地方 - console.warn('[TTS Debug] 未找到匹配音色,回退到默认:', defaultSpeaker); - - const defaultItem = list.find(s => s.value === defaultSpeaker); - return { - value: defaultSpeaker, - source: defaultItem?.source || getVoiceSource(defaultSpeaker) - }; -} - -// ============ 缓存管理 ============ - -function buildCacheKey(params) { - const payload = { - providerMode: params.providerMode || 'auth', - text: params.text || '', - speaker: params.speaker || '', - resourceId: params.resourceId || '', - format: params.format || 'mp3', - sampleRate: params.sampleRate || 24000, - speechRate: params.speechRate || 0, - loudnessRate: params.loudnessRate || 0, - emotion: params.emotion || '', - emotionScale: params.emotionScale || 0, - explicitLanguage: params.explicitLanguage || '', - disableMarkdownFilter: params.disableMarkdownFilter !== false, - disableEmojiFilter: params.disableEmojiFilter === true, - enableLanguageDetector: params.enableLanguageDetector === true, - model: params.model || '', - maxLengthToFilterParenthesis: params.maxLengthToFilterParenthesis ?? null, - postProcessPitch: params.postProcessPitch ?? 0, - contextTexts: Array.isArray(params.contextTexts) ? params.contextTexts : [], - freeSpeed: params.freeSpeed ?? null, - }; - return `tts:${hashString(JSON.stringify(payload))}`; -} - -async function getCacheStatsSafe() { - try { - const stats = await getCacheStats(); - return { ...stats, hits: cacheCounters.hits, misses: cacheCounters.misses }; - } catch { - return { count: 0, sizeMB: '0', totalBytes: 0, hits: cacheCounters.hits, misses: cacheCounters.misses }; - } -} - -async function tryLoadLocalCache(params) { - if (!config.volc.localCacheEnabled) return null; - const key = buildCacheKey(params); - try { - const entry = await getCacheEntry(key); - if (entry?.blob) { - const cutoff = Date.now() - (config.volc.cacheDays || 7) * 24 * 60 * 60 * 1000; - if (entry.createdAt && entry.createdAt < cutoff) { - await clearExpiredCache(config.volc.cacheDays || 7); - cacheCounters.misses += 1; - return null; - } - cacheCounters.hits += 1; - return { key, entry }; - } - cacheCounters.misses += 1; - return null; - } catch { - cacheCounters.misses += 1; - return null; - } -} - -async function storeLocalCache(key, blob, meta) { - if (!config.volc.localCacheEnabled) return; - try { - await setCacheEntry(key, blob, meta); - await pruneCache({ - maxEntries: config.volc.cacheMaxEntries, - maxBytes: config.volc.cacheMaxMB * 1024 * 1024, - }); - } catch {} -} - -// ============ 消息状态管理 ============ - -function ensureMessageState(messageId) { - if (!messageStateMap.has(messageId)) { - messageStateMap.set(messageId, { - messageId, - status: 'idle', - text: '', - textLength: 0, - cached: false, - usage: null, - duration: 0, - progress: 0, - error: '', - audioBlob: null, - cacheKey: '', - updatedAt: 0, - currentSegment: 0, - totalSegments: 0, - }); - } - return messageStateMap.get(messageId); -} - -function getMessageElement(messageId) { - return document.querySelector(`.mes[mesid="${messageId}"]`); -} - -function getMessageData(messageId) { - const context = getContext(); - return (context.chat || [])[messageId] || null; -} - -function getSpeakTextFromMessage(message) { - if (!message || typeof message.mes !== 'string') return ''; - return extractSpeakText(message.mes, { - skipRanges: config.skipRanges, - readRanges: config.readRanges, - readRangesEnabled: config.readRangesEnabled, - }); -} - -// ============ 队列管理 ============ - -function clearMessageFromQueue(messageId) { - clearFreeQueueForMessage(messageId); - if (!player) return; - const prefix = `msg-${messageId}-`; - player.queue = player.queue.filter(item => !item.id?.startsWith(prefix)); - if (player.currentItem?.messageId === messageId) { - player._stopCurrent(true); - player.currentItem = null; - player.isPlaying = false; - player._playNext(); - } -} - -// ============ 状态保护更新器 ============ - -function createProtectedStateUpdater(messageId) { - return (updates) => { - const st = ensureMessageState(messageId); - - // 如果播放器正在播放/暂停,保护这个状态不被队列状态覆盖 - const isPlayerActive = st.status === 'playing' || st.status === 'paused'; - const isQueueStatus = updates.status === 'sending' || - updates.status === 'queued' || - updates.status === 'cached'; - - if (isPlayerActive && isQueueStatus) { - // 只更新进度相关字段,不覆盖播放状态 - const rest = { ...updates }; - delete rest.status; - Object.assign(st, rest); - } else { - Object.assign(st, updates); - } - - updateTtsPanel(messageId, st); - }; -} - -// ============ 混合模式辅助 ============ - -function expandMixedSegments(resolvedSegments) { - const expanded = []; - - for (const seg of resolvedSegments) { - if (seg.resolvedSource === 'free' && seg.text && seg.text.length > 200) { - const splitSegs = splitTtsSegmentsForFree([{ - text: seg.text, - emotion: seg.emotion || '', - context: seg.context || '', - speaker: seg.speaker || '', - }]); - - for (const splitSeg of splitSegs) { - expanded.push({ - ...splitSeg, - resolvedSpeaker: seg.resolvedSpeaker, - resolvedSource: 'free', - }); - } - } else { - expanded.push(seg); - } - } - - return expanded; -} - -async function speakSingleFreeSegment(messageId, segment, segmentIndex, batchId) { - const state = ensureMessageState(messageId); - - state.status = 'sending'; - state.currentSegment = segmentIndex + 1; - state.text = segment.text; - state.textLength = segment.text.length; - state.updatedAt = Date.now(); - updateTtsPanel(messageId, state); - - const freeSpeed = normalizeSpeed(config?.volc?.speechRate); - const voiceKey = segment.resolvedSpeaker || FREE_DEFAULT_VOICE; - const emotion = normalizeEmotion(segment.emotion); - - const cacheParams = { - providerMode: 'free', - text: segment.text, - speaker: voiceKey, - freeSpeed, - emotion: emotion || '', - }; - - const cacheHit = await tryLoadLocalCache(cacheParams); - if (cacheHit?.entry?.blob) { - state.cached = true; - state.status = 'cached'; - updateTtsPanel(messageId, state); - player.enqueue({ - id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`, - messageId, - segmentIndex, - batchId, - audioBlob: cacheHit.entry.blob, - text: segment.text, - }); - return; - } - - try { - const { synthesizeFreeV1 } = await import('./tts-api.js'); - const { audioBase64 } = await synthesizeFreeV1({ - text: segment.text, - voiceKey, - speed: freeSpeed, - emotion: emotion || null, - }); - - const byteString = atob(audioBase64); - const bytes = new Uint8Array(byteString.length); - for (let j = 0; j < byteString.length; j++) { - bytes[j] = byteString.charCodeAt(j); - } - const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); - - const cacheKey = buildCacheKey(cacheParams); - storeLocalCache(cacheKey, audioBlob, { - text: segment.text.slice(0, 200), - textLength: segment.text.length, - speaker: voiceKey, - resourceId: 'free', - }).catch(() => {}); - - state.status = 'queued'; - updateTtsPanel(messageId, state); - - player.enqueue({ - id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`, - messageId, - segmentIndex, - batchId, - audioBlob, - text: segment.text, - }); - } catch (err) { - state.status = 'error'; - state.error = err?.message || '合成失败'; - updateTtsPanel(messageId, state); - } -} - -// ============ 主播放入口 ============ - -async function handleMessagePlayClick(messageId) { - if (!isModuleEnabled()) return; - - const state = ensureMessageState(messageId); - - if (state.status === 'sending' || state.status === 'queued') { - clearMessageFromQueue(messageId); - state.status = 'idle'; - state.currentSegment = 0; - state.totalSegments = 0; - updateTtsPanel(messageId, state); - return; - } - - if (player?.currentItem?.messageId === messageId && player?.currentAudio) { - if (player.currentAudio.paused) { - player.currentAudio.play().catch(() => {}); - } else { - player.currentAudio.pause(); - } - return; - } - await speakMessage(messageId, { mode: 'manual' }); -} - -async function speakMessage(messageId, { mode = 'manual' } = {}) { - if (!isModuleEnabled()) return; - - const message = getMessageData(messageId); - if (!message || message.is_user) return; - - const messageEl = getMessageElement(messageId); - if (!messageEl) return; - - ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); - - const speakText = getSpeakTextFromMessage(message); - if (!speakText.trim()) { - const state = ensureMessageState(messageId); - state.status = 'idle'; - state.text = ''; - state.currentSegment = 0; - state.totalSegments = 0; - updateTtsPanel(messageId, state); - return; - } - - const mySpeakers = config.volc?.mySpeakers || []; - const defaultSpeaker = config.volc.defaultSpeaker || FREE_DEFAULT_VOICE; - const defaultResolved = resolveSpeakerWithSource('', mySpeakers, defaultSpeaker); - - let segments = parseTtsSegments(speakText); - if (!segments.length) { - const state = ensureMessageState(messageId); - state.status = 'idle'; - state.currentSegment = 0; - state.totalSegments = 0; - updateTtsPanel(messageId, state); - return; - } - - const resolvedSegments = segments.map(seg => { - const resolved = seg.speaker - ? resolveSpeakerWithSource(seg.speaker, mySpeakers, defaultSpeaker) - : defaultResolved; - return { - ...seg, - resolvedSpeaker: resolved.value, - resolvedSource: resolved.source - }; - }); - - const needsAuth = resolvedSegments.some(s => s.resolvedSource === 'auth'); - if (needsAuth && !isAuthConfigured()) { - toastr?.warning?.('部分音色需要配置鉴权 API,将仅播放免费音色'); - const freeOnly = resolvedSegments.filter(s => s.resolvedSource === 'free'); - if (!freeOnly.length) { - const state = ensureMessageState(messageId); - state.status = 'error'; - state.error = '所有音色均需要鉴权'; - updateTtsPanel(messageId, state); - return; - } - resolvedSegments.length = 0; - resolvedSegments.push(...freeOnly); - } - - const batchId = generateBatchId(); - if (mode === 'manual') clearMessageFromQueue(messageId); - - const hasFree = resolvedSegments.some(s => s.resolvedSource === 'free'); - const hasAuth = resolvedSegments.some(s => s.resolvedSource === 'auth'); - const isMixed = hasFree && hasAuth; - - const state = ensureMessageState(messageId); - - if (isMixed) { - const expandedSegments = expandMixedSegments(resolvedSegments); - - state.totalSegments = expandedSegments.length; - state.currentSegment = 0; - state.status = 'sending'; - updateTtsPanel(messageId, state); - - const ctx = { - config, - player, - tryLoadLocalCache, - storeLocalCache, - buildCacheKey, - updateState: (updates) => { - Object.assign(state, updates); - updateTtsPanel(messageId, state); - }, - }; - - for (let i = 0; i < expandedSegments.length; i++) { - if (!isModuleEnabled()) return; - - const seg = expandedSegments[i]; - state.currentSegment = i + 1; - updateTtsPanel(messageId, state); - - if (seg.resolvedSource === 'free') { - await speakSingleFreeSegment(messageId, seg, i, batchId); - } else { - await speakSegmentAuth(messageId, seg, i, batchId, { - isFirst: i === 0, - ...ctx - }); - } - } - return; - } - - state.totalSegments = resolvedSegments.length; - state.currentSegment = 0; - state.status = 'sending'; - updateTtsPanel(messageId, state); - - if (hasFree) { - await speakMessageFree({ - messageId, - segments: resolvedSegments, - defaultSpeaker, - mySpeakers, - player, - config, - tryLoadLocalCache, - storeLocalCache, - buildCacheKey, - updateState: createProtectedStateUpdater(messageId), - clearMessageFromQueue, - mode, - }); - return; - } - - if (hasAuth) { - await speakMessageAuth({ - messageId, - segments: resolvedSegments, - batchId, - config, - player, - tryLoadLocalCache, - storeLocalCache, - buildCacheKey, - updateState: (updates) => { - const st = ensureMessageState(messageId); - Object.assign(st, updates); - updateTtsPanel(messageId, st); - }, - isModuleEnabled, - }); - } -} - -// ============ 指令块增强 ============ - -function parseDirectiveParams(raw) { - const result = { speaker: '', emotion: '', context: '' }; - if (!raw) return result; - - const parts = String(raw).split(';').map(s => s.trim()).filter(Boolean); - for (const part of parts) { - const idx = part.indexOf('='); - if (idx === -1) continue; - const key = part.slice(0, idx).trim().toLowerCase(); - let val = part.slice(idx + 1).trim(); - if ((val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'"))) { - val = val.slice(1, -1); - } - if (key === 'speaker') result.speaker = val; - if (key === 'emotion') result.emotion = val; - if (key === 'context') result.context = val; - } - return result; -} - -function buildTtsTagHtml(parsed, rawParams) { - const parts = []; - if (parsed.speaker) parts.push(parsed.speaker); - if (parsed.emotion) parts.push(parsed.emotion); - if (parsed.context) { - const ctx = parsed.context.length > 10 - ? parsed.context.slice(0, 10) + '…' - : parsed.context; - parts.push(`"${ctx}"`); - } - - const hasParams = parts.length > 0; - const title = rawParams ? escapeHtml(rawParams.replace(/;/g, '; ')) : ''; - - let html = ``; - html += ``; - - if (hasParams) { - const textParts = parts.map(p => `${escapeHtml(p)}`); - html += textParts.join(' · '); - } - - html += ``; - return html; -} - -function enhanceTtsDirectives(container) { - if (!container) return; - - // Rewrites already-rendered message HTML; no new HTML source is introduced here. - // eslint-disable-next-line no-unsanitized/property - const html = container.innerHTML; - TTS_DIRECTIVE_REGEX.lastIndex = 0; - if (!TTS_DIRECTIVE_REGEX.test(html)) return; - - TTS_DIRECTIVE_REGEX.lastIndex = 0; - const enhanced = html.replace(TTS_DIRECTIVE_REGEX, (match, params) => { - const parsed = parseDirectiveParams(params); - return buildTtsTagHtml(parsed, params); - }); - - if (enhanced !== html) { - // Replaces existing message HTML with enhanced tokens only. - // eslint-disable-next-line no-unsanitized/property - container.innerHTML = enhanced; - } -} - -function enhanceAllTtsDirectives() { - if (!isModuleEnabled()) return; - document.querySelectorAll('#chat .mes .mes_text').forEach(mesText => { - observeDirective(mesText); - }); -} - - -function handleDirectiveEnhance(data) { - if (!isModuleEnabled()) return; - setTimeout(() => { - if (!isModuleEnabled()) return; - const messageId = typeof data === 'object' - ? (data.messageId ?? data.id ?? data.index ?? data.mesId) - : data; - if (!Number.isFinite(messageId)) { - enhanceAllTtsDirectives(); - return; - } - const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`); - if (mesText) { - processedDirectives.delete(mesText); - observeDirective(mesText); - } - }, 100); -} - -function onGenerationEnd() { - if (!isModuleEnabled()) return; - setTimeout(enhanceAllTtsDirectives, 150); -} - -// ============ 消息渲染处理 ============ - -function renderExistingMessageUIs() { - if (!isModuleEnabled()) return; - - const context = getContext(); - const chat = context.chat || []; - chat.forEach((message, messageId) => { - if (!message || message.is_user) return; - const messageEl = getMessageElement(messageId); - if (!messageEl) return; - - ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); - - const mesText = messageEl.querySelector('.mes_text'); - if (mesText) observeDirective(mesText); - - const state = ensureMessageState(messageId); - state.text = ''; - state.textLength = 0; - updateTtsPanel(messageId, state); - }); -} - -async function onCharacterMessageRendered(data) { - if (!isModuleEnabled()) return; - - try { - const context = getContext(); - const chat = context.chat; - const messageId = data.messageId ?? (chat.length - 1); - const message = chat[messageId]; - - if (!message || message.is_user) return; - - const messageEl = getMessageElement(messageId); - if (!messageEl) return; - - ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); - - const mesText = messageEl.querySelector('.mes_text'); - if (mesText) { - enhanceTtsDirectives(mesText); - processedDirectives.add(mesText); - } - - updateTtsPanel(messageId, ensureMessageState(messageId)); - - if (!config?.autoSpeak) return; - if (!isModuleEnabled()) return; - await speakMessage(messageId, { mode: 'auto' }); - } catch {} -} - -function onChatChanged() { - clearAllFreeQueues(); - if (player) player.clear(); - messageStateMap.clear(); - removeAllTtsPanels(); - - setTimeout(() => { - renderExistingMessageUIs(); - }, 100); -} - -// ============ 配置管理 ============ - -async function loadConfig() { - config = await TtsStorage.load(); - config.volc = config.volc || {}; - - if (Array.isArray(config.volc.mySpeakers)) { - config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({ - ...s, - source: s.source || getVoiceSource(s.value) - })); - } - - config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false; - config.volc.disableEmojiFilter = config.volc.disableEmojiFilter === true; - config.volc.enableLanguageDetector = config.volc.enableLanguageDetector === true; - config.volc.explicitLanguage = typeof config.volc.explicitLanguage === 'string' ? config.volc.explicitLanguage : ''; - config.volc.speechRate = normalizeSpeed(Number.isFinite(config.volc.speechRate) ? config.volc.speechRate : 1.0); - config.volc.maxLengthToFilterParenthesis = Number.isFinite(config.volc.maxLengthToFilterParenthesis) ? config.volc.maxLengthToFilterParenthesis : 100; - config.volc.postProcessPitch = Number.isFinite(config.volc.postProcessPitch) ? config.volc.postProcessPitch : 0; - config.volc.emotionScale = Math.min(5, Math.max(1, Number.isFinite(config.volc.emotionScale) ? config.volc.emotionScale : 5)); - config.volc.serverCacheEnabled = config.volc.serverCacheEnabled === true; - config.volc.localCacheEnabled = true; - config.volc.cacheDays = Math.max(1, Number.isFinite(config.volc.cacheDays) ? config.volc.cacheDays : 7); - config.volc.cacheMaxEntries = Math.max(10, Number.isFinite(config.volc.cacheMaxEntries) ? config.volc.cacheMaxEntries : 200); - config.volc.cacheMaxMB = Math.max(10, Number.isFinite(config.volc.cacheMaxMB) ? config.volc.cacheMaxMB : 200); - config.volc.usageReturn = config.volc.usageReturn === true; - config.autoSpeak = config.autoSpeak !== false; - config.skipTags = config.skipTags || [...DEFAULT_SKIP_TAGS]; - config.skipCodeBlocks = config.skipCodeBlocks !== false; - config.skipRanges = Array.isArray(config.skipRanges) ? config.skipRanges : []; - config.readRanges = Array.isArray(config.readRanges) ? config.readRanges : []; - config.readRangesEnabled = config.readRangesEnabled === true; - - return config; -} - -async function saveConfig(updates) { - Object.assign(config, updates); - await TtsStorage.set('volc', config.volc); - await TtsStorage.set('autoSpeak', config.autoSpeak); - await TtsStorage.set('skipRanges', config.skipRanges || []); - await TtsStorage.set('readRanges', config.readRanges || []); - await TtsStorage.set('readRangesEnabled', config.readRangesEnabled === true); - await TtsStorage.set('skipTags', config.skipTags); - await TtsStorage.set('skipCodeBlocks', config.skipCodeBlocks); - - try { - return await TtsStorage.saveNow({ silent: false }); - } catch { - return false; - } -} - -// ============ 设置面板 ============ - -function openSettings() { - if (document.getElementById(OVERLAY_ID)) return; - - overlay = document.createElement('div'); - overlay.id = OVERLAY_ID; - - // 使用动态高度而非100vh - const updateOverlayHeight = () => { - if (overlay && overlay.style.display !== 'none') { - overlay.style.height = `${window.innerHeight}px`; - } - }; - - overlay.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: ${window.innerHeight}px; - background: rgba(0,0,0,0.5); - z-index: 99999; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - `; - - const iframe = document.createElement('iframe'); - iframe.src = HTML_PATH; - iframe.style.cssText = ` - width: min(1300px, 96vw); - height: min(1050px, 94vh); - max-height: calc(100% - 24px); - border: none; - border-radius: 12px; - background: #1a1a1a; - `; - - overlay.appendChild(iframe); - document.body.appendChild(overlay); - - // 监听视口变化 - window.addEventListener('resize', updateOverlayHeight); - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', updateOverlayHeight); - } - - // 存储清理函数 - overlay._cleanup = () => { - window.removeEventListener('resize', updateOverlayHeight); - if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', updateOverlayHeight); - } - }; - - // Guarded by isTrustedIframeEvent (origin + source). - // eslint-disable-next-line no-restricted-syntax - window.addEventListener('message', handleIframeMessage); -} - -function closeSettings() { - window.removeEventListener('message', handleIframeMessage); - const overlayEl = document.getElementById(OVERLAY_ID); - if (overlayEl) { - overlayEl._cleanup?.(); - overlayEl.remove(); - } - overlay = null; -} - -async function handleIframeMessage(ev) { - const iframe = overlay?.querySelector('iframe'); - if (!isTrustedIframeEvent(ev, iframe)) return; - if (!ev.data?.type?.startsWith('xb-tts:')) return; - - const { type, payload } = ev.data; - - switch (type) { - case 'xb-tts:ready': { - const cacheStats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:config', payload: { ...config, cacheStats } }); - break; - } - case 'xb-tts:close': - closeSettings(); - break; - case 'xb-tts:save-config': { - const ok = await saveConfig(payload); - if (ok) { - const cacheStats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } }); - updateAutoSpeakAll(); - updateSpeedAll(); - updateVoiceAll(); - } else { - postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); - } - break; - } - case 'xb-tts:toast': - if (payload.type === 'error') toastr.error(payload.message); - else if (payload.type === 'success') toastr.success(payload.message); - else toastr.info(payload.message); - break; - case 'xb-tts:test-speak': - await handleTestSpeak(payload, iframe); - break; - case 'xb-tts:clear-queue': - player.clear(); - break; - case 'xb-tts:cache-refresh': { - const stats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); - break; - } - case 'xb-tts:cache-clear-expired': { - const removed = await clearExpiredCache(config.volc.cacheDays || 7); - const stats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); - postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: `已清理 ${removed} 条` } }); - break; - } - case 'xb-tts:cache-clear-all': { - await clearAllCache(); - const stats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); - postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: '已清空全部' } }); - break; - } - } -} - -async function handleTestSpeak(payload, iframe) { - try { - const { text, speaker, source, resourceId } = payload; - const testText = text || '你好,这是一段测试语音。'; - - if (source === 'free') { - const { synthesizeFreeV1 } = await import('./tts-api.js'); - const { audioBase64 } = await synthesizeFreeV1({ - text: testText, - voiceKey: speaker || FREE_DEFAULT_VOICE, - speed: normalizeSpeed(config.volc?.speechRate), - }); - - const byteString = atob(audioBase64); - const bytes = new Uint8Array(byteString.length); - for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j); - const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); - - player.enqueue({ id: 'test-' + Date.now(), audioBlob }); - postToIframe(iframe, { type: 'xb-tts:test-done' }); - } else { - if (!isAuthConfigured()) { - postToIframe(iframe, { - type: 'xb-tts:test-error', - payload: '请先配置 AppID 和 Access Token' - }); - return; - } - - const rid = resourceId || inferResourceIdBySpeaker(speaker); - const result = await synthesizeV3({ - appId: config.volc.appId, - accessKey: config.volc.accessKey, - resourceId: rid, - speaker: speaker || config.volc.defaultSpeaker, - text: testText, - speechRate: speedToV3SpeechRate(config.volc.speechRate), - emotionScale: config.volc.emotionScale, - }, buildV3Headers(rid, config)); - - player.enqueue({ id: 'test-' + Date.now(), audioBlob: result.audioBlob }); - postToIframe(iframe, { type: 'xb-tts:test-done' }); - } - } catch (err) { - postToIframe(iframe, { - type: 'xb-tts:test-error', - payload: err.message - }); - } -} - -// ============ 初始化与清理 ============ - -export async function initTts() { - if (moduleInitialized) return; - - await loadConfig(); - player = new TtsPlayer(); - initTtsPanelStyles(); - moduleInitialized = true; - - setPanelConfigHandlers({ - getConfig: () => config, - saveConfig: saveConfig, - openSettings: openSettings, - clearQueue: (messageId) => { - clearMessageFromQueue(messageId); - clearFreeQueueForMessage(messageId); - - const state = ensureMessageState(messageId); - state.status = 'idle'; - state.currentSegment = 0; - state.totalSegments = 0; - state.error = ''; - updateTtsPanel(messageId, state); - }, - }); - - player.onStateChange = (state, item, info) => { - if (!isModuleEnabled()) return; - const messageId = item?.messageId; - if (typeof messageId !== 'number' || messageId < 0) return; - const msgState = ensureMessageState(messageId); - - switch (state) { - case 'metadata': - msgState.duration = info?.duration || msgState.duration || 0; - break; - - case 'progress': - msgState.progress = info?.currentTime || 0; - msgState.duration = info?.duration || msgState.duration || 0; - break; - - case 'playing': - msgState.status = 'playing'; - if (typeof item?.segmentIndex === 'number') { - msgState.currentSegment = item.segmentIndex + 1; - } - break; - - case 'paused': - msgState.status = 'paused'; - break; - - case 'ended': { - // 检查是否是最后一个段落 - const segIdx = typeof item?.segmentIndex === 'number' ? item.segmentIndex : -1; - const total = msgState.totalSegments || 1; - - // 判断是否为最后一个段落 - // segIdx 是 0-based,total 是总数 - // 如果 segIdx >= total - 1,说明是最后一个 - const isLastSegment = total <= 1 || segIdx >= total - 1; - - if (isLastSegment) { - // 真正播放完成 - msgState.status = 'ended'; - msgState.progress = msgState.duration; - } else { - // 还有后续段落 - // 检查队列中是否有该消息的待播放项 - const prefix = `msg-${messageId}-`; - const hasQueued = player.queue.some(q => q.id?.startsWith(prefix)); - - if (hasQueued) { - // 后续段落已在队列中,等待播放 - msgState.status = 'queued'; - } else { - // 后续段落还在请求中 - msgState.status = 'sending'; - } - } - break; - } - - case 'blocked': - msgState.status = 'blocked'; - break; - - case 'error': - msgState.status = 'error'; - break; - - case 'enqueued': - // 只在非播放/暂停状态时更新 - if (msgState.status !== 'playing' && msgState.status !== 'paused') { - msgState.status = 'queued'; - } - break; - - case 'idle': - case 'cleared': - // 播放器空闲,但可能还有段落在请求 - // 不主动改变状态,让请求完成后的逻辑处理 - break; - } - updateTtsPanel(messageId, msgState); - }; - - events.on(event_types.CHARACTER_MESSAGE_RENDERED, onCharacterMessageRendered); - events.on(event_types.CHAT_CHANGED, onChatChanged); - events.on(event_types.MESSAGE_EDITED, handleDirectiveEnhance); - events.on(event_types.MESSAGE_UPDATED, handleDirectiveEnhance); - events.on(event_types.MESSAGE_SWIPED, handleDirectiveEnhance); - events.on(event_types.GENERATION_STOPPED, onGenerationEnd); - events.on(event_types.GENERATION_ENDED, onGenerationEnd); - - renderExistingMessageUIs(); - setupNovelDrawObserver(); - - window.registerModuleCleanup?.('tts', cleanupTts); - - window.xiaobaixTts = { - openSettings, - closeSettings, - player, - speak: async (text, options = {}) => { - if (!isModuleEnabled()) return; - - const mySpeakers = config.volc?.mySpeakers || []; - const resolved = options.speaker - ? resolveSpeakerWithSource(options.speaker, mySpeakers, config.volc.defaultSpeaker) - : { value: config.volc.defaultSpeaker, source: getVoiceSource(config.volc.defaultSpeaker) }; - - if (resolved.source === 'free') { - const { synthesizeFreeV1 } = await import('./tts-api.js'); - const { audioBase64 } = await synthesizeFreeV1({ - text, - voiceKey: resolved.value, - speed: normalizeSpeed(config.volc?.speechRate), - emotion: options.emotion || null, - }); - - const byteString = atob(audioBase64); - const bytes = new Uint8Array(byteString.length); - for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j); - const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); - - player.enqueue({ id: 'manual-' + Date.now(), audioBlob, text }); - } else { - if (!isAuthConfigured()) { - toastr?.error?.('请先配置鉴权 API'); - return; - } - - const resourceId = options.resourceId || inferResourceIdBySpeaker(resolved.value); - const result = await synthesizeV3({ - appId: config.volc.appId, - accessKey: config.volc.accessKey, - resourceId, - speaker: resolved.value, - text, - speechRate: speedToV3SpeechRate(config.volc.speechRate), - ...options, - }, buildV3Headers(resourceId, config)); - - player.enqueue({ id: 'manual-' + Date.now(), audioBlob: result.audioBlob, text }); - } - }, - }; -} - -export function cleanupTts() { - moduleInitialized = false; - - events.cleanup(); - clearAllFreeQueues(); - cleanupNovelDrawObserver(); - cleanupDirectiveObserver(); - if (player) { - player.clear(); - player.onStateChange = null; - player = null; - } - - closeSettings(); - removeAllTtsPanels(); - - try { - import('./tts-panel.js').then(m => m.clearPanelConfigHandlers?.()); - } catch {} - - messageStateMap.clear(); - cacheCounters.hits = 0; - cacheCounters.misses = 0; - delete window.xiaobaixTts; -} +// ============ 导入 ============ + +import { event_types } from "../../../../../../script.js"; +import { extension_settings, getContext } from "../../../../../extensions.js"; +import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; +import { createModuleEvents } from "../../core/event-manager.js"; +import { TtsStorage } from "../../core/server-storage.js"; +import { extractSpeakText, parseTtsSegments, DEFAULT_SKIP_TAGS, normalizeEmotion, splitTtsSegmentsForFree } from "./tts-text.js"; +import { TtsPlayer } from "./tts-player.js"; +import { synthesizeV3, FREE_DEFAULT_VOICE } from "./tts-api.js"; +import { + ensureTtsPanel, + updateTtsPanel, + removeAllTtsPanels, + initTtsPanelStyles, + setPanelConfigHandlers, + clearPanelConfigHandlers, + updateAutoSpeakAll, + updateSpeedAll, + updateVoiceAll, + initFloatingPanel, + destroyFloatingPanel, + resetFloatingState, + updateButtonVisibility, +} from "./tts-panel.js"; +import { getCacheEntry, setCacheEntry, getCacheStats, clearExpiredCache, clearAllCache, pruneCache } from './tts-cache.js'; +import { speakMessageFree, clearAllFreeQueues, clearFreeQueueForMessage } from './tts-free-provider.js'; +import { + speakMessageAuth, + speakSegmentAuth, + inferResourceIdBySpeaker, + buildV3Headers, + speedToV3SpeechRate +} from './tts-auth-provider.js'; +import { postToIframe, isTrustedIframeEvent } from "../../core/iframe-messaging.js"; + +// ============ 常量 ============ + +const MODULE_ID = 'tts'; +const OVERLAY_ID = 'xiaobaix-tts-overlay'; +const HTML_PATH = `${extensionFolderPath}/modules/tts/tts-overlay.html`; +const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi; + +const FREE_VOICE_KEYS = new Set([ + 'female_1', 'female_2', 'female_3', 'female_4', 'female_5', 'female_6', 'female_7', + 'male_1', 'male_2', 'male_3', 'male_4' +]); + +// ============ NovelDraw 兼容 ============ + +let ndImageObserver = null; +let ndRenderPending = new Set(); +let ndRenderTimer = null; + +function scheduleNdRerender(mesText) { + ndRenderPending.add(mesText); + if (ndRenderTimer) return; + + ndRenderTimer = setTimeout(() => { + ndRenderTimer = null; + const pending = Array.from(ndRenderPending); + ndRenderPending.clear(); + + if (!isModuleEnabled()) return; + + for (const el of pending) { + if (!el.isConnected) continue; + TTS_DIRECTIVE_REGEX.lastIndex = 0; + // Tests existing message HTML only. + // eslint-disable-next-line no-unsanitized/property + if (TTS_DIRECTIVE_REGEX.test(el.innerHTML)) { + enhanceTtsDirectives(el); + } + } + }, 50); +} + +function setupNovelDrawObserver() { + if (ndImageObserver) return; + + const chatEl = document.getElementById('chat'); + if (!chatEl) return; + + ndImageObserver = new MutationObserver((mutations) => { + if (!isModuleEnabled()) return; + + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + + const isNdImg = node.classList?.contains('xb-nd-img'); + const hasNdImg = isNdImg || node.querySelector?.('.xb-nd-img'); + if (!hasNdImg) continue; + + const mesText = node.closest('.mes_text'); + if (mesText) { + scheduleNdRerender(mesText); + } + } + } + }); + + ndImageObserver.observe(chatEl, { childList: true, subtree: true }); +} + +function cleanupNovelDrawObserver() { + ndImageObserver?.disconnect(); + ndImageObserver = null; + if (ndRenderTimer) { + clearTimeout(ndRenderTimer); + ndRenderTimer = null; + } + ndRenderPending.clear(); +} + +// ============ 状态 ============ + +let player = null; +let moduleInitialized = false; +let overlay = null; +let config = null; +const messageStateMap = new Map(); +const cacheCounters = { hits: 0, misses: 0 }; + +const events = createModuleEvents(MODULE_ID); + +// ============ 指令块懒加载 ============ + +let directiveObserver = null; +const processedDirectives = new WeakSet(); + +function setupDirectiveObserver() { + if (directiveObserver) return; + + directiveObserver = new IntersectionObserver((entries) => { + if (!isModuleEnabled()) return; + + for (const entry of entries) { + if (!entry.isIntersecting) continue; + + const mesText = entry.target; + if (processedDirectives.has(mesText)) { + directiveObserver.unobserve(mesText); + continue; + } + + TTS_DIRECTIVE_REGEX.lastIndex = 0; + // Tests existing message HTML only. + // eslint-disable-next-line no-unsanitized/property + if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) { + enhanceTtsDirectives(mesText); + } + processedDirectives.add(mesText); + directiveObserver.unobserve(mesText); + } + }, { rootMargin: '300px' }); +} + +function observeDirective(mesText) { + if (!mesText || processedDirectives.has(mesText)) return; + + setupDirectiveObserver(); + + // 已在视口附近,立即处理 + const rect = mesText.getBoundingClientRect(); + if (rect.top < window.innerHeight + 300 && rect.bottom > -300) { + TTS_DIRECTIVE_REGEX.lastIndex = 0; + // Tests existing message HTML only. + // eslint-disable-next-line no-unsanitized/property + if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) { + enhanceTtsDirectives(mesText); + } + processedDirectives.add(mesText); + return; + } + + // 不在视口,加入观察队列 + directiveObserver.observe(mesText); +} + +function cleanupDirectiveObserver() { + directiveObserver?.disconnect(); + directiveObserver = null; +} + +// ============ 模块状态检查 ============ + +function isModuleEnabled() { + if (!moduleInitialized) return false; + try { + const settings = extension_settings[EXT_ID]; + if (!settings?.enabled) return false; + if (!settings?.tts?.enabled) return false; + return true; + } catch { + return false; + } +} + +// ============ 工具函数 ============ + +function hashString(input) { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); + } + return (hash >>> 0).toString(16); +} + +function generateBatchId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeSpeed(value) { + const num = Number.isFinite(value) ? value : 1.0; + if (num >= 0.5 && num <= 2.0) return num; + return Math.min(2.0, Math.max(0.5, 1 + num / 100)); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ============ 音色来源判断 ============ + +function getVoiceSource(value) { + if (!value) return 'free'; + if (FREE_VOICE_KEYS.has(value)) return 'free'; + return 'auth'; +} + +function isAuthConfigured() { + return !!(config?.volc?.appId && config?.volc?.accessKey); +} + +function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) { + const list = Array.isArray(mySpeakers) ? mySpeakers : []; + + // ★ 调试日志 + if (speakerName) { + console.log('[TTS Debug] resolveSpeaker:', { + 查找的名称: speakerName, + mySpeakers: list.map(s => ({ name: s.name, value: s.value, source: s.source })), + 默认音色: defaultSpeaker + }); + } + + if (!speakerName) { + const defaultItem = list.find(s => s.value === defaultSpeaker); + return { + value: defaultSpeaker, + source: defaultItem?.source || getVoiceSource(defaultSpeaker) + }; + } + + const byName = list.find(s => s.name === speakerName); + console.log('[TTS Debug] byName 查找结果:', byName); // ★ 调试 + + if (byName?.value) { + return { + value: byName.value, + source: byName.source || getVoiceSource(byName.value) + }; + } + + const byValue = list.find(s => s.value === speakerName); + console.log('[TTS Debug] byValue 查找结果:', byValue); // ★ 调试 + + if (byValue?.value) { + return { + value: byValue.value, + source: byValue.source || getVoiceSource(byValue.value) + }; + } + + if (FREE_VOICE_KEYS.has(speakerName)) { + return { value: speakerName, source: 'free' }; + } + + // ★ 回退到默认,这是问题发生的地方 + console.warn('[TTS Debug] 未找到匹配音色,回退到默认:', defaultSpeaker); + + const defaultItem = list.find(s => s.value === defaultSpeaker); + return { + value: defaultSpeaker, + source: defaultItem?.source || getVoiceSource(defaultSpeaker) + }; +} + +// ============ 缓存管理 ============ + +function buildCacheKey(params) { + const payload = { + providerMode: params.providerMode || 'auth', + text: params.text || '', + speaker: params.speaker || '', + resourceId: params.resourceId || '', + format: params.format || 'mp3', + sampleRate: params.sampleRate || 24000, + speechRate: params.speechRate || 0, + loudnessRate: params.loudnessRate || 0, + emotion: params.emotion || '', + emotionScale: params.emotionScale || 0, + explicitLanguage: params.explicitLanguage || '', + disableMarkdownFilter: params.disableMarkdownFilter !== false, + disableEmojiFilter: params.disableEmojiFilter === true, + enableLanguageDetector: params.enableLanguageDetector === true, + model: params.model || '', + maxLengthToFilterParenthesis: params.maxLengthToFilterParenthesis ?? null, + postProcessPitch: params.postProcessPitch ?? 0, + contextTexts: Array.isArray(params.contextTexts) ? params.contextTexts : [], + freeSpeed: params.freeSpeed ?? null, + }; + return `tts:${hashString(JSON.stringify(payload))}`; +} + +async function getCacheStatsSafe() { + try { + const stats = await getCacheStats(); + return { ...stats, hits: cacheCounters.hits, misses: cacheCounters.misses }; + } catch { + return { count: 0, sizeMB: '0', totalBytes: 0, hits: cacheCounters.hits, misses: cacheCounters.misses }; + } +} + +async function tryLoadLocalCache(params) { + if (!config.volc.localCacheEnabled) return null; + const key = buildCacheKey(params); + try { + const entry = await getCacheEntry(key); + if (entry?.blob) { + const cutoff = Date.now() - (config.volc.cacheDays || 7) * 24 * 60 * 60 * 1000; + if (entry.createdAt && entry.createdAt < cutoff) { + await clearExpiredCache(config.volc.cacheDays || 7); + cacheCounters.misses += 1; + return null; + } + cacheCounters.hits += 1; + return { key, entry }; + } + cacheCounters.misses += 1; + return null; + } catch { + cacheCounters.misses += 1; + return null; + } +} + +async function storeLocalCache(key, blob, meta) { + if (!config.volc.localCacheEnabled) return; + try { + await setCacheEntry(key, blob, meta); + await pruneCache({ + maxEntries: config.volc.cacheMaxEntries, + maxBytes: config.volc.cacheMaxMB * 1024 * 1024, + }); + } catch {} +} + +// ============ 消息状态管理 ============ + +function ensureMessageState(messageId) { + if (!messageStateMap.has(messageId)) { + messageStateMap.set(messageId, { + messageId, + status: 'idle', + text: '', + textLength: 0, + cached: false, + usage: null, + duration: 0, + progress: 0, + error: '', + audioBlob: null, + cacheKey: '', + updatedAt: 0, + currentSegment: 0, + totalSegments: 0, + }); + } + return messageStateMap.get(messageId); +} + +function getMessageElement(messageId) { + return document.querySelector(`.mes[mesid="${messageId}"]`); +} + +function getMessageData(messageId) { + const context = getContext(); + return (context.chat || [])[messageId] || null; +} + +function getSpeakTextFromMessage(message) { + if (!message || typeof message.mes !== 'string') return ''; + return extractSpeakText(message.mes, { + skipRanges: config.skipRanges, + readRanges: config.readRanges, + readRangesEnabled: config.readRangesEnabled, + }); +} + +// ============ 队列管理 ============ + +function clearMessageFromQueue(messageId) { + clearFreeQueueForMessage(messageId); + if (!player) return; + const prefix = `msg-${messageId}-`; + player.queue = player.queue.filter(item => !item.id?.startsWith(prefix)); + if (player.currentItem?.messageId === messageId) { + player._stopCurrent(true); + player.currentItem = null; + player.isPlaying = false; + player._playNext(); + } +} + +// ============ 状态保护更新器 ============ + +function createProtectedStateUpdater(messageId) { + return (updates) => { + const st = ensureMessageState(messageId); + + // 如果播放器正在播放/暂停,保护这个状态不被队列状态覆盖 + const isPlayerActive = st.status === 'playing' || st.status === 'paused'; + const isQueueStatus = updates.status === 'sending' || + updates.status === 'queued' || + updates.status === 'cached'; + + if (isPlayerActive && isQueueStatus) { + // 只更新进度相关字段,不覆盖播放状态 + const rest = { ...updates }; + delete rest.status; + Object.assign(st, rest); + } else { + Object.assign(st, updates); + } + + updateTtsPanel(messageId, st); + }; +} + +// ============ 混合模式辅助 ============ + +function expandMixedSegments(resolvedSegments) { + const expanded = []; + + for (const seg of resolvedSegments) { + if (seg.resolvedSource === 'free' && seg.text && seg.text.length > 200) { + const splitSegs = splitTtsSegmentsForFree([{ + text: seg.text, + emotion: seg.emotion || '', + context: seg.context || '', + speaker: seg.speaker || '', + }]); + + for (const splitSeg of splitSegs) { + expanded.push({ + ...splitSeg, + resolvedSpeaker: seg.resolvedSpeaker, + resolvedSource: 'free', + }); + } + } else { + expanded.push(seg); + } + } + + return expanded; +} + +async function speakSingleFreeSegment(messageId, segment, segmentIndex, batchId) { + const state = ensureMessageState(messageId); + + state.status = 'sending'; + state.currentSegment = segmentIndex + 1; + state.text = segment.text; + state.textLength = segment.text.length; + state.updatedAt = Date.now(); + updateTtsPanel(messageId, state); + + const freeSpeed = normalizeSpeed(config?.volc?.speechRate); + const voiceKey = segment.resolvedSpeaker || FREE_DEFAULT_VOICE; + const emotion = normalizeEmotion(segment.emotion); + + const cacheParams = { + providerMode: 'free', + text: segment.text, + speaker: voiceKey, + freeSpeed, + emotion: emotion || '', + }; + + const cacheHit = await tryLoadLocalCache(cacheParams); + if (cacheHit?.entry?.blob) { + state.cached = true; + state.status = 'cached'; + updateTtsPanel(messageId, state); + player.enqueue({ + id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`, + messageId, + segmentIndex, + batchId, + audioBlob: cacheHit.entry.blob, + text: segment.text, + }); + return; + } + + try { + const { synthesizeFreeV1 } = await import('./tts-api.js'); + const { audioBase64 } = await synthesizeFreeV1({ + text: segment.text, + voiceKey, + speed: freeSpeed, + emotion: emotion || null, + }); + + const byteString = atob(audioBase64); + const bytes = new Uint8Array(byteString.length); + for (let j = 0; j < byteString.length; j++) { + bytes[j] = byteString.charCodeAt(j); + } + const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); + + const cacheKey = buildCacheKey(cacheParams); + storeLocalCache(cacheKey, audioBlob, { + text: segment.text.slice(0, 200), + textLength: segment.text.length, + speaker: voiceKey, + resourceId: 'free', + }).catch(() => {}); + + state.status = 'queued'; + updateTtsPanel(messageId, state); + + player.enqueue({ + id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`, + messageId, + segmentIndex, + batchId, + audioBlob, + text: segment.text, + }); + } catch (err) { + state.status = 'error'; + state.error = err?.message || '合成失败'; + updateTtsPanel(messageId, state); + } +} + +// ============ 主播放入口 ============ + +async function handleMessagePlayClick(messageId) { + if (!isModuleEnabled()) return; + + const state = ensureMessageState(messageId); + + if (state.status === 'sending' || state.status === 'queued') { + clearMessageFromQueue(messageId); + state.status = 'idle'; + state.currentSegment = 0; + state.totalSegments = 0; + updateTtsPanel(messageId, state); + return; + } + + if (player?.currentItem?.messageId === messageId && player?.currentAudio) { + if (player.currentAudio.paused) { + player.currentAudio.play().catch(() => {}); + } else { + player.currentAudio.pause(); + } + return; + } + await speakMessage(messageId, { mode: 'manual' }); +} + +async function speakMessage(messageId, { mode = 'manual' } = {}) { + if (!isModuleEnabled()) return; + + const message = getMessageData(messageId); + if (!message || message.is_user) return; + + const messageEl = getMessageElement(messageId); + if (!messageEl) return; + + ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); + + const speakText = getSpeakTextFromMessage(message); + if (!speakText.trim()) { + const state = ensureMessageState(messageId); + state.status = 'idle'; + state.text = ''; + state.currentSegment = 0; + state.totalSegments = 0; + updateTtsPanel(messageId, state); + return; + } + + const mySpeakers = config.volc?.mySpeakers || []; + const defaultSpeaker = config.volc.defaultSpeaker || FREE_DEFAULT_VOICE; + const defaultResolved = resolveSpeakerWithSource('', mySpeakers, defaultSpeaker); + + let segments = parseTtsSegments(speakText); + if (!segments.length) { + const state = ensureMessageState(messageId); + state.status = 'idle'; + state.currentSegment = 0; + state.totalSegments = 0; + updateTtsPanel(messageId, state); + return; + } + + const resolvedSegments = segments.map(seg => { + const resolved = seg.speaker + ? resolveSpeakerWithSource(seg.speaker, mySpeakers, defaultSpeaker) + : defaultResolved; + return { + ...seg, + resolvedSpeaker: resolved.value, + resolvedSource: resolved.source + }; + }); + + const needsAuth = resolvedSegments.some(s => s.resolvedSource === 'auth'); + if (needsAuth && !isAuthConfigured()) { + toastr?.warning?.('部分音色需要配置鉴权 API,将仅播放免费音色'); + const freeOnly = resolvedSegments.filter(s => s.resolvedSource === 'free'); + if (!freeOnly.length) { + const state = ensureMessageState(messageId); + state.status = 'error'; + state.error = '所有音色均需要鉴权'; + updateTtsPanel(messageId, state); + return; + } + resolvedSegments.length = 0; + resolvedSegments.push(...freeOnly); + } + + const batchId = generateBatchId(); + if (mode === 'manual') clearMessageFromQueue(messageId); + + const hasFree = resolvedSegments.some(s => s.resolvedSource === 'free'); + const hasAuth = resolvedSegments.some(s => s.resolvedSource === 'auth'); + const isMixed = hasFree && hasAuth; + + const state = ensureMessageState(messageId); + + if (isMixed) { + const expandedSegments = expandMixedSegments(resolvedSegments); + + state.totalSegments = expandedSegments.length; + state.currentSegment = 0; + state.status = 'sending'; + updateTtsPanel(messageId, state); + + const ctx = { + config, + player, + tryLoadLocalCache, + storeLocalCache, + buildCacheKey, + updateState: (updates) => { + Object.assign(state, updates); + updateTtsPanel(messageId, state); + }, + }; + + for (let i = 0; i < expandedSegments.length; i++) { + if (!isModuleEnabled()) return; + + const seg = expandedSegments[i]; + state.currentSegment = i + 1; + updateTtsPanel(messageId, state); + + if (seg.resolvedSource === 'free') { + await speakSingleFreeSegment(messageId, seg, i, batchId); + } else { + await speakSegmentAuth(messageId, seg, i, batchId, { + isFirst: i === 0, + ...ctx + }); + } + } + return; + } + + state.totalSegments = resolvedSegments.length; + state.currentSegment = 0; + state.status = 'sending'; + updateTtsPanel(messageId, state); + + if (hasFree) { + await speakMessageFree({ + messageId, + segments: resolvedSegments, + defaultSpeaker, + mySpeakers, + player, + config, + tryLoadLocalCache, + storeLocalCache, + buildCacheKey, + updateState: createProtectedStateUpdater(messageId), + clearMessageFromQueue, + mode, + }); + return; + } + + if (hasAuth) { + await speakMessageAuth({ + messageId, + segments: resolvedSegments, + batchId, + config, + player, + tryLoadLocalCache, + storeLocalCache, + buildCacheKey, + updateState: (updates) => { + const st = ensureMessageState(messageId); + Object.assign(st, updates); + updateTtsPanel(messageId, st); + }, + isModuleEnabled, + }); + } +} + +// ============ 指令块增强 ============ + +function parseDirectiveParams(raw) { + const result = { speaker: '', emotion: '', context: '' }; + if (!raw) return result; + + const parts = String(raw).split(';').map(s => s.trim()).filter(Boolean); + for (const part of parts) { + const idx = part.indexOf('='); + if (idx === -1) continue; + const key = part.slice(0, idx).trim().toLowerCase(); + let val = part.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (key === 'speaker') result.speaker = val; + if (key === 'emotion') result.emotion = val; + if (key === 'context') result.context = val; + } + return result; +} + +function buildTtsTagHtml(parsed, rawParams) { + const parts = []; + if (parsed.speaker) parts.push(parsed.speaker); + if (parsed.emotion) parts.push(parsed.emotion); + if (parsed.context) { + const ctx = parsed.context.length > 10 + ? parsed.context.slice(0, 10) + '…' + : parsed.context; + parts.push(`"${ctx}"`); + } + + const hasParams = parts.length > 0; + const title = rawParams ? escapeHtml(rawParams.replace(/;/g, '; ')) : ''; + + let html = ``; + html += ``; + + if (hasParams) { + const textParts = parts.map(p => `${escapeHtml(p)}`); + html += textParts.join(' · '); + } + + html += ``; + return html; +} + +function enhanceTtsDirectives(container) { + if (!container) return; + + // Rewrites already-rendered message HTML; no new HTML source is introduced here. + // eslint-disable-next-line no-unsanitized/property + const html = container.innerHTML; + TTS_DIRECTIVE_REGEX.lastIndex = 0; + if (!TTS_DIRECTIVE_REGEX.test(html)) return; + + TTS_DIRECTIVE_REGEX.lastIndex = 0; + const enhanced = html.replace(TTS_DIRECTIVE_REGEX, (match, params) => { + const parsed = parseDirectiveParams(params); + return buildTtsTagHtml(parsed, params); + }); + + if (enhanced !== html) { + // Replaces existing message HTML with enhanced tokens only. + // eslint-disable-next-line no-unsanitized/property + container.innerHTML = enhanced; + } +} + +function enhanceAllTtsDirectives() { + if (!isModuleEnabled()) return; + document.querySelectorAll('#chat .mes .mes_text').forEach(mesText => { + observeDirective(mesText); + }); +} + + +function handleDirectiveEnhance(data) { + if (!isModuleEnabled()) return; + setTimeout(() => { + if (!isModuleEnabled()) return; + const messageId = typeof data === 'object' + ? (data.messageId ?? data.id ?? data.index ?? data.mesId) + : data; + if (!Number.isFinite(messageId)) { + enhanceAllTtsDirectives(); + return; + } + const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`); + if (mesText) { + processedDirectives.delete(mesText); + observeDirective(mesText); + } + }, 100); +} + +function onGenerationEnd() { + if (!isModuleEnabled()) return; + setTimeout(enhanceAllTtsDirectives, 150); +} + +// ============ 消息渲染处理 ============ + +function renderExistingMessageUIs() { + if (!isModuleEnabled()) return; + + const context = getContext(); + const chat = context.chat || []; + chat.forEach((message, messageId) => { + if (!message || message.is_user) return; + const messageEl = getMessageElement(messageId); + if (!messageEl) return; + + ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); + + const mesText = messageEl.querySelector('.mes_text'); + if (mesText) observeDirective(mesText); + + const state = ensureMessageState(messageId); + state.text = ''; + state.textLength = 0; + updateTtsPanel(messageId, state); + }); +} + +async function onCharacterMessageRendered(data) { + if (!isModuleEnabled()) return; + + try { + const context = getContext(); + const chat = context.chat; + const messageId = data.messageId ?? (chat.length - 1); + const message = chat[messageId]; + + if (!message || message.is_user) return; + + const messageEl = getMessageElement(messageId); + if (!messageEl) return; + + ensureTtsPanel(messageEl, messageId, handleMessagePlayClick); + + const mesText = messageEl.querySelector('.mes_text'); + if (mesText) { + enhanceTtsDirectives(mesText); + processedDirectives.add(mesText); + } + + updateTtsPanel(messageId, ensureMessageState(messageId)); + + if (!config?.autoSpeak) return; + if (!isModuleEnabled()) return; + await speakMessage(messageId, { mode: 'auto' }); + } catch {} +} + +function onChatChanged() { + clearAllFreeQueues(); + if (player) player.clear(); + messageStateMap.clear(); + removeAllTtsPanels(); + resetFloatingState(); + + setTimeout(() => { + renderExistingMessageUIs(); + }, 100); +} + +// ============ 配置管理 ============ + +async function loadConfig() { + config = await TtsStorage.load(); + config.volc = config.volc || {}; + + if (Array.isArray(config.volc.mySpeakers)) { + config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({ + ...s, + source: s.source || getVoiceSource(s.value) + })); + } + + config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false; + config.volc.disableEmojiFilter = config.volc.disableEmojiFilter === true; + config.volc.enableLanguageDetector = config.volc.enableLanguageDetector === true; + config.volc.explicitLanguage = typeof config.volc.explicitLanguage === 'string' ? config.volc.explicitLanguage : ''; + config.volc.speechRate = normalizeSpeed(Number.isFinite(config.volc.speechRate) ? config.volc.speechRate : 1.0); + config.volc.maxLengthToFilterParenthesis = Number.isFinite(config.volc.maxLengthToFilterParenthesis) ? config.volc.maxLengthToFilterParenthesis : 100; + config.volc.postProcessPitch = Number.isFinite(config.volc.postProcessPitch) ? config.volc.postProcessPitch : 0; + config.volc.emotionScale = Math.min(5, Math.max(1, Number.isFinite(config.volc.emotionScale) ? config.volc.emotionScale : 5)); + config.volc.serverCacheEnabled = config.volc.serverCacheEnabled === true; + config.volc.localCacheEnabled = true; + config.volc.cacheDays = Math.max(1, Number.isFinite(config.volc.cacheDays) ? config.volc.cacheDays : 7); + config.volc.cacheMaxEntries = Math.max(10, Number.isFinite(config.volc.cacheMaxEntries) ? config.volc.cacheMaxEntries : 200); + config.volc.cacheMaxMB = Math.max(10, Number.isFinite(config.volc.cacheMaxMB) ? config.volc.cacheMaxMB : 200); + config.volc.usageReturn = config.volc.usageReturn === true; + config.autoSpeak = config.autoSpeak !== false; + config.skipTags = config.skipTags || [...DEFAULT_SKIP_TAGS]; + config.skipCodeBlocks = config.skipCodeBlocks !== false; + config.skipRanges = Array.isArray(config.skipRanges) ? config.skipRanges : []; + config.readRanges = Array.isArray(config.readRanges) ? config.readRanges : []; + config.readRangesEnabled = config.readRangesEnabled === true; + config.showFloorButton = config.showFloorButton !== false; + config.showFloatingButton = config.showFloatingButton === true; + + return config; +} + +async function saveConfig(updates) { + Object.assign(config, updates); + await TtsStorage.set('volc', config.volc); + await TtsStorage.set('autoSpeak', config.autoSpeak); + await TtsStorage.set('skipRanges', config.skipRanges || []); + await TtsStorage.set('readRanges', config.readRanges || []); + await TtsStorage.set('readRangesEnabled', config.readRangesEnabled === true); + await TtsStorage.set('skipTags', config.skipTags); + await TtsStorage.set('skipCodeBlocks', config.skipCodeBlocks); + await TtsStorage.set('showFloorButton', config.showFloorButton); + await TtsStorage.set('showFloatingButton', config.showFloatingButton); + + try { + return await TtsStorage.saveNow({ silent: false }); + } catch { + return false; + } +} + +// ============ 设置面板 ============ + +function openSettings() { + if (document.getElementById(OVERLAY_ID)) return; + + overlay = document.createElement('div'); + overlay.id = OVERLAY_ID; + + // 使用动态高度而非100vh + const updateOverlayHeight = () => { + if (overlay && overlay.style.display !== 'none') { + overlay.style.height = `${window.innerHeight}px`; + } + }; + + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: ${window.innerHeight}px; + background: rgba(0,0,0,0.5); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + `; + + const iframe = document.createElement('iframe'); + iframe.src = HTML_PATH; + iframe.style.cssText = ` + width: min(1300px, 96vw); + height: min(1050px, 94vh); + max-height: calc(100% - 24px); + border: none; + border-radius: 12px; + background: #1a1a1a; + `; + + overlay.appendChild(iframe); + document.body.appendChild(overlay); + + // 监听视口变化 + window.addEventListener('resize', updateOverlayHeight); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', updateOverlayHeight); + } + + // 存储清理函数 + overlay._cleanup = () => { + window.removeEventListener('resize', updateOverlayHeight); + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', updateOverlayHeight); + } + }; + + // Guarded by isTrustedIframeEvent (origin + source). + // eslint-disable-next-line no-restricted-syntax + window.addEventListener('message', handleIframeMessage); +} + +function closeSettings() { + window.removeEventListener('message', handleIframeMessage); + const overlayEl = document.getElementById(OVERLAY_ID); + if (overlayEl) { + overlayEl._cleanup?.(); + overlayEl.remove(); + } + overlay = null; +} + +async function handleIframeMessage(ev) { + const iframe = overlay?.querySelector('iframe'); + if (!isTrustedIframeEvent(ev, iframe)) return; + if (!ev.data?.type?.startsWith('xb-tts:')) return; + + const { type, payload } = ev.data; + + switch (type) { + case 'xb-tts:ready': { + const cacheStats = await getCacheStatsSafe(); + postToIframe(iframe, { type: 'xb-tts:config', payload: { ...config, cacheStats } }); + break; + } + case 'xb-tts:close': + closeSettings(); + break; + case 'xb-tts:save-config': { + const ok = await saveConfig(payload); + if (ok) { + const cacheStats = await getCacheStatsSafe(); + postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } }); + updateAutoSpeakAll(); + updateSpeedAll(); + updateVoiceAll(); + } else { + postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); + } + break; + } + case 'xb-tts:save-button-mode': { + const { showFloorButton, showFloatingButton } = payload; + config.showFloorButton = showFloorButton; + config.showFloatingButton = showFloatingButton; + const ok = await saveConfig({ showFloorButton, showFloatingButton }); + if (ok) { + updateButtonVisibility(showFloorButton, showFloatingButton); + if (showFloorButton) { + renderExistingMessageUIs(); + } + postToIframe(iframe, { type: 'xb-tts:button-mode-saved' }); + } + break; + } + case 'xb-tts:toast': + if (payload.type === 'error') toastr.error(payload.message); + else if (payload.type === 'success') toastr.success(payload.message); + else toastr.info(payload.message); + break; + case 'xb-tts:test-speak': + await handleTestSpeak(payload, iframe); + break; + case 'xb-tts:clear-queue': + player.clear(); + break; + case 'xb-tts:cache-refresh': { + const stats = await getCacheStatsSafe(); + postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); + break; + } + case 'xb-tts:cache-clear-expired': { + const removed = await clearExpiredCache(config.volc.cacheDays || 7); + const stats = await getCacheStatsSafe(); + postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); + postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: `已清理 ${removed} 条` } }); + break; + } + case 'xb-tts:cache-clear-all': { + await clearAllCache(); + const stats = await getCacheStatsSafe(); + postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats }); + postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: '已清空全部' } }); + break; + } + } +} + +async function handleTestSpeak(payload, iframe) { + try { + const { text, speaker, source, resourceId } = payload; + const testText = text || '你好,这是一段测试语音。'; + + if (source === 'free') { + const { synthesizeFreeV1 } = await import('./tts-api.js'); + const { audioBase64 } = await synthesizeFreeV1({ + text: testText, + voiceKey: speaker || FREE_DEFAULT_VOICE, + speed: normalizeSpeed(config.volc?.speechRate), + }); + + const byteString = atob(audioBase64); + const bytes = new Uint8Array(byteString.length); + for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j); + const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); + + player.enqueue({ id: 'test-' + Date.now(), audioBlob }); + postToIframe(iframe, { type: 'xb-tts:test-done' }); + } else { + if (!isAuthConfigured()) { + postToIframe(iframe, { + type: 'xb-tts:test-error', + payload: '请先配置 AppID 和 Access Token' + }); + return; + } + + const rid = resourceId || inferResourceIdBySpeaker(speaker); + const result = await synthesizeV3({ + appId: config.volc.appId, + accessKey: config.volc.accessKey, + resourceId: rid, + speaker: speaker || config.volc.defaultSpeaker, + text: testText, + speechRate: speedToV3SpeechRate(config.volc.speechRate), + emotionScale: config.volc.emotionScale, + }, buildV3Headers(rid, config)); + + player.enqueue({ id: 'test-' + Date.now(), audioBlob: result.audioBlob }); + postToIframe(iframe, { type: 'xb-tts:test-done' }); + } + } catch (err) { + postToIframe(iframe, { + type: 'xb-tts:test-error', + payload: err.message + }); + } +} + +// ============ 初始化与清理 ============ + +export async function initTts() { + if (moduleInitialized) return; + + await loadConfig(); + player = new TtsPlayer(); + initTtsPanelStyles(); + moduleInitialized = true; + + setPanelConfigHandlers({ + getConfig: () => config, + saveConfig: saveConfig, + openSettings: openSettings, + clearQueue: (messageId) => { + clearMessageFromQueue(messageId); + clearFreeQueueForMessage(messageId); + + const state = ensureMessageState(messageId); + state.status = 'idle'; + state.currentSegment = 0; + state.totalSegments = 0; + state.error = ''; + updateTtsPanel(messageId, state); + }, + getLastAIMessageId: () => { + const context = getContext(); + const chat = context.chat || []; + for (let i = chat.length - 1; i >= 0; i--) { + if (chat[i] && !chat[i].is_user) return i; + } + return -1; + }, + speakMessage: (messageId) => handleMessagePlayClick(messageId), + }); + + initFloatingPanel(); + + player.onStateChange = (state, item, info) => { + if (!isModuleEnabled()) return; + const messageId = item?.messageId; + if (typeof messageId !== 'number' || messageId < 0) return; + const msgState = ensureMessageState(messageId); + + switch (state) { + case 'metadata': + msgState.duration = info?.duration || msgState.duration || 0; + break; + + case 'progress': + msgState.progress = info?.currentTime || 0; + msgState.duration = info?.duration || msgState.duration || 0; + break; + + case 'playing': + msgState.status = 'playing'; + if (typeof item?.segmentIndex === 'number') { + msgState.currentSegment = item.segmentIndex + 1; + } + break; + + case 'paused': + msgState.status = 'paused'; + break; + + case 'ended': { + // 检查是否是最后一个段落 + const segIdx = typeof item?.segmentIndex === 'number' ? item.segmentIndex : -1; + const total = msgState.totalSegments || 1; + + // 判断是否为最后一个段落 + // segIdx 是 0-based,total 是总数 + // 如果 segIdx >= total - 1,说明是最后一个 + const isLastSegment = total <= 1 || segIdx >= total - 1; + + if (isLastSegment) { + // 真正播放完成 + msgState.status = 'ended'; + msgState.progress = msgState.duration; + } else { + // 还有后续段落 + // 检查队列中是否有该消息的待播放项 + const prefix = `msg-${messageId}-`; + const hasQueued = player.queue.some(q => q.id?.startsWith(prefix)); + + if (hasQueued) { + // 后续段落已在队列中,等待播放 + msgState.status = 'queued'; + } else { + // 后续段落还在请求中 + msgState.status = 'sending'; + } + } + break; + } + + case 'blocked': + msgState.status = 'blocked'; + break; + + case 'error': + msgState.status = 'error'; + break; + + case 'enqueued': + // 只在非播放/暂停状态时更新 + if (msgState.status !== 'playing' && msgState.status !== 'paused') { + msgState.status = 'queued'; + } + break; + + case 'idle': + case 'cleared': + // 播放器空闲,但可能还有段落在请求 + // 不主动改变状态,让请求完成后的逻辑处理 + break; + } + updateTtsPanel(messageId, msgState); + }; + + events.on(event_types.CHARACTER_MESSAGE_RENDERED, onCharacterMessageRendered); + events.on(event_types.CHAT_CHANGED, onChatChanged); + events.on(event_types.MESSAGE_EDITED, handleDirectiveEnhance); + events.on(event_types.MESSAGE_UPDATED, handleDirectiveEnhance); + events.on(event_types.MESSAGE_SWIPED, handleDirectiveEnhance); + events.on(event_types.GENERATION_STOPPED, onGenerationEnd); + events.on(event_types.GENERATION_ENDED, onGenerationEnd); + + renderExistingMessageUIs(); + setupNovelDrawObserver(); + + window.registerModuleCleanup?.('tts', cleanupTts); + + window.xiaobaixTts = { + openSettings, + closeSettings, + player, + speak: async (text, options = {}) => { + if (!isModuleEnabled()) return; + + const mySpeakers = config.volc?.mySpeakers || []; + const resolved = options.speaker + ? resolveSpeakerWithSource(options.speaker, mySpeakers, config.volc.defaultSpeaker) + : { value: config.volc.defaultSpeaker, source: getVoiceSource(config.volc.defaultSpeaker) }; + + if (resolved.source === 'free') { + const { synthesizeFreeV1 } = await import('./tts-api.js'); + const { audioBase64 } = await synthesizeFreeV1({ + text, + voiceKey: resolved.value, + speed: normalizeSpeed(config.volc?.speechRate), + emotion: options.emotion || null, + }); + + const byteString = atob(audioBase64); + const bytes = new Uint8Array(byteString.length); + for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j); + const audioBlob = new Blob([bytes], { type: 'audio/mpeg' }); + + player.enqueue({ id: 'manual-' + Date.now(), audioBlob, text }); + } else { + if (!isAuthConfigured()) { + toastr?.error?.('请先配置鉴权 API'); + return; + } + + const resourceId = options.resourceId || inferResourceIdBySpeaker(resolved.value); + const result = await synthesizeV3({ + appId: config.volc.appId, + accessKey: config.volc.accessKey, + resourceId, + speaker: resolved.value, + text, + speechRate: speedToV3SpeechRate(config.volc.speechRate), + ...options, + }, buildV3Headers(resourceId, config)); + + player.enqueue({ id: 'manual-' + Date.now(), audioBlob: result.audioBlob, text }); + } + }, + }; +} + +export function cleanupTts() { + moduleInitialized = false; + + events.cleanup(); + clearAllFreeQueues(); + cleanupNovelDrawObserver(); + cleanupDirectiveObserver(); + if (player) { + player.clear(); + player.onStateChange = null; + player = null; + } + + closeSettings(); + removeAllTtsPanels(); + destroyFloatingPanel(); + + clearPanelConfigHandlers(); + + messageStateMap.clear(); + cacheCounters.hits = 0; + cacheCounters.misses = 0; + delete window.xiaobaixTts; +}