(() => { const cfgEl = document.getElementById('syncplayer-config'); const cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {}; const publicId = cfg.public_id; const video = document.getElementById('v'); const dbg = document.getElementById('dbg'); const idleImg = document.getElementById('idle'); const idleUrl = cfg.idle_image_url; let socket; let offsetMs = 0; // server_time = client_time + offsetMs let lastRttMs = 0; let bestRttMs = Infinity; let bestOffsetMs = 0; let syncBurstInProgress = false; let debugVisible = false; let lastSocketState = 'init'; let lastError = ''; let lastEventStart = null; let caching = { enabled: cfg.cache_enabled !== false, mode: cfg.cache_mode || 'range', // 'head' | 'range' rangeBytes: (typeof cfg.cache_range_bytes === 'number') ? cfg.cache_range_bytes : 1048576, maxConcurrent: 2, active: 0, queue: [], paused: false, started: false, done: 0, total: 0, lastCacheErr: '', }; if (!publicId) { lastError = 'missing public_id (check /display/ URL)'; } window.addEventListener('error', (e) => { // Capture JS errors that would otherwise halt connect() and leave the overlay stuck. const msg = (e && e.message) ? e.message : String(e || 'window_error'); lastError = `js_error: ${msg}`; }); window.addEventListener('unhandledrejection', (e) => { const msg = (e && e.reason && e.reason.message) ? e.reason.message : String((e && e.reason) || 'unhandledrejection'); lastError = `promise_rejection: ${msg}`; }); // Active event state on client let active = { eventId: null, startTimeMs: null, videoUrl: null, isReady: false, hasStarted: false, }; function clearActive() { active.eventId = null; active.startTimeMs = null; active.videoUrl = null; active.isReady = false; active.hasStarted = false; } function showIdleImage(show) { if (!idleImg) return; if (!idleUrl) { // If not configured, keep it hidden. idleImg.style.display = 'none'; return; } if (!idleImg.src) idleImg.src = idleUrl; idleImg.style.display = show ? 'block' : 'none'; // When idle is visible, keep video element visually hidden (some platforms show last frame otherwise) if (video) video.style.visibility = show ? 'hidden' : 'visible'; } function isVideoActivelyPlaying() { if (!video) return false; if (video.readyState < 2) return false; if (video.ended) return false; if (video.paused) return false; return true; } function nowMs() { // performance.timeOrigin + performance.now gives a monotonic, high-res clock const origin = (typeof performance.timeOrigin === 'number') ? performance.timeOrigin : (Date.now() - performance.now()); return origin + performance.now(); } function serverNowMs() { return nowMs() + offsetMs; } function setOffsetSample(newOffset, rtt) { // Prefer the lowest-RTT sample (least asymmetric delay). // Also apply a bit of smoothing to avoid wild jumps. // rtt can briefly be negative due to clock resolution or scheduling jitter; ignore those. if (isFinite(rtt) && rtt > 0 && rtt < bestRttMs) { bestRttMs = rtt; bestOffsetMs = newOffset; } // During a sync burst (connect/event), snap quickly to best sample. // Otherwise, slowly converge. const alpha = syncBurstInProgress ? 0.65 : 0.15; offsetMs = offsetMs * (1 - alpha) + bestOffsetMs * alpha; lastRttMs = rtt; } function setDebug(text) { if (!debugVisible) return; dbg.textContent = text; } function updateDebugOverlay() { if (!debugVisible) return; const conn = socket && socket.connected; const now = serverNowMs(); const ev = lastEventStart; const readyState = video ? video.readyState : -1; const paused = video ? video.paused : true; const ct = video ? video.currentTime : 0; setDebug( `id=${publicId}\n` + `socket=${conn ? 'connected' : 'disconnected'} state=${lastSocketState}\n` + `offsetMs=${offsetMs.toFixed(2)} rttMs=${lastRttMs.toFixed(2)} bestRttMs=${isFinite(bestRttMs) ? bestRttMs.toFixed(2) : 'inf'}\n` + `serverNowMs=${now.toFixed(1)}\n` + `video: readyState=${readyState} paused=${paused} ct=${ct.toFixed(3)} rate=${(video.playbackRate||1).toFixed(3)}\n` + (ev ? `eventId=${ev.event_id} start=${ev.start_time_ms.toFixed(1)}\nurl=${ev.video_url}\n` : 'eventId=(none)\n') + (caching.enabled ? `cache: ${caching.done}/${caching.total} active=${caching.active} paused=${caching.paused}\n` : '') + (caching.lastCacheErr ? `cacheErr=${caching.lastCacheErr}\n` : '') + (lastError ? `err=${lastError}\n` : '') ); } async function fetchJson(url) { const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`http ${res.status}`); return await res.json(); } async function warmOne(url) { // Best-effort cache warming. Range requests are widely supported and cheap. try { let headers = {}; let method = 'GET'; if (caching.mode === 'head') { method = 'HEAD'; } else { headers['Range'] = `bytes=0-${Math.max(0, caching.rangeBytes - 1)}`; } const res = await fetch(url, { method, headers, cache: 'force-cache' }); // If the server ignores Range and returns 200, do NOT download the full video. // Treat it as "warmed enough" (the request itself may prime TCP + DNS). if (method === 'GET') { if (res.status === 206) { try { await res.arrayBuffer(); } catch (e) {} } } if (!res.ok && res.status !== 206 && res.status !== 200) { throw new Error(`warm status=${res.status}`); } } catch (e) { caching.lastCacheErr = String(e && e.message ? e.message : e); } } function cachePump() { if (!caching.enabled || caching.paused) return; while (caching.active < caching.maxConcurrent && caching.queue.length) { const url = caching.queue.shift(); caching.active++; warmOne(url).finally(() => { caching.active--; caching.done++; cachePump(); }); } } async function startCacheWarmup() { if (!caching.enabled || caching.started) return; caching.started = true; try { const data = await fetchJson('/api/videos'); const vids = (data && data.videos) ? data.videos : []; caching.queue = vids.map(v => v.url); caching.total = caching.queue.length; cachePump(); } catch (e) { caching.lastCacheErr = String(e && e.message ? e.message : e); } } function connect() { if (typeof io !== 'function') { lastSocketState = 'no_io'; lastError = 'Socket.IO client not loaded (check /socket.io/socket.io.js)'; return; } // Prefer websocket, but allow polling fallback (important for some environments / server async modes). socket = io({ transports: ['websocket', 'polling'], upgrade: true, reconnection: true, reconnectionDelayMax: 1000 }); socket.on('connect_error', (err) => { lastSocketState = 'connect_error'; lastError = (err && err.message) ? err.message : String(err || 'connect_error'); console.warn('socket connect_error', err); }); socket.on('error', (err) => { lastSocketState = 'socket_error'; lastError = (err && err.error) ? err.error : String(err || 'socket_error'); console.warn('socket error', err); }); socket.on('connect', () => { lastSocketState = 'connected'; lastError = ''; socket.emit('hello', { public_id: publicId }); startTimeSyncLoop(); startHeartbeatLoop(); // Quickly converge on a good offset right after connecting. // This reduces the chance we schedule the next event using a bad clock estimate. timeSyncBurst(8, 120); socket.emit('request_state', { public_id: publicId }); }); socket.on('hello_ack', () => { lastSocketState = 'hello_ack'; }); socket.on('disconnect', () => { lastSocketState = 'disconnected'; active.isReady = false; // If we lose state, prefer showing idle image over a black frame. showIdleImage(true); }); socket.on('time_sync', (msg) => { // msg: {t1_ms, t2_ms, t3_ms}; we receive at t4 // NTP formulas: // rtt = (t4 - t1) - (t3 - t2) // offset = ((t2 - t1) + (t3 - t4)) / 2 const t4 = nowMs(); const t1 = msg.t1_ms; const t2 = msg.t2_ms; const t3 = msg.t3_ms; if (!isFinite(t1) || !isFinite(t2) || !isFinite(t3)) return; const rtt = (t4 - t1) - (t3 - t2); const newOffset = ((t2 - t1) + (t3 - t4)) / 2.0; setOffsetSample(newOffset, rtt); }); socket.on('event_start', async (msg) => { caching.paused = true; // don't contend bandwidth at synchronized start lastEventStart = msg; // msg: {event_id, video_url, start_time_ms} active.eventId = msg.event_id; active.startTimeMs = msg.start_time_ms; active.videoUrl = msg.video_url; active.isReady = false; active.hasStarted = false; // Any incoming event means something will play; hide idle image. showIdleImage(false); // A trigger may arrive while our offset is still converging. // Do a quick burst to get a good offset before scheduling playback. await timeSyncBurst(8, 120); await prepareVideo(msg.video_url); scheduleStart(); }); socket.on('event_state', async (msg) => { if (!msg.active) return; caching.paused = true; lastEventStart = msg; active.eventId = msg.event_id; active.startTimeMs = msg.start_time_ms; active.videoUrl = msg.video_url; active.isReady = false; active.hasStarted = false; showIdleImage(false); await prepareVideo(msg.video_url); // Late join: seek to expected position then play. const expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0); try { video.currentTime = expectedSec; } catch (e) {} // Start immediately (but still apply drift correction) try { await video.play(); } catch (e) {} active.hasStarted = true; }); } async function prepareVideo(url) { return new Promise((resolve) => { // Force new load video.pause(); video.playbackRate = 1.0; video.src = url; video.load(); // We expect video to start soon; make sure it's visible if (video) video.style.visibility = 'visible'; video.onerror = () => { const err = video.error; lastError = err ? `media_error code=${err.code}` : 'media_error'; console.warn('video error', err); }; const cleanup = () => { video.removeEventListener('canplaythrough', onReady); video.removeEventListener('canplay', onReady); video.removeEventListener('loadeddata', onReady); }; const onReady = () => { cleanup(); // Pre-roll: ensure at t=0 and paused try { video.currentTime = 0; } catch (e) {} video.pause(); active.isReady = true; resolve(); }; // Some embedded browsers never fire canplaythrough reliably. Accept earlier readiness signals. video.addEventListener('canplaythrough', onReady); video.addEventListener('canplay', onReady); video.addEventListener('loadeddata', onReady); // Safety timeout setTimeout(() => { if (!active.isReady) onReady(); }, 2000); }); } function scheduleStart() { if (!active.startTimeMs || !active.isReady || active.hasStarted) return; const tick = async () => { const delta = active.startTimeMs - serverNowMs(); if (delta <= 0) { try { // Always align to the expected timeline position before starting. // This avoids the “starts at 0 then fixes later” effect. let expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0); try { video.currentTime = expectedSec; } catch (e) {} await video.play(); active.hasStarted = true; } catch (e) { // autoplay policies: should work because muted // retry quickly setTimeout(tick, 50); return; } return; } // Use rAF for sub-frame precision as we approach start if (delta < 50) { requestAnimationFrame(tick); } else { setTimeout(tick, Math.min(250, delta - 25)); } }; tick(); } function startTimeSyncLoop() { // perform sync every 10 seconds const doSync = () => { if (!socket || !socket.connected) return; const t1 = nowMs(); socket.emit('time_sync', { t1_ms: t1 }); }; doSync(); setInterval(doSync, 10000); } async function timeSyncBurst(samples = 6, spacingMs = 120) { if (!socket || !socket.connected) return; if (syncBurstInProgress) return; syncBurstInProgress = true; bestRttMs = Infinity; // Fire a small burst of NTP-like pings and use the best RTT sample. for (let i = 0; i < samples; i++) { const t1 = nowMs(); socket.emit('time_sync', { t1_ms: t1 }); // eslint-disable-next-line no-await-in-loop await new Promise(r => setTimeout(r, spacingMs)); } // Let the last replies land. await new Promise(r => setTimeout(r, spacingMs)); syncBurstInProgress = false; } function startHeartbeatLoop() { setInterval(() => { if (!socket || !socket.connected) return; socket.emit('heartbeat', { public_id: publicId, // NTP RTT estimate (ms) latency_ms: lastRttMs, offset_ms: offsetMs, ready: !!active.isReady, }); }, 2000); } function startDriftCorrection() { setInterval(() => { if (!active.hasStarted || !active.startTimeMs) return; if (video.readyState < 2) return; if (video.paused) return; let expectedSec = (serverNowMs() - active.startTimeMs) / 1000.0; const actualSec = video.currentTime; const driftSec = actualSec - expectedSec; // + means video ahead const driftMs = driftSec * 1000.0; // Apply correction if (Math.abs(driftMs) > 100) { // Hard correction try { video.currentTime = Math.max(0, expectedSec); } catch (e) {} video.playbackRate = 1.0; } else if (Math.abs(driftMs) > 15) { // Soft correction via playbackRate if (driftMs > 0) { // ahead -> slow down video.playbackRate = 0.99; } else { // behind -> speed up video.playbackRate = 1.01; } } else { // close enough video.playbackRate = 1.0; } setDebug( `id=${publicId}\n` + `serverNowMs=${serverNowMs().toFixed(3)}\n` + `offsetMs=${offsetMs.toFixed(3)} rttMs=${lastRttMs.toFixed(3)}\n` + `eventId=${active.eventId} start=${active.startTimeMs}\n` + `expected=${expectedSec.toFixed(3)} actual=${actualSec.toFixed(3)} driftMs=${driftMs.toFixed(2)}\n` + `rate=${video.playbackRate.toFixed(3)} state=${video.readyState}` ); }, 1000); } video.addEventListener('ended', () => { if (socket && socket.connected && active.eventId) { socket.emit('playback_ended', { public_id: publicId, event_id: active.eventId }); } // After a trigger ends, show idle until we receive a new event. clearActive(); showIdleImage(true); }); // If we ever have no active playback, show idle image. video.addEventListener('pause', () => { // If video isn't actively playing and we don't expect immediate play, show idle. // Don't show idle during pre-roll scheduling (active.videoUrl set but not started yet). if (!isVideoActivelyPlaying() && !active.hasStarted) return; if (!isVideoActivelyPlaying()) showIdleImage(true); }); video.addEventListener('playing', () => { showIdleImage(false); }); video.addEventListener('error', () => { // Show idle on fatal errors to avoid black screen. showIdleImage(true); }); // Debug overlay toggle: Ctrl+D function toggleDebug() { debugVisible = !debugVisible; dbg.style.display = debugVisible ? 'block' : 'none'; if (debugVisible) { setDebug(`id=${publicId}\n(debug enabled)\nconnecting...`); } } setInterval(updateDebugOverlay, 500); // Many Chromium-based browsers reserve Ctrl+D (bookmark). Use F2 or Ctrl+Shift+D. window.addEventListener('keydown', (e) => { if (e.key === 'F2') { e.preventDefault(); toggleDebug(); return; } if (e.ctrlKey && e.shiftKey && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); toggleDebug(); return; } }); // Fallback for kiosk remotes: double click anywhere toggles. window.addEventListener('dblclick', (e) => { e.preventDefault(); toggleDebug(); }); // Prevent context menu / accidental UI window.addEventListener('contextmenu', (e) => e.preventDefault()); connect(); startDriftCorrection(); // Start cache warming shortly after page load. setTimeout(startCacheWarmup, 500); // Initial state: show idle image until we get an event_state/event_start. showIdleImage(true); })();