Spaces:
Sleeping
Sleeping
| <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> | |