Update local plugin changes

This commit is contained in:
henrryyes
2026-01-18 01:48:30 +08:00
parent b9b02d48ae
commit 0bd3cc57c5
7 changed files with 2272 additions and 1409 deletions

View File

@@ -1,8 +1,21 @@
// tts-panel.js
/**
* TTS 播放器面板 - 极简胶囊版 v2
* 黑白灰配色,舒缓动画
* TTS 播放器面板 - 极简胶囊版 v4
* 新增:自动朗读快捷开关,支持双向同步
*/
import { registerToToolbar, removeFromToolbar } from '../../core/message-toolbar.js';
// ═══════════════════════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════════════════════
const INITIAL_RENDER_LIMIT = 1;
// ═══════════════════════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════════════════════
let stylesInjected = false;
const panelMap = new Map();
const pendingCallbacks = new Map();
@@ -28,9 +41,9 @@ export function clearPanelConfigHandlers() {
clearQueueFn = null;
}
// ============ 工具函数 ============
// ============ 样式 ============
// ═══════════════════════════════════════════════════════════════════════════
// 样式
// ═══════════════════════════════════════════════════════════════════════════
function injectStyles() {
if (stylesInjected) return;
@@ -40,7 +53,7 @@ function injectStyles() {
═══════════════════════════════════════════════════════════════ */
.xb-tts-panel {
--h: 30px;
--h: 34px;
--bg: rgba(0, 0, 0, 0.55);
--bg-hover: rgba(0, 0, 0, 0.7);
--border: rgba(255, 255, 255, 0.08);
@@ -48,13 +61,13 @@ function injectStyles() {
--text: rgba(255, 255, 255, 0.85);
--text-sub: rgba(255, 255, 255, 0.45);
--text-dim: rgba(255, 255, 255, 0.25);
--success: rgba(255, 255, 255, 0.9);
--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;
margin: 8px 0;
z-index: 10;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -70,7 +83,7 @@ function injectStyles() {
height: var(--h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 15px;
border-radius: 17px;
padding: 0 3px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
@@ -84,6 +97,14 @@ function injectStyles() {
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);
@@ -97,8 +118,8 @@ function injectStyles() {
═══════════════════════════════════════════════════════════════ */
.xb-tts-btn {
width: 26px;
height: 26px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
@@ -107,9 +128,10 @@ function injectStyles() {
color: var(--text);
cursor: pointer;
border-radius: 50%;
font-size: 10px;
font-size: 11px;
transition: all 0.25s ease;
flex-shrink: 0;
position: relative;
}
.xb-tts-btn:hover {
@@ -120,12 +142,26 @@ function injectStyles() {
transform: scale(0.92);
}
/* 播放按钮 */
.xb-tts-btn.play-btn {
font-size: 11px;
/* 播放按钮的自动朗读指示点 */
.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;
@@ -137,8 +173,8 @@ function injectStyles() {
/* 展开按钮 */
.xb-tts-btn.expand-btn {
width: 22px;
height: 22px;
width: 24px;
height: 24px;
font-size: 8px;
color: var(--text-dim);
opacity: 0.6;
@@ -206,7 +242,7 @@ function injectStyles() {
}
/* ═══════════════════════════════════════════════════════════════
波形动画 - 舒缓版
波形动画
═══════════════════════════════════════════════════════════════ */
.xb-tts-wave {
@@ -383,19 +419,71 @@ function injectStyles() {
font-variant-numeric: tabular-nums;
}
/* 工具栏 */
/* ═══════════════════════════════════════════════════════════════
工具栏(包含自动朗读开关)
═══════════════════════════════════════════════════════════════ */
.xb-tts-tools {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
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 {
@@ -405,6 +493,7 @@ function injectStyles() {
padding: 4px 6px;
border-radius: 4px;
transition: all 0.2s;
flex-shrink: 0;
}
.xb-tts-icon-btn:hover {
color: var(--text);
@@ -448,24 +537,31 @@ function injectStyles() {
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.
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
div.innerHTML = `
<div class="xb-tts-capsule">
<div class="xb-tts-loading"></div>
<button class="xb-tts-btn play-btn" title="播放">▶</button>
<button class="xb-tts-btn play-btn" title="播放">
<span class="xb-tts-auto-dot"></span>
</button>
<div class="xb-tts-info">
<div class="xb-tts-wave">
@@ -500,7 +596,11 @@ function createPanel(messageId) {
<span class="xb-tts-val speed-val">${currentSpeed.toFixed(1)}x</span>
</div>
<div class="xb-tts-tools">
<span class="xb-tts-usage">--</span>
<span class="xb-tts-usage">-</span>
<div class="xb-tts-auto-toggle${isAutoSpeak ? ' on' : ''}" title="AI回复后自动朗读">
<span class="xb-tts-auto-indicator"></span>
<span class="xb-tts-auto-text">自动朗读</span>
</div>
<span class="xb-tts-icon-btn settings-btn" title="TTS 设置">⚙</span>
</div>
</div>
@@ -514,40 +614,91 @@ function buildVoiceOptions(select, config) {
const current = config?.volc?.defaultSpeaker || '';
if (mySpeakers.length === 0) {
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
select.innerHTML = '<option value="" disabled>暂无音色</option>';
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);
// UI options from config values only.
// eslint-disable-next-line no-unsanitized/property
select.innerHTML = mySpeakers.map(s => {
const selected = isMyVoice && s.value === current ? ' selected' : '';
return `<option value="${s.value}"${selected}>${s.name || s.value}</option>`;
}).join('');
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;
}
}
function mountPanel(messageEl, messageId, onPlay) {
if (panelMap.has(messageId)) return panelMap.get(messageId);
// ═══════════════════════════════════════════════════════════════════════════
// IntersectionObserver 管理
// ═══════════════════════════════════════════════════════════════════════════
function setupObserver() {
if (observer) return;
const nameBlock = messageEl.querySelector('.mes_block > .ch_name') ||
messageEl.querySelector('.name_text')?.parentElement;
if (!nameBlock) return null;
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);
if (nameBlock.nextSibling) {
nameBlock.parentNode.insertBefore(panel, nameBlock.nextSibling);
} else {
nameBlock.parentNode.appendChild(panel);
}
// 使用工具栏管理器注册
const success = registerToToolbar(messageId, panel, {
position: 'left',
id: `tts-${messageId}`
});
if (!success) return null;
const ui = {
root: panel,
@@ -560,8 +711,10 @@ function mountPanel(messageEl, messageId, onPlay) {
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);
@@ -577,6 +730,11 @@ function mountPanel(messageEl, messageId, onPlay) {
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';
}
};
@@ -586,6 +744,22 @@ function mountPanel(messageEl, messageId, onPlay) {
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) {
@@ -597,11 +771,14 @@ function mountPanel(messageEl, messageId, onPlay) {
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();
}
};
@@ -614,59 +791,123 @@ function mountPanel(messageEl, messageId, onPlay) {
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 existingUi = panelMap.get(messageId);
if (existingUi.root && existingUi.root.isConnected) {
return existingUi;
}
existingUi._cleanup?.();
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 + 500 && rect.bottom > -500) {
return mountPanel(messageEl, messageId, onPlay);
}
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const mid = Number(el.getAttribute('mesid'));
const cb = pendingCallbacks.get(mid);
if (cb) {
mountPanel(el, mid, cb);
pendingCallbacks.delete(mid);
observer.unobserve(el);
}
}
});
}, { rootMargin: '500px' });
}
pendingCallbacks.set(messageId, onPlay);
observer.observe(messageEl);
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;
@@ -679,7 +920,6 @@ export function updateTtsPanel(messageId, state) {
ui.root.dataset.status = status;
ui.root.dataset.hasQueue = hasQueue ? 'true' : 'false';
// 状态文本和图标
let statusText = '';
let playIcon = '▶';
let showStop = false;
@@ -726,18 +966,25 @@ export function updateTtsPanel(messageId, state) {
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}%`;
@@ -748,29 +995,31 @@ export function updateTtsPanel(messageId, state) {
ui.progressInner.style.width = '0%';
}
// 用量显示
if (state.textLength) {
if (state.textLength && ui.usageText) {
ui.usageText.textContent = `${state.textLength}`;
}
}
export function removeAllTtsPanels() {
panelMap.forEach(ui => {
ui._cleanup?.();
ui.root?.remove();
});
panelMap.clear();
pendingCallbacks.clear();
observer?.disconnect();
observer = null;
}
export function removeTtsPanel(messageId) {
const ui = panelMap.get(messageId);
if (ui) {
ui._cleanup?.();
ui.root?.remove();
panelMap.delete(messageId);
}
pendingCallbacks.delete(messageId);
}
export function removeAllTtsPanels() {
panelMap.forEach((ui) => {
ui._cleanup?.();
});
panelMap.clear();
pendingCallbacks.clear();
observer?.disconnect();
observer = null;
}
export function getPanelMap() {
return panelMap;
}