nopia tts ctrl

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل 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();
})();