// ==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 https://update.greasyfork.icu/scripts/557449/%E5%A4%9A%E8%A7%92%E8%89%B2TTS%E6%92%AD%E6%94%BE%E5%99%A8.user.js // @updateURL https://update.greasyfork.icu/scripts/557449/%E5%A4%9A%E8%A7%92%E8%89%B2TTS%E6%92%AD%E6%94%BE%E5%99%A8.meta.js // ==/UserScript== /* * ============================= * COPYRIGHT NOTICE & USER GUIDELINES * ============================= * * Copyright Notice (English) * -------------------------- * 1. All rights to this script (including but not limited to code, logical architecture, and functional design) are exclusively owned by JChSh (Bilibili UID: 511242). This work is protected by the Copyright Law of the People's Republic of China and relevant international conventions. * 2. Without the prior written permission of the copyright owner, no organization or individual may use this script for commercial purposes. Commercial use includes sale, rental, advertising insertion, and association with commercial services. * 3. Non-commercial derivative works are permitted only if: * - You have obtained written permission from JChSh. * - You clearly credit the source: "This work is based on the script created by JChSh (Bilibili UID: 511242). Special thanks to Brain-like Community members cnfh1746_06138 & kikukiku0662 for the GAL mode and multi-character emotional design ideas." * - The original copyright notice is not modified or deleted. * 4. It is strictly prohibited to use this script for illegal activities. * * 版权声明 (中文) * ----------------- * 1. 本脚本(含代码、逻辑架构、功能设计)的完整版权归 JChSh(Bilibili UID:511242) 所有,受《中华人民共和国著作权法》等法律保护。 * 2. 未经版权人事先书面许可,严禁任何形式的商业化用途(包括但不限于售卖、出租、植入广告等)。商业侵权产生的一切收益归版权人所有,侵权方需承担法律责任。 * 3. 非商业化二创需满足:① 获得 JChSh 书面许可;② 在显著位置标注:“本作品基于 JChSh(Bilibili UID:511242)的脚本创作,GAL模式与多角色情感思路特别致谢 类脑社区 cnfh1746_06138 & kikukiku0662”;③ 保留原版权声明。 * 4. 严禁利用本脚本从事违法犯罪行为,使用者需自行承担法律责任。 * * User Guidelines * --------------- * 1. You are granted only the right to use this script for non-commercial purposes. * 2. You shall bear all risks associated with the use of this script. The copyright owner makes no warranties regarding compatibility or stability. * 3. The copyright owner reserves the right to pursue legal action against any violation. *For the full Copyright Notice & User Guidelines, please visit: https://github.com/JiangChSh1995/tm-tts-multirole * * 用户须知 * -------- * 1. 您仅获得本脚本的非商业性使用权限,无权转让或商业化利用。 * 2. 您应自行承担使用本脚本的风险,版权人不对稳定性做担保。 * 3. 对于违反本声明的行为,版权人保留追究法律责任的权利。 * 4.完整版用户须知以及版权声明请访问: https://github.com/JiangChSh1995/tm-tts-multirole * * ============================= */ (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 = `

TTS 播放器设置

🔌 连接设置

API连接/测试
合成音频生成
支持 api_type: "openai" 或 "gpt-sovits"
${refAudioPath ? `✅ 已保存: ${refAudioPath}` : '未选择文件'}

🎮 功能设置

🏷️ 分组角色设置

检测到的角色池

`; 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 = `
${gName} (${gData.characters.length})
${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 = `

🔍 网络诊断结果

${resultText}
`; 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(); } })();