1/* ===================================================== 2 WATV TTS Player - tts-player.js 3 watvTTS ë³ìë wp_localize_script ë¡ PHPìì ì ë¬ë¨ 4===================================================== */ 5 6window.addEventListener('load', () => { 7 speechSynthesis.onvoiceschanged = loadVoices; 8 loadVoices(); 9}); 10 11let isPlaying = false; 12let isPaused = false; 13let utterances = []; 14let currentIndex = 0; 15let currentRate = 1; 16let voices = []; 17let isAutoScrollEnabled = true; 18let isManualMove = false; 19let totalChars = 0; 20let passedChars = 0; 21let totalSeconds = 0; 22let isTTSUnlocked = false; 23let ignoreOnEnd = false; 24let selectedLang = null; 25let isToastShowing = false; 26let isSplitDone = false; 27let playToken = 0; 28let lastErrorIndex = -1; 29let errorRetryCount = 0; 30 31/* ââ TTS ì ê¸ í´ì (iOS/Chrome ì ì± ëì) ââ */ 32function unlockTTS() { 33 if (isTTSUnlocked) return; 34 const dummy = new SpeechSynthesisUtterance(''); 35 speechSynthesis.speak(dummy); 36 speechSynthesis.cancel(); 37 isTTSUnlocked = true; 38} 39 40/* ââ ì¬ì ìë ââ */ 41const speedList = [0.5, 0.75, 1, 1.25, 1.5, 2]; 42 43function updateSpeedButtons() { 44 const minusBtn = document.querySelector('.btn-speed-minus'); 45 const plusBtn = document.querySelector('.btn-speed-plus'); 46 if (!minusBtn || !plusBtn) return; 47 48 const idx = speedList.indexOf(currentRate); 49 minusBtn.classList.toggle('disabled', idx <= 0); 50 plusBtn.classList.toggle('disabled', idx >= speedList.length - 1); 51} 52 53function changeSpeed(direction) { 54 let idx = speedList.indexOf(currentRate); 55 if (idx === -1) idx = 2; 56 idx = Math.max(0, Math.min(speedList.length - 1, idx + direction)); 57 setSpeed(speedList[idx]); 58} 59 60function setSpeed(rate) { 61 currentRate = rate; 62 const indicator = document.getElementById('speed-indicator'); 63 if (indicator) indicator.innerText = rate + 'x'; 64 updateSpeedButtons(); 65 if (isPlaying) { 66 playToken++; 67 ignoreOnEnd = true; 68 speechSynthesis.cancel(); 69 70 setTimeout(() => { 71 ignoreOnEnd = false; 72 playCurrent(); 73 }, 120); 74 } 75} 76 77/* ââ ìì± ëª©ë¡ ë¡ë ââ */ 78function loadVoices() { 79 const v = speechSynthesis.getVoices(); 80 if (v.length) voices = v; 81} 82 83const voiceLoader = setInterval(() => { 84 loadVoices(); 85 if (voices.length > 0) clearInterval(voiceLoader); 86}, 200); 87 88speechSynthesis.onvoiceschanged = loadVoices; 89 90/* ââ ì¬ì/ì¼ìì ì§ SVG ââ */ 91const playSVG = ` 92<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> 93<path d="M9 25.7495V5.97774C9 4.47129 10.6631 3.55834 11.934 4.36711L27.469 14.253C28.6479 15.0032 28.6479 16.7241 27.469 17.4743L11.934 27.3602C10.6631 28.1689 9 27.256 9 25.7495Z" fill="black"/> 94</svg>`; 95 96const pauseSVG = ` 97<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> 98<path d="M9 8V23.5556" stroke="black" stroke-width="6.22222" stroke-linecap="round"/> 99<path d="M23 8V23.5556" stroke="black" stroke-width="6.22222" stroke-linecap="round"/> 100</svg>`; 101 102function updatePlayIcons(svg) { 103 document.querySelectorAll('.btn-play').forEach(el => el.innerHTML = svg); 104} 105 106/* ââ ì¸ì´ ê°ì§ ââ */ 107function getDocLang() { 108 let lang = document.documentElement.lang || 'ko'; 109 lang = lang.toLowerCase(); 110 if (lang.startsWith('ko')) return 'ko-KR'; 111 if (lang.startsWith('en')) return 'en-US'; 112 if (lang.startsWith('es')) return 'es-ES'; 113 if (lang.startsWith('hi')) return 'hi-IN'; 114 if (lang.startsWith('vi')) return 'vi-VN'; 115 if (lang.startsWith('pt')) return 'pt-BR'; 116 if (lang.startsWith('mn')) return 'mn-MN'; 117 if (lang.startsWith('ne')) return 'ne-NP'; 118 if (lang.startsWith('ru')) return 'ru-RU'; 119 if (lang.startsWith('de')) return 'de-DE'; 120 if (lang.startsWith('ja')) return 'ja-JP'; 121 if (lang.startsWith('zh-cn') || lang.includes('hans') || lang === 'zh') return 'zh-CN'; 122 if (lang.startsWith('zh-tw') || lang.startsWith('zh-hk') || lang.includes('hant')) return 'zh-TW'; 123 if (lang.startsWith('id')) return 'id-ID'; 124 if (lang.startsWith('fr')) return 'fr-FR'; 125 return 'en-US'; 126} 127 128function detectLang(text) { 129 if (!text) return getDocLang(); 130 if (/[ê°-í£]/.test(text)) return 'ko-KR'; 131 if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'ja-JP'; 132 if (/[\u4E00-\u9FFF]/.test(text)) { 133 return (document.documentElement.lang.includes('tw') || document.documentElement.lang.includes('hk')) 134 ? 'zh-TW' : 'zh-CN'; 135 } 136 if (/[а-ÑÐ-Я]/.test(text)) return 'ru-RU'; 137 if (/[\u0900-\u097F]/.test(text)) return 'ne-NP'; 138 if (/[à âçéèêëîïôûùüÿñæÅ]/.test(text)) { 139 if (/[ñ¿¡]/.test(text)) return 'es-ES';
140 if (/[ãõ]/.test(text)) return 'pt-BR'; 141 if (/[à áảãạÄằắẳẵặâầấẩẫáºÃ¨Ã©áº»áº½áº¹]/.test(text.toLowerCase())) return 'vi-VN'; 142 return 'fr-FR'; 143 } 144 if (/[äöüÃ]/.test(text)) return 'de-DE'; 145 if (/[a-zA-Z]{2,}/.test(text)) { 146 const d = getDocLang(); 147 if (d === 'id-ID' || d === 'vi-VN') return d; 148 return 'en-US'; 149 } 150 return getDocLang(); 151} 152 153function getBestVoice(lang) { 154 if (!voices || !voices.length) return null; 155 const norm = (lang || 'en-US').toLowerCase().replace('_', '-'); 156 const base = norm.split('-')[0]; 157 const badVoices = ['agnes','eddy','flo','albert','fred','zarvox','grandma','grandpa','bad news','good news']; 158 let list = voices.filter(v => (v.lang || '').toLowerCase().startsWith(base)); 159 if (!list.length) return null; 160 list = list.filter(v => !badVoices.some(b => (v.name || '').toLowerCase().includes(b))); 161 const priority = ['google','siri','microsoft','natural']; 162 list.sort((a, b) => { 163 const aS = priority.some(k => a.name.toLowerCase().includes(k)) ? 1 : 0; 164 const bS = priority.some(k => b.name.toLowerCase().includes(k)) ? 1 : 0; 165 return bS - aS; 166 }); 167 return list[0] || null; 168} 169 170function isLangSupported(lang) { 171 return voices.some(v => (v.lang || '').toLowerCase().startsWith(lang.split('-')[0])); 172} 173 174function getSafeLang(lang) { 175 if (isLangSupported(lang)) return lang; 176 if (isLangSupported('en-US')) return 'en-US'; 177 if (isLangSupported('ko-KR')) return 'ko-KR'; 178 return voices[0]?.lang || 'en-US'; 179} 180 181/* ââ í ì¤í¸ ì림 ââ */ 182function showTTSToast(message) { 183 if (isToastShowing) return; 184 const parent = document.querySelector('.post-actions') || document.body; 185 let toast = parent.querySelector('.tts-toast'); 186 if (!toast) { 187 toast = document.createElement('div'); 188 toast.className = 'tts-toast'; 189 parent.appendChild(toast); 190 } 191 toast.innerText = message; 192 isToastShowing = true; 193 toast.classList.add('show'); 194 setTimeout(() => { 195 toast.classList.remove('show'); 196 setTimeout(() => { isToastShowing = false; }, 500); 197 }, 5000); 198} 199 200/* ââ í ì¤í¸ ì 리 ââ */ 201function cleanText(text) { 202 return text.replace(/https?:\/\/\S+/g, '').replace(/[^\S\r\n]+/g, ' ').trim(); 203} 204 205function protectNumbers(text) { 206 return text.replace(/(\d)\.(\d)/g, '$1__DOT__$2'); 207} 208 209function restoreNumbers(text) { 210 return text.replace(/__DOT__/g, '.'); 211} 212 213function normalizeSentence(text) { 214 return text.replace(/\s+/g, ' ').trim(); 215} 216 217function protectCommonAbbreviations(text) { 218 const baseLanguage = getDocLang().split('-')[0].toLowerCase(); 219 220 if (baseLanguage === 'es') { 221 const eraProtected = text.replace(/\b([ad])\.\s*C\./gi, (match, era, offset, source) => { 222 const rest = source.slice(offset + match.length); 223 const nextCharacter = rest.trimStart().charAt(0); 224 const continuesSentence = /^[,;:)]$/.test(nextCharacter) || 225 /^[a-záéÃóúüñ]/u.test(nextCharacter); 226 227 // a. C. y / d. C., en â ë ë§ì¹¨í 모ë ë³´í¸ 228 // d. C. Entonces â ë§ì§ë§ ë§ì¹¨íë ë¬¸ì¥ ëì¼ë¡ ì ì§ 229 return `${era}__DOT__ C${continuesSentence ? '__DOT__' : '.'}`; 230 }); 231 232 // etc., diciendo / etc. y ... ì²ë¼ 문ì¥ì´ ê³ìë ëë§ ë³´í¸ 233 return eraProtected.replace( 234 /\b[Ee]tc\.(?=\s*[,;:]|\s+[a-záéÃóúüñ])/gu, 235 'etc__DOT__' 236 ); 237 } 238 239 return text; 240} 241 242/* 243 * ë¬¸ì¥ ìì ". . .", "...", "â¦"를 ë§ì¹¨í 3ê°ë¡ ì¤ì¸í´ 244 * ë°ë¡ ë¶ë¦¬íì§ ìëë¡ ìì ë³´í¸í ë¤ ìë 모ìì¼ë¡ ë³µìíë¤. 245 */ 246function splitIntoSentences(text) { 247 const ellipses = []; 248 const abbreviationProtectedText = protectCommonAbbreviations(text); 249 const bibleProtectedText = window.watvBibleTTS?.protectInlineReferenceDots( 250 abbreviationProtectedText, 251 getDocLang() 252 ) || abbreviationProtectedText; 253 const protectedText = protectNumbers(bibleProtectedText).replace( 254 // ... / ï¼ï¼ï¼ / â¦â¦ / â¯â¯ / ⥠/ ããã ë± ë¤êµì´ ë§ì¤ìí 255 /(?:[.ï¼]\s*){3,}|[â¦â¯â¥]+|ã{3,}/g, 256 match => { 257 const trailingSpace = match.match(/\s+$/)?.[0] || ''; 258 const original = trailingSpace 259 ? match.slice(0, -trailingSpace.length) 260 : match; 261 const token = `__TTS_ELLIPSIS_${ellipses.length}__`; 262 263 ellipses.push(original); 264 return token + trailingSpace; 265 } 266 ); 267 268 const regex = /[¿¡ãããã\u201C\u201Dâ]?[^.!?ãï¼ï¼]+[.!?ãï¼ï¼]+[\s\u201C\u201D'»ãããã\]\)]*|[¿¡ãããã\u201C\u201Dâ]?[^.!?ãï¼ï¼]+$/g; 269 const rawSentences = protectedText.match(regex) || [protectedText]; 270 271 return rawSentences 272 .map(sentence => { 273 const restored = restoreNumbers(sentence.trim()).replace( 274 /__TTS_ELLIPSIS_(\d+)__/g, 275 (match, index) => ellipses[Number(index)] || match 276 ); 277 278 const bibleRestored = window.watvBibleTTS?.restoreInlineReferenceDots(restored) || restored; 279 return normalizeSentence(bibleRestored); 280 }) 281 .filter(Boolean); 282} 283 284/* ââ ì±ê²½ ì½ì´ ë³í: tts-bible-books.jsì ë¶ë¦¬ ââ */ 285function expandBibleReference(text) { 286 return window.watvBibleTTS?.expandCitation(text, getDocLang()) || text; 287} 288 289/* ââ ìê° í¬ë§· ââ */ 290function formatTime(sec) { 291 const m = Math.floor(sec / 60); 292 const s = sec % 60; 293 return m + ':' + (s < 10 ? '0' + s : s); 294} 295 296/* ââ ì´ ìê° ê³ì° ââ */ 297function calculateTotalTime() { 298 if (!utterances.length) return; 299 totalChars = utterances.reduce((sum, u) => sum + u.text.length, 0); 300 301 const totalTimeEl = document.querySelector('.tts-total-time'); 302 if (totalTimeEl && totalTimeEl.innerText.includes(':')) { 303 const parts = totalTimeEl.innerText.split(':'); 304 totalSeconds = parseInt(parts[0]) * 60 + parseInt(parts[1]); 305 } else { 306 totalSeconds = Math.ceil((totalChars / 200) * 60); 307 } 308 updateTotalTimeUI(); 309} 310 311function updateTotalTimeUI() { 312 document.querySelectorAll('.tts-total-time').forEach(el => { 313 if (el.innerText.trim() !== '') return; 314 el.innerText = formatTime(totalSeconds); 315 }); 316} 317 318/* ââ ì§íë¥ ì ë°ì´í¸ ââ */ 319function updateProgress() { 320 if (!utterances.length || totalChars === 0) return; 321 const percent = (passedChars / totalChars) * 100; 322 323 const bar = document.getElementById('tts-progress-bar'); 324 const mini = document.getElementById('tts-progress-bar-mini'); 325 if (bar) bar.style.width = percent + '%'; 326 if (mini) mini.style.width = percent + '%'; 327 328 let currentSec = Math.floor(totalSeconds * (percent / 100)); 329 if (currentSec > totalSeconds) currentSec = totalSeconds; 330 const el = document.getElementById('tts-current-time'); 331 if (el) el.innerText = formatTime(currentSec); 332} 333 334/* ââ í ì¤í¸ ë¶ë¦¬ (splitText) ââ */ 335function splitText() { 336 const content = document.getElementById('tts-content'); 337 if (!content) return; 338 339 // ââ ì´ë¯¸ DOM íì±ì´ ìë£ë ê²½ì° utterancesë§ ì¬ìì§ (DOM ì¬ê°ê³µ ë°©ì§) ââ 340 if (isSplitDone) { 341 utterances = []; 342 const excludeClasses = ['.related', '.related-post', '.table-of-contents', '.share-inner', '.tts-no']; 343 344 ['post-title', 'post-title-sub', 'single-title', 'single-title-sub', 'single-sub', 'post-area', 'single-name', 'content-title h1', 'content-title h2'].forEach(cls => { 345 const el = document.querySelector('.' + cls); 346 if (el && el.innerText.trim()) { 347 if (excludeClasses.some(excl => el.closest(excl))) return; 348 utterances.push({ el, text: el.innerText.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() }); 349 } 350 }); 351 352 document.querySelectorAll('#tts-content .tts-sentence').forEach(span => { 353 const text = (span.dataset.ttsText || span.innerText).trim(); 354 if (text) utterances.push({ el: span, text }); 355 }); 356 357 calculateTotalTime(); 358 return; 359 } 360
361 // ââ ìµì´ 1íë§ DOM íì± & span ì½ì ââ 362 utterances = []; 363 364 const excludeClasses = ['.related', '.related-post', '.table-of-contents', '.share-inner', '.tts-no']; 365 366 ['post-title', 'post-title-sub', 'single-title', 'single-title-sub', 'single-sub', 'post-area', 'single-name', 'content-title h1', 'content-title h2'].forEach(cls => { 367 const el = document.querySelector('.' + cls); 368 if (el && el.innerText.trim()) { 369 if (excludeClasses.some(excl => el.closest(excl))) return; 370 utterances.push({ el, text: el.innerText.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() }); 371 } 372 }); 373 374 const elements = [...document.querySelectorAll( 375 '#tts-content .ment, #tts-content .ment div, #tts-content p, #tts-content h2, #tts-content h3, #tts-content h4, #tts-content h5, #tts-content li, #tts-content dt, #tts-content dd, #tts-content blockquote' 376 )].filter(el => excludeClasses.every(excl => !el.closest(excl))); 377 378 elements.forEach(el => { 379 if (!el) return; 380 if (el.querySelector('.tts-sentence') && el.tagName !== 'BLOCKQUOTE') return; 381 382 // ââ ì±êµ¬: blockquote/p/small 구조ë ì ì§íê³ ë³¸ë¬¸ë§ span ì²ë¦¬ ââ 383 if (el.tagName === 'BLOCKQUOTE') { 384 if (el.querySelector('.tts-sentence')) return; 385 386 const textContainer = el.querySelector('p') || el; 387 const smallEl = textContainer.querySelector('small'); 388 const smallText = smallEl ? normalizeSentence(smallEl.innerText) : ''; 389 const spokenSmallText = expandBibleReference(smallText); 390 const verseUtterances = []; 391 392 // strong/em ë± ì±êµ¬ ìì ì¸ë¼ì¸ ë§í¬ì ë ì ì§í기 ìí´ í ì¤í¸ ë ¸ëë§ ìì§ 393 const walker = document.createTreeWalker( 394 textContainer, 395 NodeFilter.SHOW_TEXT, 396 { 397 acceptNode(node) { 398 if (!node.textContent.trim()) return NodeFilter.FILTER_REJECT; 399 400 const parent = node.parentElement; 401 if (!parent || parent.closest('small, sup, sub, .tts-no')) { 402 return NodeFilter.FILTER_REJECT; 403 } 404 405 return NodeFilter.FILTER_ACCEPT; 406 } 407 } 408 ); 409 410 const textNodes = []; 411 while (walker.nextNode()) textNodes.push(walker.currentNode); 412 413 textNodes.forEach(node => { 414 const rawText = normalizeSentence(node.textContent); 415 if (!rawText) return; 416 417 const sentences = splitIntoSentences(rawText); 418 const fragment = document.createDocumentFragment(); 419 420 sentences.forEach((sentence, index) => { 421 const span = document.createElement('span'); 422 span.className = 'tts-sentence'; 423 span.textContent = sentence; 424 fragment.appendChild(span); 425 426 if (index < sentences.length - 1) { 427 fragment.appendChild(document.createTextNode(' ')); 428 } 429 430 verseUtterances.push({ el: span, text: sentence }); 431 }); 432 433 node.replaceWith(fragment); 434 }); 435 436 // íë©´ì <small> ì½ì´ë ê·¸ëë¡ ëê³ ìì±ììë§ ì ì ëª ì¹ì¼ë¡ ì½ì 437 if (spokenSmallText && verseUtterances.length) { 438 const lastUtterance = verseUtterances[verseUtterances.length - 1]; 439 lastUtterance.text += ` ${spokenSmallText}`; 440 lastUtterance.el.dataset.ttsText = lastUtterance.text; 441 } 442 443 utterances.push(...verseUtterances); 444 return; 445 } 446 447 // ââ .ment: ìì íê·¸ë ì ì§íê³ ì§ì í ì¤í¸ë§ 문ì¥ë³ span ì²ë¦¬ ââ 448 if ( 449 el.classList.contains('ment') && 450 el.querySelector('p, blockquote, ul, ol, h2, h3, h4, h5') 451 ) { 452 [...el.childNodes].forEach(node => { 453 if (node.nodeType !== Node.TEXT_NODE) return; 454 455 const rawText = node.textContent.replace(/\s+/g, ' ').trim(); 456 if (!rawText) return; 457 458 const sentences = splitIntoSentences(rawText); 459 const fragment = document.createDocumentFragment(); 460 461 sentences.forEach((sentence, index) => { 462 const span = document.createElement('span'); 463 span.className = 'tts-sentence'; 464 span.textContent = sentence; 465 fragment.appendChild(span); 466 467 if (index < sentences.length - 1) { 468 fragment.appendChild(document.createTextNode(' ')); 469 } 470 471 utterances.push({ el: span, text: sentence }); 472 }); 473 474 node.replaceWith(fragment); 475 }); 476 477 return; 478 } 479 480 const clone = el.cloneNode(true); 481 if (el.closest('.post-cite') || el.closest('blockquote')) return; 482 483 clone.querySelectorAll('sup, sub').forEach(e => e.remove()); 484 clone.querySelectorAll('a').forEach(a => { 485 if (a.classList.contains('btn')) { a.remove(); return; } 486 a.parentNode.replaceChild(document.createTextNode(a.textContent), a); 487 }); 488 489 const authorEl = clone.querySelector('small'); 490 const author = authorEl ? authorEl.innerText.trim() : ''; 491 clone.querySelectorAll('small').forEach(e => e.remove()); 492 493 clone.querySelectorAll('br').forEach(br => { 494 br.replaceWith('__BR__'); 495 }); 496 497 const rawText = clone.innerText.replace(/\s+/g, ' ').trim(); 498 if (!rawText) return; 499 500 const brLines = rawText.split('__BR__').map(s =>
500 s.trim()).filter(Boolean); 501 502 const fragment = document.createDocumentFragment(); 503 504 brLines.forEach((line, lineIndex) => { 505 const sentences = splitIntoSentences(line); 506 507 sentences.forEach((sentence, index) => { 508 const span = document.createElement('span'); 509 span.className = 'tts-sentence'; 510 span.innerText = sentence; 511 fragment.appendChild(span); 512 if (index < sentences.length - 1) fragment.appendChild(document.createTextNode(' ')); 513 utterances.push({ el: span, text: sentence }); 514 }); 515 516 if (lineIndex < brLines.length - 1) { 517 fragment.appendChild(document.createElement('br')); 518 } 519 }); 520 521 if (author) { 522 const small = document.createElement('small'); 523 small.innerText = author; 524 fragment.appendChild(document.createTextNode(' ')); 525 fragment.appendChild(small); 526 } 527 528 el.innerHTML = ''; 529 el.appendChild(fragment); 530 }); 531 532 const titleSpan = document.querySelector('.tts-title span'); 533 if (titleSpan) { 534 const duration = Math.max(10, titleSpan.offsetWidth / 60); 535 titleSpan.style.animationDuration = duration + 's'; 536 } 537 538 isSplitDone = true; 539 calculateTotalTime(); 540} 541 542/* ââ ì¬ì íµì¬ í¨ì ââ */ 543function playCurrent() { 544 const token = playToken; 545 546 if (currentIndex >= utterances.length) { 547 resetUI(); 548 return; 549 } 550 551 const { el, text } = utterances[currentIndex]; 552 const spokenText = normalizeTTSText(text); 553 554 // ë§ì¹¨íë ë°ì´í ê°ì 구ëì ë§ ë¨ì í목ì ì½ì§ ìê³ ê±´ëë 555 if (!spokenText) { 556 currentIndex++; 557 playCurrent(); 558 return; 559 } 560 561 if (!voices.length) { 562 setTimeout(() => playCurrent(), 200); 563 return; 564 } 565 566 const targetLang = selectedLang || getDocLang(); 567 568 if (!isLangSupported(targetLang)) { 569 console.warn('â ìì± ìì:', targetLang); 570 const msg = (typeof watvTTS !== 'undefined' && watvTTS.langNotSupported) 571 ? watvTTS.langNotSupported 572 : 'íì¬ ì¸ì´ë ìì± ì¬ìì ì§ìíì§ ììµëë¤.'; 573 showTTSToast(msg); 574 resetUI(); 575 return; 576 } 577 578 let voice = getBestVoice(targetLang) || voices.find(v => v.default) || voices[0]; 579 580 const utter = new SpeechSynthesisUtterance(spokenText); 581 utter.voice = voice; 582 utter.lang = voice?.lang || targetLang; 583 utter.rate = currentRate; 584 utter.pitch = 1; 585 586 // ââ ì´ utterance ì ì© ìíì¤ ë²í¸ë¡ onend ì¤ë³µ/ì ë ¹ í¸ì¶ ì°¨ë¨ ââ 587 const myIndex = currentIndex; 588 589 utter.onstart = () => { 590 ignoreOnEnd = false;
591 isManualMove = false; 592 passedChars = utterances.slice(0, myIndex).reduce((sum, u) => sum + u.text.length, 0); 593 updateProgress(); 594 }; 595 596 utter.onend = () => { 597 if (token !== playToken) return; 598 if (ignoreOnEnd || isManualMove || myIndex !== currentIndex) return; 599 600 if (isPlaying && !isPaused) { 601 lastErrorIndex = -1; 602 errorRetryCount = 0; 603 currentIndex++; 604 playCurrent(); 605 } 606 }; 607 608 utter.onerror = (e) => { 609 if (token !== playToken) return; 610 if (e.error === 'interrupted' || e.error === 'canceled') return; 611 612 if (!isManualMove && myIndex === currentIndex) { 613 if (lastErrorIndex === myIndex) { 614 errorRetryCount++; 615 } else { 616 lastErrorIndex = myIndex; 617 errorRetryCount = 1; 618 } 619 620 console.warn('TTS playback error:', { 621 error: e.error, 622 index: myIndex, 623 text: spokenText, 624 retry: errorRetryCount 625 }); 626 627 // ì¼ìì ì¸ ìì± ìì§ ì¤ë¥ì¼ ì ìì¼ë¯ë¡ ê°ì 문ì¥ì í ë²ë§ ì¬ìë 628 if (errorRetryCount === 1 && isPlaying && !isPaused) { 629 setTimeout(() => playCurrent(), 150); 630 return; 631 } 632 633 // ì¬ìëê¹ì§ ì¤í¨íë©´ ë©ìì§ ìì´ ì¬ìë§ ìì íê² ì¤ë¨ 634 resetUI(); 635 } 636 }; 637 638 let targetEl = el; 639 if (!targetEl.classList.contains('tts-sentence')) { 640 const inner = targetEl.querySelector('.tts-sentence'); 641 if (inner) targetEl = inner; 642 } 643 644 document.querySelectorAll('.tts-active').forEach(e => e.classList.remove('tts-active')); 645 targetEl.classList.add('tts-active'); 646 647 if (isAutoScrollEnabled && targetEl) { 648 const rect = targetEl.getBoundingClientRect(); 649 window.scrollTo({ 650 top: rect.top + window.pageYOffset - (window.innerHeight / 2), 651 behavior: 'smooth' 652 }); 653 } 654 655 safeSpeak(utter); 656} 657 658/* ââ safeSpeak: cancel í ignoreOnEnd ë³µì ë³´ì¥ ââ */ 659function safeSpeak(utter) { 660 if (!speechSynthesis.speaking && !speechSynthesis.pending) { 661 speechSynthesis.speak(utter); 662 return; 663 } 664 ignoreOnEnd = true; 665 speechSynthesis.cancel(); 666 setTimeout(() => { 667 ignoreOnEnd = false; // â íµì¬: cancel í ë°ëì ë³µì 668 speechSynthesis.speak(utter); 669 }, 80); 670} 671 672/* ââ ì´ì / ë¤ì ââ */ 673function next() { 674 if (currentIndex < utterances.length - 1) { 675 isManualMove = true; ignoreOnEnd = true; 676 speechSynthesis.cancel(); 677 currentIndex++; 678 requestAnimationFrame(() => { ignoreOnEnd = false; isManualMove = false; playCurrent(); }); 679 } 680} 681 682function prev() { 683 if (currentIndex > 0) { 684 isManualMove = true; ignoreOnEnd = true; 685 speechSynthesis.cancel(); 686 currentIndex--; 687 requestAnimationFrame(() => { ignoreOnEnd = false; isManualMove = false; playCurrent(); }); 688 } 689} 690 691function restartTTS() { 692 playToken++; 693 ignoreOnEnd = true; 694 isManualMove = false; 695 isPaused = false; 696 currentIndex = 0; 697 passedChars = 0; 698 lastErrorIndex = -1; 699 errorRetryCount = 0; 700 701 speechSynthesis.cancel(); 702 703 setTimeout(() => { 704 ignoreOnEnd = false; 705 isPlaying = true; 706 playCurrent(); 707 }, 120); 708} 709 710/* ââ ì¬ì/ì¼ìì ì§ í ê¸ ââ */ 711function toggleRead() { 712 const player = document.querySelector('.tts-player-wrap'); 713 const btn = document.querySelector('.btn-tts'); 714 715 if (!isPlaying) { 716 if (!utterances.length) splitText(); 717 718 player?.classList.add('playing'); 719 btn?.classList.add('playing'); 720 updatePlayIcons(pauseSVG); 721 722 restartTTS(); 723 return; 724 } 725 726 if (isPaused) { 727 speechSynthesis.resume(); 728 isPaused = false; 729 player?.classList.add('playing'); 730 btn?.classList.add('playing'); 731 updatePlayIcons(pauseSVG); 732 } else { 733 speechSynthesis.pause(); 734 isPaused = true; 735 player?.classList.remove('playing'); 736 btn?.classList.remove('playing'); 737 updatePlayIcons(playSVG); 738 } 739} 740 741/* ââ íë ì´ì´ ì´ê¸° / ë«ê¸° ââ */ 742function openPlayer() { 743 const player = document.querySelector('.tts-player-wrap'); 744 const btn = document.querySelector('.btn-tts'); 745 if (!player || !btn) return; 746 747 if (!isPlaying) { 748 splitText(); 749 750 player.classList.add('active', 'playing'); 751 btn.classList.add('playing'); 752 updatePlayIcons(pauseSVG); 753 754 restartTTS(); 755 return; 756 } 757 if (isPaused) { 758 speechSynthesis.resume(); isPaused = false;
759 player.classList.add('playing'); btn.classList.add('playing'); 760 updatePlayIcons(pauseSVG); 761 } else { 762 speechSynthesis.pause(); isPaused = true; 763 player.classList.remove('playing'); btn.classList.remove('playing'); 764 updatePlayIcons(playSVG); 765 } 766} 767 768function closePlayer() { 769 speechSynthesis.cancel(); 770 resetUI(); 771 const player = document.querySelector('.tts-player-wrap'); 772 if (player) player.classList.remove('open', 'active', 'playing'); 773} 774 775function resetUI() { 776 // Avoid changing playToken here to prevent token races when restarting playback 777 isPlaying = false; isPaused = false; currentIndex = 0; passedChars = 0; 778 ignoreOnEnd = false; isManualMove = false; 779 lastErrorIndex = -1; errorRetryCount = 0; 780 781 if (speechSynthesis.speaking || speechSynthesis.pending || speechSynthesis.paused) { 782 speechSynthesis.cancel(); 783 } 784 785 const player = document.querySelector('.tts-player-wrap'); 786 const btn = document.querySelector('.btn-tts'); 787 788 player?.classList.remove('active', 'playing'); 789 btn?.classList.remove('playing'); 790 updatePlayIcons(playSVG); 791 792 document.querySelectorAll('.tts-active').forEach(e => e.classList.remove('tts-active')); 793 794 const bar = document.getElementById('tts-progress-bar'); 795 const mini = document.getElementById('tts-progress-bar-mini'); 796 const time = document.getElementById('tts-current-time'); 797 798 if (bar) bar.style.width = '0%'; 799 if (mini) mini.style.width = '0%'; 800 if (time) time.innerText = '0:00'; 801} 802 803/* ââ ë°ë í ê¸ ââ */ 804function toggleBody(e) { 805 e.stopPropagation(); 806 document.getElementById('tts-player')?.classList.toggle('open'); 807} 808 809/* ââ ìë ì¤í¬ë¡¤ í ê¸ ââ */ 810function toggleAutoScroll(btn) { 811 isAutoScrollEnabled = !isAutoScrollEnabled; 812 if (!btn) return; 813 btn.classList.toggle('active', isAutoScrollEnabled); 814 const label = btn.querySelector('.label'); 815 if (label) label.innerText = isAutoScrollEnabled ? btn.dataset.on : btn.dataset.off; 816} 817 818/* ââ ì¸ì´ ì í ë²í¼ ââ */ 819function setVoiceLang(lang) { 820 selectedLang = lang; 821 document.querySelectorAll('.tts-lang-control button').forEach(b => b.classList.remove('active')); 822 // event might be undefined when called programmatically; try to find the button by data attribute 823 const targetBtn = (typeof event !== 'undefined' && event && event.target) 824 ? event.target 825 : document.querySelector('.tts-lang-control button[data-lang="' + lang + '"]'); 826 if (targetBtn) targetBtn.classList.add('active'); 827 if (isPlaying) playCurrent(); 828} 829 830/* ââ DOMContentLoaded ì´ê¸°í ââ */ 831document.addEventListener('DOMContentLoaded', () => { 832 updatePlayIcons(playSVG); 833 updateSpeedButtons(); 834 835 const ttsWrap = document.querySelector('.tts'); 836 if (ttsWrap) { 837 ttsWrap.addEventListener('click', () => { 838 unlockTTS(); 839 setTimeout(() => { speechSynthesis.resume(); openPlayer(); }, 50); 840 }); 841 } 842 843 const closeBtn = document.querySelector('.tts-btn-close'); 844 if (closeBtn) { 845 closeBtn.addEventListener('click', e => { e.preventDefault(); closePlayer(); }); 846 } 847 848 const progress = document.getElementById('tts-progress'); 849 if (progress) { 850 progress.addEventListener('click', e => { 851 if (!utterances.length) return; 852 const rect = progress.getBoundingClientRect(); 853 const percent = (e.clientX - rect.left) / rect.width; 854 currentIndex = Math.floor(percent * utterances.length); 855 playToken++; 856 ignoreOnEnd = true; 857 isManualMove = true; 858 speechSynthesis.cancel(); 859 860 setTimeout(() => { 861 ignoreOnEnd = false; 862 isManualMove = false; 863 isPlaying = true; 864 isPaused = false; 865 playCurrent(); 866 }, 120); 867 }); 868 } 869}); 870 871function normalizeTTSText(text) { 872 const normalized = text 873 .replace(/ASEZ\s+WAO/gi, 'ìì¸ì¦ ìì¤') 874 .replace(/ASEZ\s+STAR/gi, 'ìì¸ì¦ ì¤í') 875 .replace(/ASEZ/gi, 'ìì¸ì¦') 876 // [â¦] / [...] ê°ì ìëµ íìë ê´í¸ê¹ì§ ìì±ìì ì ê±° 877 .replace(/\[\s*(?:(?:[.ï¼]\s*){3,}|[â¦â¯â¥]+|ã{3,})\s*\]/g, ' ') 878 // ë¤êµì´ ë§ì¤ìíë íë©´ìë§ ì ì§íê³ ìì±ìë ëê¸°ì§ ìì 879 .replace(/(?:[.ï¼]\s*){3,}|[â¦â¯â¥]+|ã{3,}/g, ' ') 880 // ì¬ë¬ ì¸ì´ì ì¸ì©ë¶í¸ ì ê±° 881 .replace(/[ââ"ï¼ãããã«»â¹âºââããââââââãããããã]/g, '') 882 // God's, don't ê°ì ë¨ì´ ë´ë¶ apostropheë ì ì§ 883 .replace(/['ââï¼]/g, (mark, offset, source) => { 884 const previous = source[offset - 1] || ''; 885 const next = source[offset + 1] || ''; 886 const isInsideWord = /[\p{L}\p{N}]/u.test(previous) && 887 /[\p{L}\p{N}]/u.test(next); 888 889 return isInsideWord ? mark : ''; 890 }) 891 .replace(/\s{2,}/g, ' ') 892 .trim(); 893 894 // 구ëì ë§ ìë 문ì¥ì ë¸ë¼ì°ì ê° "dot", "apostrophe"ë¡ ì½ì§ ìê² ì ì¸ 895 return /[\p{L}\p{N}]/u.test(normalized) ? normalized : ''; 896} 897
898/* ââ íì´ì§ ë ë ë ì 리 (bfcache ëë¹) ââ */ 899window.addEventListener('pagehide', () => { 900 try { speechSynthesis.cancel(); } catch (err) {} 901 resetUI(); 902 const player = document.querySelector('.tts-player-wrap'); 903 player?.classList.remove('open', 'active', 'playing'); 904}); 905 906/* ââ Safari ë¤ë¡ê°ê¸°(bfcache) ë³µì ëì ââ */ 907window.addEventListener('pageshow', (e) => { 908 if (e.persisted) { 909 try { speechSynthesis.cancel(); } catch (err) {} 910 isTTSUnlocked = false; 911 resetUI(); 912 const player = document.querySelector('.tts-player-wrap'); 913 player?.classList.remove('open', 'active', 'playing'); 914 } 915});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.