Greasy Fork

Greasy Fork is available in English.

跳跳眼

快速阅读脚本。支持 RSVP 与高亮追踪。适用于有阅读障碍的用户。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         跳跳眼
// @namespace    http://greasyfork.icu/zh-CN/users/1535852-severedline
// @version      2.1.1
// @description  快速阅读脚本。支持 RSVP 与高亮追踪。适用于有阅读障碍的用户。
// @author       qwerty
// @license      Unlicense
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_getResourceText
// @grant        GM_getResourceURL
// @resource     jiebaWasmJs https://cdn.jsdelivr.net/npm/[email protected]/pkg/web/jieba_rs_wasm.js
// @resource     jiebaWasmBg https://cdn.jsdelivr.net/npm/[email protected]/pkg/web/jieba_rs_wasm_bg.wasm
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/hotkeys-js.min.js
// @require      https://cdn.jsdelivr.net/npm/@mozilla/[email protected]/Readability.min.js
// ==/UserScript==

/* global Readability, DOMPurify, hotkeys */

(function () {
    'use strict';

    let overlayHost = null;
    let wasmEngine = null;

    const icons = {
        play: `<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor"><path d="M8 5.14v14l11-7-11-7z"/></svg>`,
        pause: `<svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>`,
        prev: `<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/></svg>`,
        next: `<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/></svg>`,
        close: `<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
        theme: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 3a9 9 0 109 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 01-4.4 2.26 5.403 5.403 0 01-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/></svg>`,
        mode: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>`,
        book: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM9 4h2v5l-1-.75L9 9V4zm9 16H6V4h1v9l3-2.25L13 13V4h5v16z"/></svg>`,
        tts: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>`,
        ttsOff: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>`,
    };

    async function initWasmEngine() {
        if (wasmEngine) return;
        showToast('🚀 正在加载分词引擎...');
        try {
            let jsCode = GM_getResourceText('jiebaWasmJs');
            if (!jsCode) throw new Error('无法获取 WASM JS');
            jsCode = jsCode
                .replace(/export function ([a-zA-Z0-9_]+)/g, 'wasmSandbox.$1 = function $1')
                .replace(/export\s*\{[^}]+\}\s*;/g, '')
                .replace(/export default ([a-zA-Z0-9_]+);?/g, 'wasmSandbox.__wbg_init = $1;')
                .replace(/import\.meta\.url/g, 'location.href')
                .replace(/import\.meta/g, '{}');
            const wasmSandbox = {};
            const runSandbox = new Function('wasmSandbox', jsCode);
            runSandbox(wasmSandbox);
            const wasmUrl = GM_getResourceURL('jiebaWasmBg');
            if (!wasmUrl) throw new Error('无法获取 WASM 二进制文件');
            const wasmRes = await fetch(wasmUrl);
            const wasmBuffer = await wasmRes.arrayBuffer();
            await wasmSandbox.__wbg_init(wasmBuffer);
            wasmEngine = wasmSandbox;
            removeToast();
        } catch (error) {
            removeToast();
            console.warn('WASM 分词加载失败,回退到浏览器内置分词器', error);
            wasmEngine = 'fallback';
        }
    }

    document.addEventListener('keydown', (e) => {
        if (e.altKey && e.code === 'KeyR') {
            e.preventDefault();
            launch();
        }
    });

    if (typeof GM_registerMenuCommand !== 'undefined') {
        GM_registerMenuCommand('📖 启动速读 (选中文本/全文)[Alt+R]', launch);
    }

    function createMobileFab() {
        if (document.getElementById('sr-mobile-fab')) return;
        const fab = document.createElement('div');
        fab.id = 'sr-mobile-fab';
        fab.innerHTML = icons.book;
        Object.assign(fab.style, {
            position: 'fixed',
            bottom: '30px',
            right: '30px',
            zIndex: '2147483646',
            width: '50px',
            height: '50px',
            borderRadius: '25px',
            backgroundColor: 'rgba(28, 28, 30, 0.65)',
            color: '#fff',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backdropFilter: 'blur(10px)',
            boxShadow: '0 8px 24px rgba(0,0,0,0.15)',
            cursor: 'pointer',
            transition: 'all 0.3s ease',
            opacity: '0.4',
        });

        let scrollTimeout;
        window.addEventListener(
            'scroll',
            () => {
                fab.style.opacity = '1';
                fab.style.pointerEvents = 'auto';
                clearTimeout(scrollTimeout);
                scrollTimeout = setTimeout(() => {
                    fab.style.opacity = '0.3';
                    fab.style.pointerEvents = 'none';
                }, 2000);
            },
            { passive: true },
        );

        fab.style.pointerEvents = 'none';
        setTimeout(() => (fab.style.pointerEvents = 'auto'), 500);

        fab.onclick = () => launch();
        document.body.appendChild(fab);
    }

    if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) {
        createMobileFab();
    }

    async function launch() {
        if (overlayHost) return;

        try {
            await initWasmEngine();

            const selection = window.getSelection();
            const selectionText = selection.toString().trim();
            let articleTitle = document.title;
            let articleHTML = '';

            if (selectionText) {
                articleTitle = '选中文本阅读';
                const range = selection.getRangeAt(0);
                const container = document.createElement('div');
                container.appendChild(range.cloneContents());

                articleHTML = DOMPurify.sanitize(
                    container.innerHTML ||
                    selectionText
                        .split('\n')
                        .filter((p) => p.trim())
                        .map((p) => `<p>${p}</p>`)
                        .join(''),
                );
            } else {
                const clone = document.cloneNode(true);
                const elementsToRemove = clone.querySelectorAll(
                    'script, style, noscript, iframe, svg, canvas, video, audio',
                );
                elementsToRemove.forEach((el) => el.remove());

                const reader = new Readability(clone);
                const article = reader.parse();
                if (article && article.content && article.content.trim() !== '') {
                    articleTitle = article.title;
                    articleHTML = DOMPurify.sanitize(article.content);
                } else {
                    articleHTML = document.body.innerText
                        .split('\n')
                        .filter((p) => p.trim())
                        .map((p) => `<p>${DOMPurify.sanitize(p)}</p>`)
                        .join('');
                }
            }
            buildUI(articleTitle, articleHTML);
        } catch (error) {
            console.error('启动失败流程终止', error);
        }
    }

    function buildUI(title, htmlContent) {
        const originalOverflow = document.body.style.overflow;
        overlayHost = document.createElement('div');
        const shadow = overlayHost.attachShadow({ mode: 'open' });

        shadow.innerHTML = `
            <style>
                :host {
                    --bg: rgba(250, 250, 252, 0.95);
                    --text-main: #1d1d1f;
                    --text-secondary: #86868b;
                    --accent: #FF3B30;
                    --hl-bg: #FFD60A;
                    --hl-text: #000;
                    --panel-bg: rgba(255, 255, 255, 0.7);
                    --border: rgba(0, 0, 0, 0.08);
                    --shadow-btn: 0 8px 16px rgba(0,0,0,0.1);
                    --shadow-panel: 0 16px 64px rgba(0,0,0,0.15);
                }
                .dark-mode {
                    --bg: rgba(28, 28, 30, 0.95);
                    --text-main: #f5f5f7;
                    --text-secondary: #a1a1a6;
                    --accent: #FF453A;
                    --hl-bg: #E5A300;
                    --hl-text: #fff;
                    --panel-bg: rgba(44, 44, 46, 0.7);
                    --border: rgba(255, 255, 255, 0.1);
                    --shadow-btn: 0 8px 16px rgba(0,0,0,0.4);
                    --shadow-panel: 0 16px 64px rgba(0,0,0,0.6);
                }

                #overlay {
                    position: fixed; inset: 0; z-index: 2147483647;
                    background: var(--bg); color: var(--text-main);
                    backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%);
                    display: flex; flex-direction: column;
                    font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Micro Hei", sans-serif !important;
                    -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
                    letter-spacing: 0.015em; font-weight: 400;
                    transition: background 0.4s ease, color 0.4s ease;
                    opacity: 0; animation: fadeIn 0.3s forwards;
                }
                @keyframes fadeIn { to { opacity: 1; } }

                #top-bar {
                    position: absolute; top: 0; left: 0; right: 0; z-index: 10;
                    display: flex; justify-content: space-between; align-items: center;
                    padding: 24px 40px; transition: opacity 0.4s ease, transform 0.4s ease;
                }
                #top-bar.playing { opacity: 0; transform: translateY(-10px); pointer-events: none; }
                #top-bar h2 { margin: 0; font-size: 18px; font-weight: 600; opacity: 0.85; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

                .icon-btn {
                    background: transparent; border: none; color: var(--text-main);
                    display: inline-flex; align-items: center; justify-content: center;
                    cursor: pointer; border-radius: 14px; padding: 10px; transition: 0.2s ease;
                }
                .icon-btn:hover { background: var(--border); transform: scale(1.05); }
                .actions { display: flex; gap: 12px; }

                #content-area { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; }
                #rsvp-view { height: 100%; width: 100%; display: flex; align-items: center; justify-content: center; position: absolute; inset: 0; padding: 0 20px;}
                .rsvp-box { display: flex; width: 100%; font-size: clamp(40px, 8vw, 86px); font-family: "SF Mono", "Courier New", Courier, monospace !important; font-weight: 600; letter-spacing: 0.05em; }
                .rsvp-box .prefix { flex: 1; text-align: right; opacity: 0.9; }
                .rsvp-box .focal { flex: 0 0 auto; color: var(--accent); text-align: center; position: relative; }
                .rsvp-box .suffix { flex: 1; text-align: left; opacity: 0.9; }

                .guideline-top, .guideline-bottom { position: absolute; left: 50%; width: 4px; height: 16px; background: var(--accent); transform: translateX(-50%); border-radius: 2px; opacity: 0.8;}
                .guideline-top { top: -15%; }
                .guideline-bottom { bottom: -15%; }

                #highlight-view { height: 100%; width: 100%; overflow-y: auto; position: absolute; inset: 0; display: none; scroll-behavior: smooth; }
                #highlight-view::-webkit-scrollbar { width: 6px; }
                #highlight-view::-webkit-scrollbar-track { background: transparent; }
                #highlight-view::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
                #highlight-view::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }

                .hl-container { max-width: 760px; margin: 0 auto; padding: 120px 32px 240px; font-size: 28px; line-height: 1.8; color: var(--text-secondary); font-weight: 400;}
                .sr-word { transition: all 0.1s ease-out; border-radius: 6px; padding: 2px 3px; }
                .sr-word.active { background-color: var(--hl-bg); color: var(--hl-text); font-weight: 600; box-shadow: 0 2px 10px rgba(0,0,0,0.15); transform: scale(1.05); display: inline-block; position: relative; z-index: 2;}

                #bottom-island {
                    position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%);
                    background: var(--panel-bg); padding: 24px 40px; border-radius: 36px;
                    box-shadow: var(--shadow-panel); border: 1px solid var(--border);
                    display: flex; flex-direction: column; gap: 24px;
                    width: 90%; max-width: 680px;
                    backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%);
                    transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s ease; z-index: 10;
                }
                #bottom-island.playing { opacity: 0.15; transform: translateX(-50%) scale(0.95) translateY(20px); }
                #bottom-island.playing:hover { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); }

                .slider-row, .controls-row { display: flex; align-items: center; justify-content: space-between; gap: 24px; font-size: 15px; font-weight: 500; opacity: 0.85;}
                .wpm-control { display: flex; align-items: center; gap: 16px; flex: 1; }
                .playback-controls { display: flex; align-items: center; gap: 16px; }

                .play-btn { background: var(--text-main); color: var(--bg); border-radius: 50%; width: 64px; height: 64px; box-shadow: var(--shadow-btn); }
                .play-btn:hover { transform: scale(1.08); background: var(--text-main); }

                input[type=range] { flex: 1; -webkit-appearance: none; background: transparent; cursor: pointer; height: 24px; margin: 0; }
                input[type=range]::-webkit-slider-runnable-track { width: 100%; height: 6px; background: var(--border); border-radius: 3px; }
                input[type=range]::-webkit-slider-thumb {
                    -webkit-appearance: none; height: 22px; width: 22px; border-radius: 50%;
                    background: var(--text-main); margin-top: -8px;
                    box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: 0.2s;
                }
                input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }

                @media (max-width: 768px) {
                    #top-bar { padding: 16px 20px; }
                    #top-bar h2 { font-size: 15px; }
                    .rsvp-box { font-size: 46px; }
                    .hl-container { font-size: 20px; padding: 100px 20px 200px; }
                    #bottom-island { width: 95%; padding: 20px 24px; bottom: 20px; border-radius: 28px; gap: 16px;}
                    .controls-row { flex-direction: column; gap: 20px; }
                    .wpm-control { width: 100%; }
                    .playback-controls { width: 100%; justify-content: center; gap: 30px; }
                    .play-btn { width: 56px; height: 56px; }
                    .icon-btn svg { width: 26px; height: 26px; }
                }
            </style>

            <div id="overlay" tabindex="0" style="outline: none;">
                <div id="top-bar">
                    <h2>${title}</h2>
                    <div class="actions">
                        <button class="icon-btn" id="btn-tts" title="切换朗读">${icons.ttsOff}</button>
                        <button class="icon-btn" id="btn-theme" title="切换深色/浅色">${icons.theme}</button>
                        <button class="icon-btn" id="btn-mode" title="切换模式">${icons.mode}</button>
                        <button class="icon-btn" id="btn-close" title="退出 (Esc)">${icons.close}</button>
                    </div>
                </div>

                <div id="content-area">
                    <div id="rsvp-view">
                        <div class="rsvp-box" id="rsvp-box">
                            <span class="prefix"></span>
                            <span class="focal"><div class="guideline-top"></div>准<div class="guideline-bottom"></div></span>
                            <span class="suffix">备...</span>
                        </div>
                    </div>
                    <div id="highlight-view">
                        <div class="hl-container" id="hl-container"></div>
                    </div>
                </div>

                <div id="bottom-island">
                    <div class="slider-row">
                        <span id="progress-text" style="min-width: 110px;">0 / 0 (0%)</span>
                        <input type="range" id="progress-slider" min="0" value="0">
                    </div>
                    <div class="controls-row">
                        <div class="wpm-control">
                            <span id="wpm-text" style="min-width: 85px;">350 WPM</span>
                            <input type="range" id="wpm-slider" min="100" max="1000" step="25" value="350">
                        </div>
                        <div class="playback-controls">
                            <button class="icon-btn" id="btn-prev">${icons.prev}</button>
                            <button class="icon-btn play-btn" id="btn-play">${icons.play}</button>
                            <button class="icon-btn" id="btn-next">${icons.next}</button>
                        </div>
                    </div>
                </div>
            </div>
        `;
        document.documentElement.appendChild(overlayHost);
        document.body.style.overflow = 'hidden';

        const hlContainer = shadow.getElementById('hl-container');
        hlContainer.innerHTML = htmlContent;
        const playableWords = [];

        function traverseAndWrap(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                const text = node.textContent;
                if (!text.trim()) return;

                let words;
                if (wasmEngine && wasmEngine !== 'fallback') {
                    words = wasmEngine.cut(text, false);
                } else {
                    if (typeof Intl.Segmenter === 'function') {
                        if (!window.__sr_segmenter) {
                            window.__sr_segmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' });
                        }

                        words = [...window.__sr_segmenter.segment(text)]
                            .map((s) => s.segment)
                            .filter((w) => w.trim().length > 0 || /[\n\r]/.test(w));
                    } else {
                        words = text.split('').filter((w) => w.trim().length > 0 || /[\n\r]/.test(w));
                    }
                }
                const fragment = document.createDocumentFragment();

                words.forEach((w) => {
                    if (w.trim().length === 0) {
                        fragment.appendChild(document.createTextNode(w));
                    } else {
                        const span = document.createElement('span');
                        span.textContent = w;
                        span.className = 'sr-word';
                        span.dataset.index = playableWords.length;
                        fragment.appendChild(span);
                        playableWords.push({ word: w.trim(), el: span });
                    }
                });
                node.parentNode.replaceChild(fragment, node);
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.tagName.toUpperCase())) return;
                Array.from(node.childNodes).forEach(traverseAndWrap);
            }
        }

        Array.from(hlContainer.childNodes).forEach(traverseAndWrap);

        let isPlaying = false,
            currentIndex = 0,
            timerId = null,
            lastActiveEl = null;

        let isTtsEnabled = false;
        let wpm = parseInt(localStorage.getItem('sr_wpm')) || 350;
        let isRsvpMode = localStorage.getItem('sr_mode') !== 'false';
        let isDarkMode =
            localStorage.getItem('sr_theme') !== null
                ? localStorage.getItem('sr_theme') === 'true'
                : window.matchMedia('(prefers-color-scheme: dark)').matches;

        const ui = {
            topBar: shadow.getElementById('top-bar'),
            bottomIsland: shadow.getElementById('bottom-island'),
            btnPlay: shadow.getElementById('btn-play'),
            btnPrev: shadow.getElementById('btn-prev'),
            btnNext: shadow.getElementById('btn-next'),
            btnClose: shadow.getElementById('btn-close'),
            btnTts: shadow.getElementById('btn-tts'),
            btnTheme: shadow.getElementById('btn-theme'),
            btnMode: shadow.getElementById('btn-mode'),
            rsvpBox: shadow.getElementById('rsvp-box'),
            rsvpView: shadow.getElementById('rsvp-view'),
            hlView: shadow.getElementById('highlight-view'),
            progSlider: shadow.getElementById('progress-slider'),
            progText: shadow.getElementById('progress-text'),
            wpmSlider: shadow.getElementById('wpm-slider'),
            wpmText: shadow.getElementById('wpm-text'),
            overlay: shadow.getElementById('overlay'),
        };

        ui.progSlider.max = Math.max(0, playableWords.length - 1);

        function getORPIndex(word) {
            const len = word.length;
            if (len <= 1) return 0;
            if (len >= 6) return Math.ceil(len / 3);
            return Math.floor(len / 2);
        }

        async function playTTS() {
            if (!isTtsEnabled) return;
            window.speechSynthesis.cancel();
            let textToSpeak = '';
            let charIndexMap = [];
            for (let i = currentIndex; i < playableWords.length; i++) {
                let w = playableWords[i].word;
                charIndexMap.push({ charStart: textToSpeak.length, wordIndex: i });
                textToSpeak += w;
                if (textToSpeak.length > 100 && /[。!?.!?\n]/.test(w)) break;
                if (textToSpeak.length >= 150) break;
            }
            if (!textToSpeak) return;

            const utterance = new SpeechSynthesisUtterance(textToSpeak);

            let voices = window.speechSynthesis.getVoices();
            if (voices.length === 0) {
                await Promise.race([
                    new Promise((resolve) =>
                        window.speechSynthesis.addEventListener('voiceschanged', resolve, { once: true }),
                    ),
                    new Promise((resolve) => setTimeout(resolve, 1000)),
                ]);
                voices = window.speechSynthesis.getVoices();
            }

            const zhVoice =
                voices.find((v) => v.lang === 'zh-CN' && v.localService) ||
                voices.find((v) => v.lang === 'zh-CN') ||
                voices.find((v) => v.lang === 'zh-TW' && v.localService) ||
                voices.find((v) => v.lang === 'zh-TW') ||
                voices.find((v) => v.lang === 'zh-HK') ||
                voices.find((v) => v.lang === 'zh') ||
                voices.find((v) => v.lang.startsWith('cmn')) ||
                voices.find((v) => v.lang.includes('zh')) ||
                voices[0];

            if (zhVoice) {
                utterance.voice = zhVoice;
                utterance.lang = zhVoice.lang;
            } else {
                utterance.lang = 'zh-CN';
            }

            utterance.rate = Math.max(0.5, Math.min(3.5, 0.5 + wpm / 150));
            utterance.volume = 1.0;

            let lastSyncedIndex = -1;
            utterance.onboundary = (e) => {
                if (!isPlaying || !isTtsEnabled) return;
                let matchedIndex = currentIndex;
                for (let i = 0; i < charIndexMap.length; i++) {
                    if (charIndexMap[i].charStart <= e.charIndex) {
                        matchedIndex = charIndexMap[i].wordIndex;
                    } else {
                        break;
                    }
                }
                if (matchedIndex !== lastSyncedIndex) {
                    lastSyncedIndex = matchedIndex;
                    currentIndex = matchedIndex;
                    requestAnimationFrame(() => updateDisplay());
                }
            };

            utterance.onend = () => {
                if (!isPlaying || !isTtsEnabled) return;
                if (charIndexMap.length === 0) {
                    currentIndex++;
                } else {
                    currentIndex = charIndexMap[charIndexMap.length - 1].wordIndex + 1;
                }
                updateDisplay();
                if (currentIndex < playableWords.length && isPlaying && isTtsEnabled) {
                    requestAnimationFrame(() => playTTS());
                } else {
                    if (currentIndex >= playableWords.length) {
                        currentIndex = playableWords.length - 1;
                        updateDisplay();
                    }
                    pause();
                }
            };

            utterance.onerror = (e) => {
                if (e.error === 'interrupted' || e.error === 'canceled') return;
                console.warn('TTS 错误:', e.error, e);
                if (e.error === 'language-unavailable' || e.error === 'voice-unavailable') {
                    console.warn('中文语音不可用,尝试使用默认语音');

                    const fallback = new SpeechSynthesisUtterance(textToSpeak);
                    fallback.lang = 'zh-CN';
                    fallback.rate = utterance.rate;
                    fallback.onboundary = utterance.onboundary;
                    fallback.onend = utterance.onend;
                    fallback.onerror = () => {
                        console.warn('回退语音也失败了');
                        pause();
                    };
                    window.speechSynthesis.speak(fallback);
                    return;
                }
                pause();
            };

            window.speechSynthesis.speak(utterance);
        }

        function updateDisplay() {
            if (playableWords.length === 0) return;
            const current = playableWords[currentIndex];
            const wordText = current.word;

            const orp = getORPIndex(wordText);
            const prefix = wordText.substring(0, orp);
            const focal = wordText.substring(orp, orp + 1);
            const suffix = wordText.substring(orp + 1);

            ui.rsvpBox.innerHTML = `<span class="prefix">${prefix}</span><span class="focal"><div class="guideline-top"></div>${focal}<div class="guideline-bottom"></div></span><span class="suffix">${suffix}</span>`;

            if (lastActiveEl) {
                lastActiveEl.classList.remove('active');
            }
            if (current.el) {
                current.el.classList.add('active');
                lastActiveEl = current.el;
                if (!isRsvpMode) {
                    const c = ui.hlView;
                    const elTop = current.el.offsetTop;
                    const cTop = c.scrollTop;
                    const cHeight = c.clientHeight;

                    if (elTop < cTop + cHeight * 0.2 || elTop > cTop + cHeight * 0.8) {
                        current.el.scrollIntoView({ behavior: wpm > 300 ? 'auto' : 'smooth', block: 'center' });
                    }
                }
            }

            ui.progSlider.value = currentIndex;
            const percent = Math.floor(((currentIndex + 1) / playableWords.length) * 100) || 0;
            ui.progText.textContent = `${currentIndex + 1} / ${playableWords.length} (${percent}%)`;
        }

        function play() {
            if (isPlaying || currentIndex >= playableWords.length) return;
            isPlaying = true;
            ui.btnPlay.innerHTML = icons.pause;
            ui.bottomIsland.classList.add('playing');
            ui.topBar.classList.add('playing');
            if (isTtsEnabled) {
                playTTS();
            } else {
                queueNextWord();
            }
        }

        function pause() {
            isPlaying = false;
            ui.btnPlay.innerHTML = icons.play;
            ui.bottomIsland.classList.remove('playing');
            ui.topBar.classList.remove('playing');
            clearTimeout(timerId);
            window.speechSynthesis.cancel();
        }

        function queueNextWord() {
            if (!isPlaying) return;
            updateDisplay();
            const word = playableWords[currentIndex].word;
            currentIndex++;

            if (currentIndex >= playableWords.length) {
                pause();
                currentIndex = playableWords.length - 1;
                updateDisplay();
                return;
            }

            let delay = 60000 / wpm;
            if (/[.!?。!?…]/.test(word)) {
                delay *= Math.max(1.5, 2.5 - wpm / 500);
            } else if (/[,;:,;:、——]/.test(word)) {
                delay *= Math.max(1.2, 1.8 - wpm / 800);
            } else if (/[\n\r]/.test(word)) {
                delay *= Math.max(1.5, 2.2 - wpm / 600);
            } else if (word.length >= 4) {
                delay *= Math.min(1.4, 1 + (word.length - 3) * 0.05);
            }

            timerId = setTimeout(queueNextWord, delay);
        }

        function seek(offset) {
            currentIndex = Math.max(0, Math.min(playableWords.length - 1, currentIndex + offset));
            updateDisplay();
            if (isPlaying && isTtsEnabled) playTTS();
        }
        function setWpm(val) {
            wpm = Math.max(100, Math.min(1000, val));
            ui.wpmSlider.value = wpm;
            ui.wpmText.textContent = `${wpm} WPM`;
            if (isPlaying && isTtsEnabled) playTTS();
        }

        hotkeys.setScope('swiftread');
        hotkeys('space', 'swiftread', (e) => {
            e.preventDefault();
            isPlaying ? pause() : play();
        });
        hotkeys('left', 'swiftread', (e) => {
            e.preventDefault();
            seek(-1);
        });
        hotkeys('right', 'swiftread', (e) => {
            e.preventDefault();
            seek(1);
        });
        hotkeys('up', 'swiftread', (e) => {
            e.preventDefault();
            setWpm(wpm + 25);
        });
        hotkeys('down', 'swiftread', (e) => {
            e.preventDefault();
            setWpm(wpm - 25);
        });
        hotkeys('esc', 'swiftread', (e) => {
            e.preventDefault();
            ui.btnClose.click();
        });

        ui.btnPlay.onclick = () => (isPlaying ? pause() : play());
        ui.btnPrev.onclick = () => seek(-10);
        ui.btnNext.onclick = () => seek(10);

        ui.btnClose.onclick = () => {
            pause();
            clearTimeout(timerId);
            hotkeys.unbind('space,left,right,up,down,esc', 'swiftread');
            hotkeys.deleteScope('swiftread');
            playableWords.length = 0;
            lastActiveEl = null;

            if (hlContainer) {
                hlContainer.innerHTML = '';
            }

            Object.keys(ui).forEach((key) => (ui[key] = null));

            overlayHost.remove();
            overlayHost = null;
            document.body.style.overflow = originalOverflow;
        };

        if (isDarkMode) ui.overlay.classList.add('dark-mode');
        ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none';
        ui.hlView.style.display = isRsvpMode ? 'none' : 'block';
        setWpm(wpm);

        ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff;
        ui.btnTts.onclick = () => {
            isTtsEnabled = !isTtsEnabled;
            ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff;
            if (!isTtsEnabled) window.speechSynthesis.cancel();
            if (isPlaying) {
                pause();
                play();
            }
        };

        ui.btnTheme.onclick = () => {
            const isDark = ui.overlay.classList.toggle('dark-mode');
            localStorage.setItem('sr_theme', isDark);
        };
        ui.btnMode.onclick = () => {
            isRsvpMode = !isRsvpMode;
            localStorage.setItem('sr_mode', isRsvpMode);
            ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none';
            ui.hlView.style.display = isRsvpMode ? 'none' : 'block';
            updateDisplay();
        };

        ui.wpmSlider.oninput = (e) => {
            setWpm(parseInt(e.target.value));
            localStorage.setItem('sr_wpm', e.target.value);
        };
        ui.progSlider.oninput = (e) => {
            if (isPlaying) pause();
            currentIndex = parseInt(e.target.value);
            updateDisplay();
        };
        setTimeout(() => ui.overlay.focus(), 100);

        ui.rsvpView.onclick = () => (isPlaying ? pause() : play());
        ui.hlView.onclick = (e) => {
            if (window.getSelection().toString().trim().length > 0) return;
            if (e.target.classList.contains('sr-word')) {
                if (isPlaying) pause();
                currentIndex = parseInt(e.target.dataset.index, 10);
                updateDisplay();
                return;
            }
            isPlaying ? pause() : play();
        };

        updateDisplay();
    }

    let toastEl = null;
    function showToast(msg) {
        if (toastEl) return;
        toastEl = document.createElement('div');
        toastEl.textContent = msg;
        Object.assign(toastEl.style, {
            position: 'fixed',
            top: '40px',
            left: '50%',
            transform: 'translateX(-50%)',
            background: 'rgba(28,28,30,0.85)',
            color: '#fff',
            padding: '14px 28px',
            borderRadius: '30px',
            zIndex: '2147483647',
            fontFamily: 'system-ui, sans-serif',
            fontSize: '15px',
            fontWeight: '500',
            backdropFilter: 'blur(10px)',
            boxShadow: '0 12px 32px rgba(0,0,0,0.2)',
        });
        document.documentElement.appendChild(toastEl);
    }
    function removeToast() {
        if (toastEl) {
            toastEl.remove();
            toastEl = null;
        }
    }
})();