'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;
}