'use client'; import '../utils/arabicFontFix.js'; import { useEffect } from 'react'; // ─── Constants ──────────────────────────────────────────────────────────────── const MAX_ELEMENT_RETRIES = 3; const PENDING_TIMEOUT_MS = 20_000; const FAST_POLL_MS = 500; const FAST_POLL_DURATION = 15_000; const SLOW_POLL_MS = 3_000; const SLOW_POLL_DURATION = 45_000; const INITIAL_SCAN_DELAYS = [50, 250, 600, 1200, 2500, 4500]; export default function VttEngine({ articleRef, slug, htmlContent }) { useEffect(() => { if (!htmlContent) return; const articleEl = articleRef.current; if (!articleEl) return; // ── Lifecycle guard const abortController = new AbortController(); let isActive = true; // ── Per-run state const inFlightElements = new WeakSet(); const processedSrcs = new Set(); const retryCounts = new WeakMap(); const pendingTimestamps = new WeakMap(); // ── Cleanup registry const cleanupFns = []; const safeCleanup = () => cleanupFns.forEach(fn => { try { fn(); } catch (_) {} }); cleanupFns.push(() => { isActive = false; abortController.abort(); }); // ── Wipe stale flags from previous render const querySelectorStr = 'video, audio, iframe, .tuwa-subtitle-box'; articleEl.querySelectorAll(querySelectorStr) .forEach(el => el.removeAttribute('data-vtt-processed')); articleEl.querySelectorAll('.tuwa-subtitle-overlay') .forEach(o => o.remove()); // ── VTT parsing const parseVttTime = (timeStr) => { if (!timeStr) return 0; const clean = timeStr.trim().split(/\s+/)[0].replace(',', '.'); const parts = clean.split(':'); let s = 0; if (parts.length === 3) s = +parts[0] * 3600 + +parts[1] * 60 + +parts[2]; else if (parts.length === 2) s = +parts[0] * 60 + +parts[1]; else s = +parts[0]; return isNaN(s) ? 0 : s; }; const parseVtt = (vttText) => { const cues = []; const lines = vttText.replace(/\r\n/g, '\n').split('\n'); let cur = null; for (let line of lines) { line = line.trim(); if (!line) { if (cur?.textLines.length) { cues.push({ start: cur.start, end: cur.end, text: cur.textLines.join('
') }); } cur = null; continue; } if (/^(WEBVTT|Kind:|Language:|NOTE)/.test(line)) continue; if (line.includes('-->')) { if (cur?.textLines.length) { cues.push({ start: cur.start, end: cur.end, text: cur.textLines.join('
') }); } const [t0, t1] = line.split('-->'); cur = { start: parseVttTime(t0), end: parseVttTime(t1), textLines: [] }; } else if (cur) { if (!line.match(/^\d+$/) || cur.textLines.length > 0) { cur.textLines.push(line); } } } if (cur?.textLines.length) { cues.push({ start: cur.start, end: cur.end, text: cur.textLines.join('
') }); } return cues; }; // ── Overlay creation const createOverlay = (container, isStandaloneBox) => { let existing = null; try { existing = container.querySelector(':scope > .tuwa-subtitle-overlay'); } catch (e) { existing = Array.from(container.children).find(c => c.classList.contains('tuwa-subtitle-overlay')); } if (existing) return existing; const overlay = document.createElement('div'); overlay.className = 'tuwa-subtitle-overlay'; overlay.style.zIndex = '9999'; overlay.style.padding = '0 20px'; overlay.style.boxSizing = 'border-box'; overlay.style.minHeight = '44px'; overlay.style.display = 'block'; overlay.style.width = '100%'; overlay.style.textAlign = 'center'; if (isStandaloneBox) { overlay.style.position = 'relative'; overlay.style.marginTop = '10px'; } else { if (window.getComputedStyle(container).position === 'static') { container.style.position = 'relative'; } overlay.style.position = 'absolute'; overlay.style.bottom = '8%'; overlay.style.left = '0'; overlay.style.pointerEvents = 'none'; } container.appendChild(overlay); return overlay; }; // ── Subtitle updater const attachSubtitleUpdater = (overlay, cues, originalMediaEl) => { let lastCueText = null; let mediaEl = originalMediaEl; const updateSubtitle = (currentTime) => { if (!isActive) return; const activeCue = cues.find(c => currentTime >= c.start && currentTime <= c.end); const newText = activeCue ? activeCue.text : null; if (newText === lastCueText) return; lastCueText = newText; overlay.innerHTML = newText ? `${newText}` : ''; }; const tag = mediaEl.tagName.toLowerCase(); // Native MP4 Video/Audio Fixes if (tag === 'video' || tag === 'audio') { updateSubtitle(mediaEl.currentTime || 0); const onTimeUpdate = () => updateSubtitle(mediaEl.currentTime); mediaEl.addEventListener('timeupdate', onTimeUpdate); mediaEl.addEventListener('play', onTimeUpdate); mediaEl.addEventListener('seeking', onTimeUpdate); mediaEl.addEventListener('seeked', onTimeUpdate); cleanupFns.push(() => { mediaEl.removeEventListener('timeupdate', onTimeUpdate); mediaEl.removeEventListener('play', onTimeUpdate); mediaEl.removeEventListener('seeking', onTimeUpdate); mediaEl.removeEventListener('seeked', onTimeUpdate); }); } else if (tag === 'iframe') { let newSrc = mediaEl.src || ''; const isYouTube = newSrc.includes('youtube.com') || newSrc.includes('youtu.be'); // Guard: Do not touch non-YouTube iframes (e.g., MP4s embedded via third-party iframe) if (!isYouTube) return; let needsReplace = false; try { const url = new URL(newSrc); if (!url.searchParams.has('enablejsapi') || !url.searchParams.has('origin')) { url.searchParams.set('enablejsapi', '1'); url.searchParams.set('origin', window.location.origin); newSrc = url.toString(); needsReplace = true; } } catch (_) { if (!newSrc.includes('enablejsapi=1')) { newSrc += (newSrc.includes('?') ? '&' : '?') + 'enablejsapi=1&origin=' + encodeURIComponent(window.location.origin); needsReplace = true; } } // SPA Fix: Clone and replace iframe to prevent YouTube connection handshake drops if (needsReplace) { const clone = mediaEl.cloneNode(true); if (!clone.id) clone.id = `yt-iframe-${Math.random().toString(36).substr(2, 9)}`; clone.src = newSrc; if (mediaEl.parentNode) { mediaEl.parentNode.replaceChild(clone, mediaEl); } mediaEl = clone; } else { if (!mediaEl.id) mediaEl.id = `yt-iframe-${Math.random().toString(36).substr(2, 9)}`; } let pollingStarted = false; let ytPlayerInstance = null; const startYTPolling = (ytPlayer) => { if (pollingStarted || !isActive) return; pollingStarted = true; const interval = setInterval(() => { if (!isActive) { clearInterval(interval); return; } try { if (typeof ytPlayer.getCurrentTime === 'function') { const time = ytPlayer.getCurrentTime(); if (time !== undefined && time !== null) { updateSubtitle(time); } } } catch (_) {} }, 100); cleanupFns.push(() => clearInterval(interval)); }; const setupYT = () => { if (!isActive) return; let isReady = false; const ytPlayer = new window.YT.Player(mediaEl, { events: { onReady: (e) => { isReady = true; if (!isActive) { try { e.target.destroy(); } catch (_) {} return; } ytPlayerInstance = e.target; startYTPolling(e.target); }, }, }); // Fallback if YouTube API misses the onReady event due to network delay const fallbackTimer = setTimeout(() => { if (!isReady && isActive && ytPlayer && typeof ytPlayer.getCurrentTime === 'function') { ytPlayerInstance = ytPlayer; startYTPolling(ytPlayer); } }, 3000); cleanupFns.push(() => { clearTimeout(fallbackTimer); try { (ytPlayerInstance || ytPlayer).destroy(); ytPlayerInstance = null; } catch (_) {} }); }; if (window.YT?.Player) { setupYT(); } else { window.ytQueue = window.ytQueue || []; window.ytQueue.push(setupYT); } } }; // ── Fetch with retry const fetchVttWithRetry = async (url, signal, maxRetries = 3) => { let lastError; for (let attempt = 0; attempt < maxRetries; attempt++) { if (!isActive || signal.aborted) throw new DOMException('Aborted', 'AbortError'); try { const res = await fetch(url, { signal }); if (res.ok) return await res.text(); const err = new Error(`HTTP ${res.status}`); if (res.status >= 400 && res.status < 500) throw err; throw err; } catch (err) { if (err.name === 'AbortError') throw err; lastError = err; if (attempt < maxRetries - 1) { await new Promise(r => setTimeout(r, 500 * (2 ** attempt))); } } } throw lastError; }; // ── Core init for a single element const initSingleSubtitle = async (targetEl) => { if (!isActive) return; if ( targetEl.tagName === 'IFRAME' && !targetEl.dataset.vtt && !targetEl.closest('.tuwa-subtitle-box') ) { targetEl.dataset.vttProcessed = 'ignored'; return; } const currentState = targetEl.dataset.vttProcessed; if (currentState === 'pending') { const start = pendingTimestamps.get(targetEl); if (start && Date.now() - start > PENDING_TIMEOUT_MS) { targetEl.removeAttribute('data-vtt-processed'); try { inFlightElements.delete(targetEl); } catch (_) {} pendingTimestamps.delete(targetEl); } else { return; } } if ( inFlightElements.has(targetEl) || currentState === 'true' || currentState === 'ignored' ) return; if (currentState === 'error') { const retries = retryCounts.get(targetEl) || 0; if (retries >= MAX_ELEMENT_RETRIES) return; } let mediaEl = null, container = null, vttUrl = null, isStandaloneBox = false; if (targetEl.classList.contains('tuwa-subtitle-box')) { isStandaloneBox = true; container = targetEl; const mediaId = targetEl.dataset.video || targetEl.dataset.youtube || null; vttUrl = targetEl.dataset.vtt || null; mediaEl = targetEl.querySelector('video, audio, iframe'); if (!mediaEl && mediaId) { mediaEl = articleEl?.querySelector(`#${CSS.escape(mediaId)}`) || articleEl?.querySelector(`[data-media-id="${CSS.escape(mediaId)}"]`); } } else { mediaEl = targetEl; vttUrl = targetEl.dataset.vtt || null; container = targetEl.parentElement; if (!vttUrl) { const box = targetEl.closest('.tuwa-subtitle-box'); if (box) { vttUrl = box.dataset.vtt || null; container = box; isStandaloneBox = true; } } } if (!vttUrl) { targetEl.dataset.vttProcessed = 'ignored'; return; } if (!mediaEl) return; if (!container) container = mediaEl.parentElement; // Extract raw source URL to prevent duplicate processing on rerenders let mediaSrc = mediaEl.src || mediaEl.currentSrc || ''; if (!mediaSrc && mediaEl.tagName === 'VIDEO') { const source = mediaEl.querySelector('source'); if (source) mediaSrc = source.src || ''; } const mediaIdentifier = mediaSrc.split('?')[0] || mediaEl.id || Math.random().toString(); const srcKey = `${vttUrl}||${mediaIdentifier}`; if (processedSrcs.has(srcKey)) { targetEl.dataset.vttProcessed = 'true'; return; } inFlightElements.add(targetEl); targetEl.dataset.vttProcessed = 'pending'; pendingTimestamps.set(targetEl, Date.now()); try { const vttText = await fetchVttWithRetry(vttUrl, abortController.signal); if (!isActive) return; const cues = parseVtt(vttText); if (cues.length > 0) { const overlay = createOverlay(container, isStandaloneBox); attachSubtitleUpdater(overlay, cues, mediaEl); processedSrcs.add(srcKey); targetEl.dataset.vttProcessed = 'true'; retryCounts.delete(targetEl); } else { targetEl.dataset.vttProcessed = 'error'; retryCounts.set(targetEl, (retryCounts.get(targetEl) || 0) + 1); } } catch (err) { if (err.name === 'AbortError') return; if (isActive) { targetEl.dataset.vttProcessed = 'error'; retryCounts.set(targetEl, (retryCounts.get(targetEl) || 0) + 1); console.warn('[VttEngine] VTT fetch failed:', err.message, '→', vttUrl); } } finally { pendingTimestamps.delete(targetEl); try { inFlightElements.delete(targetEl); } catch (_) {} } }; const scanAndInit = () => { if (!isActive || !articleEl?.isConnected) return; articleEl.querySelectorAll(querySelectorStr).forEach(el => initSingleSubtitle(el)); }; const onMediaPlay = (e) => { if (!isActive) return; const target = e.target; if (!target || !['VIDEO', 'AUDIO', 'IFRAME'].includes(target.tagName)) return; const state = target.dataset.vttProcessed; if (state !== 'true') { if (state === 'error' || state === 'ignored' || state === 'pending') { target.removeAttribute('data-vtt-processed'); retryCounts.delete(target); try { inFlightElements.delete(target); } catch (_) {} pendingTimestamps.delete(target); } let box = target.closest('.tuwa-subtitle-box'); // Native MP4 fix: Lookup sibling subtitle box by ID connection if (!box) { const mediaId = target.id || target.dataset.mediaId; if (mediaId) { try { box = articleEl.querySelector(`.tuwa-subtitle-box[data-video="${CSS.escape(mediaId)}"], .tuwa-subtitle-box[data-youtube="${CSS.escape(mediaId)}"]`); } catch (_) {} } } if (box && box.dataset.vttProcessed !== 'true') { box.removeAttribute('data-vtt-processed'); retryCounts.delete(box); try { inFlightElements.delete(box); } catch (_) {} pendingTimestamps.delete(box); } setTimeout(() => { if (!isActive) return; initSingleSubtitle(target); if (box) initSingleSubtitle(box); }, 200); } }; articleEl.addEventListener('play', onMediaPlay, true /* capture */); cleanupFns.push(() => articleEl.removeEventListener('play', onMediaPlay, true)); const onVisibilityChange = () => { if (document.visibilityState === 'visible' && isActive) { setTimeout(scanAndInit, 300); } }; document.addEventListener('visibilitychange', onVisibilityChange); cleanupFns.push(() => document.removeEventListener('visibilitychange', onVisibilityChange)); const onOnline = () => { if (isActive) setTimeout(scanAndInit, 500); }; window.addEventListener('online', onOnline); cleanupFns.push(() => window.removeEventListener('online', onOnline)); requestAnimationFrame(() => { INITIAL_SCAN_DELAYS.forEach(delay => { const t = setTimeout(() => { if (isActive) scanAndInit(); }, delay); cleanupFns.push(() => clearTimeout(t)); }); }); const fastPoll = setInterval(scanAndInit, FAST_POLL_MS); const slowPollTimer = setTimeout(() => { clearInterval(fastPoll); if (!isActive) return; const slowPoll = setInterval(scanAndInit, SLOW_POLL_MS); const stopSlowPoll = setTimeout(() => clearInterval(slowPoll), SLOW_POLL_DURATION); cleanupFns.push(() => { clearInterval(slowPoll); clearTimeout(stopSlowPoll); }); }, FAST_POLL_DURATION); cleanupFns.push(() => { clearInterval(fastPoll); clearTimeout(slowPollTimer); }); if (!window.tuwaYTInitialized) { window.tuwaYTInitialized = true; const prev = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = () => { if (prev) prev(); (window.ytQueue || []).forEach(fn => { try { fn(); } catch (_) {} }); window.ytQueue = []; }; } if ( articleEl.querySelector('iframe') && !document.querySelector('script[src="https://www.youtube.com/iframe_api"]') ) { const s = document.createElement('script'); s.src = 'https://www.youtube.com/iframe_api'; document.head.appendChild(s); } return () => { safeCleanup(); try { articleEl.querySelectorAll(querySelectorStr) .forEach(el => el.removeAttribute('data-vtt-processed')); articleEl.querySelectorAll('.tuwa-subtitle-overlay') .forEach(o => { o.innerHTML = ''; o.remove(); }); } catch (_) {} }; }, [articleRef, slug, htmlContent]); return null; }