Spaces:
Sleeping
Sleeping
Upload 7 files
Browse files- .dockerignore +5 -0
- Dockerfile +18 -0
- README.md +17 -9
- package.json +13 -0
- public/admin.html +111 -0
- public/client.html +169 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
});
|