小說語音朗讀

小說朗讀腳本,支援朗讀控制與句子點擊切換

// ==UserScript==
// @name         小說語音朗讀
// @namespace    Anong0u0
// @version      2025-03-04.1
// @description  小說朗讀腳本,支援朗讀控制與句子點擊切換
// @author       Anong0u0
// @match        https://*.wa01.com/novel/pagea/*.html
// @match        https://*.ttkan.co/novel/pagea/*.html
// @match        https://*.linovelib.com/novel/*/*.html
// @match        https://czbooks.net/n/*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=linovelib.com
// @grant        GM_setValue
// @grant        GM_getValue
// @license      Beerware
// ==/UserScript==

const siteMap = {
    "wa01.com" : ".content",
    "ttkan.co" : ".content",
    "linovelib.com" : ":is(#acontent, #TextContent)",
    "czbooks.net" : ".content",
}

const delay = (ms = 0) => new Promise((r)=>{setTimeout(r, ms)})
const waitElementLoad = (elementSelector, selectCount = 1, tryTimes = 1, interval = 0, baseElement = null) =>
{
    return new Promise(async (resolve, reject)=>
    {
        let t = 1, result;
        if(baseElement == null) baseElement = document
        while(true)
        {
            if(selectCount != 1) {if((result = baseElement.querySelectorAll(elementSelector)).length >= selectCount) break;}
            else {if(result = baseElement.querySelector(elementSelector)) break;}

            if(tryTimes>0 && ++t>tryTimes) return reject(new Error("Wait Timeout"));
            await delay(interval);
        }
        resolve(result);
    })
}

const style = document.createElement("style");
style.textContent = `
    .tts-paragraph {
        cursor: pointer;
        font-family: "Microsoft YaHei";
    }
    .tts-paragraph:hover { text-decoration: underline; }
    .tts-paragraph.active { color: red !important; }
    .tts-control-panel {
        position: fixed;
        right: 10px;
        top: 50%;
        transform: translateY(-50%);
        display: flex;
        flex-direction: column;
        gap: 10px;
        z-index: 1000;
    }
    .tts-button {
        width: 50px;
        height: 50px;
        font-size: 24px;
        border: none;
        border-radius: 10px;
        cursor: pointer;
    }
    .tts-slider-container {
        position: absolute;
        right: 60px;
        top: 50%;
        transform: translateY(-50%);
        padding: 5px;
        background: rgba(0, 0, 0, 0.7);
        border-radius: 5px;
        display: flex;
        align-items: center;
        gap: 5px;
        visibility: hidden;
        pointer-events: auto;
    }
    .tts-slider-container span { color: white; }
`;
document.body.append(style);

let toggleBtn;

document.sc = {
    speechIndex: 0,
    speechPaused: false,
    currentUtterance: null,
    pElements: [],
    speechRate: GM_getValue("speechRate", 5),
    speechVolume: GM_getValue("speechVolume", 0.25),

    toggleSpeech(selector) {
        if (!this.pElements.length) {
            const element = document.querySelector(selector);
            if (!element) {
                console.error("Element not found");
                return;
            }

            this.pElements = element.querySelectorAll("p");
            if(this.pElements.length === 0) {
                element.innerHTML = element.innerText.split(/\n+/).map(para => `<p>${para}</p>`).join('');
                this.pElements = element.querySelectorAll("p");
            }

            this.pElements.forEach((p, index) => {
                p.classList.add("tts-paragraph");
                p.addEventListener("click", () => {
                    if (this.speechPaused) this.toggleSpeech();
                    this.speakSpecific(index);
                });
            });

            document.addEventListener("keydown", (e) => {
                if (e.code === "ArrowLeft" && this.speechIndex > 0) {
                    this.speakSpecific(this.speechIndex - 1);
                } else if (e.code === "ArrowRight" && this.speechIndex < this.pElements.length - 1) {
                    this.speakSpecific(this.speechIndex + 1);
                } else if (e.code === "Space") {
                    e.preventDefault();
                    this.toggleSpeech();
                }
            });

            this.speechIndex = 0;
            this.speechPaused = false;
            this.speakNext();
        } else {
            if (this.speechPaused) {
                this.speechPaused = false;
                this.speakNext();
                toggleBtn.innerText ="⏸";
                toggleBtn.style = "background-color: lightcoral";
            } else {
                this.speechPaused = true;
                speechSynthesis.cancel();
                toggleBtn.innerText = "▶";
                toggleBtn.style = "background-color: lightgreen";
            }
        }
    },

    speakNext() {
        if (this.speechIndex >= this.pElements.length || this.speechPaused) return;

        this.speakSpecific(this.speechIndex);
    },

    speakSpecific(index) {
        if (index >= this.pElements.length) return;

        if (this.currentUtterance) {
            speechSynthesis.cancel();
        }

        this.speechIndex = index;
        const currentParagraph = this.pElements[this.speechIndex];
        this.currentUtterance = new SpeechSynthesisUtterance(currentParagraph.innerText);
        this.currentUtterance.lang = "zh-TW";
        this.currentUtterance.rate = this.speechRate;
        this.currentUtterance.volume = this.speechVolume;

        this.pElements.forEach(p => p.classList.remove("active"));
        currentParagraph.classList.add("active");

        currentParagraph.scrollIntoView({ behavior: "smooth", block: "center" });

        this.currentUtterance.onend = () => {
            currentParagraph.classList.remove("active");
            this.speechIndex++;
            this.speakNext();
        };

        speechSynthesis.speak(this.currentUtterance);
    }
};

const controlPanel = document.createElement("div");
controlPanel.className = "tts-control-panel";

const navigateChapter = (offset) => {
    const chapter = Number(location.href.match(/\d+(?=[^\d]*$)/))
    if (chapter) {
        location.href = location.href.replace(/\d+(?=[^\d]*$)/, Math.max(1, chapter + offset));
    }
}
const prevChapter = () => {
    if (typeof ReadParams != "undefined" && ReadParams?.url_previous) location.href = ReadParams.url_previous;
    const t = document.querySelector(":is(a.prev-chapter, .mlfy_page>a:nth-child(1))");
    if (t) {
        t.click();
        return;
    }
    navigateChapter(-1);
}
const nextChapter = () => {
    if (typeof ReadParams != "undefined" && ReadParams?.url_next) location.href = ReadParams.url_next;
    const t = document.querySelector(":is(a.next-chapter, .mlfy_page>a:nth-child(5))");
    if (t) {
        t.click();
        return;
    }
    navigateChapter(1);
}

const buttons = [
    { icon: "⏮", action: () => prevChapter(), tip: "上一章" },
    { icon: "▲", action: () => {
        if (document.sc.speechIndex > 0) {
            document.sc.speakSpecific(document.sc.speechIndex - 1);
        }
    }, tip: "上一句" },
    { icon: "⏸", action: (e) => document.sc.toggleSpeech(), tip: "播放/暫停" },
    { icon: "▼", action: () => {
        if (document.sc.speechIndex < document.sc.pElements.length - 1) {
            document.sc.speakSpecific(document.sc.speechIndex + 1);
        }
    }, tip: "下一句" },
    { icon: "⏭", action: () => nextChapter(1), tip: "下一章" },
    { icon: "♿", sliderAction: (value) => {
        document.sc.speechRate = value;
        GM_setValue("speechRate", value);
    }, min: 1, max: 10, tip: "語速" },
    { icon: "🔊", sliderAction: (value) => {
        document.sc.speechVolume = value / 100;
        GM_setValue("speechVolume", document.sc.speechVolume);
    }, min: 0, max: 100, tip: "音量" }
];

buttons.forEach(({ icon, action, sliderAction, min, max, tip }) => {
    const buttonContainer = document.createElement("div");
    buttonContainer.style.position = "relative";

    const button = document.createElement("button");
    button.className = "tts-button";
    button.innerText = icon;
    button.onclick = action;
    button.title = tip;
    if(icon === "⏸") button.style = "background-color: lightcoral";

    buttonContainer.appendChild(button);

    if (sliderAction) {
        const sliderContainer = document.createElement("div");
        sliderContainer.className = "tts-slider-container";

        const slider = document.createElement("input");
        slider.type = "range";
        slider.min = min.toString();
        slider.max = max.toString();
        slider.value = (icon === "🔊" ? document.sc.speechVolume * 100 : document.sc.speechRate).toString();
        slider.oninput = (event) => {
            sliderValue.innerText = event.target.value;
            sliderAction(Number(event.target.value));
            document.sc.toggleSpeech()
            document.sc.toggleSpeech()
        };

        const sliderValue = document.createElement("span");
        sliderValue.innerText = slider.value;

        sliderContainer.appendChild(slider);
        sliderContainer.appendChild(sliderValue);

        buttonContainer.addEventListener("mouseenter", () => sliderContainer.style.visibility = "visible");
        buttonContainer.addEventListener("mouseleave", () => setTimeout(() => {
            if (!sliderContainer.matches(":hover")) {
                sliderContainer.style.visibility = "hidden";
            }
        }, 300));

        buttonContainer.appendChild(sliderContainer);
    }
    controlPanel.appendChild(buttonContainer);
});

toggleBtn = controlPanel.querySelector("[title='播放/暫停']");

document.body.appendChild(controlPanel);

const selector = location.host.match(/\w+\.\w+$/);
delay(100)
    .then(() => waitElementLoad(siteMap[selector], 1, -1, 200))
    .then(() => {
    speechSynthesis.cancel();
    document.sc.toggleSpeech(siteMap[selector]);
})