krishgokul92 commited on
Commit
f40632a
·
verified ·
1 Parent(s): d25057d

Upload 7 files

Browse files
Files changed (7) hide show
  1. .dockerignore +5 -0
  2. Dockerfile +18 -0
  3. README.md +17 -9
  4. package.json +13 -0
  5. public/admin.html +111 -0
  6. public/client.html +169 -0
  7. server.js +70 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ node_modules
4
+ npm-debug.log
5
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces (Docker) compatible
2
+ FROM node:20-alpine
3
+
4
+ WORKDIR /app
5
+
6
+ # Install prod deps (better build cache)
7
+ COPY package*.json ./
8
+ RUN npm ci --only=production
9
+
10
+ # App files
11
+ COPY public ./public
12
+ COPY server.js ./server.js
13
+
14
+ # Spaces provides PORT (usually 7860)
15
+ ENV PORT=7860
16
+ EXPOSE 7860
17
+
18
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,10 +1,18 @@
1
- ---
2
- title: Timer
3
- emoji: 📊
4
- colorFrom: red
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LAN Timer — Hugging Face Space (Docker)
 
 
 
 
 
 
 
2
 
3
+ - Admin: `/admin`
4
+ - Clients: `/client` (use `?room=xyz` to group)
5
+ - Blackout control from Admin
6
+ - Fixed 5-char **SS:CC** (seconds:centiseconds) display on clients
7
+
8
+ ## Run locally
9
+ ```bash
10
+ npm install
11
+ npm start
12
+ # http://localhost:7860/admin
13
+ ```
14
+
15
+ ## Deploy to Hugging Face Spaces (Docker)
16
+ 1. Create Space (type: Docker).
17
+ 2. Upload this repo (zip contents).
18
+ 3. Commit; Space will build and run at `https://<space>.hf.space`.
package.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "lan-timer",
3
+ "version": "1.2.0",
4
+ "private": true,
5
+ "type": "commonjs",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "express": "^4.19.2",
11
+ "socket.io": "^4.7.5"
12
+ }
13
+ }
public/admin.html ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Timer Admin</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ body { margin:0; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:#0b0f14; color:#e6edf3; }
10
+ .wrap { max-width: 960px; margin: 40px auto; padding: 0 20px; }
11
+ .card { background: #121820; border: 1px solid #1f2933; border-radius: 16px; padding: 20px; box-shadow: 0 6px 24px rgba(0,0,0,.35); }
12
+ h1 { margin: 0 0 6px; font-weight: 700; letter-spacing: .2px; }
13
+ p.sub { margin: 0 16px 20px 0; color: #8ea0b5; }
14
+ .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
15
+ .btn { border: 0; border-radius: 12px; padding: 12px 18px; font-weight: 600; cursor: pointer; background:#1e2936; color:#fff; transition: transform .02s ease, background .2s ease }
16
+ .btn:active { transform: translateY(1px); }
17
+ .btn.start { background: #2261ff; }
18
+ .btn.stop { background: #f43f5e; }
19
+ .btn.reset { background: #0ea5e9; }
20
+ .btn.blackon { background:#000; border:1px solid #2c3a4a; }
21
+ .btn.blackoff { background:#16a34a; }
22
+ .input { background: #0d141c; color:#e6edf3; border:1px solid #1f2933; border-radius:12px; padding: 10px 12px; min-width: 160px; }
23
+ .badge { background:#10161f; border:1px solid #223041; padding: 6px 10px; border-radius: 999px; color:#9fb4cc; display:inline-flex; gap:8px; align-items: center; }
24
+ .grid { display:grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
25
+ .stat { background:#0d141c; border: 1px solid #1f2933; border-radius:12px; padding: 14px; }
26
+ .stat b { font-size: 24px; }
27
+ code { background: #0d141c; border:1px solid #1f2933; padding:2px 6px; border-radius:6px; }
28
+ </style>
29
+ </head>
30
+ <body>
31
+ <div class="wrap">
32
+ <div class="card">
33
+ <h1>Timer Admin</h1>
34
+ <p class="sub">Controls all clients in the same room over WebSockets. Use a small start delay for tight sync.</p>
35
+
36
+ <div class="row">
37
+ <label>Start delay (ms)
38
+ <input id="delay" class="input" type="number" value="3000" min="200" step="100">
39
+ </label>
40
+ <label>Label
41
+ <input id="label" class="input" type="text" placeholder="e.g. Heat 1">
42
+ </label>
43
+ <button id="start" class="btn start">Start</button>
44
+ <button id="stop" class="btn stop">Stop</button>
45
+ <button id="reset" class="btn reset">Reset</button>
46
+ <span class="badge" id="stats">Clients: 0 • Admins: 1</span>
47
+ </div>
48
+
49
+ <div class="row">
50
+ <button id="blackOn" class="btn blackon">Blackout ON</button>
51
+ <button id="blackOff" class="btn blackoff">Show Timer</button>
52
+ </div>
53
+
54
+ <div class="grid">
55
+ <div class="stat">
56
+ <div>Room</div>
57
+ <b id="roomName">default</b>
58
+ </div>
59
+ <div class="stat">
60
+ <div>Server time (ms)</div>
61
+ <b id="serverNow">--</b>
62
+ </div>
63
+ <div class="stat">
64
+ <div>Hint</div>
65
+ <div>Open <code>/client</code> on each device. Use <code>?room=yourcode</code> to isolate groups.</div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <script src="/socket.io/socket.io.js"></script>
72
+ <script>
73
+ const params = new URLSearchParams(location.search);
74
+ const room = params.get('room') || 'default';
75
+ const socket = io({ query: { role: 'admin', room } });
76
+
77
+ const delayEl = document.getElementById('delay');
78
+ const labelEl = document.getElementById('label');
79
+ const statsEl = document.getElementById('stats');
80
+ const roomEl = document.getElementById('roomName');
81
+ const serverNowEl = document.getElementById('serverNow');
82
+
83
+ roomEl.textContent = room;
84
+
85
+ function syncOnce() { socket.emit('sync:ping', { t0: Date.now() }); }
86
+ socket.on('sync:pong', ({ t0, t1, t2 }) => {
87
+ const t3 = Date.now();
88
+ const offset = ((t1 - t0) + (t2 - t3)) / 2;
89
+ serverNowEl.textContent = String((Date.now() + offset)|0);
90
+ });
91
+ setInterval(syncOnce, 1000);
92
+ syncOnce();
93
+
94
+ socket.on('stats', ({ numAdmins, numClients }) => {
95
+ statsEl.textContent = `Clients: ${numClients} • Admins: ${numAdmins}`;
96
+ });
97
+
98
+ document.getElementById('start').onclick = () => {
99
+ socket.emit('admin:start', {
100
+ delayMs: Number(delayEl.value || 3000),
101
+ label: labelEl.value || ''
102
+ });
103
+ };
104
+ document.getElementById('stop').onclick = () => socket.emit('admin:stop');
105
+ document.getElementById('reset').onclick = () => socket.emit('admin:reset');
106
+
107
+ document.getElementById('blackOn').onclick = () => socket.emit('admin:blackout', { on: true });
108
+ document.getElementById('blackOff').onclick = () => socket.emit('admin:blackout', { on: false });
109
+ </script>
110
+ </body>
111
+ </html>
public/client.html ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Timer Client</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ html, body { height:100%; }
10
+ body {
11
+ margin:0; min-height:100%;
12
+ display:grid; place-items:center;
13
+ background:#05080d; color:#e6edf3;
14
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
15
+ }
16
+ .box { text-align:center; }
17
+ .time {
18
+ font-size: clamp(96px, 18vw, 240px);
19
+ font-weight: 900;
20
+ letter-spacing: 1px;
21
+ line-height: 1.0;
22
+ font-variant-numeric: tabular-nums;
23
+ -webkit-font-feature-settings: "tnum" 1;
24
+ font-feature-settings: "tnum" 1;
25
+ }
26
+ .label { color:#93a7bd; margin-top: 6px; font-size: clamp(14px, 3vw, 18px); }
27
+ .state { position: fixed; top:12px; right:12px; background:#0d141c; border:1px solid #1f2933; padding:6px 10px; border-radius:999px; color:#9fb4cc; font-size:13px; }
28
+ .room { position: fixed; top:12px; left:12px; background:#0d141c; border:1px solid #1f2933; padding:6px 10px; border-radius:999px; color:#9fb4cc; font-size:13px; }
29
+ .hint { position: fixed; bottom:12px; left:50%; transform: translateX(-50%); color:#7f93a8; font-size:12px; }
30
+ .blackout { position: fixed; inset: 0; background: #000; display: none; z-index: 999999; }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <div class="room" id="room">room: default</div>
35
+ <div class="state" id="state">IDLE</div>
36
+ <div class="box" id="box">
37
+ <div class="time" id="time">00:00</div>
38
+ <div class="label" id="label"></div>
39
+ </div>
40
+ <div class="hint">Keep this page visible for best accuracy. Screen is kept awake when possible.</div>
41
+ <div class="blackout" id="blackout"></div>
42
+
43
+ <script src="/socket.io/socket.io.js"></script>
44
+ <script>
45
+ const qs = new URLSearchParams(location.search);
46
+ const room = qs.get('room') || 'default';
47
+ document.getElementById('room').textContent = `room: ${room}`;
48
+
49
+ const socket = io({ query: { role: 'client', room } });
50
+
51
+ let wakeLock;
52
+ async function keepAwake() {
53
+ try { if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); }
54
+ catch(_) {}
55
+ }
56
+ keepAwake();
57
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') keepAwake(); });
58
+
59
+ const offsets = [];
60
+ function syncOnce() { socket.emit('sync:ping', { t0: Date.now() }); }
61
+ socket.on('sync:pong', ({ t0, t1, t2 }) => {
62
+ const t3 = Date.now();
63
+ const delay = (t3 - t0) - (t2 - t1);
64
+ const offset = ((t1 - t0) + (t2 - t3)) / 2;
65
+ offsets.push({ delay, offset, ts: t3 });
66
+ if (offsets.length > 40) offsets.shift();
67
+ });
68
+ for (let i=0;i<10;i++) setTimeout(syncOnce, i*150);
69
+ setInterval(syncOnce, 3000);
70
+
71
+ function bestOffset() {
72
+ if (offsets.length === 0) return 0;
73
+ const best = [...offsets].sort((a,b)=>a.delay-b.delay).slice(0,7).map(x=>x.offset);
74
+ return Math.round(best.reduce((s,v)=>s+v,0)/best.length);
75
+ }
76
+
77
+ const State = { IDLE:'IDLE', RUNNING:'RUNNING', STOPPED:'STOPPED' };
78
+ let state = State.IDLE;
79
+ let label = '';
80
+ let startServerAt = null;
81
+ let pauseOffsetMs = 0;
82
+ let rafId = 0;
83
+ const timeEl = document.getElementById('time');
84
+ const stateEl = document.getElementById('state');
85
+ const labelEl = document.getElementById('label');
86
+ const blackoutEl = document.getElementById('blackout');
87
+
88
+ function serverMsToLocalPerf(msServer) {
89
+ const off = bestOffset();
90
+ const localNowWall = Date.now();
91
+ const localNowPerf = performance.now();
92
+ const whenLocalWall = msServer - off;
93
+ const delta = whenLocalWall - localNowWall;
94
+ return localNowPerf + delta;
95
+ }
96
+
97
+ // Fixed 5-char format: "SS:CC" (seconds:centiseconds), wraps at 99:99
98
+ function fmt(ms) {
99
+ if (ms < 0) ms = 0;
100
+ const totalCs = Math.floor(ms / 10);
101
+ const secs = Math.floor(totalCs / 100) % 100;
102
+ const cs = totalCs % 100;
103
+ return `${String(secs).padStart(2,'0')}:${String(cs).padStart(2,'0')}`;
104
+ }
105
+
106
+ let zeroPerfTs = null;
107
+
108
+ function startAtServerTime(startAt) {
109
+ startServerAt = startAt;
110
+ pauseOffsetMs = 0;
111
+ zeroPerfTs = serverMsToLocalPerf(startAt);
112
+ state = State.RUNNING; labelEl.textContent = label; updateState();
113
+ scheduleRaf();
114
+ }
115
+
116
+ function stopPause() {
117
+ if (state !== State.RUNNING) return;
118
+ const nowPerf = performance.now();
119
+ const elapsed = nowPerf - zeroPerfTs;
120
+ pauseOffsetMs = Math.max(0, elapsed);
121
+ state = State.STOPPED; updateState();
122
+ cancelAnimationFrame(rafId);
123
+ renderElapsed(pauseOffsetMs);
124
+ }
125
+
126
+ function resetAll() {
127
+ state = State.IDLE; label = ''; startServerAt = null; pauseOffsetMs = 0; zeroPerfTs = null;
128
+ cancelAnimationFrame(rafId);
129
+ renderElapsed(0); updateState(); labelEl.textContent = '';
130
+ }
131
+
132
+ function renderElapsed(ms) { timeEl.textContent = fmt(ms); }
133
+ function updateState() { stateEl.textContent = state; }
134
+
135
+ function scheduleRaf() {
136
+ cancelAnimationFrame(rafId);
137
+ const loop = () => {
138
+ const nowPerf = performance.now();
139
+ const ms = nowPerf - zeroPerfTs;
140
+ renderElapsed(ms);
141
+ rafId = requestAnimationFrame(loop);
142
+ };
143
+ rafId = requestAnimationFrame(loop);
144
+ }
145
+
146
+ socket.on('cmd', (msg) => {
147
+ if (!msg || !msg.type) return;
148
+ switch (msg.type) {
149
+ case 'start':
150
+ label = msg.label || '';
151
+ startAtServerTime(msg.startAt);
152
+ break;
153
+ case 'stop':
154
+ stopPause();
155
+ break;
156
+ case 'reset':
157
+ resetAll();
158
+ break;
159
+ case 'blackout':
160
+ blackoutEl.style.display = msg.on ? 'block' : 'none';
161
+ document.documentElement.style.cursor = msg.on ? 'none' : 'auto';
162
+ break;
163
+ }
164
+ });
165
+
166
+ renderElapsed(0);
167
+ </script>
168
+ </body>
169
+ </html>
server.js ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // server.js — HF Spaces ready (PORT + 0.0.0.0) with blackout + rooms
2
+ const path = require('path');
3
+ const express = require('express');
4
+ const http = require('http');
5
+ const { Server } = require('socket.io');
6
+
7
+ const app = express();
8
+ const server = http.createServer(app);
9
+ const io = new Server(server, {
10
+ pingInterval: 10000,
11
+ pingTimeout: 5000,
12
+ cors: { origin: true }
13
+ });
14
+
15
+ app.use(express.static(path.join(__dirname, 'public')));
16
+
17
+ // Pretty routes
18
+ app.get('/', (req, res) => res.redirect('/admin'));
19
+ app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
20
+ app.get('/client', (req, res) => res.sendFile(path.join(__dirname, 'public', 'client.html')));
21
+
22
+ // Hugging Face sets PORT (usually 7860). Bind to 0.0.0.0 for container.
23
+ const HOST = '0.0.0.0';
24
+ const PORT = process.env.PORT || 7860;
25
+
26
+ // Helpers to read room/role from connection query
27
+ function getRoom(socket) { return (socket.handshake.query.room || 'default').toString(); }
28
+ function getRole(socket) { return (socket.handshake.query.role || 'client').toString(); }
29
+
30
+ io.on('connection', (socket) => {
31
+ const room = getRoom(socket);
32
+ const role = getRole(socket);
33
+ socket.join(room);
34
+
35
+ const emitStats = () => {
36
+ const sockets = io.sockets.adapter.rooms.get(room) || new Set();
37
+ const clients = Array.from(sockets).map(id => io.sockets.sockets.get(id));
38
+ const numAdmins = clients.filter(s => s?.handshake?.query?.role === 'admin').length;
39
+ const numClients = clients.length - numAdmins;
40
+ io.to(room).emit('stats', { numAdmins, numClients });
41
+ };
42
+ emitStats();
43
+
44
+ // NTP-like sync
45
+ socket.on('sync:ping', (msg = {}) => {
46
+ const t1 = Date.now();
47
+ socket.emit('sync:pong', { t0: msg.t0, t1, t2: Date.now() });
48
+ });
49
+
50
+ // Commands
51
+ socket.on('admin:start', ({ delayMs = 3000, label = '' } = {}) => {
52
+ if (role !== 'admin') return;
53
+ const startAt = Date.now() + Math.max(500, Number(delayMs));
54
+ io.to(room).emit('cmd', { type: 'start', startAt, label });
55
+ });
56
+ socket.on('admin:stop', () => { if (role === 'admin') io.to(room).emit('cmd', { type: 'stop' }); });
57
+ socket.on('admin:reset', () => { if (role === 'admin') io.to(room).emit('cmd', { type: 'reset' }); });
58
+
59
+ // Blackout toggle
60
+ socket.on('admin:blackout', ({ on = true } = {}) => {
61
+ if (role !== 'admin') return;
62
+ io.to(room).emit('cmd', { type: 'blackout', on: !!on });
63
+ });
64
+
65
+ socket.on('disconnect', emitStats);
66
+ });
67
+
68
+ server.listen(PORT, HOST, () => {
69
+ console.log(`Timer server on http://${HOST}:${PORT}`);
70
+ });