537 lines
17 KiB
JavaScript
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);
|
|
})();
|