Initial LiveCast implementation

Real-time one-to-many WebRTC audio broadcast per the v1 PRD.

- server/: Node + ws signaling server. /ws relays SDP and ICE between a
  single broadcaster and N listeners (routed by listener id), /ice issues
  ephemeral coturn HMAC credentials with a public-STUN local-dev fallback,
  /health reports status.
- web/: React + Vite + Tailwind SPA. /broadcast captures the mic and creates
  one peer connection per listener with a live level meter and listener count;
  /listen gates audio behind a user gesture, plays incoming audio with an
  audio-reactive waveform, fires a go-live notification, and reconnects.
- deploy/: Caddyfile, coturn turnserver.conf, and a systemd unit.
- README runbook covers the full provisioning-to-handoff roadmap.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-04 10:18:16 -05:00
co-authored by Claude Opus 4.8
commit c6305be89d
24 changed files with 4265 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
.DS_Store
+158
View File
@@ -0,0 +1,158 @@
# LiveCast
Real-time one-to-many audio broadcast. A single broadcaster streams live
microphone audio from a phone browser to many listeners over WebRTC, with
sub-second latency and no app install. A lightweight Node signaling server
brokers the WebRTC handshake and tracks live state; coturn provides STUN and
TURN so connections succeed across NAT, firewalls, and cellular networks.
Built for RedClaw Systems LLC. Target deployment: `event.redclaw.dev`.
## How it works
```
Broadcaster phone Signaling (WSS) Listener browser
| | |
|-- register ----------->|<------- register -------|
|<-- listener-wants-offer-| |
|-- offer (SDP) -------->|-- offer --------------->|
|<-- answer -------------|<------- answer ----------|
|-- ICE <----------------+----------------> ICE ----|
|======== peer-to-peer audio (SRTP), via TURN if needed ========|
```
Audio never passes through the signaling server. It flows peer-to-peer, or
through the TURN relay only when a direct path cannot be established.
## Project layout
```
server/ Node + ws signaling server (/ws, /ice, /health)
web/ React + Vite SPA (/broadcast, /listen)
deploy/ Caddyfile, coturn turnserver.conf, systemd unit
```
## Quick start (local)
No TURN server is required for local testing. With `TURN_SECRET` unset the
`/ice` endpoint serves a public STUN server only, which is enough for two
devices on the same LAN.
Terminal 1, signaling server:
```
cd server
npm install
npm run dev # listens on :8080
```
Terminal 2, web app:
```
cd web
npm install
npm run dev # Vite on :5173, proxies /ws and /ice to :8080
```
Then, on two devices on the same network (or two browser tabs):
- Open `http://<your-lan-ip>:5173/broadcast`, tap **Go Live**, grant the mic.
- Open `http://<your-lan-ip>:5173/listen`, tap **Connect & Enable Audio**.
Check the server directly:
```
curl localhost:8080/health # {"status":"ok","broadcasterOnline":false,...}
curl localhost:8080/ice # STUN-only iceServers in local-dev mode
```
## Environment
Server (`server/.env`, see `.env.example`):
| Variable | Purpose |
| --- | --- |
| `PORT` | Internal listen port (default 8080) |
| `TURN_HOST` | coturn host, used to build STUN/TURN URLs |
| `STUN_HOST` | Public STUN used as the local-dev fallback |
| `TURN_SECRET` | Shared secret for ephemeral TURN creds. Leave blank locally. |
| `TURN_REALM` | coturn realm |
| `TURN_TTL_SECONDS` | TURN credential lifetime (default 43200 = 12h) |
Web (`web/.env`, see `.env.example`):
| Variable | Purpose |
| --- | --- |
| `VITE_SIGNAL_URL` | WSS signaling URL. Blank derives it from the origin. |
## Production deployment runbook
All on a single small VPS (Hetzner or Vultr). Follow the PRD roadmap phases.
### Phase 0, provisioning
- Provision the VPS. Install Caddy, Node LTS, and coturn.
- DNS: `event.redclaw.dev` and `turn.redclaw.dev` A records to the host.
- Open firewall ports: TCP 80, 443; TCP/UDP 3478, 5349, 443 (TURN); UDP 49152-65535 (relay range).
### Phase 1, TURN/STUN
- Generate the shared secret: `openssl rand -hex 32`.
- Put it in `deploy/turnserver.conf` (`static-auth-secret`) and in `server/.env` (`TURN_SECRET`). The two must match.
- Install the config: `sudo cp deploy/turnserver.conf /etc/turnserver.conf`, then start coturn.
- Verify with a Trickle ICE test against `turn.redclaw.dev` that both `srflx` (STUN) and `relay` (TURN) candidates appear (AC-10).
- Confirm TURN-over-TLS on 443 works from a locked-down network.
### Phase 2, signaling
- Copy `server/` to `/opt/livecast/server`, run `npm install --omit=dev`.
- Create `/opt/livecast/server/.env` from `.env.example` with the production values.
- Install the systemd unit: `sudo cp deploy/livecast-signaling.service /etc/systemd/system/`, then `sudo systemctl enable --now livecast-signaling`.
- Verify `/health` returns 200 (AC-7).
### Phase 3, web app
- Build: `cd web && VITE_SIGNAL_URL=wss://event.redclaw.dev/ws npm run build` (or leave it blank to derive from the origin, which also works behind the proxy).
- Copy `web/dist` to `/var/www/livecast`.
- Install `deploy/Caddyfile` at `/etc/caddy/Caddyfile`, then `sudo systemctl reload caddy`. Caddy auto-provisions TLS.
- Verify HTTPS loads with no mixed-content or insecure-WebSocket warnings (AC-9).
### Phase 4, cross-network validation
- Test matrix: iOS Safari, Android Chrome, desktop, across Wi-Fi and cellular, broadcaster and listener on different networks (AC-1).
- Latency under 1 second listener-side (AC-2).
- Force-relay test: temporarily set `iceTransportPolicy: "relay"` in the client and confirm audio still flows through TURN (AC-3).
- Notification and live-state timing within 2 seconds of go-live and of stop (AC-4, AC-5).
- Broadcaster reconnect after a brief drop restores audio without a page reload (AC-6).
### Phase 5, hardening and handoff
- Confirm systemd restart policy and add log rotation.
- Add health monitoring on `/health` and on coturn.
- Confirm TURN credentials delivered to the browser are time-limited and expire (AC-8).
- Sign off against the acceptance criteria.
## Acceptance criteria checklist
- [ ] AC-1: Go live from iOS Safari and Android Chrome over cellular and Wi-Fi.
- [ ] AC-2: Listener on a different network hears audio under 1 second latency.
- [ ] AC-3: Connection succeeds forced through TURN (`iceTransportPolicy: "relay"`).
- [ ] AC-4: On-page LIVE indicator and browser notification within 2 seconds of start.
- [ ] AC-5: Stopping flips all listeners to offline within 2 seconds.
- [ ] AC-6: Broadcaster reconnect restores audio without a page reload.
- [ ] AC-7: `/health` returns 200 with status JSON.
- [ ] AC-8: TURN credentials delivered to the browser are time-limited.
- [ ] AC-9: All endpoints serve over valid TLS, no mixed content.
- [ ] AC-10: Trickle ICE test returns both `srflx` and `relay` candidates.
## Scope
v1 is a single channel: one broadcaster, many listeners, audio only. No auth,
no recording, no multi-channel, no two-way audio, no video.
### Future (v2+, not scheduled)
- Access control: token or shared secret in the URL to gate broadcast and listen.
- Multi-channel support (multiple simultaneous rooms).
- Optional recording to Cloudflare R2.
- Listener presence list and reactions.
+32
View File
@@ -0,0 +1,32 @@
# LiveCast reverse proxy (PRD section 6.1, 6.2).
#
# Caddy terminates TLS (auto Let's Encrypt), serves the static SPA build, and
# reverse-proxies the signaling WebSocket (/ws) and the ICE endpoint (/ice) to
# the Node signaling process on the internal port 8080.
#
# Deploy: copy the built web/dist to /var/www/livecast and place this file at
# /etc/caddy/Caddyfile, then `systemctl reload caddy`.
event.redclaw.dev {
encode zstd gzip
# Signaling WebSocket and ICE credential endpoint to the Node server.
@signaling path /ws /ice /health
reverse_proxy @signaling 127.0.0.1:8080
# Everything else is the static SPA. try_files makes client-side routing
# (/broadcast, /listen) resolve to index.html.
root * /var/www/livecast
try_files {path} /index.html
file_server
}
# Optional: if TURN-over-TLS terminates with a cert Caddy manages, having this
# block makes Caddy obtain and renew the certificate for turn.redclaw.dev.
# coturn then reads the cert/key files directly (see turnserver.conf).
turn.redclaw.dev {
tls {
on_demand
}
respond "LiveCast TURN host" 200
}
+27
View File
@@ -0,0 +1,27 @@
# systemd unit for the LiveCast signaling server (PRD section 6.7).
#
# Install:
# sudo cp deploy/livecast-signaling.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now livecast-signaling
#
# Logs: journalctl -u livecast-signaling -f
# The EnvironmentFile holds PORT, TURN_SECRET, TURN_REALM, TURN_HOST, etc.
[Unit]
Description=LiveCast signaling server (WebRTC handshake relay + ephemeral TURN creds)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/livecast/server
EnvironmentFile=/opt/livecast/server/.env
ExecStart=/usr/bin/node /opt/livecast/server/index.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+36
View File
@@ -0,0 +1,36 @@
# coturn configuration for LiveCast (PRD section 6.3).
# Install at /etc/turnserver.conf and run coturn as a systemd service.
#
# Generate the secret with `openssl rand -hex 32` and use the SAME value for
# the signaling server's TURN_SECRET env var. The secret never reaches the
# browser; the server hands out short-lived HMAC credentials via /ice.
listening-port=3478
tls-listening-port=5349
# Also listen on 443 for TURN-over-TLS to defeat strict firewalls:
alt-tls-listening-port=443
fingerprint
use-auth-secret
static-auth-secret=REPLACE_WITH_LONG_RANDOM_SECRET
realm=turn.redclaw.dev
total-quota=100
stale-nonce=600
cert=/etc/letsencrypt/live/turn.redclaw.dev/fullchain.pem
pkey=/etc/letsencrypt/live/turn.redclaw.dev/privkey.pem
no-tlsv1
no-tlsv1_1
# Lock down the relay port range and open it in the firewall:
min-port=49152
max-port=65535
# Hardening:
no-cli
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
# Firewall: open TCP/UDP 3478, 5349, 443 (TURN) and UDP 49152-65535 (relay).
+25
View File
@@ -0,0 +1,25 @@
# LiveCast signaling server environment.
# Copy to .env and fill in for production. Leave TURN_SECRET unset for local
# development: the /ice endpoint then serves a public STUN server only so the
# app runs end to end on a LAN without coturn.
# Internal listen port (Caddy reverse-proxies /ws and /ice to this).
PORT=8080
# Host that runs coturn. Used to build the STUN/TURN URLs returned by /ice.
TURN_HOST=turn.redclaw.dev
# Public STUN server used as the local-dev fallback when TURN_SECRET is unset.
STUN_HOST=stun.l.google.com:19302
# Shared secret for coturn use-auth-secret. Must match static-auth-secret in
# turnserver.conf. NEVER ship this to the browser. Generate with:
# openssl rand -hex 32
TURN_SECRET=
# coturn realm. Must match realm in turnserver.conf.
TURN_REALM=turn.redclaw.dev
# Lifetime of the ephemeral TURN credentials handed to the browser, in seconds.
# 43200 = 12 hours.
TURN_TTL_SECONDS=43200
+294
View File
@@ -0,0 +1,294 @@
// LiveCast signaling server.
//
// Responsibilities (PRD section 4.3):
// - Relay the WebRTC handshake (SDP offers/answers, ICE candidates) between a
// single broadcaster and an arbitrary set of listeners, routed by listener id.
// - Track single-channel live state and notify listeners on change.
// - GET /health -> JSON status including broadcaster online state.
// - GET /ice -> iceServers config with ephemeral, time-limited TURN creds.
//
// Audio never passes through this server. It only brokers the connection.
import http from "node:http";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Minimal .env loader (no dependency). Real env vars always win.
loadEnvFile(path.join(__dirname, ".env"));
const PORT = parseInt(process.env.PORT || "8080", 10);
const TURN_HOST = process.env.TURN_HOST || "turn.redclaw.dev";
const STUN_HOST = process.env.STUN_HOST || "stun.l.google.com:19302";
const TURN_SECRET = process.env.TURN_SECRET || "";
const TURN_REALM = process.env.TURN_REALM || TURN_HOST;
const TURN_TTL_SECONDS = parseInt(process.env.TURN_TTL_SECONDS || "43200", 10);
const startedAt = Date.now();
// ---------------------------------------------------------------------------
// Channel state (single channel, single broadcaster).
// ---------------------------------------------------------------------------
let broadcaster = null; // the broadcaster WebSocket, or null when offline
const listeners = new Map(); // listenerId -> WebSocket
let nextListenerId = 1;
function send(socket, obj) {
if (socket && socket.readyState === socket.OPEN) {
socket.send(JSON.stringify(obj));
}
}
function broadcastToListeners(obj) {
for (const socket of listeners.values()) {
send(socket, obj);
}
}
// ---------------------------------------------------------------------------
// Ephemeral TURN credentials (PRD sections 6.4, 6.5).
// coturn use-auth-secret / REST mechanism:
// username = <unix expiry timestamp>
// credential = base64( HMAC-SHA1( static-auth-secret, username ) )
// The static secret stays on the server and never reaches the browser.
// ---------------------------------------------------------------------------
function buildIceServers() {
if (!TURN_SECRET) {
// Local-development fallback: public STUN only, no TURN relay.
return {
iceServers: [{ urls: `stun:${STUN_HOST}` }],
iceTransportPolicy: "all",
turn: false,
};
}
const expiry = Math.floor(Date.now() / 1000) + TURN_TTL_SECONDS;
const username = String(expiry);
const credential = crypto
.createHmac("sha1", TURN_SECRET)
.update(username)
.digest("base64");
return {
iceServers: [
{ urls: `stun:${TURN_HOST}:3478` },
{
urls: [
`turn:${TURN_HOST}:3478?transport=udp`,
`turn:${TURN_HOST}:3478?transport=tcp`,
`turns:${TURN_HOST}:443?transport=tcp`,
],
username,
credential,
},
],
iceTransportPolicy: "all",
turn: true,
};
}
// ---------------------------------------------------------------------------
// HTTP server: /health and /ice. Everything else 404s.
// ---------------------------------------------------------------------------
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// Permissive CORS: /ice creds are public and short-lived; /health is status.
res.setHeader("Access-Control-Allow-Origin", "*");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (url.pathname === "/health") {
sendJson(res, 200, {
status: "ok",
broadcasterOnline: broadcaster !== null,
listeners: listeners.size,
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
});
return;
}
if (url.pathname === "/ice") {
const config = buildIceServers();
sendJson(res, 200, { iceServers: config.iceServers, iceTransportPolicy: config.iceTransportPolicy });
return;
}
sendJson(res, 404, { error: "not found" });
});
function sendJson(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store",
});
res.end(body);
}
// ---------------------------------------------------------------------------
// WebSocket signaling on path /ws.
// ---------------------------------------------------------------------------
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== "/ws") {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", (ws) => {
ws.role = null; // "broadcaster" | "listener", set on register
ws.listenerId = null;
ws.isAlive = true;
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("message", (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
send(ws, { type: "error", error: "invalid-json" });
return;
}
handleMessage(ws, msg);
});
ws.on("close", () => handleClose(ws));
ws.on("error", () => {}); // closing is handled by the close event
});
function handleMessage(ws, msg) {
switch (msg.type) {
case "register-broadcaster": {
if (broadcaster && broadcaster !== ws) {
// Single channel only (PRD non-goal: multi-broadcaster).
send(ws, { type: "error", error: "broadcaster-exists" });
return;
}
broadcaster = ws;
ws.role = "broadcaster";
send(ws, { type: "registered", role: "broadcaster", listeners: listeners.size });
broadcastToListeners({ type: "broadcaster-online" });
// Ask the broadcaster to offer to listeners already waiting.
for (const id of listeners.keys()) {
send(ws, { type: "listener-wants-offer", listenerId: id });
}
break;
}
case "register-listener": {
const id = nextListenerId++;
ws.role = "listener";
ws.listenerId = id;
listeners.set(id, ws);
send(ws, { type: "welcome", id, broadcasterOnline: broadcaster !== null });
if (broadcaster) {
send(broadcaster, { type: "listener-wants-offer", listenerId: id });
}
break;
}
case "offer": {
// From broadcaster to a specific listener.
if (ws.role !== "broadcaster") return;
const target = listeners.get(msg.listenerId);
send(target, { type: "offer", sdp: msg.sdp });
break;
}
case "answer": {
// From a listener back to the broadcaster.
if (ws.role !== "listener") return;
send(broadcaster, { type: "answer", listenerId: ws.listenerId, sdp: msg.sdp });
break;
}
case "ice-candidate": {
if (ws.role === "broadcaster") {
const target = listeners.get(msg.listenerId);
send(target, { type: "ice-candidate", candidate: msg.candidate });
} else if (ws.role === "listener") {
send(broadcaster, {
type: "ice-candidate",
listenerId: ws.listenerId,
candidate: msg.candidate,
});
}
break;
}
default:
send(ws, { type: "error", error: "unknown-type" });
}
}
function handleClose(ws) {
if (ws.role === "broadcaster" && broadcaster === ws) {
broadcaster = null;
broadcastToListeners({ type: "broadcaster-offline" });
} else if (ws.role === "listener" && ws.listenerId !== null) {
listeners.delete(ws.listenerId);
send(broadcaster, { type: "listener-left", listenerId: ws.listenerId });
}
}
// Heartbeat: drop sockets that stop responding (PRD reliability).
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(heartbeat));
// ---------------------------------------------------------------------------
httpServer.listen(PORT, () => {
console.log(`[livecast] signaling listening on :${PORT} (ws path /ws)`);
if (!TURN_SECRET) {
console.warn(
"[livecast] TURN_SECRET is not set. /ice serves public STUN only " +
"(local-dev fallback). Set TURN_SECRET for production TURN relay."
);
} else {
console.log(`[livecast] TURN enabled for realm ${TURN_REALM} (ttl ${TURN_TTL_SECONDS}s)`);
}
});
function loadEnvFile(file) {
if (!fs.existsSync(file)) return;
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
if (!m) continue;
const key = m[1];
let val = m[2];
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = val;
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "livecast-signaling",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "livecast-signaling",
"version": "1.0.0",
"dependencies": {
"ws": "^8.18.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "livecast-signaling",
"version": "1.0.0",
"description": "LiveCast signaling server: WebRTC handshake relay, ephemeral TURN credentials, health.",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
# LiveCast web build environment.
#
# In production set this to the WSS signaling URL. When left blank the client
# derives the URL from the current origin (wss://<host>/ws), which is correct
# behind the Caddy reverse proxy. In local dev it is also left blank and the
# Vite proxy forwards /ws to the Node server on :8080.
VITE_SIGNAL_URL=
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="theme-color" content="#0a0a0a" />
<title>LiveCast</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2704
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "livecast-web",
"version": "1.0.0",
"description": "LiveCast web app: broadcaster and listener pages over WebRTC.",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+298
View File
@@ -0,0 +1,298 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Signaling, fetchIceServers } from "../lib/signaling.js";
import { Button } from "./ui/button.jsx";
import { Card, CardContent } from "./ui/card.jsx";
import { cn } from "../lib/utils.js";
// Broadcaster page (PRD 4.1). Captures the mic and creates one peer connection
// per listener, attaching the local audio track to each.
export default function Broadcaster() {
const [status, setStatus] = useState("idle"); // idle | starting | live | error
const [error, setError] = useState("");
const [listenerCount, setListenerCount] = useState(0);
const [micLevel, setMicLevel] = useState(0);
const sig = useRef(null);
const stream = useRef(null);
const peers = useRef(new Map()); // listenerId -> { pc, pending: [] }
const ice = useRef(null);
const audioCtx = useRef(null);
const raf = useRef(null);
const updateCount = useCallback(() => setListenerCount(peers.current.size), []);
const teardown = useCallback(() => {
if (raf.current) cancelAnimationFrame(raf.current);
raf.current = null;
for (const { pc } of peers.current.values()) {
try {
pc.close();
} catch {
// ignore
}
}
peers.current.clear();
if (stream.current) {
stream.current.getTracks().forEach((t) => t.stop());
stream.current = null;
}
if (audioCtx.current) {
audioCtx.current.close().catch(() => {});
audioCtx.current = null;
}
if (sig.current) {
sig.current.close();
sig.current = null;
}
setListenerCount(0);
setMicLevel(0);
}, []);
const createPeerFor = useCallback(async (listenerId) => {
const pc = new RTCPeerConnection(ice.current);
const record = { pc, pending: [] };
peers.current.set(listenerId, record);
updateCount();
for (const track of stream.current.getTracks()) {
pc.addTrack(track, stream.current);
}
pc.onicecandidate = (e) => {
if (e.candidate) {
sig.current?.send({ type: "ice-candidate", listenerId, candidate: e.candidate });
}
};
pc.onconnectionstatechange = () => {
if (["failed", "closed", "disconnected"].includes(pc.connectionState)) {
// Listener will re-request an offer if it comes back.
if (peers.current.get(listenerId) === record) {
try {
pc.close();
} catch {
// ignore
}
peers.current.delete(listenerId);
updateCount();
}
}
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sig.current?.send({ type: "offer", listenerId, sdp: pc.localDescription });
}, [updateCount]);
const startMeter = useCallback(() => {
const Ctx = window.AudioContext || window.webkitAudioContext;
const ctx = new Ctx();
audioCtx.current = ctx;
ctx.resume().catch(() => {});
const source = ctx.createMediaStreamSource(stream.current);
const analyser = ctx.createAnalyser();
analyser.fftSize = 512;
source.connect(analyser);
const buf = new Uint8Array(analyser.fftSize);
const tick = () => {
analyser.getByteTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) {
const v = (buf[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / buf.length);
setMicLevel(Math.min(1, rms * 2.2));
raf.current = requestAnimationFrame(tick);
};
tick();
}, []);
const start = useCallback(async () => {
setError("");
setStatus("starting");
try {
stream.current = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
} catch (err) {
setStatus("error");
if (err && err.name === "NotAllowedError") {
setError("Microphone permission was denied. Allow mic access and try again.");
} else if (err && err.name === "NotFoundError") {
setError("No microphone was found on this device.");
} else {
setError("Could not start the microphone. " + (err?.message || ""));
}
return;
}
ice.current = await fetchIceServers();
const s = new Signaling("broadcaster");
sig.current = s;
s.on("listener-wants-offer", (m) => {
// Replace any stale peer for this listener id before offering.
const existing = peers.current.get(m.listenerId);
if (existing) {
try {
existing.pc.close();
} catch {
// ignore
}
peers.current.delete(m.listenerId);
}
createPeerFor(m.listenerId).catch((e) => console.warn("offer failed", e));
});
s.on("answer", async (m) => {
const rec = peers.current.get(m.listenerId);
if (!rec) return;
await rec.pc.setRemoteDescription(m.sdp);
for (const c of rec.pending) {
rec.pc.addIceCandidate(c).catch(() => {});
}
rec.pending = [];
});
s.on("ice-candidate", (m) => {
const rec = peers.current.get(m.listenerId);
if (!rec) return;
if (rec.pc.remoteDescription) {
rec.pc.addIceCandidate(m.candidate).catch(() => {});
} else {
rec.pending.push(m.candidate);
}
});
s.on("listener-left", (m) => {
const rec = peers.current.get(m.listenerId);
if (rec) {
try {
rec.pc.close();
} catch {
// ignore
}
peers.current.delete(m.listenerId);
updateCount();
}
});
s.connect();
startMeter();
setStatus("live");
}, [createPeerFor, startMeter, updateCount]);
const stop = useCallback(() => {
teardown();
setStatus("idle");
}, [teardown]);
// Tear down on unmount and when the page is hidden/closed (FR-B6).
useEffect(() => {
const onHide = () => teardown();
window.addEventListener("pagehide", onHide);
return () => {
window.removeEventListener("pagehide", onHide);
teardown();
};
}, [teardown]);
const live = status === "live";
return (
<div className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-6 px-5 py-10">
<header className="flex flex-col items-center gap-2 text-center">
<span className="text-sm font-medium uppercase tracking-[0.2em] text-neutral-500">
LiveCast
</span>
<h1 className="text-2xl font-bold">Broadcast</h1>
</header>
<Card className="w-full">
<CardContent className="flex flex-col items-center gap-6 py-8">
<StatusBadge live={live} />
{/* Mic level meter (FR-B3) */}
<div className="w-full">
<div className="mb-2 flex justify-between text-xs text-neutral-500">
<span>Mic level</span>
<span>{live ? `${Math.round(micLevel * 100)}%` : "off"}</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-neutral-800">
<div
className={cn(
"h-full rounded-full transition-[width] duration-75",
micLevel > 0.85 ? "bg-live" : "bg-emerald-400"
)}
style={{ width: `${Math.round(micLevel * 100)}%` }}
/>
</div>
</div>
{/* Listener count (FR-B5) */}
<div className="flex flex-col items-center">
<span className="text-4xl font-bold tabular-nums">{listenerCount}</span>
<span className="text-xs uppercase tracking-wide text-neutral-500">
{listenerCount === 1 ? "listener" : "listeners"}
</span>
</div>
{!live ? (
<Button
size="xl"
variant="live"
className="w-full"
disabled={status === "starting"}
onClick={start}
>
{status === "starting" ? "Starting..." : "Go Live"}
</Button>
) : (
<Button size="xl" variant="outline" className="w-full" onClick={stop}>
Stop
</Button>
)}
{error ? (
<p className="text-center text-sm text-live" role="alert">
{error}
</p>
) : null}
</CardContent>
</Card>
<p className="max-w-xs text-center text-xs text-neutral-600">
Tap Go Live and grant microphone access. Listeners hear you in real time.
</p>
</div>
);
}
function StatusBadge({ live }) {
return (
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block h-3 w-3 rounded-full",
live ? "bg-live animate-pulse-live" : "bg-neutral-600"
)}
/>
<span
className={cn(
"text-sm font-semibold uppercase tracking-wide",
live ? "text-live" : "text-neutral-500"
)}
>
{live ? "On Air" : "Offline"}
</span>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Signaling, fetchIceServers } from "../lib/signaling.js";
import { Button } from "./ui/button.jsx";
import { Card, CardContent } from "./ui/card.jsx";
import { cn } from "../lib/utils.js";
// Listener page (PRD 4.2). One gesture unlocks audio and notifications, then
// the page tracks live state, plays incoming audio, and renders a waveform.
export default function Listener() {
const [connected, setConnected] = useState(false); // has the user tapped Connect
const [phase, setPhase] = useState("offline"); // offline | connecting | live
const [reconnecting, setReconnecting] = useState(false);
const sig = useRef(null);
const pcRef = useRef(null);
const pending = useRef([]);
const ice = useRef(null);
const audioEl = useRef(null);
const canvasEl = useRef(null);
const audioCtx = useRef(null);
const raf = useRef(null);
const notifiedThisSession = useRef(false);
const notifyLive = useCallback(() => {
if (notifiedThisSession.current) return;
notifiedThisSession.current = true;
if ("Notification" in window && Notification.permission === "granted") {
try {
new Notification("LiveCast is on air", {
body: "The broadcast just started. Tap to listen.",
tag: "livecast-live",
});
} catch {
// some browsers require a service worker; ignore failures
}
}
}, []);
const stopWaveform = useCallback(() => {
if (raf.current) cancelAnimationFrame(raf.current);
raf.current = null;
const canvas = canvasEl.current;
if (canvas) {
const ctx = canvas.getContext("2d");
ctx?.clearRect(0, 0, canvas.width, canvas.height);
}
}, []);
const startWaveform = useCallback((stream) => {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!audioCtx.current) audioCtx.current = new Ctx();
const ctx = audioCtx.current;
ctx.resume().catch(() => {});
const source = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
source.connect(analyser);
const buf = new Uint8Array(analyser.fftSize);
const draw = () => {
const canvas = canvasEl.current;
if (!canvas) return;
const c = canvas.getContext("2d");
const w = canvas.width;
const h = canvas.height;
analyser.getByteTimeDomainData(buf);
c.clearRect(0, 0, w, h);
c.lineWidth = 2;
c.strokeStyle = "#ef4444";
c.beginPath();
const slice = w / buf.length;
for (let i = 0; i < buf.length; i++) {
const v = buf[i] / 128;
const y = (v * h) / 2;
const x = i * slice;
if (i === 0) c.moveTo(x, y);
else c.lineTo(x, y);
}
c.stroke();
raf.current = requestAnimationFrame(draw);
};
draw();
}, []);
const closePeer = useCallback(() => {
if (pcRef.current) {
try {
pcRef.current.close();
} catch {
// ignore
}
pcRef.current = null;
}
pending.current = [];
stopWaveform();
if (audioEl.current) audioEl.current.srcObject = null;
}, [stopWaveform]);
const handleOffer = useCallback(
async (sdp) => {
closePeer();
setPhase("connecting");
const pc = new RTCPeerConnection(ice.current);
pcRef.current = pc;
pc.ontrack = (e) => {
const stream = e.streams[0];
if (audioEl.current) {
audioEl.current.srcObject = stream;
audioEl.current.play().catch(() => {});
}
startWaveform(stream);
setPhase("live");
};
pc.onicecandidate = (e) => {
if (e.candidate) sig.current?.send({ type: "ice-candidate", candidate: e.candidate });
};
pc.onconnectionstatechange = () => {
if (["failed", "disconnected"].includes(pc.connectionState)) {
// Wait for the broadcaster to re-offer; show connecting meanwhile.
setPhase("connecting");
}
};
await pc.setRemoteDescription(sdp);
for (const c of pending.current) pc.addIceCandidate(c).catch(() => {});
pending.current = [];
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sig.current?.send({ type: "answer", sdp: pc.localDescription });
},
[closePeer, startWaveform]
);
const connect = useCallback(async () => {
// One-time gesture: satisfy autoplay and request notification permission.
if ("Notification" in window && Notification.permission === "default") {
try {
await Notification.requestPermission();
} catch {
// ignore
}
}
// Prime the audio element and audio context within the gesture (iOS Safari).
try {
const Ctx = window.AudioContext || window.webkitAudioContext;
audioCtx.current = new Ctx();
await audioCtx.current.resume();
} catch {
// ignore
}
if (audioEl.current) audioEl.current.play().catch(() => {});
setConnected(true);
ice.current = await fetchIceServers();
const s = new Signaling("listener");
sig.current = s;
s.on("welcome", (m) => {
if (m.broadcasterOnline) {
setPhase("connecting");
notifyLive();
} else {
setPhase("offline");
}
});
s.on("broadcaster-online", () => {
setPhase("connecting");
notifyLive();
});
s.on("broadcaster-offline", () => {
notifiedThisSession.current = false;
closePeer();
setPhase("offline");
});
s.on("offer", (m) => {
handleOffer(m.sdp).catch((e) => console.warn("answer failed", e));
});
s.on("ice-candidate", (m) => {
const pc = pcRef.current;
if (pc && pc.remoteDescription) {
pc.addIceCandidate(m.candidate).catch(() => {});
} else {
pending.current.push(m.candidate);
}
});
s.onReconnecting(() => setReconnecting(true));
s.onOpen(() => setReconnecting(false));
s.onClose(() => {
// Connection to signaling lost; the broadcaster state is unknown.
if (phase === "live") setPhase("connecting");
});
s.connect();
}, [closePeer, handleOffer, notifyLive, phase]);
useEffect(() => {
const onHide = () => {
closePeer();
sig.current?.close();
};
window.addEventListener("pagehide", onHide);
return () => {
window.removeEventListener("pagehide", onHide);
closePeer();
sig.current?.close();
if (audioCtx.current) audioCtx.current.close().catch(() => {});
};
}, [closePeer]);
return (
<div className="mx-auto flex min-h-full max-w-md flex-col items-center justify-center gap-6 px-5 py-10">
<header className="flex flex-col items-center gap-2 text-center">
<span className="text-sm font-medium uppercase tracking-[0.2em] text-neutral-500">
LiveCast
</span>
<h1 className="text-2xl font-bold">Listen</h1>
</header>
<Card className="w-full">
<CardContent className="flex flex-col items-center gap-6 py-10">
<LiveIndicator phase={phase} reconnecting={reconnecting} />
<canvas
ref={canvasEl}
width={320}
height={96}
className="h-24 w-full max-w-xs rounded-xl bg-neutral-900/60"
/>
{!connected ? (
<Button size="xl" variant="live" className="w-full" onClick={connect}>
Connect &amp; Enable Audio
</Button>
) : (
<p className="text-center text-sm text-neutral-500">
{phase === "live"
? "You are listening live."
: phase === "connecting"
? "Connecting to the broadcast..."
: "Waiting for the broadcast to start."}
</p>
)}
</CardContent>
</Card>
{/* Hidden audio sink for the incoming stream (FR-L3). */}
<audio ref={audioEl} autoPlay playsInline className="hidden" />
<p className="max-w-xs text-center text-xs text-neutral-600">
Audio plays automatically once the broadcast is live. Keep this tab open.
</p>
</div>
);
}
function LiveIndicator({ phase, reconnecting }) {
const live = phase === "live";
const connecting = phase === "connecting";
return (
<div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-block h-4 w-4 rounded-full",
live ? "bg-live animate-pulse-live" : connecting ? "bg-amber-400" : "bg-neutral-600"
)}
/>
<span
className={cn(
"text-lg font-bold uppercase tracking-wide",
live ? "text-live" : connecting ? "text-amber-400" : "text-neutral-500"
)}
>
{live ? "Live" : connecting ? "Connecting" : "Offline"}
</span>
</div>
{reconnecting ? (
<span className="text-xs text-neutral-500">Reconnecting to server...</span>
) : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils.js";
// Minimal shadcn-style button. Kept dependency-light (no Radix Slot) since the
// app only needs a plain button element.
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black disabled:pointer-events-none disabled:opacity-50 select-none",
{
variants: {
variant: {
default: "bg-white text-black hover:bg-neutral-200 focus-visible:ring-white",
live: "bg-live text-white hover:bg-red-600 focus-visible:ring-live",
outline:
"border border-neutral-700 bg-transparent text-neutral-100 hover:bg-neutral-900 focus-visible:ring-neutral-500",
},
size: {
default: "h-12 px-6 text-base",
lg: "h-16 px-8 text-lg",
xl: "h-24 px-10 text-2xl",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export function Button({ className, variant, size, ...props }) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { cn } from "../../lib/utils.js";
export function Card({ className, ...props }) {
return (
<div
className={cn(
"rounded-3xl border border-neutral-800 bg-neutral-950/80 shadow-xl backdrop-blur",
className
)}
{...props}
/>
);
}
export function CardContent({ className, ...props }) {
return <div className={cn("p-6", className)} {...props} />;
}
+24
View File
@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #0a0a0a;
color: #fafafa;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
/* Prevent rubber-band scroll on the broadcast/listen single-screen pages. */
overscroll-behavior: none;
}
+144
View File
@@ -0,0 +1,144 @@
// WebSocket signaling client for LiveCast.
//
// Wraps the raw WebSocket with:
// - role registration on connect (broadcaster | listener)
// - automatic reconnect with exponential backoff (FR-L6, reliability)
// - per-message-type handlers
// - runtime fetch of ICE servers from /ice (TURN creds stay ephemeral and
// server-controlled; the client never hardcodes them, PRD engineering notes)
function signalUrl() {
const configured = import.meta.env.VITE_SIGNAL_URL;
if (configured) return configured;
// Same-origin: works behind the Caddy proxy in prod and the Vite proxy in dev.
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/ws`;
}
// Fetch the ICE configuration (STUN, plus ephemeral TURN in production).
export async function fetchIceServers() {
try {
const res = await fetch("/ice", { cache: "no-store" });
if (!res.ok) throw new Error(`ice status ${res.status}`);
const data = await res.json();
return {
iceServers: data.iceServers || [],
iceTransportPolicy: data.iceTransportPolicy || "all",
};
} catch (err) {
console.warn("[signaling] /ice fetch failed, falling back to public STUN", err);
return {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
iceTransportPolicy: "all",
};
}
}
export class Signaling {
constructor(role) {
this.role = role; // "broadcaster" | "listener"
this.ws = null;
this.handlers = new Map(); // type -> Set<fn>
this.lifecycle = { open: new Set(), close: new Set(), reconnecting: new Set() };
this.closedByUser = false;
this.backoff = 500; // ms, doubles up to a cap
this.backoffMax = 10_000;
this.reconnectTimer = null;
}
connect() {
this.closedByUser = false;
this._open();
}
_open() {
const ws = new WebSocket(signalUrl());
this.ws = ws;
ws.onopen = () => {
this.backoff = 500;
this.send({ type: `register-${this.role}` });
this.lifecycle.open.forEach((fn) => fn());
};
ws.onmessage = (event) => {
let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
const set = this.handlers.get(msg.type);
if (set) set.forEach((fn) => fn(msg));
};
ws.onclose = () => {
this.lifecycle.close.forEach((fn) => fn());
if (!this.closedByUser) this._scheduleReconnect();
};
ws.onerror = () => {
// onclose will follow and drive the reconnect.
try {
ws.close();
} catch {
// ignore
}
};
}
_scheduleReconnect() {
if (this.reconnectTimer) return;
const delay = this.backoff;
this.backoff = Math.min(this.backoff * 2, this.backoffMax);
this.lifecycle.reconnecting.forEach((fn) => fn(delay));
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._open();
}, delay);
}
// Register a handler for a server message type. Returns an unsubscribe fn.
on(type, fn) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type).add(fn);
return () => this.handlers.get(type)?.delete(fn);
}
onOpen(fn) {
this.lifecycle.open.add(fn);
return () => this.lifecycle.open.delete(fn);
}
onClose(fn) {
this.lifecycle.close.add(fn);
return () => this.lifecycle.close.delete(fn);
}
onReconnecting(fn) {
this.lifecycle.reconnecting.add(fn);
return () => this.lifecycle.reconnecting.delete(fn);
}
send(obj) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(obj));
}
}
close() {
this.closedByUser = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch {
// ignore
}
this.ws = null;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// Standard shadcn-style class combiner.
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import ReactDOM from "react-dom/client";
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from "react-router-dom";
import Broadcaster from "./components/Broadcaster.jsx";
import Listener from "./components/Listener.jsx";
import "./index.css";
const router = createBrowserRouter([
{ path: "/", element: <Navigate to="/listen" replace /> },
{ path: "/broadcast", element: <Broadcaster /> },
{ path: "/listen", element: <Listener /> },
{ path: "*", element: <Navigate to="/listen" replace /> },
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
+21
View File
@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
colors: {
live: "#ef4444",
},
keyframes: {
"pulse-live": {
"0%, 100%": { opacity: "1", transform: "scale(1)" },
"50%": { opacity: "0.55", transform: "scale(0.92)" },
},
},
animation: {
"pulse-live": "pulse-live 1.2s ease-in-out infinite",
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
// In dev, proxy the signaling endpoints to the local Node server on :8080 so
// the browser talks to same-origin /ws and /ice exactly as it will in prod.
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
host: true, // expose on the LAN so phones can reach the dev server
proxy: {
"/ice": "http://localhost:8080",
"/health": "http://localhost:8080",
"/ws": { target: "ws://localhost:8080", ws: true },
},
},
});