timer / public /index.html
krishgokul92's picture
Update public/index.html
af7d7e4 verified
<!doctype html>
<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>