timer / public /client.html
krishgokul92's picture
Upload 7 files
f40632a verified
<!doctype html>
<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>