File size: 4,734 Bytes
f520256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c432f6
f520256
 
 
bb146f8
 
1ec267e
f520256
 
 
 
 
 
 
 
 
 
 
 
1ec267e
bb146f8
 
f520256
 
 
 
1c432f6
bb146f8
1c432f6
 
 
 
 
 
 
 
bb146f8
 
1c432f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f520256
bb146f8
f520256
 
 
1ec267e
 
bb146f8
f520256
 
bb146f8
1c432f6
 
 
 
 
bb146f8
1c432f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb146f8
1c432f6
 
 
 
 
 
 
 
 
bb146f8
f520256
 
 
 
 
 
 
bb146f8
f520256
 
 
 
 
 
 
bb146f8
f520256
 
 
 
 
 
1c432f6
bb146f8
1ec267e
 
 
 
 
bb146f8
 
 
 
 
 
 
1ec267e
 
4790603
 
 
 
 
1c432f6
 
 
 
 
f520256
 
bb146f8
f520256
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
const path = require("path");
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");

const app = express();
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: "*" } });

app.use(express.static(path.join(__dirname, "public")));

app.get("/", (_req, res) => {
  res.sendFile(path.join(__dirname, "public", "admin.html"));
});

app.get("/client", (_req, res) => {
  res.sendFile(path.join(__dirname, "public", "client.html"));
});

/** ---------------- Stopwatch state ---------------- */
let running = false;
let startNs = 0n;
let accumulatedNs = 0n;
let color = "#E8EEF8";   // shared color for all displays
let blackout = false;    // shared blackout flag

const nowNs = () => process.hrtime.bigint();
const nsToMillis = (ns) => Number(ns) / 1e6;

function currentElapsedNs() {
  if (!running) return accumulatedNs;
  return nowNs() - startNs;
}

function broadcastState() {
  const payload = {
    running,
    elapsedMs: nsToMillis(currentElapsedNs()),
    serverSentAtMs: Date.now(),
    color,
    blackout,
  };
  io.emit("state", payload);
}

/** --------------- Client tracking for Admin --------------- */
const clients = new Map(); // socketId -> {id, ip, ua, url, connectedAt, latencyMs, tz, lang, screen}

function getIp(socket) {
  const xf = socket.handshake.headers["x-forwarded-for"];
  if (xf && typeof xf === "string") return xf.split(",")[0].trim();
  return (socket.handshake.address || "").replace("::ffff:", "") || "unknown";
}

function broadcastClients() {
  const list = Array.from(clients.values()).sort((a, b) => a.connectedAt - b.connectedAt);
  io.to("admins").emit("clients:list", { count: list.length, clients: list });
}

function startLatencyProbe(socket) {
  const interval = setInterval(() => {
    const sentTs = Date.now();
    socket.emit("srv:ping", { sentTs });
  }, 5000);
  socket.data.latencyInterval = interval;
}

function stopLatencyProbe(socket) {
  if (socket.data.latencyInterval) {
    clearInterval(socket.data.latencyInterval);
    socket.data.latencyInterval = null;
  }
}

/** ----------------- Socket handling ----------------- */
io.on("connection", (socket) => {
  // Send current state immediately
  socket.emit("state", {
    running,
    elapsedMs: nsToMillis(currentElapsedNs()),
    serverSentAtMs: Date.now(),
    color,
    blackout,
  });

  // Admin subscribes to client list updates
  socket.on("admin:join", () => {
    socket.join("admins");
    broadcastClients();
  });

  // Client announces itself for admin dashboard
  socket.on("client:hello", (info = {}) => {
    const rec = {
      id: socket.id,
      ip: getIp(socket),
      ua: String(info.ua || socket.handshake.headers["user-agent"] || "unknown"),
      url: String(info.url || "unknown"),
      connectedAt: Date.now(),
      latencyMs: null,
      tz: info.tz || "",
      lang: info.lang || "",
      screen: info.screen || ""
    };
    clients.set(socket.id, rec);
    startLatencyProbe(socket);
    broadcastClients();
  });

  // Latency update
  socket.on("srv:pong", ({ sentTs }) => {
    if (!clients.has(socket.id)) return;
    const rtt = Date.now() - (sentTs || Date.now());
    const half = Math.max(0, Math.round(rtt / 2));
    const rec = clients.get(socket.id);
    rec.latencyMs = half;
    broadcastClients();
  });

  /** Stopwatch admin commands */
  socket.on("cmd:start", () => {
    if (!running) {
      startNs = nowNs() - accumulatedNs;
      running = true;
      broadcastState();
    }
  });

  socket.on("cmd:stop", () => {
    if (running) {
      accumulatedNs = nowNs() - startNs;
      running = false;
      broadcastState();
    }
  });

  socket.on("cmd:reset", () => {
    running = false;
    accumulatedNs = 0n;
    startNs = 0n;
    broadcastState();
  });

  // Color (hex) from admin
  socket.on("cmd:color", (hex) => {
    if (typeof hex !== "string") return;
    const ok = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(hex.trim());
    if (!ok) return;
    color = hex.trim();
    broadcastState();
  });

  // Blackout toggle from admin
  socket.on("cmd:blackout", (enabled) => {
    blackout = Boolean(enabled);
    broadcastState();
  });

  // 🚀 Wake command from admin: tell all clients to visually "wake up"
  socket.on("cmd:wake", () => {
    io.emit("wake");
  });

  socket.on("disconnect", () => {
    stopLatencyProbe(socket);
    clients.delete(socket.id);
    broadcastClients();
  });
});

// Heartbeat: periodically rebroadcast state so late-joining clients resync
setInterval(broadcastState, 200);

const PORT = process.env.PORT || 7860;
server.listen(PORT, "0.0.0.0", () => {
  console.log(`Stopwatch server running on http://0.0.0.0:${PORT}`);
});