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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+39
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=
|
||||||
@@ -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>
|
||||||
Generated
+2704
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user