// ==UserScript== // @name 在线听书助手 🎧 // @namespace https://github.com/ebook-tts // @version 4.0.0 // @description 在线阅读时朗读网页内容,支持系统音色+多路网络音色(有道/搜狗/Google TTS)、中文音色名、语速/音调/音量调节、悬浮球设置面板 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect dict.youdao.com // @connect tts.baidu.com // @connect translate.google.com // @connect translate.googleapis.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 音色中文名称映射表(本地系统音色) // ===================================================== const VOICE_ZH_NAME = { 'Microsoft Huihui': '微软 慧慧(普通话·女)', 'Microsoft Kangkang': '微软 康康(普通话·男)', 'Microsoft Yaoyao': '微软 瑶瑶(普通话·女童)', 'Microsoft Tracy': '微软 晓晓(粤语·女)', 'Microsoft Danny': '微软 丹尼(粤语·男)', 'Microsoft HiuGaai': '微软 晓佳(粤语·女)', 'Microsoft HiuMaan': '微软 晓曼(粤语·女)', 'Microsoft WanLung': '微软 云龙(粤语·男)', 'Microsoft Xiaoxiao': '微软 晓晓(普通话·女)', 'Microsoft Yunxi': '微软 云希(普通话·男)', 'Microsoft Yunjian': '微软 云健(普通话·男)', 'Microsoft Xiaochen': '微软 晓辰(普通话·女)', 'Microsoft Xiaohan': '微软 晓涵(普通话·女)', 'Microsoft Xiaomeng': '微软 晓梦(普通话·女)', 'Microsoft Xiaomo': '微软 晓墨(普通话·女)', 'Microsoft Xiaoqiu': '微软 晓秋(普通话·女)', 'Microsoft Xiaorui': '微软 晓睿(普通话·女)', 'Microsoft Xiaoshuang': '微软 晓双(普通话·女童)', 'Microsoft Xiaoyan': '微软 晓颜(普通话·女)', 'Microsoft Xiaoyou': '微软 晓悠(普通话·女童)', 'Microsoft Xiaozhen': '微软 晓甄(普通话·女)', 'Microsoft Yunfeng': '微软 云枫(普通话·男)', 'Microsoft Yunhao': '微软 云皓(普通话·男)', 'Microsoft Yunxia': '微软 云夏(普通话·男)', 'Microsoft Yunyang': '微软 云扬(普通话·男)', 'Microsoft Yunye': '微软 云野(普通话·男)', 'Microsoft Yunze': '微软 云泽(普通话·男)', 'Ting-Ting': '婷婷(普通话·女)', 'Sin-ji': '善慈(粤语·女)', 'Mei-Jia': '美佳(闽南话·女)', 'Yu-shu': '玉书(台湾·女)', 'Google 普通话(中国大陆)': '谷歌 普通话(大陆·女)', 'Google 粤語(香港)': '谷歌 粤语(香港·女)', 'Google 國語(臺灣)': '谷歌 国语(台湾·女)', 'Google 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 网络音色列表(多引擎,通过 GM_xmlhttpRequest 调用,无跨域限制) // ===================================================== const ONLINE_VOICES = [ // 有道TTS(dict.youdao.com,最稳定,无需Key) { id: 'youdao:0', name: '有道·女声(普通话·自然)', lang: 'zh-CN', gender: '女', engine: 'youdao', type: '0' }, { id: 'youdao:1', name: '有道·男声(普通话·沉稳)', lang: 'zh-CN', gender: '男', engine: 'youdao', type: '1' }, { id: 'youdao:3', name: '有道·萝莉(普通话·女童)', lang: 'zh-CN', gender: '女', engine: 'youdao', type: '3' }, { id: 'youdao:4', name: '有道·大叔(普通话·低沉)', lang: 'zh-CN', gender: '男', engine: 'youdao', type: '4' }, // 百度TTS(tts.baidu.com) { id: 'baidu:0', name: '百度·女声(普通话·标准)', lang: 'zh-CN', gender: '女', engine: 'baidu', type: '0' }, { id: 'baidu:1', name: '百度·男声(普通话·磁性)', lang: 'zh-CN', gender: '男', engine: 'baidu', type: '1' }, // Google TTS(translate.googleapis.com) { id: 'google:zh-CN', name: 'Google·普通话(大陆·女)', lang: 'zh-CN', gender: '女', engine: 'google', type: 'zh-CN' }, { id: 'google:zh-TW', name: 'Google·国语(台湾·女)', lang: 'zh-TW', gender: '女', engine: 'google', type: 'zh-TW' }, ]; // ===================================================== // 状态管理 // ===================================================== const state = { isPlaying: false, isPaused: false, utterance: null, audioEl: null, audioQueue: [], // 待播放音频队列 paragraphs: [], currentIndex: 0, voices: [], selectedVoiceId: GM_getValue('selectedVoiceId', ''), rate: parseFloat(GM_getValue('rate', '1.0')), pitch: parseFloat(GM_getValue('pitch', '1.0')), volume: parseFloat(GM_getValue('volume', '1.0')), highlightEl: null, panelOpen: false, voiceMode: GM_getValue('voiceMode', 'local'), onlineAbort: null, // 中断控制器 }; // ===================================================== // 样式 // ===================================================== GM_addStyle(` #erp-fab { position: fixed !important; bottom: 28px !important; right: 28px !important; z-index: 2147483646 !important; width: 52px !important; height: 52px !important; border-radius: 50% !important; background: linear-gradient(135deg, #7c4dff, #e040fb) !important; box-shadow: 0 4px 20px rgba(124,77,255,0.55) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 24px !important; border: none !important; outline: none !important; transition: transform 0.2s, box-shadow 0.2s !important; user-select: none !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; clip: auto !important; clip-path: none !important; transform: none !important; } #erp-fab:hover { transform: scale(1.1) !important; box-shadow: 0 6px 28px rgba(124,77,255,0.7) !important; } #erp-fab:active { transform: scale(0.95) !important; } #erp-fab .erp-fab-dot { position: absolute !important; top: 3px !important; right: 3px !important; width: 10px !important; height: 10px !important; border-radius: 50% !important; background: #4caf50 !important; border: 2px solid #fff !important; display: none !important; } #erp-fab .erp-fab-dot.show { display: block !important; } #erp-fab .erp-fab-dot.playing { background: #7c4dff !important; animation: erp-fab-pulse 1.2s infinite !important; } #erp-fab .erp-fab-dot.paused { background: #ff9800 !important; } @keyframes erp-fab-pulse { 0%,100%{opacity:1;transform:scale(1);} 50%{opacity:0.5;transform:scale(1.3);} } #erp-panel { position: fixed !important; bottom: 90px !important; right: 20px !important; z-index: 2147483647 !important; width: 330px !important; background: linear-gradient(160deg,#1a1a2e 0%,#16213e 55%,#0f3460 100%) !important; border-radius: 20px !important; box-shadow: 0 10px 48px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.09) !important; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif !important; font-size: 13px !important; color: #e8eaf6 !important; user-select: none !important; overflow: hidden !important; transform-origin: bottom right !important; transition: opacity 0.2s, transform 0.2s !important; opacity: 0 !important; transform: scale(0.85) translateY(10px) !important; pointer-events: none !important; } #erp-panel.open { opacity: 1 !important; transform: scale(1) translateY(0) !important; pointer-events: auto !important; } #erp-panel .erp-hd { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 13px 16px 11px !important; background: rgba(255,255,255,0.05) !important; border-bottom: 1px solid rgba(255,255,255,0.07) !important; cursor: move !important; } #erp-panel .erp-hd-title { font-size: 14px !important; font-weight: 700 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; } #erp-panel .erp-status-dot { width: 8px !important; height: 8px !important; border-radius: 50% !important; background: rgba(255,255,255,0.2) !important; transition: background 0.3s !important; flex-shrink: 0 !important; } #erp-panel .erp-status-dot.playing { background: #7c4dff !important; box-shadow: 0 0 6px #7c4dff !important; animation: erp-fab-pulse 1.2s infinite !important; } #erp-panel .erp-status-dot.paused { background: #ff9800 !important; } #erp-panel .erp-hd-close { width: 26px !important; height: 26px !important; border-radius: 7px !important; background: rgba(255,255,255,0.08) !important; border: none !important; color: rgba(255,255,255,0.6) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; transition: all 0.15s !important; } #erp-panel .erp-hd-close:hover { background: rgba(255,100,100,0.25) !important; color: #ff6b6b !important; } #erp-panel .erp-player { padding: 14px 16px 10px !important; } #erp-panel .erp-progress-wrap { margin-bottom: 12px !important; } #erp-panel .erp-progress-bar { height: 3px !important; background: rgba(255,255,255,0.1) !important; border-radius: 3px !important; overflow: hidden !important; } #erp-panel .erp-progress-fill { height: 100% !important; width: 0% !important; background: linear-gradient(90deg,#7c4dff,#e040fb) !important; border-radius: 3px !important; transition: width 0.4s !important; } #erp-panel .erp-progress-info { display: flex !important; justify-content: space-between !important; margin-top: 5px !important; font-size: 11px !important; color: rgba(255,255,255,0.38) !important; } #erp-panel .erp-controls { display: flex !important; align-items: center !important; justify-content: center !important; gap: 10px !important; } #erp-panel .erp-btn { border: none !important; cursor: pointer !important; outline: none !important; background: transparent !important; color: #e8eaf6 !important; transition: all 0.15s !important; padding: 0 !important; margin: 0 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; font-size: 13px !important; line-height: 1 !important; } #erp-panel .erp-btn:hover { transform: scale(1.12) !important; } #erp-panel .erp-btn:active { transform: scale(0.93) !important; } #erp-panel .erp-ctrl { width: 40px !important; height: 40px !important; border-radius: 50% !important; background: rgba(255,255,255,0.09) !important; font-size: 16px !important; } #erp-panel .erp-ctrl:hover { background: rgba(255,255,255,0.16) !important; } #erp-panel .erp-play-btn { width: 52px !important; height: 52px !important; font-size: 22px !important; background: linear-gradient(135deg,#7c4dff,#e040fb) !important; box-shadow: 0 4px 18px rgba(124,77,255,0.45) !important; border-radius: 50% !important; } #erp-panel .erp-play-btn:hover { box-shadow: 0 6px 24px rgba(124,77,255,0.65) !important; } #erp-panel .erp-stop-btn { color: #ff6b6b !important; } #erp-panel .erp-stop-btn:hover { background: rgba(255,107,107,0.15) !important; } #erp-panel .erp-sep { height: 1px !important; background: rgba(255,255,255,0.07) !important; margin: 10px 0 !important; } #erp-panel .erp-settings { padding: 0 16px 14px !important; } #erp-panel .erp-row { margin-bottom: 11px !important; } #erp-panel .erp-row:last-child { margin-bottom: 0 !important; } #erp-panel .erp-lbl { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.5) !important; margin-bottom: 5px !important; font-weight: 500 !important; } #erp-panel .erp-lbl b { color: rgba(255,255,255,0.85) !important; font-weight: 600 !important; } #erp-panel .erp-tabs { display: flex !important; gap: 4px !important; margin-bottom: 8px !important; background: rgba(255,255,255,0.05) !important; border-radius: 8px !important; padding: 3px !important; } #erp-panel .erp-tab { flex: 1 !important; padding: 5px 0 !important; text-align: center !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; color: rgba(255,255,255,0.45) !important; border: none !important; background: transparent !important; transition: all 0.15s !important; } #erp-panel .erp-tab.active { background: linear-gradient(135deg,#7c4dff,#e040fb) !important; color: #fff !important; box-shadow: 0 2px 8px rgba(124,77,255,0.4) !important; } #erp-panel .erp-select { width: 100% !important; padding: 7px 10px !important; border-radius: 8px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.11) !important; color: #e8eaf6 !important; font-size: 12px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-select:hover, #erp-panel .erp-select:focus { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-select option, #erp-panel .erp-select optgroup { background: #1a1a2e !important; color: #e8eaf6 !important; } #erp-panel .erp-range { width: 100% !important; height: 4px !important; -webkit-appearance: none !important; appearance: none !important; background: rgba(255,255,255,0.14) !important; border-radius: 2px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-range::-webkit-slider-thumb { -webkit-appearance: none !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; box-shadow: 0 0 6px rgba(124,77,255,0.5) !important; } #erp-panel .erp-range::-moz-range-thumb { width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; border: none !important; } #erp-panel .erp-online-tip { font-size: 11px !important; color: rgba(255,255,255,0.4) !important; text-align: center !important; padding: 4px 0 2px !important; } #erp-panel .erp-online-tip.err { color: #ff6b6b !important; } #erp-panel .erp-online-tip.ok { color: #4caf50 !important; } #erp-panel .erp-loading-spin { display: inline-block !important; animation: erp-spin 0.8s linear infinite !important; } @keyframes erp-spin { to { transform: rotate(360deg); } } .erp-highlight { background-color: rgba(124,77,255,0.13) !important; border-left: 3px solid #7c4dff !important; padding-left: 8px !important; border-radius: 3px !important; outline: 2px solid rgba(124,77,255,0.22) !important; outline-offset: 3px !important; transition: background-color 0.3s !important; } `); // ===================================================== // 音色名中文化 // ===================================================== function getVoiceDisplayName(voice) { for (const key of Object.keys(VOICE_ZH_NAME)) { if (voice.name.includes(key)) return VOICE_ZH_NAME[key]; } let name = voice.name.replace(/^Microsoft\s+/i, '微软 ').replace(/^Google\s+/i, '谷歌 '); const langMap = { 'zh-CN': '普通话', 'zh-HK': '粤语', 'zh-TW': '台湾', 'cmn-CN': '普通话', 'yue-HK': '粤语' }; const langLabel = langMap[voice.lang] || voice.lang; return `${name}(${langLabel})`; } // ===================================================== // 提取正文段落 // ===================================================== function extractParagraphs() { const BLOCK = 'p,article,section,blockquote,h1,h2,h3,h4,h5,h6,li,dd,td'; const IGNORE = new Set(['SCRIPT','STYLE','NOSCRIPT','IFRAME','BUTTON','INPUT','TEXTAREA','SELECT','NAV','FOOTER','HEADER']); const isIgnored = el => { let n=el; while(n&&n!==document.body){if(IGNORE.has(n.tagName))return true;n=n.parentElement;} return false; }; const isVisible = el => { const s=getComputedStyle(el); return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'; }; const sels = ['article','main','.content','.chapter','.book-content','#content','#chapter','.reader-content','.novel-content','.text-content','.article-content','.post-content','[role="main"]','.entry-content','.read-content','.story-body','.article-body','.page-content']; let container = null; for (const s of sels) { const el = document.querySelector(s); if (el && (el.innerText||'').trim().length > 150) { container = el; break; } } if (!container) container = document.body; const all = [...container.querySelectorAll(BLOCK)]; const filtered = all.filter(el => { if (isIgnored(el)||!isVisible(el)) return false; return (el.innerText||'').trim().replace(/\s+/g,' ').length > 15; }); return filtered .filter((el,i)=>!filtered.some((o,j)=>j!==i&&o.contains(el)&&o!==el)) .map(el=>({text:(el.innerText||'').trim().replace(/\s+/g,' '),el})); } // ===================================================== // 本地 TTS // ===================================================== function loadVoices() { return new Promise(resolve => { let v = speechSynthesis.getVoices(); if (v.length) { resolve(v); return; } speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); setTimeout(() => resolve(speechSynthesis.getVoices()), 2500); }); } // ===================================================== // 网络 TTS —— 微软 Edge TTS WebSocket API // 通过 wss://speech.platform.bing.com 连接合成语音 // ===================================================== function edgeTTSSpeak(text, voiceName, rate, volume, onAudioReady, onError) { // 生成唯一请求ID // ===================================================== // 网络 TTS —— 多引擎 HTTP 方案(GM_xmlhttpRequest,无跨域限制) // 引擎优先级:有道 → 百度 → Google → 本地兜底 // ===================================================== // 将 ArrayBuffer 转为 Blob URL 并播放 function playAudioBuffer(arrayBuffer, mimeType, onEnd, onError) { try { const blob = new Blob([arrayBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = state.volume; state.audioEl = audio; audio.play().catch(onError); audio.onended = () => { URL.revokeObjectURL(url); onEnd(); }; audio.onerror = () => { URL.revokeObjectURL(url); onError(); }; } catch(e) { onError(e); } } // 有道TTS(中文最稳,4种音色) function fetchYoudaoTTS(text, type, onReady, onError) { // 有道每次最多约200字,超长则截断 const chunk = text.slice(0, 200); const url = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(chunk)}&type=${type}`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://dict.youdao.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('有道TTS返回异常: ' + r.status); } }, onerror: () => onError('有道TTS网络错误'), ontimeout: () => onError('有道TTS超时'), }); } // 百度TTS(备用,spd语速1-15,per音色0=女4=情感) function fetchBaiduTTS(text, per, rate, onReady, onError) { const spd = Math.round(rate * 5); // 0.5~2.5 → 2~12 const chunk = text.slice(0, 200); const url = `https://tts.baidu.com/text2audio?lan=zh&ie=UTF-8&spd=${spd}&per=${per}&text=${encodeURIComponent(chunk)}`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://www.baidu.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('百度TTS返回异常: ' + r.status); } }, onerror: () => onError('百度TTS网络错误'), ontimeout: () => onError('百度TTS超时'), }); } // Google TTS(支持多语言) function fetchGoogleTTS(text, lang, onReady, onError) { const chunk = text.slice(0, 200); const url = `https://translate.googleapis.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunk)}&tl=${lang}&client=gtx&ttsspeed=1`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://translate.google.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('Google TTS返回异常: ' + r.status); } }, onerror: () => onError('Google TTS网络错误'), ontimeout: () => onError('Google TTS超时'), }); } // 统一网络TTS入口(根据音色ID选引擎,失败自动降级) function fetchOnlineTTS(text, voiceId, onReady, onError) { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; if (vo.engine === 'youdao') { fetchYoudaoTTS(text, vo.type, onReady, err => { // 有道失败 → 降级到百度 fetchBaiduTTS(text, '0', state.rate, onReady, err2 => onError(err + ' / ' + err2)); }); } else if (vo.engine === 'baidu') { fetchBaiduTTS(text, vo.type, state.rate, onReady, err => { // 百度失败 → 降级到有道 fetchYoudaoTTS(text, '0', onReady, err2 => onError(err + ' / ' + err2)); }); } else if (vo.engine === 'google') { fetchGoogleTTS(text, vo.type, onReady, err => { // Google失败 → 降级到有道 fetchYoudaoTTS(text, '0', onReady, err2 => onError(err + ' / ' + err2)); }); } else { onError('未知引擎: ' + vo.engine); } } // ===================================================== // 播放控制 // ===================================================== function speak(index) { if (index < 0 || index >= state.paragraphs.length) { stopAll(); return; } // 先停止当前播放 abortCurrent(); state.currentIndex = index; doHighlight(); scrollToEl(state.paragraphs[index].el); state.isPlaying = true; state.isPaused = false; updateUI(); const text = state.paragraphs[index].text; const onEnd = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; const onErr = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; if (state.voiceMode === 'online') { // 网络音色:通过 GM_xmlhttpRequest 获取音频 const voiceId = state.selectedVoiceId || ONLINE_VOICES[0].id; setOnlineTip('⟳ 正在合成音频…'); let cancelled = false; state.onlineAbort = () => { cancelled = true; }; fetchOnlineTTS( text, voiceId, (arrayBuffer, mimeType) => { if (cancelled || !state.isPlaying || state.currentIndex !== index) return; setOnlineTip('🌐 网络音色合成成功', 'ok'); playAudioBuffer(arrayBuffer, mimeType, onEnd, () => { if (state.isPlaying) onErr(); }); }, (errMsg) => { if (cancelled || !state.isPlaying) return; setOnlineTip('⚠ 网络音色失败,已切换到本地音色', 'err'); speakLocal(text, onEnd, onErr); } ); } else { speakLocal(text, onEnd, onErr); } } function speakLocal(text, onEnd, onErr) { speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(text); const voices = speechSynthesis.getVoices(); const v = voices.find(v => v.voiceURI === state.selectedVoiceId); if (v) utter.voice = v; utter.rate = state.rate; utter.pitch = state.pitch; utter.volume = state.volume; utter.onend = onEnd; utter.onerror = e => { if (e.error !== 'interrupted' && e.error !== 'canceled') onErr(); }; state.utterance = utter; speechSynthesis.speak(utter); } function abortCurrent() { speechSynthesis.cancel(); if (state.onlineAbort) { state.onlineAbort(); state.onlineAbort = null; } if (state.audioEl) { state.audioEl.pause(); state.audioEl.src = ''; state.audioEl = null; } } function pauseResume() { if (state.isPaused) { if (state.audioEl) { state.audioEl.play().catch(() => {}); } else { speechSynthesis.resume(); } state.isPaused = false; state.isPlaying = true; } else if (state.isPlaying) { if (state.audioEl) { state.audioEl.pause(); } else { speechSynthesis.pause(); } state.isPaused = true; state.isPlaying = false; } else { state.paragraphs = extractParagraphs(); if (!state.paragraphs.length) { showTip('未找到可朗读的正文,请在文章页面使用'); return; } speak(state.currentIndex || 0); return; } updateUI(); } function stopAll() { abortCurrent(); state.isPlaying = false; state.isPaused = false; clearHighlight(); setOnlineTip(''); updateUI(); } function prevPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.max(0, state.currentIndex - 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else { state.currentIndex = idx; } } function nextPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.min(state.paragraphs.length - 1, state.currentIndex + 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else { state.currentIndex = idx; } } // ===================================================== // 高亮 // ===================================================== function doHighlight() { clearHighlight(); const p = state.paragraphs[state.currentIndex]; if (p && p.el) { p.el.classList.add('erp-highlight'); state.highlightEl = p.el; } } function clearHighlight() { document.querySelectorAll('.erp-highlight').forEach(el => el.classList.remove('erp-highlight')); state.highlightEl = null; } function scrollToEl(el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ===================================================== // 创建 UI(使用宿主容器挂到 body,强制样式保持可见) // ===================================================== function createUI() { if (document.getElementById('erp-host')) return; // 宿主容器(用来做守护检测的锚点) const host = document.createElement('div'); host.id = 'erp-host'; host.style.cssText = 'all:initial;position:fixed;bottom:0;right:0;width:0;height:0;z-index:2147483647;'; document.body.appendChild(host); const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.title = '听书助手(Alt+S 播放)'; fab.innerHTML = `🎧`; // 强制内联样式,双保险防止被页面覆盖 fab.style.cssText = [ 'position:fixed','bottom:28px','right:28px', 'z-index:2147483646','width:52px','height:52px', 'border-radius:50%','border:none','outline:none', 'background:linear-gradient(135deg,#7c4dff,#e040fb)', 'box-shadow:0 4px 20px rgba(124,77,255,0.55)', 'cursor:pointer','display:flex','align-items:center', 'justify-content:center','font-size:24px', 'user-select:none','transition:transform 0.2s,box-shadow 0.2s', 'visibility:visible','opacity:1','pointer-events:auto', ].join('!important;') + '!important'; document.body.appendChild(fab); const panel = document.createElement('div'); panel.id = 'erp-panel'; panel.innerHTML = `