Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>LAN Timer</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { color-scheme: dark; } | |
| /* Base */ | |
| body { | |
| margin:0; background:#0b0f14; color:#e6edf3; | |
| font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | |
| } | |
| a { color:#7fb3ff; text-decoration:none; } | |
| .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; } | |
| /* Controls / admin */ | |
| .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; } | |
| /* Client timer */ | |
| .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; } | |
| /* Role blocks */ | |
| .hidden { display:none; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header badges (client view) --> | |
| <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">Controls all clients in the same room over WebSockets. Use a small start delay for tight sync.</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>Server time (ms)</div> | |
| <b id="serverNow">--</b> | |
| </div> | |
| <div class="stat"> | |
| <div>Hint</div> | |
| <div> | |
| Open <code>/?role=client</code> on each device. | |
| Use <code>?room=yourcode</code> on both admin and clients 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> | |
| // --- URL params --- | |
| const qs = new URLSearchParams(location.search); | |
| const role = (qs.get('role') || 'client').toLowerCase(); // 'admin' | 'client' | |
| const room = (qs.get('room') || 'default').toLowerCase(); | |
| // --- Elements --- | |
| 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'); | |
| // Show correct view | |
| if (role === 'admin') { | |
| adminView.classList.remove('hidden'); | |
| } else { | |
| clientView.classList.remove('hidden'); | |
| roomBadge.classList.remove('hidden'); | |
| stateBadge.classList.remove('hidden'); | |
| } | |
| // Optional: guard admin from any blackout element existing | |
| if (role !== 'client') { const el = document.getElementById('blackout'); if (el) el.remove(); } | |
| // --- Socket.IO connection (rooms + role) --- | |
| const socket = io({ query: { role, room } }); | |
| // Ask server to refresh stats on admin load | |
| if (role === 'admin') socket.emit('stats:refresh'); | |
| // --- Wake lock (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(); }); | |
| } | |
| // ========================= | |
| // Precise clock sync pieces | |
| // ========================= | |
| const offsets = []; // { delay, offset, ts } | |
| let preSyncFast = false; // enable high-rate sync until start | |
| let preSyncUntil = 0; | |
| function syncOnce() { socket.emit('sync:ping', { t0: Date.now() }); } | |
| // normal cadence | |
| let syncNormal = setInterval(syncOnce, 3000); | |
| // burst cadence (activated by 'preSync' or when start is scheduled) | |
| let syncBurstTimer = null; | |
| function startBurstSync() { | |
| if (syncBurstTimer) return; | |
| preSyncFast = true; | |
| syncBurstTimer = setInterval(syncOnce, 50); // 20 Hz | |
| } | |
| function stopBurstSync() { | |
| preSyncFast = false; | |
| if (syncBurstTimer) { clearInterval(syncBurstTimer); syncBurstTimer = null; } | |
| } | |
| // do some quick initial samples | |
| for (let i = 0; i < 10; i++) setTimeout(syncOnce, i * 120); | |
| // ingest samples | |
| socket.on('sync:pong', ({ t0, t1, t2 }) => { | |
| const t3 = Date.now(); | |
| const delay = (t3 - t0) - (t2 - t1); | |
| const offset = ((t1 - t0) + (t2 - t3)) / 2; // server - client | |
| offsets.push({ delay, offset, ts: t3 }); | |
| if (offsets.length > 120) offsets.shift(); | |
| }); | |
| function bestOffset() { | |
| if (!offsets.length) return 0; | |
| // choose the lowest-delay decile and average | |
| const sorted = [...offsets].sort((a,b)=>a.delay-b.delay); | |
| const slice = sorted.slice(0, Math.max(5, Math.floor(sorted.length/10))); | |
| return Math.round(slice.reduce((s,x)=>s+x.offset,0) / slice.length); | |
| } | |
| // derive server time on this client (ms) | |
| function serverNow() { return Date.now() + bestOffset(); } | |
| // --- Admin: server clock view + stats | |
| const roomNameEl = document.getElementById('roomName'); | |
| const statsEl = document.getElementById('stats'); | |
| const serverNowEl= document.getElementById('serverNow'); | |
| if (roomNameEl) roomNameEl.textContent = room; | |
| if (role === 'admin') { | |
| setInterval(() => { serverNowEl.textContent = String(serverNow()|0); }, 1000); | |
| } | |
| socket.on('stats', ({ numAdmins, numClients }) => { | |
| if (statsEl) statsEl.textContent = `Clients: ${numClients} • Admins: ${numAdmins}`; | |
| }); | |
| // ========================= | |
| // Timer state (client side) | |
| // ========================= | |
| const State = { IDLE:'IDLE', RUNNING:'RUNNING', STOPPED:'STOPPED' }; | |
| let state = State.IDLE; | |
| let startAtServerMs = 0; // the server-time moment when timer hits 00:00 | |
| let rafId = 0; | |
| function setState(s){ state=s; if (stateBadge) stateBadge.textContent = s; } | |
| // Fixed 5-char format "SS:CC" (centiseconds) | |
| function fmt(ms) { | |
| if (ms < 0) ms = 0; | |
| const totalCs = Math.floor(ms / 10); | |
| const secs = Math.floor(totalCs / 100) % 100; // wrap at 100s | |
| const cs = totalCs % 100; | |
| return `${String(secs).padStart(2,'0')}:${String(cs).padStart(2,'0')}`; | |
| } | |
| // Renders from *server* clock so all clients show the same number at the same instant | |
| function render() { | |
| const elapsed = serverNow() - startAtServerMs; | |
| timeEl.textContent = fmt(elapsed); | |
| rafId = requestAnimationFrame(render); | |
| } | |
| // Optional: final busy-wait in the last ~3 ms before T0 to reduce sub-frame wobble | |
| const BUSY_WAIT_MS = 0; // set to 3 for ultra-tight start; 0 keeps CPU friendly | |
| function armStartAt(startAt) { | |
| startAtServerMs = startAt; | |
| setState(State.RUNNING); | |
| // stop any previous loop | |
| cancelAnimationFrame(rafId); | |
| // schedule first frame near T0 | |
| const schedule = () => { | |
| const dt = startAtServerMs - serverNow(); | |
| if (dt <= (BUSY_WAIT_MS || 1)) { | |
| // optional busy-wait | |
| if (BUSY_WAIT_MS > 0) { | |
| const end = performance.now() + BUSY_WAIT_MS; | |
| while (performance.now() < end) {} // spin ~3ms | |
| } | |
| // start rendering loop | |
| rafId = requestAnimationFrame(render); | |
| } else { | |
| // check again soon; use shorter checks as we approach T0 | |
| 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"; | |
| } | |
| // ============== | |
| // Command intake | |
| // ============== | |
| socket.on('cmd', (msg) => { | |
| if (!msg || !msg.type) return; | |
| switch (msg.type) { | |
| case 'preSync': | |
| // enter high-rate sync until start | |
| preSyncUntil = msg.until || 0; | |
| startBurstSync(); | |
| // automatically stop burst shortly after T0 | |
| setTimeout(() => stopBurstSync(), Math.max(1000, preSyncUntil - Date.now() + 1000)); | |
| break; | |
| case 'start': | |
| // If we didn't receive preSync for some reason, still start burst now | |
| if (!preSyncFast) { | |
| preSyncUntil = msg.startAt; | |
| startBurstSync(); | |
| setTimeout(() => stopBurstSync(), Math.max(1000, preSyncUntil - Date.now() + 1000)); | |
| } | |
| armStartAt(msg.startAt); | |
| break; | |
| case 'stop': | |
| stopPause(); break; | |
| case 'reset': | |
| resetAll(); break; | |
| case 'blackout': | |
| // Only clients go black | |
| if (role === 'client') { | |
| blackoutEl.style.display = msg.on ? 'block' : 'none'; | |
| document.documentElement.style.cursor = msg.on ? 'none' : 'auto'; | |
| } | |
| break; | |
| } | |
| }); | |
| // Initial UI (client) | |
| if (role === 'client') { | |
| document.getElementById('roomBadge').textContent = `room: ${room}`; | |
| setState(State.IDLE); | |
| timeEl.textContent = "00:00"; | |
| } | |
| // --- Admin controls wiring --- | |
| 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> | |