// ==UserScript==
// @name 多角色TTS播放器
// @namespace http://tampermonkey.net/
// @version 1.5
// @description 网页通用TTS播放器,集成GAL游戏流式语音引擎,支持多角色与情绪自动识别、自定义API连接(OpenAI/GPT-SoVITS双模式)、自动播放及移动端UI适配,支持Json自定义模式。
// @author JChSh
// @match *://*/*
// @connect *
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_info
// @license All Rights Reserved
// @run-at document-end
// @downloadURL none
// ==/UserScript==
/*
* =============================
* COPYRIGHT & LICENSE NOTICE
* =============================
*
* Project: MultiRole-TTS-Player (Universal Web Version)
* Refactored & Adapted by: JChSh (Bilibili UID: 511242)
*
* [ ORIGINAL WORK ATTRIBUTION / 原作致谢 ]
* This script is a derivative work heavily based on the "SillyTavern TTS Player".
* 本脚本是基于“SillyTavern 酒馆TTS播放器”进行的通用化重构作品。
*
* The Core Copyright Holders (Original Code Authors) are:
* 核心代码版权所有者(原作者):
* - cnfh1746_06138 (Core Logic & Architecture / 核心逻辑与架构)
* - kikukiku0662 (GAL Mode & Emotion Engine / GAL模式与情感引擎)
*
* [ CREDIT STATEMENT / 归属声明 ]
* 1. The original logic (including GalStreamingPlayer, audio caching, and emotion detection) belongs to cnfh1746_06138 & kikukiku0662.
* 原有的核心逻辑(包括流式播放器、音频缓存、情感检测等)归 cnfh1746_06138 & kikukiku0662 所有。
*
* 2. The universal adaptations, UI modifications, and configuration refactoring are provided by JChSh.
* 网页通用化适配、UI 调整及配置重构工作由 JChSh 提供。
*
* [ LICENSE / 许可协议 ]
* Redistribution and use of this script, with or without modification, are permitted provided that:
* - This entire copyright notice and attribution list remain intact.
* - You do not claim the original code as your own exclusive work.
*
* 允许分发和修改本脚本,但必须满足:
* - 保留完整的版权声明和作者名单。
* - 不得将原作代码声称为自己的独家作品。
* =============================
*/
(function() {
'use strict';
// 模块:全局变量定义与配置初始化
let ttsApiUrl = GM_getValue('ttsApiUrl', 'http://127.0.0.1:8000');
let authToken = GM_getValue('authToken', '');
let authType = GM_getValue('authType', authToken ? 'bearer' : 'none');
let authCustomPrefix = GM_getValue('authCustomPrefix', '');
let ttsFetchTimeout = GM_getValue('ttsFetchTimeout', 60000);
let ttsGenTimeout = GM_getValue('ttsGenTimeout', 180000);
const defaultJson = '{\n "api_type": "gpt-sovits",\n "speed_facter": 1.0,\n "volume": 1.0,\n "top_k": 10,\n "top_p": 1.0,\n "temperature": 1.0\n}';
let customDataJson = GM_getValue('customDataJson', defaultJson);
let mergeAudioEnabled = GM_getValue('mergeAudioEnabled', false);
let refAudioPath = GM_getValue('refAudioPath', '');
let promptText = GM_getValue('promptText', '');
let savedRefAudioBase64 = GM_getValue('savedRefAudioBase64', null);
let refAudioFile = null;
let playbackMode = GM_getValue('playbackMode', 'stream');
let autoPlayEnabled = GM_getValue('autoPlayEnabled', false);
let edgeMode = GM_getValue('edgeMode', false);
let detectionMode = GM_getValue('detectionMode', 'character_and_dialogue');
let quotationStyle = GM_getValue('quotationStyle', 'japanese');
let characterVoices = GM_getValue('characterVoicesOnline', {});
let characterGroups = GM_getValue('characterGroupsOnline', {});
let allDetectedCharacters = new Set(GM_getValue('allDetectedCharactersOnline', []));
let floatPanelPos = GM_getValue('floatPanelPos', { top: '20%', right: '20px' });
let settingsPanelPos = GM_getValue('settingsPanelPos', { top: '50%', left: '50%' });
let isPlaying = false;
let isPaused = false;
let isGenerating = false;
let generationQueue = [];
let playbackQueue = [];
let sessionAudioCache = [];
let currentAudio = null;
let lastProcessedMessageId = null;
let lastMessageParts = [];
let autoPlayTimer = null;
let isEdgeHidden = false;
let originalPosition = null;
let edgeIndicatorLastTop = null;
let logStore = [];
const URL_WHITELIST_KEY = 'tts_url_whitelist';
// 模块:日志与通知系统
function addLog(type, message, details = null) {
const entry = {
id: Date.now() + Math.random(),
timestamp: new Date().toLocaleTimeString(),
type: type,
message: message,
details: details
};
logStore.push(entry);
if (logStore.length > 100) logStore.shift();
}
function initConsoleLogger() {
const methods = ['log', 'warn', 'error', 'info'];
methods.forEach(method => {
const original = console[method];
console[method] = function(...args) {
original.apply(console, args);
const msg = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
let type = 'sys';
if (msg.includes('[TTS]')) type = 'sys';
else if (method === 'error') type = 'err';
else if (method === 'warn') type = 'warn';
addLog(type, msg);
};
});
}
function showNotification(message, type = 'info', duration = 3000) {
let container = document.getElementById('tts-notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'tts-notification-container';
document.body.appendChild(container);
}
const notif = document.createElement('div');
notif.className = `tts-notification ${type}`;
notif.textContent = message;
container.appendChild(notif);
setTimeout(() => notif.classList.add('show'), 100);
setTimeout(() => {
notif.classList.remove('show');
setTimeout(() => notif.remove(), 300);
}, duration);
}
// 模块:工具函数
function detectLanguage(text) {
if (!text) return 'zh';
if (/^[\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\s]+$/.test(text)) return 'zh';
if (/^[a-zA-Z\s.,?!'"-]+$/.test(text)) return 'en';
if (/^[\u3040-\u30ff\u31f0-\u31ff\uff66-\uff9f\u4e00-\u9fa5\s]+$/.test(text) && /[ぁ-んァ-ヶ]/.test(text)) return 'ja';
if (/^[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\s]+$/.test(text)) return 'ko';
return 'zh';
}
const b64toFile = (b64Data, filename) => {
if (!b64Data || typeof b64Data !== 'string') return null;
try {
const arr = b64Data.split(',');
if (arr.length < 2) return null;
const mimeMatch = arr[0].match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : 'audio/wav';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) u8arr[n] = bstr.charCodeAt(n);
return new File([u8arr], filename, { type: mime });
} catch (e) {
console.error("恢复音频文件失败", e);
return null;
}
};
if (savedRefAudioBase64 && refAudioPath) {
refAudioFile = b64toFile(savedRefAudioBase64, refAudioPath);
if (refAudioFile) addLog('sys', `成功恢复参考音频: ${refAudioPath}`);
}
function isCurrentUrlWhitelisted() {
const whitelist = GM_getValue(URL_WHITELIST_KEY, []);
if (!Array.isArray(whitelist) || whitelist.length === 0) return true;
const currentUrl = window.location.href;
const currentHost = window.location.host;
return whitelist.some(url => {
try {
return new URL(url).host === currentHost || url === currentUrl;
} catch {
return url === currentHost || url === currentUrl;
}
});
}
function getCurrentQuotePair() {
if (quotationStyle === 'western') return ['"', '"'];
if (quotationStyle === 'chinese') return ['“', '”'];
return ['「', '」'];
}
function maskUrlDisplay(url) {
if (!url || url.length < 15) return url;
try {
const urlObj = new URL(url);
const protocol = urlObj.protocol + "//";
const host = urlObj.host;
const path = urlObj.pathname;
const lastPart = path.split('/').pop() || '';
return `${protocol}${host}/*/*/${lastPart.substring(Math.max(0, lastPart.length - 3))}`;
} catch(e) {
return url.substring(0, 10) + '...';
}
}
function maskTokenDisplay(token) {
if (!token || token.length < 6) return '******';
return '********' + token.substring(token.length - 4);
}
// 模块:网络请求封装
async function makeRequest(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || "POST",
url: url,
headers: options.headers || {},
data: options.data,
responseType: options.responseType,
timeout: options.timeout || ttsFetchTimeout,
onload: (res) => { resolve(res); },
onerror: (err) => {
addLog('net', `网络层错误`, { error: err });
reject(err);
},
ontimeout: () => {
addLog('net', `请求超时`, { url: url, timeout: options.timeout || ttsFetchTimeout });
reject(new Error("Timeout"));
}
});
});
}
// 模块:音频生成核心逻辑
function parseCustomInput(rawInput) {
const firstBraceIndex = rawInput.indexOf('{');
if (firstBraceIndex === -1) {
try {
return {
config: {},
jsonObj: JSON.parse(rawInput),
isCustomLang: false
};
} catch (e) {
return { config: {}, jsonObj: {}, isCustomLang: false, error: e };
}
}
const headerStr = rawInput.substring(0, firstBraceIndex);
const jsonStr = rawInput.substring(firstBraceIndex);
let apiType = null;
const apiTypeMatch = headerStr.match(/["']?api_type["']?\s*[:=]\s*["']([^"']+)["']/);
if (apiTypeMatch) apiType = apiTypeMatch[1];
const hasLang = /\blang\b/.test(headerStr);
try {
return {
config: { api_type: apiType },
jsonObj: JSON.parse(jsonStr),
isCustomLang: hasLang
};
} catch (e) {
return { config: {}, jsonObj: {}, isCustomLang: hasLang, error: e };
}
}
function processTemplateValues(obj, replacements) {
let hasReplacedText = false;
function traverse(current) {
for (const key in current) {
if (typeof current[key] === 'object' && current[key] !== null) {
traverse(current[key]);
} else if (typeof current[key] === 'string') {
if (current[key] === '{{text}}') {
current[key] = replacements.text;
hasReplacedText = true;
}
else if (current[key] === '{{audio_base64}}') {
current[key] = replacements.audioBase64 || "";
}
else if (current[key] === '{{emotion}}') {
current[key] = replacements.emotion || "";
}
else if (current[key] === '{{prompt_text}}') {
current[key] = replacements.promptText || "";
}
}
}
}
const newObj = JSON.parse(JSON.stringify(obj));
traverse(newObj);
return { newObj, hasReplacedText };
}
async function generateAudio(task) {
const lang = detectLanguage(task.dialogue);
let targetJsonStr = customDataJson;
let targetPromptText = promptText;
let targetAudioBase64 = savedRefAudioBase64;
let targetAudioFile = refAudioFile;
let foundGroup = null;
for (const [groupName, groupData] of Object.entries(characterGroups)) {
if (groupData.characters && groupData.characters.includes(task.character)) {
foundGroup = groupData;
break;
}
}
if (foundGroup) {
addLog('sys', `角色 [${task.character}] 匹配到分组预设: ${foundGroup.audioPath || '配置项'}`);
if (foundGroup.dataJson) targetJsonStr = foundGroup.dataJson;
if (foundGroup.promptText) targetPromptText = foundGroup.promptText;
if (foundGroup.audioBase64) {
targetAudioBase64 = foundGroup.audioBase64;
targetAudioFile = b64toFile(targetAudioBase64, `group_preset_${task.character}.wav`);
}
}
const parseResult = parseCustomInput(targetJsonStr);
if (parseResult.error) throw new Error("JSON 格式错误: " + parseResult.error.message);
let requestPayload = parseResult.jsonObj;
const isCustomLangMode = parseResult.isCustomLang;
let apiType = (parseResult.config.api_type || requestPayload.api_type || "").trim().toLowerCase();
if (!apiType) {
showNotification('JSON 配置缺少 api_type', 'error');
throw new Error("FATAL: Missing api_type in configuration");
}
const charSettings = (task.character && characterVoices[task.character]) ? characterVoices[task.character] : {};
const effectivePromptText = charSettings.promptText || targetPromptText || ""; // 使用 targetPromptText
const effectiveAudioBase64 = charSettings.audioBase64 || targetAudioBase64 || ""; // 使用 targetAudioBase64
let effectiveAudioFile = null;
if (charSettings.audioBase64) {
const safeCharName = task.character.replace(/[\\/:*?"<>|]/g, '_');
effectiveAudioFile = b64toFile(charSettings.audioBase64, `ref_${safeCharName}.wav`);
} else {
effectiveAudioFile = targetAudioFile; // 使用 targetAudioFile
if (!effectiveAudioFile && effectiveAudioBase64) {
effectiveAudioFile = b64toFile(effectiveAudioBase64, "ref_restored.wav");
}
}
const replacementData = {
text: task.dialogue,
emotion: task.emotion || "",
promptText: effectivePromptText,
audioBase64: effectiveAudioBase64
};
if (isCustomLangMode) {
const { newObj, hasReplacedText } = processTemplateValues(requestPayload, replacementData);
requestPayload = newObj;
if (!hasReplacedText) {
throw new Error("自定义 Lang 模式错误:JSON 中缺少 {{text}} 占位符");
}
}
if (apiType === "openai") {
if (!isCustomLangMode) {
let promptInstruction = "";
if (task.emotion) promptInstruction += `[情绪: ${task.emotion}] `;
if (task.character) promptInstruction += `[角色: ${task.character}] `;
requestPayload.input = `${promptInstruction}<|endofprompt|>${task.dialogue}`;
delete requestPayload.text;
delete requestPayload.text_lang;
delete requestPayload.api_type;
delete requestPayload.prompt_text;
delete requestPayload.refer_wav;
if (requestPayload.references && Array.isArray(requestPayload.references)) {
requestPayload.references.forEach(ref => {
if (ref.audio === "savedRefAudioBase64" || ref.audio === "{{audio_base64}}") {
ref.audio = effectiveAudioBase64;
}
if (ref.text === "promptText" || ref.text === "{{prompt_text}}") {
ref.text = effectivePromptText;
}
});
}
} else {
delete requestPayload.api_type;
}
const headers = { "Content-Type": "application/json" };
if (authToken && authToken.trim() !== "") {
headers["Authorization"] = `Bearer ${authToken}`;
}
return await executeRequest(requestPayload, headers, true, task);
}
else if (apiType === "gpt-sovits") {
if (!isCustomLangMode) {
if (charSettings.speed) {
requestPayload.speed_facter = charSettings.speed;
}
if (task.emotion && task.emotion.trim() !== '') {
requestPayload.emotion = task.emotion.trim();
}
}
delete requestPayload.api_type;
let headers = {};
if (authToken && authToken.trim() !== "") {
if (authType === 'bearer') headers["Authorization"] = `Bearer ${authToken}`;
else if (authType === 'api') headers["Authorization"] = `api ${authToken}`;
else if (authType === 'custom') headers["Authorization"] = `${authCustomPrefix} ${authToken}`.trim();
}
let finalData;
if (mergeAudioEnabled) {
if (!effectiveAudioFile || !(effectiveAudioFile instanceof File)) {
showNotification('⚠️ 参考音频丢失', 'error');
throw new Error("参考音频文件无效");
}
finalData = new FormData();
if (isCustomLangMode) {
for (const [key, value] of Object.entries(requestPayload)) {
if (value === '{{audio_file}}') {
finalData.append(key, effectiveAudioFile);
} else {
finalData.append(key, typeof value === 'object' ? JSON.stringify(value) : value);
}
}
} else {
finalData.append('text', task.dialogue);
finalData.append('text_lang', lang);
finalData.append('refer_wav', effectiveAudioFile);
finalData.append('prompt_text', effectivePromptText);
finalData.append('prompt_text_lang', detectLanguage(effectivePromptText));
for (const [key, value] of Object.entries(requestPayload)) {
finalData.append(key, typeof value === 'object' ? JSON.stringify(value) : value);
}
}
} else {
if (isCustomLangMode) {
finalData = JSON.stringify(requestPayload);
} else {
requestPayload.text = task.dialogue;
requestPayload.text_lang = lang;
finalData = JSON.stringify(requestPayload);
}
headers["Content-Type"] = "application/json";
}
return await executeRequest(finalData, headers, false, task);
}
else {
throw new Error(`不支持的 api_type: ${apiType}`);
}
}
// 统一请求执行器
async function executeRequest(data, headers, isOpenAiMode, taskOriginal = null) {
const retryInterval = 10000;
const maxDuration = Math.max(ttsFetchTimeout, ttsGenTimeout);
const maxRetries = Math.ceil(maxDuration / retryInterval);
const isFormData = data instanceof FormData;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
if (!isPlaying && !GalStreamingPlayer.isActive) {
throw new Error("ABORT_BY_USER");
}
try {
if (attempt > 1) addLog('warn', `[重试] 第 ${attempt}/${maxRetries} 次尝试...`);
const requestOpt = {
method: "POST",
headers: headers,
data: isFormData ? data : (typeof data === 'string' ? data : JSON.stringify(data)),
timeout: ttsGenTimeout
};
if (isOpenAiMode) {
requestOpt.responseType = 'blob';
}
const response = await makeRequest(ttsApiUrl, requestOpt);
if (response.status >= 400) {
let errorText = "Client Error";
try { errorText = response.responseText || await response.response.text(); } catch (e) {}
addLog('err', `API请求拒绝 (Status: ${response.status})`, { response: errorText });
if (response.status >= 400 && response.status < 501) {
throw new Error("FATAL_CLIENT_ERROR");
}
throw new Error(`SERVER_ERROR_${response.status}`);
}
let audioUrl;
if (isOpenAiMode) {
const blob = response.response;
if (!(blob instanceof Blob)) throw new Error("INVALID_RESPONSE_TYPE");
audioUrl = URL.createObjectURL(blob);
} else {
try {
const json = JSON.parse(response.responseText);
if (json.detail || json.error) throw new Error("API_BUSINESS_ERROR");
audioUrl = json.audio_url || json.url;
if (!audioUrl) throw new Error("INVALID_JSON_STRUCTURE");
} catch (jsonErr) {
if (response.response instanceof Blob) {
audioUrl = URL.createObjectURL(response.response);
} else {
console.error("DEBUG: 服务器响应文本", response.responseText);
addLog('err', '服务器返回非JSON内容', { responseText: response.responseText, error: jsonErr.message });
throw new Error("FATAL_JSON_ERROR");
}
}
}
addLog('net', `生成成功`, { audioUrl: audioUrl });
return { url: audioUrl, task: taskOriginal };
} catch (error) {
const fatalErrors = ["FATAL_CLIENT_ERROR", "FATAL_JSON_ERROR", "ABORT_BY_USER"];
if (fatalErrors.includes(error.message) || attempt === maxRetries) {
console.error(`[TTS] 终止请求: ${error.message}`);
throw error;
}
addLog('net', `请求异常: ${error.message}。10秒后重试...`);
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
}
}
function fetchAudioBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
timeout: ttsFetchTimeout,
onload: (res) => res.status === 200 ? resolve(URL.createObjectURL(res.response)) : reject(new Error(res.statusText)),
onerror: reject,
ontimeout: () => reject(new Error("Audio Download Timeout"))
});
});
}
// 模块:音频播放管理(含GAL流式引擎)
function playAudioPromise(blobUrl) {
return new Promise((resolve, reject) => {
let audioPlayer = document.getElementById('tts-audio-player');
if (!audioPlayer) {
audioPlayer = document.createElement('audio');
audioPlayer.id = 'tts-audio-player';
audioPlayer.style.display = 'none';
document.body.appendChild(audioPlayer);
}
currentAudio = audioPlayer;
const onEnded = () => { cleanup(); resolve(); };
const onError = (e) => { cleanup(); if (audioPlayer.src) reject(new Error("音频播放失败")); else resolve(); };
const cleanup = () => {
audioPlayer.removeEventListener('ended', onEnded);
audioPlayer.removeEventListener('error', onError);
};
audioPlayer.addEventListener('ended', onEnded);
audioPlayer.addEventListener('error', onError);
audioPlayer.src = blobUrl;
audioPlayer.play().catch(e => {
console.error("Play failed", e);
onError(e);
});
});
}
const GalStreamingPlayer = {
isActive: false,
currentSegments: [],
currentIndex: 0,
audioCache: new Map(),
config: { preloadCount: 3 },
async initialize(galDialogues) {
if (!galDialogues || galDialogues.length === 0) return false;
this.isActive = true;
this.currentSegments = galDialogues;
this.currentIndex = 0;
this.audioCache.clear();
addLog('sys', `[GAL] 初始化: ${galDialogues.length} 个片段`);
this.preloadSegments(0, this.config.preloadCount);
return true;
},
async preloadSegments(startIndex, count) {
if (!this.isActive) return;
for (let i = startIndex; i < Math.min(startIndex + count, this.currentSegments.length); i++) {
if (!this.audioCache.has(i)) {
this.generateSegmentAudio(this.currentSegments[i], i).catch(e => console.error(e));
}
}
},
async generateSegmentAudio(segment, index) {
if (this.audioCache.has(index)) return this.audioCache.get(index);
const task = {
dialogue: segment.content,
character: segment.character || '',
emotion: segment.emotion || '',
};
this.audioCache.set(index, { status: 'pending' });
try {
const result = await generateAudio(task);
const urlToFetch = result.url;
const blobUrl = await fetchAudioBlob(urlToFetch);
const audioData = { ...result, blobUrl: blobUrl, status: 'ready' };
this.audioCache.set(index, audioData);
return audioData;
} catch (error) {
console.error(`片段 ${index} 生成失败`, error);
this.audioCache.delete(index);
throw error;
}
},
async playNext() {
if (!this.isActive) return;
if (this.currentIndex >= this.currentSegments.length) {
addLog('sys', '[GAL] 播放结束');
handleStopClick();
return;
}
const index = this.currentIndex;
const segment = this.currentSegments[index];
addLog('sys', `[GAL] 播放片段 ${index + 1}/${this.currentSegments.length}: ${segment.content.substring(0, 15)}...`);
let audioData = this.audioCache.get(index);
if (!audioData || audioData.status === 'pending') {
while ((!audioData || audioData.status === 'pending') && this.isActive) {
if (!audioData) this.generateSegmentAudio(segment, index);
await new Promise(r => setTimeout(r, 200));
audioData = this.audioCache.get(index);
}
}
if (!this.isActive) return;
try {
await playAudioPromise(audioData.blobUrl);
if (this.isActive) {
this.currentIndex++;
this.preloadSegments(this.currentIndex + 1, 2);
this.playNext();
}
} catch (error) {
console.error("GAL播放错误", error);
handleStopClick();
}
},
stop() {
this.isActive = false;
this.currentIndex = 0;
this.audioCache.forEach(item => {
if (item.blobUrl) URL.revokeObjectURL(item.blobUrl);
});
this.audioCache.clear();
}
};
// 模块:UI界面构建与交互
function makeDraggable(element, handle, saveKey) {
let isDragging = false;
let startX, startY, startLeft, startTop;
const onStart = (e) => {
if (e.target.closest('button, input, select, textarea, .tts-close-btn')) return;
isDragging = true;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const rect = element.getBoundingClientRect();
if (element.style.right && element.style.right !== 'auto') {
element.style.left = rect.left + 'px';
element.style.right = 'auto';
}
startLeft = rect.left;
startTop = rect.top;
startX = clientX;
startY = clientY;
element.classList.add('dragging');
element.style.transition = 'none';
e.preventDefault();
};
const onMove = (e) => {
if (!isDragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const dx = clientX - startX;
const dy = clientY - startY;
let newLeft = Math.max(0, Math.min(window.innerWidth - element.offsetWidth, startLeft + dx));
let newTop = Math.max(0, Math.min(window.innerHeight - element.offsetHeight, startTop + dy));
element.style.left = newLeft + 'px';
element.style.top = newTop + 'px';
};
const onEnd = () => {
if (!isDragging) return;
isDragging = false;
element.classList.remove('dragging');
element.style.transition = '';
GM_setValue(saveKey, { top: element.style.top, left: element.style.left });
};
handle.addEventListener('mousedown', onStart);
handle.addEventListener('touchstart', onStart, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onEnd);
document.addEventListener('touchend', onEnd);
}
function createUI() {
if (document.getElementById('tts-floating-panel')) return;
const panel = document.createElement('div');
panel.id = 'tts-floating-panel';
panel.className = `tts-panel ${edgeMode ? 'edge-mode' : ''}`;
if (floatPanelPos.left && parseInt(floatPanelPos.left) > window.innerWidth - 40) floatPanelPos.left = (window.innerWidth - 60) + 'px';
if (floatPanelPos.left) {
panel.style.left = floatPanelPos.left;
panel.style.top = floatPanelPos.top;
} else {
panel.style.top = floatPanelPos.top;
panel.style.right = floatPanelPos.right;
}
panel.innerHTML = `
`;
panel.addEventListener('mouseenter', () => { if (edgeMode) panel.classList.add('expanded'); });
panel.addEventListener('mouseleave', () => { if (edgeMode) panel.classList.remove('expanded'); });
document.body.appendChild(panel);
makeDraggable(panel, panel, 'floatPanelPos');
document.getElementById('tts-play-btn').onclick = () => handlePlayClick();
document.getElementById('tts-stop-btn').onclick = handleStopClick;
document.getElementById('tts-replay-btn').onclick = handleReplayClick;
document.getElementById('tts-reinfer-btn').onclick = handleReinferClick;
document.getElementById('tts-detect-btn').onclick = handleFrontendDetect;
document.getElementById('tts-settings-btn').onclick = toggleSettingsPanel;
document.getElementById('tts-hide-btn').onclick = toggleEdgeHide;
}
function toggleSettingsPanel() {
const exist = document.getElementById('tts-settings-modal');
if (exist) { exist.remove(); return; }
const modal = document.createElement('div');
modal.id = 'tts-settings-modal';
modal.className = 'tts-modal';
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const isMobile = windowWidth < 768;
let useSavedPos = false;
if (!isMobile) {
const isDefault = settingsPanelPos.top === '50%' || settingsPanelPos.left === '50%';
if (!isDefault) {
const leftNum = parseInt(settingsPanelPos.left);
const topNum = parseInt(settingsPanelPos.top);
const isValid = !isNaN(leftNum) && !isNaN(topNum) && topNum > 20 && topNum < (windowHeight - 50) && leftNum > 0 && leftNum < (windowWidth - 50);
if (isValid) useSavedPos = true;
}
}
const content = document.createElement('div');
content.className = 'tts-modal-content';
if (useSavedPos) {
modal.style.justifyContent = 'flex-start';
modal.style.alignItems = 'flex-start';
content.style.position = 'absolute';
content.style.left = settingsPanelPos.left;
content.style.top = settingsPanelPos.top;
content.style.margin = '0';
} else {
modal.style.justifyContent = 'center';
modal.style.alignItems = 'center';
content.style.position = 'relative';
content.style.left = 'auto';
content.style.top = 'auto';
content.style.transform = 'none';
}
const displayUrl = maskUrlDisplay(ttsApiUrl);
const displayToken = maskTokenDisplay(authToken);
content.innerHTML = `
🔌 连接设置
支持 api_type: "openai" 或 "gpt-sovits"
🎮 功能设置
`;
modal.appendChild(content);
document.body.appendChild(modal);
makeDraggable(content, content.querySelector('.tts-modal-header'), 'settingsPanelPos');
bindSettingsEvents(modal, content);
renderCharacterGroups(content);
renderDetectedChars(content);
}
// 模块:设置面板逻辑与事件绑定(含脱敏还原逻辑)
function bindSettingsEvents(modal, content) {
content.querySelector('.tts-close-btn').onclick = () => modal.remove();
content.querySelector('#btn-logs').onclick = showConsoleLogger;
content.querySelector('#btn-white').onclick = showWhitelistManager;
content.querySelector('#btn-net').onclick = performNetworkTest;
const bindInput = (id, setter) => {
const el = content.querySelector(id);
if (el) el.addEventListener('change', (e) => setter(e.target.type === 'checkbox' ? e.target.checked : e.target.value));
};
const urlInput = content.querySelector('#cfg-api-url');
urlInput.addEventListener('change', (e) => {
const newVal = e.target.value;
if (newVal !== maskUrlDisplay(ttsApiUrl)) {
ttsApiUrl = newVal;
GM_setValue('ttsApiUrl', newVal);
}
});
const authTypeSelect = content.querySelector('#auth-type');
const customPrefixWrap = content.querySelector('#custom-prefix-wrap');
const customAuthPrefix = content.querySelector('#custom-auth-prefix');
const ttsBearerToken = content.querySelector('#tts-bearer-token');
const placeholderMap = {
none: '无需输入',
bearer: '输入 Bearer Token',
api: '输入 API Key',
custom: '输入自定义令牌'
};
function handleAuthTypeChange() {
const selectedType = authTypeSelect.value;
authType = selectedType;
GM_setValue('authType', selectedType);
customPrefixWrap.style.display = selectedType === 'custom' ? 'block' : 'none';
ttsBearerToken.disabled = selectedType === 'none';
customAuthPrefix.disabled = selectedType !== 'custom';
ttsBearerToken.placeholder = placeholderMap[selectedType] || '请输入';
if (selectedType !== 'custom') customAuthPrefix.value = '';
if (selectedType === 'none') {
ttsBearerToken.value = '';
authToken = '';
GM_setValue('authToken', '');
}
}
authTypeSelect.addEventListener('change', handleAuthTypeChange);
customAuthPrefix.addEventListener('change', (e) => {
authCustomPrefix = e.target.value;
GM_setValue('authCustomPrefix', authCustomPrefix);
});
ttsBearerToken.addEventListener('change', (e) => {
const newVal = e.target.value;
if (newVal !== maskTokenDisplay(authToken)) {
authToken = newVal;
GM_setValue('authToken', authToken);
}
});
handleAuthTypeChange();
content.querySelector('#cfg-timeout-fetch').addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 5) val = 30;
e.target.value = val;
ttsFetchTimeout = val * 1000;
GM_setValue('ttsFetchTimeout', ttsFetchTimeout);
});
content.querySelector('#cfg-timeout-gen').addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if (isNaN(val) || val < 10) val = 60;
e.target.value = val;
ttsGenTimeout = val * 1000;
GM_setValue('ttsGenTimeout', ttsGenTimeout);
});
bindInput('#cfg-json-data', v => {
customDataJson = v;
GM_setValue('customDataJson', v);
});
bindInput('#cfg-prompt-text', v => {
promptText = v;
GM_setValue('promptText', v);
});
bindInput('#cfg-play-mode', v => {
playbackMode = v;
GM_setValue('playbackMode', v);
});
content.querySelector('#cfg-autoplay').addEventListener('change', (e) => {
const isChecked = e.target.checked;
autoPlayEnabled = isChecked;
GM_setValue('autoPlayEnabled', isChecked);
if (isChecked) {
lastProcessedMessageId = null;
addLog('sys', '自动播放已启用 (状态重置)');
showNotification('自动播放已开启', 'success');
setTimeout(() => {
if (typeof parsePageText === 'function') {
const msgs = document.querySelectorAll('div.mes[is_user="false"]');
if (msgs.length > 0) {
lastProcessedMessageId = null;
}
}
}, 100);
} else {
addLog('sys', '自动播放已禁用');
}
});
bindInput('#cfg-quote', v => {
quotationStyle = v;
GM_setValue('quotationStyle', v);
});
content.querySelector('#cfg-merge-audio').addEventListener('change', (e) => {
mergeAudioEnabled = e.target.checked;
GM_setValue('mergeAudioEnabled', mergeAudioEnabled);
content.querySelector('#cfg-merge-area').style.display = mergeAudioEnabled ? 'block' : 'none';
});
const detectSelect = content.querySelector('select[name="detection_mode"]');
if (detectSelect) {
detectSelect.addEventListener('change', (e) => {
detectionMode = e.target.value;
GM_setValue('detectionMode', detectionMode);
});
}
content.querySelector('#cfg-ref-file').addEventListener('change', (e) => {
const file = e.target.files[0];
const statusDiv = content.querySelector('#cfg-file-status');
if (file) {
statusDiv.textContent = `⏳ 正在处理: ${file.name}...`;
statusDiv.style.color = 'orange';
const reader = new FileReader();
reader.onload = (evt) => {
const result = evt.target.result;
try {
GM_setValue('savedRefAudioBase64', result);
GM_setValue('refAudioPath', file.name);
savedRefAudioBase64 = result;
refAudioPath = file.name;
refAudioFile = file;
statusDiv.textContent = `✅ 已保存: ${file.name}`;
statusDiv.style.color = 'green';
addLog('sys', `文件上传成功: ${file.name}`);
} catch (err) {
console.error('[TTS] 存储音频失败', err);
statusDiv.textContent = `❌ 保存失败: 文件太大 (限制约5MB)`;
statusDiv.style.color = 'red';
refAudioFile = file;
alert("文件过大,无法永久保存到插件存储中。\n但在本页面刷新前,合音功能依然可用。");
}
};
reader.onerror = () => {
statusDiv.textContent = `❌ 读取文件失败`;
statusDiv.style.color = 'red';
};
reader.readAsDataURL(file);
}
});
content.querySelector('#cfg-test-conn').onclick = performNetworkTest;
content.querySelector('#add-group-btn').onclick = () => {
const name = content.querySelector('#new-group-name').value.trim();
const color = content.querySelector('#new-group-color').value;
if (!name) return;
if (!characterGroups[name]) {
const snapshot = {
color: color,
characters: [],
dataJson: customDataJson,
promptText: promptText,
audioBase64: savedRefAudioBase64,
audioPath: refAudioPath
};
characterGroups[name] = snapshot;
GM_setValue('characterGroupsOnline', characterGroups);
renderCharacterGroups(content);
const audioStatus = savedRefAudioBase64 ? "含音频" : "无音频";
alert(`分组【${name}】创建成功!\n已锁定当前配置 (${audioStatus}) 为该分组专属预设。`);
} else {
alert("该分组名称已存在!");
}
};
// 导出与导入逻辑
const utf8_to_b64 = (str) => { try { return window.btoa(unescape(encodeURIComponent(str || ""))); } catch(e) { return ""; } };
const b64_to_utf8 = (str) => { try { return decodeURIComponent(escape(window.atob(str || ""))); } catch(e) { return ""; } };
const exportBtn = content.querySelector('#btn-export-cfg');
if (exportBtn) {
exportBtn.onclick = () => {
try {
const exportData = {
meta: { version: "1.5", date: new Date().toLocaleString(), desc: "MultiRole-TTS Config File" },
encrypted_auth: { api_url: utf8_to_b64(ttsApiUrl), token: utf8_to_b64(authToken), prefix: utf8_to_b64(authCustomPrefix) },
config: {
authType, ttsFetchTimeout, ttsGenTimeout, customDataJson, mergeAudioEnabled, promptText, refAudioPath,
playbackMode, autoPlayEnabled, detectionMode, quotationStyle, floatPanelPos, settingsPanelPos
},
groups: characterGroups, voices: characterVoices, detected: Array.from(allDetectedCharacters), globalAudio: savedRefAudioBase64
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `TTS_Config_${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
showNotification("配置已导出 (敏感信息已加密)", "success");
} catch (e) { console.error(e); alert("导出失败: " + e.message); }
};
}
const importBtn = content.querySelector('#btn-import-cfg');
const fileInput = content.querySelector('#import-file-input');
if (importBtn && fileInput) {
importBtn.onclick = () => fileInput.click();
fileInput.onchange = (e) => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target.result);
if (data.encrypted_auth) {
const ea = data.encrypted_auth;
if (ea.api_url) GM_setValue('ttsApiUrl', b64_to_utf8(ea.api_url));
if (ea.token) GM_setValue('authToken', b64_to_utf8(ea.token));
if (ea.prefix) GM_setValue('authCustomPrefix', b64_to_utf8(ea.prefix));
}
if (data.config) {
const c = data.config;
const keys = ['authType', 'ttsFetchTimeout', 'ttsGenTimeout', 'customDataJson', 'mergeAudioEnabled', 'promptText', 'refAudioPath', 'playbackMode', 'autoPlayEnabled', 'detectionMode', 'quotationStyle', 'floatPanelPos', 'settingsPanelPos'];
keys.forEach(k => { if (c[k] !== undefined) GM_setValue(k, c[k]); });
}
if (data.groups) GM_setValue('characterGroupsOnline', data.groups);
if (data.voices) GM_setValue('characterVoicesOnline', data.voices);
if (data.detected) GM_setValue('allDetectedCharactersOnline', data.detected);
if (data.globalAudio) GM_setValue('savedRefAudioBase64', data.globalAudio);
alert(`成功导入配置!\n时间: ${data.meta?.date || '未知'}\n页面将刷新以应用更改。`);
location.reload();
} catch (err) { console.error(err); alert("导入失败:文件格式错误或解密失败"); }
};
reader.readAsText(file);
fileInput.value = '';
};
}
}
function renderCharacterGroups(container) {
const wrap = container.querySelector('#character-groups-container');
wrap.innerHTML = '';
Object.entries(characterGroups).forEach(([gName, gData]) => {
const div = document.createElement('div');
div.className = 'tts-group-item';
div.innerHTML = `
${gData.characters.map(char => `
${char}
`).join('')}
`;
const sel = div.querySelector('.add-char-sel');
allDetectedCharacters.forEach(c => {
if (!gData.characters.includes(c)) {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c;
sel.appendChild(opt);
}
});
sel.onchange = (e) => {
if (e.target.value) {
gData.characters.push(e.target.value);
GM_setValue('characterGroupsOnline', characterGroups);
renderCharacterGroups(container);
}
};
div.querySelector('.del-grp').onclick = () => {
delete characterGroups[gName];
GM_setValue('characterGroupsOnline', characterGroups);
renderCharacterGroups(container);
};
div.querySelectorAll('.rm-char').forEach(btn => {
btn.onclick = (e) => {
const c = e.target.dataset.char;
gData.characters = gData.characters.filter(x => x !== c);
GM_setValue('characterGroupsOnline', characterGroups);
renderCharacterGroups(container);
};
});
wrap.appendChild(div);
});
}
function renderDetectedChars(container) {
const list = container.querySelector('#detected-chars-list');
list.innerHTML = '';
allDetectedCharacters.forEach(char => {
const item = document.createElement('div');
item.className = 'tts-char-item-simple';
item.innerHTML = `${char}`;
item.querySelector('.cfg-char').onclick = () => {
const speed = prompt(`设置 ${char} 的语速 (仅GPT-SoVITS有效):`, (characterVoices[char] && characterVoices[char].speed) || 1.0);
if (speed) {
characterVoices[char] = {
...(characterVoices[char] || {}),
speed: parseFloat(speed)
};
GM_setValue('characterVoicesOnline', characterVoices);
alert(`已保存 ${char} 的配置`);
}
};
item.querySelector('.del-char').onclick = () => {
allDetectedCharacters.delete(char);
GM_setValue('allDetectedCharactersOnline', Array.from(allDetectedCharacters));
renderDetectedChars(container);
renderCharacterGroups(container);
};
list.appendChild(item);
});
}
// 模块:诊断与调试工具
async function performNetworkTest() {
const btn = document.getElementById('cfg-test-conn') || document.activeElement;
const originalText = btn.textContent;
btn.textContent = '诊断中...';
btn.disabled = true;
const results = [];
if (typeof GM_xmlhttpRequest === 'undefined') {
results.push("❌ GM_xmlhttpRequest: 不可用 (请检查油猴权限)");
} else {
results.push("✅ GM_xmlhttpRequest: 可用");
}
results.push(`📱 User Agent: ${navigator.userAgent}`);
results.push(`🌐 Platform: ${navigator.platform}`);
if (typeof GM_info !== 'undefined') {
results.push(`🔧 Script Handler: ${GM_info.scriptHandler} ${GM_info.version}`);
results.push(`🔑 Script Version: ${GM_info.script.version}`);
}
if (navigator.connection) {
const { effectiveType, downlink } = navigator.connection;
results.push(`📡 Connection: ${effectiveType} (${downlink} Mbps)`);
}
try {
const cfRes = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://www.cloudflare.com/cdn-cgi/trace",
timeout: 5000,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error("Timeout"))
});
});
results.push(`✅ 互联网连接 (Cloudflare): ${cfRes.status} ${cfRes.statusText}`);
} catch (e) {
results.push(`❌ 互联网连接失败: ${e.message || e}`);
}
try {
const ttsRes = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: ttsApiUrl,
timeout: ttsFetchTimeout,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error("Timeout"))
});
});
if (ttsRes.status >= 200 && ttsRes.status < 300) {
results.push(`✅ TTS服务器 (${maskUrlDisplay(ttsApiUrl)}): 连接成功 (${ttsRes.status})`);
btn.style.background = '#28a745';
} else {
results.push(`❌ TTS服务器 (${maskUrlDisplay(ttsApiUrl)}): 异常状态码 ${ttsRes.status} ${ttsRes.statusText}`);
btn.style.background = '#dc3545';
}
} catch (e) {
results.push(`❌ TTS服务器 (${maskUrlDisplay(ttsApiUrl)}): 请求失败 - ${e.message || "无法连接"}`);
btn.style.background = '#dc3545';
}
btn.textContent = originalText;
btn.disabled = false;
showDiagnosticModal(results.join('\n'));
}
function showDiagnosticModal(resultText) {
const modal = document.createElement('div');
modal.className = 'tts-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.querySelector('.tts-close-btn').onclick = () => modal.remove();
modal.querySelector('#diag-copy-btn').onclick = function() {
navigator.clipboard.writeText(resultText);
this.textContent = '已复制';
setTimeout(() => this.textContent = '复制结果', 2000);
};
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
}
function showWhitelistManager() {
const whitelist = GM_getValue(URL_WHITELIST_KEY, []);
const modal = document.createElement('div');
modal.className = 'tts-modal';
modal.innerHTML = `
${whitelist.map(u => `
${u}
`).join('')}
`;
document.body.appendChild(modal);
modal.querySelector('.tts-close-btn').onclick = () => modal.remove();
const refresh = () => {
modal.remove();
showWhitelistManager();
};
const add = (u) => {
if (u && !whitelist.includes(u)) {
whitelist.push(u);
GM_setValue(URL_WHITELIST_KEY, whitelist);
refresh();
}
};
modal.querySelector('#wl-add').onclick = () => add(modal.querySelector('#wl-input').value);
modal.querySelector('#wl-add-curr').onclick = () => add(window.location.host);
modal.querySelectorAll('.wl-del').forEach(b => b.onclick = (e) => {
const idx = whitelist.indexOf(e.target.dataset.url);
if (idx > -1) {
whitelist.splice(idx, 1);
GM_setValue(URL_WHITELIST_KEY, whitelist);
refresh();
}
});
}
function showConsoleLogger() {
const modal = document.createElement('div');
modal.className = 'tts-modal';
const customStyle = `
.log-detail-box {
margin-left: 20px;
margin-top: 4px;
padding: 6px;
background: #2d2d2d;
border-radius: 4px;
color: #d63384;
font-family: monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.3;
}
.log-label {
color: #aaa;
font-weight: bold;
display: block;
margin-bottom: 4px;
border-bottom: 1px dashed #444;
padding-bottom: 2px;
}
`;
modal.innerHTML = `
`;
document.body.appendChild(modal);
const view = modal.querySelector('#log-view');
const btns = modal.querySelectorAll('.tts-filter-btn:not(#btn-filter-audio)');
const audioBtn = modal.querySelector('#btn-filter-audio');
let currentFilter = 'all';
let showOnlyAudio = false;
const render = () => {
view.innerHTML = '';
logStore.forEach(l => {
if (currentFilter !== 'all' && l.type !== currentFilter) return;
if (showOnlyAudio) {
if (!l.details || !l.details.audioUrl) return;
}
const row = document.createElement('div');
row.style.borderBottom = '1px solid #333';
row.style.padding = '6px 0';
let typeColor = '#888';
if (l.type === 'sys') typeColor = '#667eea';
if (l.type === 'net') typeColor = '#28a745';
if (l.type === 'err' || l.message.includes('错误') || l.message.includes('失败') || l.message.includes('拒绝')) typeColor = '#dc3545';
if (l.type === 'warn') typeColor = '#fd7e14';
let html = `[${l.timestamp}] [${l.type.toUpperCase()}] ${l.message}`;
if (l.details) {
if (l.details.audioUrl) {
html += `🎵 URL: ${l.details.audioUrl}
`;
}
let contentText = l.details.responseText || (l.details.error ? JSON.stringify(l.details.error, null, 2) : null);
if (contentText) {
const isUselessText = typeof contentText === 'string' && (
contentText.trim() === 'Forbidden' ||
contentText.trim() === 'Not Found' ||
contentText.includes('📄 Response / Details:${contentText}`;
}
}
}
row.innerHTML = html;
view.appendChild(row);
});
view.scrollTop = view.scrollHeight;
};
btns.forEach(b => b.onclick = (e) => {
btns.forEach(btn => btn.classList.remove('active'));
e.target.classList.add('active');
currentFilter = e.target.dataset.filter;
render();
});
audioBtn.onclick = () => {
showOnlyAudio = !showOnlyAudio;
if (showOnlyAudio) audioBtn.classList.add('active');
else audioBtn.classList.remove('active');
render();
};
modal.querySelector('#log-clear').onclick = () => {
logStore = [];
render();
};
modal.querySelector('.tts-close-btn').onclick = () => modal.remove();
render();
}
// 模块:边缘隐藏功能
function toggleEdgeHide() {
const panel = document.getElementById('tts-floating-panel');
if (!panel) return;
if (isEdgeHidden) {
showPanel();
} else {
hideToEdge();
}
}
function hideToEdge() {
const panel = document.getElementById('tts-floating-panel');
if (!panel) return;
const rect = panel.getBoundingClientRect();
originalPosition = {
left: panel.style.left || rect.left + 'px',
top: panel.style.top || rect.top + 'px',
right: panel.style.right,
transform: panel.style.transform
};
panel.style.left = 'auto';
panel.style.right = '0';
panel.style.top = rect.top + 'px';
panel.style.transform = 'translateX(100%)';
panel.classList.add('edge-hidden');
isEdgeHidden = true;
createEdgeIndicator();
const hideBtn = document.getElementById('tts-hide-btn');
if (hideBtn) {
hideBtn.innerHTML = '👁🗨';
hideBtn.title = '显示面板';
}
showNotification('面板已隐藏,点击右侧边缘角标可还原', 'info');
}
function showPanel() {
const panel = document.getElementById('tts-floating-panel');
if (!panel) return;
removeEdgeIndicator();
if (originalPosition) {
panel.style.left = originalPosition.left;
panel.style.top = originalPosition.top;
panel.style.right = originalPosition.right;
panel.style.transform = originalPosition.transform || 'none';
} else {
panel.style.left = 'auto';
panel.style.right = '20px';
panel.style.top = '20%';
panel.style.transform = 'none';
}
panel.classList.remove('edge-hidden');
isEdgeHidden = false;
const hideBtn = document.getElementById('tts-hide-btn');
if (hideBtn) {
hideBtn.innerHTML = '👁';
hideBtn.title = '边缘隐藏';
}
}
function createEdgeIndicator() {
removeEdgeIndicator();
const indicator = document.createElement('div');
indicator.id = 'tts-edge-indicator';
indicator.className = 'tts-edge-indicator';
indicator.innerHTML = ``;
indicator.title = '点击显示TTS面板';
document.body.appendChild(indicator);
const panel = document.getElementById('tts-floating-panel');
if (edgeIndicatorLastTop) {
indicator.style.top = edgeIndicatorLastTop;
} else if (panel) {
const rect = panel.getBoundingClientRect();
indicator.style.top = (rect.top + 20) + 'px';
}
makeIndicatorDraggable(indicator);
}
function removeEdgeIndicator() {
const indicator = document.getElementById('tts-edge-indicator');
if (indicator) indicator.remove();
}
function makeIndicatorDraggable(indicator) {
let isDragging = false;
let hasDragged = false;
let startY, startTop;
let mouseMoveHandler, mouseUpHandler, touchMoveHandler, touchEndHandler;
const getClientY = (e) => e.touches ? e.touches[0].clientY : e.clientY;
const dragStart = (e) => {
e.stopPropagation();
if (e.button === 2) return;
isDragging = true;
hasDragged = false;
const clientY = getClientY(e);
startY = clientY;
startTop = indicator.getBoundingClientRect().top;
indicator.style.transition = 'none';
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
mouseMoveHandler = dragMove;
mouseUpHandler = dragEnd;
touchMoveHandler = dragMove;
touchEndHandler = dragEnd;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
document.addEventListener('touchmove', touchMoveHandler, { passive: false });
document.addEventListener('touchend', touchEndHandler);
};
const dragMove = (e) => {
if (!isDragging) return;
const clientY = getClientY(e);
if (!hasDragged && Math.abs(clientY - startY) > 5) hasDragged = true;
if (!hasDragged) return;
e.preventDefault();
const deltaY = clientY - startY;
let newTop = Math.max(0, Math.min(window.innerHeight - indicator.offsetHeight, startTop + deltaY));
indicator.style.top = `${newTop}px`;
};
const dragEnd = (e) => {
if (!isDragging) return;
if (hasDragged) edgeIndicatorLastTop = indicator.style.top;
isDragging = false;
indicator.style.transition = '';
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
document.removeEventListener('touchmove', touchMoveHandler);
document.removeEventListener('touchend', touchEndHandler);
};
indicator.addEventListener('mousedown', dragStart);
indicator.addEventListener('touchstart', dragStart, { passive: false });
indicator.addEventListener('click', (e) => {
if (!hasDragged) {
showPanel();
}
});
}
// 模块:文本解析与播放流程控制
function extractTextDeep(element) {
if (!element) return '';
const iframes = element.querySelectorAll('iframe');
const [qS, qE] = getCurrentQuotePair();
if (iframes.length > 0) {
let iframeText = '';
iframes.forEach(iframe => {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
const wrappers = doc.querySelectorAll('.dialogue-wrapper');
if (wrappers.length > 0) {
wrappers.forEach(wrap => {
const char = wrap.querySelector('.dialogue-char')?.textContent.replace(/【|】/g, '').trim();
const textDiv = wrap.querySelector('.dialogue-text');
const text = textDiv?.dataset.fullText || textDiv?.textContent;
if (text) iframeText += char ? `【${char}】${qS}${text}${qE}\n` : `${qS}${text}${qE}\n`;
});
} else {
const narratives = doc.querySelectorAll('.narrative-text');
if (narratives.length > 0) narratives.forEach(n => iframeText += n.textContent + '\n');
else iframeText += doc.body.textContent + '\n';
}
}
} catch (e) {}
});
if (iframeText.trim()) return iframeText;
}
const summaryElements = element.querySelectorAll('details summary');
summaryElements.forEach(s => s.style.display = 'none');
let text = element.innerText || element.textContent;
summaryElements.forEach(s => s.style.display = '');
return text;
}
function handlePlayClick() {
if (isPlaying) {
isPaused = !isPaused;
if (isPaused) {
addLog('sys', '用户暂停');
if (currentAudio) currentAudio.pause();
} else {
addLog('sys', '用户恢复播放');
if (currentAudio) currentAudio.play();
}
updatePlayBtnState();
return;
}
const tasks = parsePageText();
if (!tasks || tasks.length === 0) {
showNotification('未检测到对话内容', 'warning');
return;
}
if (currentAudio || generationQueue.length > 0 || playbackQueue.length > 0) {
addLog('sys', '播放启动前,检测到残留状态,执行安全清理');
handleStopClick();
}
handleStopClick();
isPlaying = true;
isPaused = false;
lastMessageParts = tasks;
updatePlayBtnState();
if (playbackMode === 'gal') {
const galData = tasks.map(t => ({
character: t.character,
content: t.dialogue,
emotion: t.emotion
}));
GalStreamingPlayer.initialize(galData).then(() => {
GalStreamingPlayer.playNext();
});
return;
}
generationQueue = [...tasks];
processGenerationQueue();
}
function handleStopClick() {
addLog('sys', '停止播放,清理缓存');
if (GalStreamingPlayer.isActive) {
GalStreamingPlayer.stop();
}
isPlaying = false;
isPaused = false;
isGenerating = false;
generationQueue = [];
playbackQueue = [];
sessionAudioCache = [];
if (currentAudio) {
currentAudio.pause();
currentAudio.removeAttribute('src');
currentAudio = null;
}
updatePlayBtnState();
}
function handleReplayClick() {
if (!isPlaying) return;
if (playbackMode === 'gal') return;
addLog('sys', '重播当前片段 (检查缓存...)');
if (currentAudio) currentAudio.pause();
isPaused = false;
generationQueue = [];
playbackQueue = [];
lastMessageParts.forEach(task => {
const cachedItem = sessionAudioCache.find(c =>
c.task.dialogue === task.dialogue &&
c.task.character === task.character
);
if (cachedItem) {
playbackQueue.push(cachedItem);
} else {
generationQueue.push(task);
}
});
if (generationQueue.length > 0) processGenerationQueue();
processPlaybackQueue();
updatePlayBtnState();
}
function handleReinferClick() {
addLog('sys', '强制重新推理');
handleStopClick();
handlePlayClick();
}
function updatePlayBtnState() {
const playBtn = document.getElementById('tts-play-btn');
const stopBtn = document.getElementById('tts-stop-btn');
const replayBtn = document.getElementById('tts-replay-btn');
const reinferBtn = document.getElementById('tts-reinfer-btn');
if (!playBtn || !stopBtn) return;
if (isPlaying) {
stopBtn.style.display = 'inline-block';
replayBtn.disabled = playbackMode === 'gal';
reinferBtn.disabled = false;
if (isGenerating && playbackMode !== 'gal') {
playBtn.innerHTML = '⏳生成中...';
playBtn.disabled = true;
playBtn.title = "正在生成音频...";
} else if (isPaused) {
playBtn.innerHTML = '▶继续';
playBtn.disabled = false;
playBtn.title = "继续播放";
} else {
playBtn.innerHTML = '⏸暂停';
playBtn.disabled = false;
playBtn.title = "暂停";
}
} else {
stopBtn.style.display = 'none';
playBtn.innerHTML = '▶播放';
playBtn.disabled = false;
playBtn.title = "开始播放";
replayBtn.disabled = true;
reinferBtn.disabled = false;
}
}
function parsePageText() {
const msgs = document.querySelectorAll('div.mes[is_user="false"]');
if (msgs.length === 0) return [];
const lastMsg = msgs[msgs.length - 1];
const textEl = lastMsg.querySelector('.mes_text') || lastMsg;
const fullText = extractTextDeep(textEl).trim();
if (fullText) lastProcessedMessageId = lastMsg.getAttribute('mesid') || fullText.substring(0, 30);
const [qStart, qEnd] = getCurrentQuotePair();
const results = [];
const addToPool = (c) => {
if (c && !allDetectedCharacters.has(c)) {
allDetectedCharacters.add(c);
GM_setValue('allDetectedCharactersOnline', Array.from(allDetectedCharacters));
}
};
const cleanQuote = (t) => t.substring(1, t.length - 1).trim();
const cleanNoise = (t) => {
if (!t) return t;
return t.replace(/〈[^〉]*〉/g, '').replace(/\([^)]*\)/g, '').replace(/([^)]*)/g, '').replace(/『[^』]*』/g, '');
};
const flexibleRegex = new RegExp(`(?:【([^】]+)】(?:[^${qStart}]*?〈([^〉]+)〉)?.*?)?(${qStart}[^${qEnd}]+${qEnd})`, 'g');
if (detectionMode === 'character_and_dialogue' || detectionMode === 'character_emotion_and_dialogue') {
let match;
while ((match = flexibleRegex.exec(fullText)) !== null) {
const char = match[1] ? match[1].trim() : null;
const emotion = match[2] ? match[2].trim() : null;
const text = cleanQuote(match[3]);
if (text) {
const task = {
character: char,
dialogue: text
};
if (detectionMode === 'character_emotion_and_dialogue' && emotion) task.emotion = emotion;
results.push(task);
if (char) addToPool(char);
}
}
} else if (detectionMode === 'emotion_and_dialogue') {
const regex = new RegExp(`(?:〈([^〉]+)〉\\s*)?(${qStart}[^${qEnd}]+${qEnd})`, 'g');
let match;
while ((match = regex.exec(fullText)) !== null) {
results.push({
character: null,
emotion: match[1] ? match[1].trim() : null,
dialogue: cleanQuote(match[2])
});
}
} else if (detectionMode === 'narration_and_dialogue') {
const regex = new RegExp(`(?:【([^】]+)】(?:[^${qStart}]*?〈([^〉]+)〉)?.*?)?(${qStart}[^${qEnd}]+${qEnd})|([^${qStart}${qEnd}\\n]+)`, 'g');
let match;
while ((match = regex.exec(fullText)) !== null) {
if (match[3]) {
const char = match[1] ? match[1].trim() : null;
const emotion = match[2] ? match[2].trim() : null;
let text = cleanNoise(cleanQuote(match[3])).trim();
if (text) {
results.push({
character: char,
emotion: emotion,
dialogue: text
});
if (char) addToPool(char);
}
} else if (match[4]) {
let narration = cleanNoise(match[4]).trim();
if (narration && /[a-zA-Z\u4e00-\u9fa5]/.test(narration)) results.push({
character: null,
dialogue: narration,
isNarration: true
});
}
}
} else if (detectionMode === 'dialogue_only') {
const regex = new RegExp(`${qStart}([^${qEnd}]+?)${qEnd}`, 'g');
let match;
while ((match = regex.exec(fullText)) !== null) results.push({
character: null,
dialogue: match[1].trim()
});
} else if (detectionMode === 'entire_message') {
const segments = fullText.split('\n');
segments.forEach(seg => {
const t = cleanNoise(seg).trim();
if (t) results.push({
character: null,
dialogue: t
});
});
}
if (results.length === 0 && fullText && detectionMode !== 'entire_message') {
const fallbackRegex = new RegExp(`${qStart}([^${qEnd}]+?)${qEnd}`, 'g');
let match;
while ((match = fallbackRegex.exec(fullText)) !== null) results.push({
character: null,
dialogue: match[1].trim()
});
}
return results;
}
function handleFrontendDetect() {
const res = parsePageText();
let msg = '';
let logDetails = `检测模式: ${detectionMode}\n----------------\n`;
const previewLines = res.map((r, i) => {
let line = `${i+1}. `;
if (r.isNarration) line += `(旁白) "${r.dialogue.substring(0, 50)}..."`;
else {
if (r.character) line += `【${r.character}】`;
if (r.emotion) line += `〈${r.emotion}〉`;
line += `「${r.dialogue.substring(0, 50)}...」`;
}
return line;
});
logDetails += previewLines.join('\n');
addLog('sys', `检测完成: ${res.length} 条`, {
responseText: logDetails
});
msg = `检测到 ${res.length} 条语音片段。\n详细结果已写入系统日志。`;
alert(msg);
}
async function processGenerationQueue() {
if (!isPlaying || generationQueue.length === 0) {
isGenerating = false;
updatePlayBtnState();
return;
}
isGenerating = true;
updatePlayBtnState();
const task = generationQueue.shift();
try {
const result = await generateAudio(task);
if (!isPlaying) {
isGenerating = false;
updatePlayBtnState();
return;
}
playbackQueue.push(result);
sessionAudioCache.push(result);
if (!currentAudio || currentAudio.paused) processPlaybackQueue();
processGenerationQueue();
} catch (e) {
console.error(e);
processGenerationQueue();
}
}
async function processPlaybackQueue() {
if (!isPlaying || isPaused) return;
if (playbackQueue.length === 0) {
if (generationQueue.length === 0 && !isGenerating) {
addLog('sys', '播放结束,自动停止');
handleStopClick();
}
return;
}
const item = playbackQueue.shift();
try {
const blobUrl = await fetchAudioBlob(item.url);
if (!isPlaying) {
URL.revokeObjectURL(blobUrl);
return;
}
if (isPaused) {
playbackQueue.unshift(item);
URL.revokeObjectURL(blobUrl);
return;
}
if (!document.getElementById('tts-audio-player')) {
const aud = document.createElement('audio');
aud.id = 'tts-audio-player';
document.body.appendChild(aud);
}
currentAudio = document.getElementById('tts-audio-player');
currentAudio.src = blobUrl;
currentAudio.onended = () => {
URL.revokeObjectURL(blobUrl);
processPlaybackQueue();
};
currentAudio.onerror = () => {
URL.revokeObjectURL(blobUrl);
processPlaybackQueue();
};
const p = currentAudio.play();
if (p) p.catch(() => processPlaybackQueue());
} catch (e) {
processPlaybackQueue();
}
}
// 模块:自动播放监听
function observeChat() {
const observerCallback = (mutations, observer) => {
if (!autoPlayEnabled) return;
if (autoPlayTimer) clearTimeout(autoPlayTimer);
autoPlayTimer = setTimeout(() => {
if (!autoPlayEnabled) return;
const msgs = document.querySelectorAll('div.mes[is_user="false"]');
if (msgs.length === 0) return;
const lastMsg = msgs[msgs.length - 1];
const textEl = lastMsg.querySelector('.mes_text') || lastMsg;
const currentId = lastMsg.getAttribute('mesid') || textEl.textContent.substring(0, 50);
if (currentId === lastProcessedMessageId) return;
if (isPlaying) {
if (playbackMode === 'non-stream') {
addLog('sys', `自动播放: 忽略新消息 (非流式模式正在播放中)`);
return;
}
addLog('sys', `自动播放: 检测到新消息,清空当前队列并重新开始`);
handleStopClick();
}
const tasks = parsePageText();
if (tasks && tasks.length > 0) {
addLog('sys', `自动播放: 执行新请求 [${currentId}]`);
lastProcessedMessageId = currentId;
handlePlayClick();
}
}, 1000);
};
const observer = new MutationObserver(observerCallback);
const mountObserver = () => {
const chatContainer = document.querySelector('#chat');
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: true,
characterData: true
});
console.log('[TTS] 自动播放监听器已挂载');
} else {
setTimeout(mountObserver, 1000);
}
};
mountObserver();
}
// 模块:样式注入
GM_addStyle(`
#tts-floating-panel, div.tts-modal, #tts-notification-container {
font-family: system-ui, -apple-system, sans-serif !important;
font-size: 14px;
line-height: 1.5;
color: #333;
box-sizing: border-box;
text-align: left;
}
#tts-floating-panel *, div.tts-modal *, #tts-notification-container * {
box-sizing: border-box;
}
#tts-floating-panel {
position: fixed; z-index: 9999;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px; padding: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
transition: opacity 0.3s, transform 0.3s;
user-select: none;
display: flex; flex-direction: column; align-items: center;
width: auto; height: auto;
}
#tts-floating-panel.edge-mode {
right: 0px !important; left: auto !important;
width: auto !important; transform: translateX(0) !important;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
#tts-floating-panel.edge-hidden {
transform: translateX(120%) !important; opacity: 0.5; pointer-events: none;
}
#tts-edge-indicator {
position: fixed; right: 0px; top: 50%; width: 30px; height: 60px;
background: rgba(102, 126, 234, 0.3); border: none; color: #667eea;
display: flex; align-items: center; justify-content: center;
border-radius: 10px 0 0 10px; cursor: pointer; z-index: 10000;
transition: all 0.3s; user-select: none;
}
#tts-edge-indicator:hover { background: rgba(102,126,234,0.8); width: 36px; color: white; }
#tts-floating-panel .tts-main-controls { display: flex; gap: 5px; align-items: center; justify-content: center; flex-direction: column; }
#tts-floating-panel .tts-control-btn {
display: flex; align-items: center; justify-content: center;
min-width: 40px; height: 40px; border: none; border-radius: 12px;
font-size: 18px; cursor: pointer; transition: all 0.2s;
color: white; box-shadow: 0 4px 6px rgba(0,0,0,0.1); margin: 0; padding: 0 10px;
}
#tts-floating-panel .tts-control-btn:hover { transform: translateY(-2px); }
#tts-floating-panel .tts-control-btn .text { font-size: 12px; margin-left: 6px; display: inline-block; }
#tts-floating-panel .tts-control-btn.primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
#tts-floating-panel .tts-control-btn.danger { background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%); color: #d63384; }
#tts-floating-panel .tts-control-btn.secondary { background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); color: #495057; }
#tts-floating-panel .tts-control-btn.settings { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
div.tts-modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000;
display: flex;
align-items: center; justify-content: center;
padding: 30px;
}
div.tts-modal .tts-modal-content {
background: white;
border-radius: 16px;
width: 600px; max-width: 95vw;
max-height: 90vh;
display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
position: relative;
overflow: hidden;
margin: auto;
}
div.tts-modal .tts-modal-header {
padding: 15px 20px; flex-shrink: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; display: flex; justify-content: space-between; align-items: center;
cursor: move;
}
div.tts-modal .tts-modal-header h2 { margin: 0; font-size: 18px; color: white; }
div.tts-modal .tts-header-btn, div.tts-modal .tts-close-btn {
background: rgba(255,255,255,0.2); border: none; color: white; width: 32px; height: 32px;
border-radius: 50%; cursor: pointer; margin-left: 5px;
display: flex; justify-content: center; align-items: center; padding: 0; font-size: 14px;
}
div.tts-modal .tts-modal-body {
padding: 20px; overflow-y: auto; flex: 1; min-height: 0;
-webkit-overflow-scrolling: touch;
}
div.tts-modal .tts-setting-section { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 12px; padding: 15px; margin-bottom: 15px; }
div.tts-modal .tts-setting-section h3 { margin: 0 0 10px 0; font-size: 16px; color: #495057; border-bottom: 2px solid #dee2e6; padding-bottom: 5px; }
div.tts-modal .tts-setting-item { margin-bottom: 12px; }
div.tts-modal label { display: block; font-weight: 500; margin-bottom: 5px; font-size: 14px; color: #333; }
div.tts-modal input[type="text"],
div.tts-modal input[type="number"],
div.tts-modal textarea,
div.tts-modal select {
width: 100%; padding: 8px; border: 1px solid #ced4da; border-radius: 6px;
font-size: 14px; background-color: #ffffff !important; color: #333333 !important;
outline: none; margin: 0; min-height: 36px;
}
div.tts-modal select, div.tts-modal select option { background-color: #ffffff !important; color: #333333 !important; }
div.tts-modal input[type="file"] {
display: block;
width: 100%;
padding: 8px 0;
color: #333;
background: transparent;
cursor: pointer;
min-height: 36px;
}
div.tts-modal .tts-group-controls {
display: flex !important; align-items: center !important; gap: 8px !important; width: 100%;
}
div.tts-modal #new-group-name {
width: auto !important; flex: 1 !important; min-width: 0 !important; margin: 0 !important;
}
div.tts-modal #new-group-color { flex-shrink: 0; margin: 0 !important; }
div.tts-modal #add-group-btn { flex-shrink: 0; margin: 0 !important; }
div.tts-modal .tts-test-btn { background: #28a745; color: white; border: none; padding: 0 15px; border-radius: 6px; cursor: pointer; height: 36px; line-height: 36px; display: inline-block; }
div.tts-modal .tts-add-group-btn, div.tts-modal #add-group-btn {
background: #667eea !important; color: white !important;
border: none; padding: 0 12px; cursor: pointer; border-radius: 4px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
}
div.tts-modal .rm-char, div.tts-modal .del-grp, div.tts-modal .wl-del {
background: #dc3545 !important; color: white !important;
border: none; cursor: pointer; border-radius: 4px;
padding: 0 12px; font-size: 12px;
height: 26px; line-height: 26px;
display: inline-flex; align-items: center; justify-content: center;
}
div.tts-modal #wl-add-curr { background: #6c757d !important; color: white !important; }
div.tts-modal .tts-filter-btn { background: #fff; border: 1px solid #ccc; padding: 4px 12px; border-radius: 14px; cursor: pointer; font-size: 12px; color: #555; margin-right: 5px; }
div.tts-modal .tts-filter-btn.active { background: #667eea !important; color: white !important; border-color: #667eea !important; }
div.tts-modal #wl-input { flex: 1; width: auto !important; }
div.tts-modal #wl-add { flex-shrink: 0; white-space: nowrap; margin-left: 5px; }
div.tts-modal .tts-group-item { background: #fff; border: 1px solid #eee; border-radius: 8px; margin-bottom: 10px; }
div.tts-modal .tts-group-header { padding: 8px 12px; background: #f1f3f5; display: flex; justify-content: space-between; font-weight: bold; }
div.tts-modal .tts-group-content { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
div.tts-modal .tts-group-character {
background: #e7f5ff; color: #1c7ed6;
padding: 5px 10px; border-radius: 8px; font-size: 13px;
width: 100%; display: flex; justify-content: space-between; align-items: center;
}
div.tts-modal .tts-group-character span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 10px; }
div.tts-modal .tts-group-content > div:last-child {
width: 100%; margin-top: 5px; background-color: #e7f5ff; padding: 5px; border-radius: 8px;
}
div.tts-modal .add-char-sel { border-color: #cfe2ff !important; }
div.tts-modal #detected-chars-list { display: flex; flex-direction: column; gap: 5px; }
div.tts-modal .tts-char-item-simple {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 8px; border-bottom: 1px solid #eee; background: #fff; border-radius: 4px;
}
div.tts-modal .tts-char-item-simple:last-child { border-bottom: none; }
div.tts-modal .tts-char-item-simple > div { display: flex; gap: 6px; }
div.tts-modal .cfg-char { background: #28a745 !important; color: white !important; border: none; cursor: pointer; border-radius: 4px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; }
div.tts-modal .del-char { background: #dc3545 !important; color: white !important; border: none; cursor: pointer; border-radius: 4px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; }
div.tts-modal .auth-config-container { display: flex; flex-direction: column; gap: 7px; width: 100%; }
div.tts-modal .auth-input-group { display: flex; width: 100%; gap: 5px; }
div.tts-modal .custom-prefix-wrap { width: 100px; display: none; }
div.tts-modal .tts-switch-label {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin: 0;
cursor: pointer;
min-height: 40px;
user-select: none;
}
div.tts-modal .tts-switch-slider {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
background: #ccc;
border-radius: 24px;
transition: .3s;
vertical-align: middle;
flex-shrink: 0;
}
div.tts-modal .tts-switch-slider:before {
content: "";
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: .3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
div.tts-modal input:checked + .tts-switch-slider { background: #667eea; }
div.tts-modal input:checked + .tts-switch-slider:before { transform: translateX(20px); }
div.tts-modal input[type="checkbox"] { display: none; }
.log-detail-box { margin-left: 20px; margin-top: 4px; padding: 6px; background: #2d2d2d; border-radius: 4px; color: #d63384; font-family: monospace; font-size: 11px; white-space: pre-wrap; word-break: break-all; }
div.tts-modal .tts-io-btn {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
border: none;
padding: 0 15px;
border-radius: 4px;
height: 36px;
line-height: 36px;
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
div.tts-modal .tts-io-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
@media (max-width: 768px) {
#tts-floating-panel { transform: scale(0.9); padding: 8px; }
#tts-floating-panel .tts-control-btn .text { display: none; }
div.tts-modal {
align-items: flex-start !important;
padding-top: 20px !important;
padding-bottom: 20px !important;
padding-left: 10px !important;
padding-right: 10px !important;
}
div.tts-modal .tts-modal-content {
position: relative !important;
left: auto !important; top: auto !important; transform: none !important;
width: 100% !important; max-width: 100% !important;
max-height: 85vh !important;
margin: 0 auto !important;
}
}
`);
// 模块:初始化入口
function init() {
if (!isCurrentUrlWhitelisted()) {
console.log("TTS: 当前网站不在白名单中,已禁用。");
return;
}
initConsoleLogger();
createUI();
observeChat();
console.log("多角色TTS播放器 Loaded");
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();