stopclock / public /index.html
krishgokul92's picture
Update public/index.html
2326a82 verified
Raw
History Blame Contribute Delete
12.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LAN Timer (Async, Monotonic)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { color-scheme: dark; }
body {
margin:0; background:#0b0f14; color:#e6edf3;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.wrap { max-width: 1080px; margin: 40px auto; padding: 0 20px; }
.card {
background:#121820; border:1px solid #1f2933; border-radius:16px;
padding:20px; box-shadow:0 6px 24px rgba(0,0,0,.35);
}
h1 { margin:0 0 6px; font-weight:800; letter-spacing:.2px; font-size: clamp(28px, 3.5vw, 44px); }
p.sub { margin:0 0 20px; color:#8ea0b5; }
.row { display:flex; gap:12px; flex-wrap:wrap; align-items:center; margin-bottom:16px; }
label { display:flex; gap:8px; align-items:center; color:#9fb4cc; }
.input {
background:#0d141c; color:#e6edf3; border:1px solid #1f2933;
border-radius:12px; padding:10px 12px; min-width: 180px;
}
.btn {
border:0; border-radius:12px; padding:12px 18px; font-weight:700; cursor:pointer;
background:#1e2936; color:#fff; transition: transform .02s ease, background .2s ease
}
.btn:active { transform: translateY(1px); }
.btn.start { background:#2261ff; }
.btn.stop { background:#f43f5e; }
.btn.reset { background:#0ea5e9; }
.btn.blackon { background:#000; border:1px solid #2c3a4a; }
.btn.blackoff { background:#16a34a; }
.badge {
background:#10161f; border:1px solid #223041; padding:6px 10px; border-radius:999px;
color:#9fb4cc; display:inline-flex; gap:8px; align-items:center;
}
.grid { display:grid; grid-template-columns: repeat(3, 1fr); gap:12px; }
.stat { background:#0d141c; border:1px solid #1f2933; border-radius:12px; padding:14px; }
.stat b { font-size: 28px; }
.stage {
min-height: 70svh; display:grid; place-items:center;
background:#05080d; border:1px solid #121820; border-radius:16px;
}
.time {
font-size: clamp(120px, 20vw, 260px); font-weight: 900; letter-spacing: 1px; line-height: 1.0;
font-variant-numeric: tabular-nums;
-webkit-font-feature-settings: "tnum" 1; font-feature-settings: "tnum" 1;
}
.state {
position: fixed; top:12px; right:12px; background:#0d141c; border:1px solid #1f2933;
padding:6px 10px; border-radius:999px; color:#9fb4cc; font-size:13px;
}
.room {
position: fixed; top:12px; left:12px; background:#0d141c; border:1px solid #1f2933;
padding:6px 10px; border-radius:999px; color:#9fb4cc; font-size:13px;
}
.hint { margin-top:10px; color:#7f93a8; font-size:12px; text-align:center; }
.blackout { position: fixed; inset: 0; background: #000; display: none; z-index: 999999; }
.hidden { display:none; }
</style>
</head>
<body>
<!-- Client badges -->
<div class="room hidden" id="roomBadge">room: default</div>
<div class="state hidden" id="stateBadge">IDLE</div>
<div class="wrap">
<!-- ADMIN VIEW -->
<div id="adminView" class="card hidden">
<h1>Timer Admin</h1>
<p class="sub">Pure async sync using monotonic clocks. No wall clock.</p>
<div class="row">
<label>Start delay (ms)
<input id="delay" class="input" type="number" value="3000" min="1" step="1">
</label>
<label>Label
<input id="label" class="input" type="text" placeholder="e.g. Heat 1">
</label>
<button id="start" class="btn start">Start</button>
<button id="stop" class="btn stop">Stop</button>
<button id="reset" class="btn reset">Reset</button>
<span class="badge" id="stats">Clients: 0 • Admins: 1</span>
</div>
<div class="row">
<button id="blackOn" class="btn blackon">Blackout ON</button>
<button id="blackOff" class="btn blackoff">Show Timer</button>
</div>
<div class="grid">
<div class="stat"><div>Room</div><b id="roomName">default</b></div>
<div class="stat"><div>Monotonic mapping</div><div id="syncInfo" style="color:#9fb4cc">offset learning…</div></div>
<div class="stat">
<div>Hint</div>
<div>Open <code>/?role=client</code> on each device. Use <code>?room=yourcode</code> on both to isolate groups.</div>
</div>
</div>
</div>
<!-- CLIENT VIEW -->
<div id="clientView" class="hidden">
<div class="stage"><div class="time" id="time">00:00</div></div>
<div class="hint">Keep this page visible for best accuracy. Screen is kept awake when possible.</div>
</div>
</div>
<div class="blackout" id="blackout"></div>
<script src="/socket.io/socket.io.js"></script>
<script>
// --- Params / Role ---
const qs = new URLSearchParams(location.search);
const role = (qs.get('role') || 'client').toLowerCase();
const room = (qs.get('room') || 'default').toLowerCase();
const adminView = document.getElementById('adminView');
const clientView = document.getElementById('clientView');
const roomBadge = document.getElementById('roomBadge');
const stateBadge = document.getElementById('stateBadge');
const blackoutEl = document.getElementById('blackout');
const timeEl = document.getElementById('time');
const syncInfo = document.getElementById('syncInfo');
if (role === 'admin') {
adminView.classList.remove('hidden');
} else {
clientView.classList.remove('hidden');
roomBadge.classList.remove('hidden');
stateBadge.classList.remove('hidden');
}
// Remove blackout layer for admin (cannot be covered)
if (role !== 'client') { const el = document.getElementById('blackout'); if (el) el.remove(); }
const socket = io({ query: { role, room } });
if (role === 'admin') socket.emit('stats:refresh');
// Wake lock for client
let wakeLock;
async function keepAwake(){ try { if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); } catch(_){} }
if (role === 'client') {
keepAwake();
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') keepAwake(); });
}
// =========================
// Monotonic offset learning
// =========================
// Server sends tS = performance.now() (server), client records cRecv = performance.now() (client).
// For each SYNC: offsetCandidate = cRecv - tS = trueOffset + oneWayDelay.
// The minimum over many samples approximates trueOffset + minDelay; using the *lowest-delay* decile stabilizes mapping.
const samples = []; // {delayProxy, offset}
function syncOnce(){ socket.emit('sync:ping'); }
// initial quick samples
for (let i=0;i<12;i++) setTimeout(syncOnce, i*100);
// steady cadence
setInterval(syncOnce, 3000);
socket.on('sync:pong', ({ tS }) => {
const cRecv = performance.now();
const offset = cRecv - Number(tS); // ms
// A proxy for "delay" here is how far this sample is above the current min
const currentMin = samples.length ? Math.min(...samples.map(s => s.offset)) : offset;
const delayProxy = Math.max(0, offset - currentMin);
samples.push({ offset, delayProxy });
if (samples.length > 200) samples.shift();
if (syncInfo && role === 'admin') {
const best = bestOffset();
syncInfo.textContent = `bestOffset≈ ${best.toFixed(2)} ms (monotonic)`;
}
});
function bestOffset(){
if (!samples.length) return 0;
const sorted = [...samples].sort((a,b)=>a.delayProxy-b.delayProxy);
const n = Math.max(5, Math.floor(sorted.length/10)); // take lowest-delay decile
const slice = sorted.slice(0, n);
const avg = slice.reduce((s,x)=>s+x.offset,0)/slice.length;
return avg; // ms: add to serverPerf to map into client perf space
}
function mapServerPerfToClientPerf(x){ return x + bestOffset(); } // ms
// --- Admin Stats + Room label
const roomNameEl = document.getElementById('roomName');
if (roomNameEl) roomNameEl.textContent = room;
socket.on('stats', ({ numAdmins, numClients }) => {
const statsEl = document.getElementById('stats');
if (statsEl) statsEl.textContent = `Clients: ${numClients} • Admins: ${numAdmins}`;
});
// =========================
// Client timer (render from serverPerf mapping)
// =========================
const State = { IDLE:'IDLE', RUNNING:'RUNNING', STOPPED:'STOPPED' };
let state = State.IDLE;
let startLocalPerf = 0; // client perf timestamp for 00:00
let rafId = 0;
function setState(s){ state=s; if (stateBadge) stateBadge.textContent = s; }
// SS:CC (centiseconds), fixed 5 chars
function fmt(ms){
if (ms < 0) ms = 0;
const csTotal = Math.floor(ms/10);
const ss = Math.floor(csTotal/100) % 100;
const cs = csTotal % 100;
return `${String(ss).padStart(2,'0')}:${String(cs).padStart(2,'0')}`;
}
function loop(){
const elapsed = performance.now() - startLocalPerf;
timeEl.textContent = fmt(elapsed);
rafId = requestAnimationFrame(loop);
}
// Optional: last-millisecond busy wait (default off to save CPU)
const BUSY_WAIT_MS = 0; // set to 3 for ultra-tight starts
function armStartAtServerPerf(startServerPerf){
// Convert server perf to client perf
startLocalPerf = mapServerPerfToClientPerf(startServerPerf);
setState(State.RUNNING);
cancelAnimationFrame(rafId);
const schedule = () => {
const dt = startLocalPerf - performance.now();
if (dt <= (BUSY_WAIT_MS || 1)) {
if (BUSY_WAIT_MS > 0) {
const end = performance.now() + BUSY_WAIT_MS;
while (performance.now() < end) {} // spin ~3ms
}
rafId = requestAnimationFrame(loop);
} else {
setTimeout(schedule, Math.min(50, Math.max(5, dt/10)));
}
};
schedule();
}
function stopPause(){
if (state !== State.RUNNING) return;
setState(State.STOPPED);
cancelAnimationFrame(rafId);
}
function resetAll(){
cancelAnimationFrame(rafId);
setState(State.IDLE);
if (timeEl) timeEl.textContent = "00:00";
}
// ==============
// Commands
// ==============
let burstTimer = null;
function startBurst(){ if (!burstTimer) burstTimer = setInterval(syncOnce, 50); }
function stopBurst(){ if (burstTimer) { clearInterval(burstTimer); burstTimer = null; } }
socket.on('cmd', (msg) => {
if (!msg || !msg.type) return;
switch (msg.type) {
case 'preSync':
startBurst();
// stop burst ~1s after target (serverPerf -> clientPerf)
{
const targetLocal = mapServerPerfToClientPerf(msg.untilServerPerf || 0);
const eta = Math.max(0, targetLocal - performance.now() + 1000);
setTimeout(stopBurst, eta);
}
break;
case 'start':
// If no preSync received, still do a short burst
if (!burstTimer) {
startBurst();
const targetLocal = mapServerPerfToClientPerf(msg.startServerPerf || 0);
const eta = Math.max(0, targetLocal - performance.now() + 1000);
setTimeout(stopBurst, eta);
}
armStartAtServerPerf(Number(msg.startServerPerf));
break;
case 'stop': stopPause(); break;
case 'reset': resetAll(); break;
case 'blackout':
// Only clients blackout
if (role === 'client') {
blackoutEl.style.display = msg.on ? 'block' : 'none';
document.documentElement.style.cursor = msg.on ? 'none' : 'auto';
}
break;
}
});
// Initial client UI
if (role === 'client') {
document.getElementById('roomBadge').textContent = `room: ${room}`;
setState(State.IDLE);
timeEl.textContent = "00:00";
}
// Admin controls
const delayEl = document.getElementById('delay');
const labelEl = document.getElementById('label');
const startBtn = document.getElementById('start');
const stopBtn = document.getElementById('stop');
const resetBtn = document.getElementById('reset');
const blackOnBtn = document.getElementById('blackOn');
const blackOffBtn = document.getElementById('blackOff');
if (startBtn) startBtn.onclick = () => socket.emit('admin:start', {
delayMs: Number(delayEl.value || 3000),
label: labelEl.value || ''
});
if (stopBtn) stopBtn.onclick = () => socket.emit('admin:stop');
if (resetBtn) resetBtn.onclick = () => socket.emit('admin:reset');
if (blackOnBtn) blackOnBtn.onclick = () => socket.emit('admin:blackout', { on: true });
if (blackOffBtn) blackOffBtn.onclick = () => socket.emit('admin:blackout', { on: false });
</script>
</body>
</html>