PageSourceSearch

https://watv.org/wp-content/plugins/watv-tts-player-main/assets/tts-player.js?ver=1.0.18

js watv.org collected 2026-10-01 12:22:30 UTC 29,962 bytes, 915 lines download raw bytes

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.