nopia tts ctrl

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         nopia tts ctrl
// @namespace    http://tampermonkey.net/
// @version      4.2
// @description  한
// @author       Your Assistant
// @match        https://novelpia.com/viewer/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- 기본 설정값 ---
    const DEFAULT_SETTINGS = {
        narration: { rate: 1.2, pitch: 1, voiceName: null },
        dialogue: { rate: 1.3, pitch: 1.1, voiceName: null },
        replacements: ['▶:', '■:', '※:'].join('\n'),
        readComments: true, 
        autoNext: true, 
        nextDelay: 5,
        blankLineDelay: 0,
        ignoreSpecialChars: true,
        readTitle: true,
        removePunctuationDelay: true,
    };
    const HIGHLIGHT_COLOR = '#FFD700';

    // --- 상태 변수 및 UI 요소 ---
    let settings = {}; let voices = []; let allLines = [];
    let currentLineIndex = -1;
    let isReading = false, isPaused = false, isSeeking = false;
    let lineSegmentQueue = [];

    function initialize() {
        console.log('[TTS Script v4.2] Initializing...');
        loadSettings();
        document.addEventListener('keydown', handleKeydown);

        const readyCheck = setInterval(() => {
            if (document.querySelector('#novel_drawing font.line')) {
                clearInterval(readyCheck);
                setupVoiceList();
                createControllerUI();
                createSettingsModal();
                if (sessionStorage.getItem('ttsAutoStart') === 'true') {
                    sessionStorage.removeItem('ttsAutoStart');
                    startReading();
                }
            }
        }, 200);
    }

    function setupVoiceList() {
        function populate() { voices = speechSynthesis.getVoices().filter(v => v.lang.startsWith('ko')); }
        populate();
        if (speechSynthesis.onvoiceschanged !== undefined) { speechSynthesis.onvoiceschanged = populate; }
    }
    
    function createControllerUI(){if(document.getElementById("tts-controller"))return;const e=document.createElement("div");e.id="tts-controller",e.innerHTML=`\n            <div id="tts-buttons">\n                <button id="tts-play-pause">▶️ 시작</button>\n                <button id="tts-stop">⏹️ 정지</button>\n                <button id="tts-settings-btn">⚙️ 설정</button>\n            </div>\n            <div id="tts-progress-bar-container">\n                <div id="tts-progress-bar"></div>\n                <div id="tts-progress-handle"></div>\n            </div>`,document.body.appendChild(e);const t=document.createElement("style");t.innerHTML="#tts-controller{position:fixed;bottom:20px;right:20px;width:300px;z-index:99999;background:rgba(0,0,0,.8);color:#fff;padding:12px;border-radius:10px;display:flex;flex-direction:column;gap:10px;user-select:none}#tts-buttons{display:flex;gap:10px}#tts-buttons button{flex-grow:1;padding:8px;border:none;border-radius:5px;cursor:pointer;font-size:14px}#tts-progress-bar-container{position:relative;width:100%;height:10px;background-color:#555;border-radius:5px;cursor:pointer;margin-top:5px}#tts-progress-bar{position:absolute;left:0;top:0;height:100%;background-color:#4caf50;border-radius:5px}#tts-progress-handle{position:absolute;left:0;top:50%;width:16px;height:16px;background-color:#fff;border-radius:50%;transform:translate(-50%,-50%);cursor:grab}",document.head.appendChild(t);const n=document.getElementById("tts-play-pause"),o=document.getElementById("tts-stop");document.getElementById("tts-settings-btn").onclick=openSettingsModal,n.onclick=togglePlayPause,o.onclick=stopReading;const i=document.getElementById("tts-progress-bar-container");i.addEventListener("mousedown",e=>{isSeeking=!0,seek(e)}),document.addEventListener("mousemove",e=>{isSeeking&&seek(e)}),document.addEventListener("mouseup",()=>{isSeeking&&(isSeeking=!1,isReading&&playLine())})}
    function createSettingsModal(){const e=document.createElement("div");e.id="tts-settings-modal",e.innerHTML=`\n            <div class="tts-modal-content">\n                <span class="tts-modal-close">&times;</span>\n                <h2>TTS 전역 설정</h2>\n                <div class="tts-tabs">\n                    <button class="tts-tab-btn active" data-tab="narration">서술부</button>\n                    <button class="tts-tab-btn" data-tab="dialogue">대화부</button>\n                    <button class="tts-tab-btn" data-tab="common">공통</button>\n                </div>\n\n                <div id="tts-tab-narration" class="tts-tab-content active">\n                    <div class="tts-setting-item"><label>목소리:</label><div class="tts-voice-control"><select class="tts-voice-select"></select><button class="tts-voice-test">테스트</button></div></div>\n                    <div class="tts-setting-item"><label>재생 속도: <span class="tts-rate-value">1.2</span></label><input type="range" class="tts-rate-slider" min="0.5" max="4" step="0.1"></div>\n                    <div class="tts-setting-item"><label>음성 높낮이: <span class="tts-pitch-value">1.0</span></label><input type="range" class="tts-pitch-slider" min="0.5" max="2" step="0.1"></div>\n                </div>\n\n                <div id="tts-tab-dialogue" class="tts-tab-content">\n                    <div class="tts-setting-item"><label>목소리:</label><div class="tts-voice-control"><select class="tts-voice-select"></select><button class="tts-voice-test">테스트</button></div></div>\n                    <div class="tts-setting-item"><label>재생 속도: <span class="tts-rate-value">1.3</span></label><input type="range" class="tts-rate-slider" min="0.5" max="4" step="0.1"></div>\n                    <div class="tts-setting-item"><label>음성 높낮이: <span class="tts-pitch-value">1.1</span></label><input type="range" class="tts-pitch-slider" min="0.5" max="2" step="0.1"></div>\n                </div>\n\n                <div id="tts-tab-common" class="tts-tab-content">\n                    <div class="tts-setting-item"><label for="tts-replacements">전역 대치어 설정:</label><textarea id="tts-replacements" rows="5" placeholder="한 줄에 하나씩 '원본:대체' 형식"></textarea></div>\n                    <div class="tts-setting-item tts-checkbox-group"><input type="checkbox" id="tts-read-title"><label for="tts-read-title">시작할 때 제목 읽기</label></div>\n                    <div class="tts-setting-item tts-checkbox-group"><input type="checkbox" id="tts-ignore-chars"><label for="tts-ignore-chars">대치어 외 특수문자 무시</label></div>\n                    <div class="tts-setting-item tts-checkbox-group"><input type="checkbox" id="tts-remove-punct-delay"><label for="tts-remove-punct-delay">[?!] 문장 끝 딜레이 제거</label></div>\n                    <div class="tts-setting-item tts-checkbox-group"><input type="checkbox" id="tts-read-comments"><label for="tts-read-comments">작가의 한마디 읽기</label></div>\n                    <div class="tts-setting-item tts-checkbox-group">\n                        <input type="checkbox" id="tts-auto-next"><label for="tts-auto-next">자동으로 다음화 넘어가기</label>\n                        <input type="number" id="tts-next-delay" min="0" style="width: 60px;"><label for="tts-next-delay">초 후</label>\n                    </div>\n                    <div class="tts-setting-item tts-checkbox-group">\n                        <label for="tts-blank-delay">빈 줄 넘김 속도:</label><input type="number" id="tts-blank-delay" min="0" style="width: 70px;"> ms\n                    </div>\n                </div>\n                <button id="tts-save-settings">설정 저장</button>\n            </div>`,document.body.appendChild(e),setupModalStylesAndEvents(e)}
    function setupModalStylesAndEvents(e){const t=document.createElement("style");t.innerHTML="#tts-settings-modal{display:none;position:fixed;z-index:100000;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgba(0,0,0,.6)}.tts-modal-content{background-color:#333;color:#fff;margin:5% auto;padding:20px;border:1px solid #888;width:90%;max-width:550px;border-radius:10px}.tts-modal-close{color:#aaa;float:right;font-size:28px;font-weight:700;cursor:pointer}.tts-setting-item{margin-bottom:15px}.tts-setting-item label{display:block;margin-bottom:5px}.tts-tabs{display:flex;border-bottom:1px solid #555;margin-bottom:15px}.tts-tab-btn{background:none;border:none;color:#aaa;padding:10px 15px;cursor:pointer;font-size:16px;outline:none}.tts-tab-btn.active{color:#fff;border-bottom:2px solid #4caf50}.tts-tab-content{display:none}.tts-tab-content.active{display:block}.tts-voice-control{display:flex;gap:10px}.tts-voice-control select{flex-grow:1}.tts-voice-test{padding:0 10px;background-color:#555;border:1px solid #777;color:#fff;cursor:pointer;border-radius:4px}.tts-checkbox-group{display:flex;align-items:center;gap:5px}.tts-checkbox-group label{margin-bottom:0;display:inline-block}.tts-setting-item select,.tts-setting-item input[type=range],.tts-setting-item input[type=number],.tts-setting-item textarea{width:100%;box-sizing:border-box;padding:5px;background-color:#555;color:#fff;border:1px solid #777;border-radius:4px}#tts-save-settings{background-color:#4caf50;color:#fff;padding:10px 15px;border:none;border-radius:5px;cursor:pointer;float:right}",document.head.appendChild(t),e.querySelector(".tts-modal-close").onclick=()=>e.style.display="none",e.querySelector("#tts-save-settings").onclick=saveSettings,e.querySelectorAll(".tts-tab-btn").forEach(t=>{t.onclick=n=>{e.querySelectorAll(".tts-tab-btn, .tts-tab-content").forEach(e=>e.classList.remove("active")),n.target.classList.add("active"),e.querySelector(`#tts-tab-${n.target.dataset.tab}`).classList.add("active")}}),["narration","dialogue"].forEach(t=>{const n=e.querySelector(`#tts-tab-${t}`);n.querySelector(".tts-rate-slider").oninput=e=>n.querySelector(".tts-rate-value").textContent=parseFloat(e.target.value).toFixed(1),n.querySelector(".tts-pitch-slider").oninput=e=>n.querySelector(".tts-pitch-value").textContent=parseFloat(e.target.value).toFixed(1),n.querySelector(".tts-voice-test").onclick=()=>testVoice(t)})}
    function testVoice(e){speechSynthesis.cancel();const t=document.querySelector(`#tts-tab-${e}`),n={rate:parseFloat(t.querySelector(".tts-rate-slider").value),pitch:parseFloat(t.querySelector(".tts-pitch-slider").value),voiceName:t.querySelector(".tts-voice-select").value},o=createUtterance("안녕하세요. 목소리 테스트입니다.",n);o?speechSynthesis.speak(o):alert("텍스트가 비어있습니다.")}
    function openSettingsModal(){const e=document.getElementById("tts-settings-modal"),t=voices.map(e=>`<option value="${e.name}">${e.name} (${e.lang})</option>`).join("");["narration","dialogue"].forEach(n=>{const o=e.querySelector(`#tts-tab-${n}`),i=settings[n];o.querySelector(".tts-voice-select").innerHTML=`<option value="">브라우저 기본</option>${t}`,o.querySelector(".tts-voice-select").value=i.voiceName||"",o.querySelector(".tts-rate-slider").value=i.rate,o.querySelector(".tts-pitch-slider").value=i.pitch,o.querySelector(".tts-rate-value").textContent=i.rate.toFixed(1),o.querySelector(".tts-pitch-value").textContent=i.pitch.toFixed(1)}),e.querySelector("#tts-replacements").value=settings.replacements,e.querySelector("#tts-read-comments").checked=settings.readComments,e.querySelector("#tts-auto-next").checked=settings.autoNext,e.querySelector("#tts-next-delay").value=settings.nextDelay,e.querySelector("#tts-ignore-chars").checked=settings.ignoreSpecialChars,e.querySelector("#tts-blank-delay").value=settings.blankLineDelay,e.querySelector("#tts-read-title").checked=settings.readTitle,e.querySelector("#tts-remove-punct-delay").checked=settings.removePunctuationDelay,e.style.display="block"}
    function saveSettings(){["narration","dialogue"].forEach(e=>{const t=document.querySelector(`#tts-tab-${e}`);settings[e]={voiceName:t.querySelector(".tts-voice-select").value,rate:parseFloat(t.querySelector(".tts-rate-slider").value),pitch:parseFloat(t.querySelector(".tts-pitch-slider").value)}}),Object.assign(settings,{replacements:document.getElementById("tts-replacements").value,readComments:document.getElementById("tts-read-comments").checked,autoNext:document.getElementById("tts-auto-next").checked,nextDelay:parseInt(document.getElementById("tts-next-delay").value,10),ignoreSpecialChars:document.getElementById("tts-ignore-chars").checked,blankLineDelay:parseInt(document.getElementById("tts-blank-delay").value,10),readTitle:document.getElementById("tts-read-title").checked,removePunctuationDelay:document.getElementById("tts-remove-punct-delay").checked}),localStorage.setItem("ttsNovelpiaSettings_v4.2",JSON.stringify(settings)),document.getElementById("tts-settings-modal").style.display="none",alert("설정이 저장되었습니다.")}
    function loadSettings(){const e=localStorage.getItem("ttsNovelpiaSettings_v4.2"),t=JSON.parse(JSON.stringify(DEFAULT_SETTINGS));settings=e?Object.assign(t,JSON.parse(e)):t}
    function processText(e){let t=e;const n=settings.replacements.split("\n").filter(e=>e.includes(":"));return n.forEach(e=>{const n=e.split(/:(.*)/s);if(n.length>=2){const e=n[0],s=n[1]||"";e&&(t=t.split(e).join(s))}}),settings.removePunctuationDelay&&(t=t.replace(/[?!…]+(\s|$)/g,". ")),settings.ignoreSpecialChars&&(t=t.replace(/[^가-힣a-zA-Z0-9\s.,“”"`''‘’]/g,"")),t.trim()}
    function createUtterance(e,t){const n=processText(e);if(!n)return null;const o=new SpeechSynthesisUtterance(n);return o.lang="ko-KR",o.rate=t.rate,o.pitch=t.pitch,t.voiceName&&voices.find(e=>e.name===t.voiceName)&&(o.voice=voices.find(e=>e.name===t.voiceName)),o}
    function getVisibleText(e){if(e.querySelector("img"))return{type:"image",text:"삽화가 존재합니다."};const t=e.cloneNode(!0);return t.querySelectorAll('p[style*="height: 0px"], [style*="display: none"]').forEach(e=>e.remove()),t.innerText.replace(/[ \u00a0\u200b\t\r\n]/g,"")===""?null:{type:"text",text:t.innerText}}

    function playLine() {
        if (isPaused || isSeeking || !isReading) return;
        if (currentLineIndex >= allLines.length) {
            finishReadingSequence();
            return;
        }
        
        const lineElem = allLines[currentLineIndex];
        allLines.forEach((line, index) => line.style.backgroundColor = index === currentLineIndex ? HIGHLIGHT_COLOR : '');
        lineElem.scrollIntoView({ behavior: 'smooth', block: 'center' });
        updateProgressBar();
        
        const content = getVisibleText(lineElem);

        if (content === null) {
            const delay = settings.blankLineDelay;
            const next = () => { if (!isPaused && !isSeeking && isReading) { currentLineIndex++; playLine(); } };
            if (delay > 0) { setTimeout(next, delay); } else { next(); }
            return;
        }

        if (content.type === 'image') {
            const utterance = createUtterance(content.text, settings.narration);
            speak(utterance, () => { currentLineIndex++; playLine(); });
            return;
        }
        
        // [핵심 개선] 문장 분할 로직
        const dialogueParts = content.text.split(/([“”"`''‘’].*?[“”"`''‘’])/g).filter(Boolean);
        lineSegmentQueue = [];
        
        dialogueParts.forEach(part => {
            const isDialogue = /^[“”"`''‘’]/.test(part);
            const config = isDialogue ? settings.dialogue : settings.narration;
            // 마침표, 물음표, 느낌표를 기준으로 문장을 더 잘게 나눔
            const sentences = part.match(/[^.?!…]+[.?!…]?/g) || [part];
            
            sentences.forEach(sentence => {
                const utterance = createUtterance(sentence, config);
                if (utterance) lineSegmentQueue.push(utterance);
            });
        });
        
        playSegmentQueue();
    }
    
    function playSegmentQueue() {
        if (isPaused || isSeeking || !isReading) return;
        if (lineSegmentQueue.length === 0) {
            currentLineIndex++;
            playLine();
            return;
        }
        const utterance = lineSegmentQueue.shift();
        utterance.onend = playSegmentQueue;
        speechSynthesis.speak(utterance);
    }

    function startReading() {
        if (isReading) return;
        allLines = Array.from(document.querySelectorAll('#novel_drawing font.line'));
        isReading = true; isPaused = false;
        document.getElementById('tts-play-pause').innerText = '⏸️ 일시정지';
        currentLineIndex = -1;

        if (settings.readTitle) {
            const titleText = `${document.querySelector('.menu-top-tag').innerText.match(/\d+/)[0]}화. ${document.querySelector('.menu-top-title').innerText}`;
            const utterances = (titleText.match(/[^.?!…]+[.?!…]?/g) || [titleText]).map(sentence => createUtterance(sentence, settings.narration)).filter(Boolean);
            lineSegmentQueue = utterances;
            playSegmentQueue = () => {
                if(lineSegmentQueue.length === 0) {
                    currentLineIndex = 0;
                    playLine();
                    return;
                }
                const u = lineSegmentQueue.shift();
                u.onend = playSegmentQueue;
                speechSynthesis.speak(u);
            };
            playSegmentQueue();
        } else {
            currentLineIndex = 0;
            playLine();
        }
    }
    
    function stopReading(){isReading=!1,isPaused=!1,speechSynthesis.cancel(),currentLineIndex>=0&&allLines.length>currentLineIndex&&allLines[currentLineIndex]&&(allLines[currentLineIndex].style.backgroundColor=""),currentLineIndex=-1,document.getElementById("tts-play-pause").innerText="▶️ 시작",updateProgressBar(0)}
    function togglePlayPause(){if(!isReading)startReading();else if(isPaused){isPaused=!1,speechSynthesis.resume(),document.getElementById("tts-play-pause").innerText="⏸️ 일시정지",speechSynthesis.speaking||playSegmentQueue()}else{isPaused=!0,speechSynthesis.pause(),document.getElementById("tts-play-pause").innerText="▶️ 계속"}}
    function handleKeydown(e){if(!isReading||isSeeking)return;if("ArrowLeft"===e.key||"ArrowRight"===e.key){e.preventDefault();let t=currentLineIndex<0?0:currentLineIndex,n="ArrowLeft"===e.key?-1:1;do{t+=n}while(t>0&&t<allLines.length&&null===getVisibleText(allLines[t]));t=Math.max(0,Math.min(allLines.length-1,t)),speechSynthesis.cancel(),currentLineIndex=t,playLine()}}
    function seek(e){const t=document.getElementById("tts-progress-bar-container").getBoundingClientRect();let n=(e.clientX-t.left)/t.width*100;n=Math.max(0,Math.min(100,n));const i=Math.floor(n/100*allLines.length);currentLineIndex!==i&&(speechSynthesis.cancel(),updateProgressBar(n),currentLineIndex=i,isReading&&playLine())}
    function updateProgressBar(e){const t=void 0!==e?e:allLines.length>0?currentLineIndex/(allLines.length-1)*100:0;document.getElementById("tts-progress-bar").style.width=`${t}%`,document.getElementById("tts-progress-handle").style.left=`${t}%`}
    
    function finishReadingSequence(){if(!isReading)return;isReading=!1;speak(createUtterance("끝.",settings.narration),()=>{if(settings.readComments){const e=document.getElementById("writer_comments_box");if(e){let t=getVisibleText(e)?.text.replace("작가의 한마디 (작가후기)","작가의 한마디.")||"";return void speak(createUtterance(t,settings.narration),proceedToNextChapter)}}proceedToNextChapter()})}
    function proceedToNextChapter(){if(settings.autoNext){const e=settings.nextDelay>0?`${settings.nextDelay}초 후 다음화로 넘어갑니다.`:"즉시 다음화로 넘어갑니다.";speak(createUtterance(e,settings.narration),()=>{setTimeout(()=>{const e=document.querySelector("#next_epi_btn_bottom, .menu-next-item");e?(sessionStorage.setItem("ttsAutoStart","true"),e.click()):(alert("다음화 버튼을 찾을 수 없습니다."),stopReading())},1e3*settings.nextDelay)})}else stopReading()}
    function speak(e,t){e?(t&&(e.onend=t),speechSynthesis.speak(e)):t&&t()}
    
    initialize();
})();