Files
SyncPlayer/static/display.js
2026-02-12 10:50:49 +01:00

537 lines
17 KiB
JavaScript

(() => {
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/<public_id> 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);
})();