Spaces:
Sleeping
Sleeping
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Timer Client</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| :root { color-scheme: dark; } | |
| html, body { height:100%; } | |
| body { | |
| margin:0; min-height:100%; | |
| display:grid; place-items:center; | |
| background:#05080d; color:#e6edf3; | |
| font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | |
| } | |
| .box { text-align:center; } | |
| .time { | |
| font-size: clamp(96px, 18vw, 240px); | |
| 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; | |
| } | |
| .label { color:#93a7bd; margin-top: 6px; font-size: clamp(14px, 3vw, 18px); } | |
| .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 { position: fixed; bottom:12px; left:50%; transform: translateX(-50%); color:#7f93a8; font-size:12px; } | |
| .blackout { position: fixed; inset: 0; background: #000; display: none; z-index: 999999; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="room" id="room">room: default</div> | |
| <div class="state" id="state">IDLE</div> | |
| <div class="box" id="box"> | |
| <div class="time" id="time">00:00</div> | |
| <div class="label" id="label"></div> | |
| </div> | |
| <div class="hint">Keep this page visible for best accuracy. Screen is kept awake when possible.</div> | |
| <div class="blackout" id="blackout"></div> | |
| <script src="/socket.io/socket.io.js"></script> | |
| <script> | |
| const qs = new URLSearchParams(location.search); | |
| const room = qs.get('room') || 'default'; | |
| document.getElementById('room').textContent = `room: ${room}`; | |
| const socket = io({ query: { role: 'client', room } }); | |
| let wakeLock; | |
| async function keepAwake() { | |
| try { if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); } | |
| catch(_) {} | |
| } | |
| keepAwake(); | |
| document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') keepAwake(); }); | |
| const offsets = []; | |
| function syncOnce() { socket.emit('sync:ping', { t0: Date.now() }); } | |
| socket.on('sync:pong', ({ t0, t1, t2 }) => { | |
| const t3 = Date.now(); | |
| const delay = (t3 - t0) - (t2 - t1); | |
| const offset = ((t1 - t0) + (t2 - t3)) / 2; | |
| offsets.push({ delay, offset, ts: t3 }); | |
| if (offsets.length > 40) offsets.shift(); | |
| }); | |
| for (let i=0;i<10;i++) setTimeout(syncOnce, i*150); | |
| setInterval(syncOnce, 3000); | |
| function bestOffset() { | |
| if (offsets.length === 0) return 0; | |
| const best = [...offsets].sort((a,b)=>a.delay-b.delay).slice(0,7).map(x=>x.offset); | |
| return Math.round(best.reduce((s,v)=>s+v,0)/best.length); | |
| } | |
| const State = { IDLE:'IDLE', RUNNING:'RUNNING', STOPPED:'STOPPED' }; | |
| let state = State.IDLE; | |
| let label = ''; | |
| let startServerAt = null; | |
| let pauseOffsetMs = 0; | |
| let rafId = 0; | |
| const timeEl = document.getElementById('time'); | |
| const stateEl = document.getElementById('state'); | |
| const labelEl = document.getElementById('label'); | |
| const blackoutEl = document.getElementById('blackout'); | |
| function serverMsToLocalPerf(msServer) { | |
| const off = bestOffset(); | |
| const localNowWall = Date.now(); | |
| const localNowPerf = performance.now(); | |
| const whenLocalWall = msServer - off; | |
| const delta = whenLocalWall - localNowWall; | |
| return localNowPerf + delta; | |
| } | |
| // Fixed 5-char format: "SS:CC" (seconds:centiseconds), wraps at 99:99 | |
| function fmt(ms) { | |
| if (ms < 0) ms = 0; | |
| const totalCs = Math.floor(ms / 10); | |
| const secs = Math.floor(totalCs / 100) % 100; | |
| const cs = totalCs % 100; | |
| return `${String(secs).padStart(2,'0')}:${String(cs).padStart(2,'0')}`; | |
| } | |
| let zeroPerfTs = null; | |
| function startAtServerTime(startAt) { | |
| startServerAt = startAt; | |
| pauseOffsetMs = 0; | |
| zeroPerfTs = serverMsToLocalPerf(startAt); | |
| state = State.RUNNING; labelEl.textContent = label; updateState(); | |
| scheduleRaf(); | |
| } | |
| function stopPause() { | |
| if (state !== State.RUNNING) return; | |
| const nowPerf = performance.now(); | |
| const elapsed = nowPerf - zeroPerfTs; | |
| pauseOffsetMs = Math.max(0, elapsed); | |
| state = State.STOPPED; updateState(); | |
| cancelAnimationFrame(rafId); | |
| renderElapsed(pauseOffsetMs); | |
| } | |
| function resetAll() { | |
| state = State.IDLE; label = ''; startServerAt = null; pauseOffsetMs = 0; zeroPerfTs = null; | |
| cancelAnimationFrame(rafId); | |
| renderElapsed(0); updateState(); labelEl.textContent = ''; | |
| } | |
| function renderElapsed(ms) { timeEl.textContent = fmt(ms); } | |
| function updateState() { stateEl.textContent = state; } | |
| function scheduleRaf() { | |
| cancelAnimationFrame(rafId); | |
| const loop = () => { | |
| const nowPerf = performance.now(); | |
| const ms = nowPerf - zeroPerfTs; | |
| renderElapsed(ms); | |
| rafId = requestAnimationFrame(loop); | |
| }; | |
| rafId = requestAnimationFrame(loop); | |
| } | |
| socket.on('cmd', (msg) => { | |
| if (!msg || !msg.type) return; | |
| switch (msg.type) { | |
| case 'start': | |
| label = msg.label || ''; | |
| startAtServerTime(msg.startAt); | |
| break; | |
| case 'stop': | |
| stopPause(); | |
| break; | |
| case 'reset': | |
| resetAll(); | |
| break; | |
| case 'blackout': | |
| blackoutEl.style.display = msg.on ? 'block' : 'none'; | |
| document.documentElement.style.cursor = msg.on ? 'none' : 'auto'; | |
| break; | |
| } | |
| }); | |
| renderElapsed(0); | |
| </script> | |
| </body> | |
| </html> | |