commit 6bc7cf6318c70d882c4b99afe620eb675e89c9bb Author: Brian Date: Sun Apr 5 17:16:51 2026 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca03f74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# MediaMTX binary and anything else downloaded by install.ps1 +bin/* +!bin/.gitkeep + +# OBS runtime +obs-script/__pycache__/ +*.pyc + +# Editor / OS cruft +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# Local overrides +config/*.local.yml +config/*.local.conf diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee86ed8 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# game-stream-app + +Low-latency browser-based game streaming to a small group of friends, gated by +Authentik authentication. + +- **Streamer:** Windows PC with an NVIDIA GPU running OBS Studio. +- **Viewers:** up to ~6 friends, any modern browser, no client install. +- **Auth:** Authentik forward auth at the Nginx Proxy Manager (NPM) edge. +- **Transport:** WebRTC (WHEP) for low latency, with LL-HLS fallback. +- **Latency target:** ~200 ms over WebRTC, ~1-2 s over LL-HLS fallback. + +## How it works + +``` +OBS Studio (NVENC, WHIP out) + -> MediaMTX (localhost) ---> WHEP / HLS / API + -> Frontend HTTP server (localhost:8080) + -> NPM (TLS, Authentik forward auth, reverse proxy) + -> Friend's browser +``` + +Everything on the gaming PC (MediaMTX, HTTP server, Windows Firewall rule for +the WebRTC UDP port) is spawned and torn down by an OBS Python script - +`obs-script/game_stream.py`. You just click **Start Streaming** in OBS and the +whole pipeline comes up; click **Stop Streaming** and it all goes away. + +## Repository layout + +| Path | Purpose | +|-------------------------------|-------------------------------------------------------------| +| `config/mediamtx.yml` | MediaMTX configuration (WHIP in, WHEP/HLS out, locked-down) | +| `config/npm-advanced.conf` | Authentik forward-auth snippet for the NPM Advanced tab | +| `obs-script/game_stream.py` | OBS script: lifecycle, HTTP server, firewall toggle | +| `frontend/index.html` | Viewer page | +| `frontend/js/player.js` | WHEP client with HLS fallback | +| `frontend/js/app.js` | Status polling and DOM glue | +| `frontend/css/style.css` | Dark theme | +| `scripts/install.ps1` | Downloads MediaMTX, creates the Windows Firewall rule | +| `docs/authentik-setup.md` | Authentik proxy provider + group configuration | +| `docs/npm-setup.md` | NPM proxy host + stream (UDP) configuration | +| `docs/obs-setup.md` | OBS encoder + WHIP output settings | + +## Setup at a glance + +1. **Clone** this repo onto the Windows gaming PC. +2. **Install MediaMTX and the firewall rule:** open an elevated PowerShell in + the repo root and run `.\scripts\install.ps1`. +3. **Configure Authentik** - see `docs/authentik-setup.md`. +4. **Configure NPM** - see `docs/npm-setup.md`. +5. **Configure OBS** - see `docs/obs-setup.md`, then add + `obs-script/game_stream.py` via Tools -> Scripts. +6. **Click Start Streaming in OBS.** Friends can now open + `https://stream.hetherman.cloud`, log in with Authentik, and watch. + +## Security posture + +- TLS terminates at NPM with Let's Encrypt. +- Every request is gated by Authentik forward auth before it reaches the + frontend, WHEP signaling, HLS, or the MediaMTX API. +- MediaMTX only accepts publishers from `127.0.0.1` - nobody on the public + internet can hijack the stream. +- The UDP port used for WebRTC media is opened on the Windows Firewall only + while streaming is active (toggled by the OBS script). Even though NPM and + the router still forward the port, the OS silently drops packets between + streams, so there is no exposed listener. +- WebRTC media is DTLS-encrypted SRTP. An attacker who hits the UDP port + without an Authentik-authenticated WHEP session cannot decrypt or inject + media. +- Removing a friend from the Authentik `stream-viewers` group revokes their + access on the next auth_request subrequest (within seconds). + +## License + +MIT diff --git a/bin/.gitkeep b/bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/mediamtx.yml b/config/mediamtx.yml new file mode 100644 index 0000000..e423c77 --- /dev/null +++ b/config/mediamtx.yml @@ -0,0 +1,89 @@ +# MediaMTX configuration for game-stream-app +# Spawned as a subprocess by obs-script/game_stream.py when OBS starts streaming. + +############################################################################### +# Global +############################################################################### + +logLevel: info +logDestinations: [stdout] +readTimeout: 10s +writeTimeout: 10s +writeQueueSize: 512 + +############################################################################### +# API (used by the OBS script dock to poll viewer count / stream status) +############################################################################### + +api: yes +apiAddress: 127.0.0.1:9997 + +############################################################################### +# WebRTC (WHIP ingest + WHEP playback) +############################################################################### + +webrtc: yes +# HTTP listener for WHIP/WHEP signaling (SDP exchange). +# NPM proxies /whep/* and the OBS WHIP target (localhost) to this. +webrtcAddress: :8889 +webrtcEncryption: no # TLS is handled at NPM; this listener is LAN/localhost only +# Dedicated UDP port for SRTP media. NPM Stream forwards public UDP 8189 here. +webrtcLocalUDPAddress: :8189 +# No TCP fallback - we only want a single UDP path for simplicity. +webrtcLocalTCPAddress: '' +# Tell browsers to send media to the public hostname. +# Replace stream.hetherman.cloud if your public hostname differs. +webrtcAdditionalHosts: + - stream.hetherman.cloud +# Public STUN helps browsers discover their own reflexive candidates when +# behind NAT; the server side does not need it but it speeds up ICE. +webrtcICEServers2: + - url: stun:stun.l.google.com:19302 +# Disable trickle handshake complications - plain offer/answer is enough. +webrtcHandshakeTimeout: 10s +webrtcTrackGatherTimeout: 2s + +############################################################################### +# HLS (fallback for clients where WebRTC fails) +############################################################################### + +hls: yes +hlsAddress: :8888 +hlsEncryption: no +hlsAlwaysRemux: no +hlsVariant: lowLatency +hlsSegmentCount: 7 +hlsSegmentDuration: 200ms +hlsPartDuration: 200ms +hlsSegmentMaxSize: 50M +hlsAllowOrigin: '*' +hlsTrustedProxies: [] + +############################################################################### +# Disabled protocols (reduce attack surface) +############################################################################### + +rtsp: no +rtmp: no +srt: no + +############################################################################### +# Paths +############################################################################### + +pathDefaults: + # Drop publishers that connect but never send media. + sourceOnDemand: no + +paths: + # The single stream path. OBS publishes here via WHIP + # (http://localhost:8889/game/whip), friends watch via WHEP + # (https://stream.hetherman.cloud/whep/game/whep). + game: + source: publisher + # Only the local OBS instance is allowed to publish. + # External hijack attempts are blocked at this layer, independent of NPM. + publishIPs: + - 127.0.0.1/32 + - ::1/128 + # No reader restrictions - NPM + Authentik gate reads at the edge. diff --git a/config/npm-advanced.conf b/config/npm-advanced.conf new file mode 100644 index 0000000..eb18a04 --- /dev/null +++ b/config/npm-advanced.conf @@ -0,0 +1,47 @@ +# Paste this snippet into the "Advanced" tab of the NPM Proxy Host for +# stream.hetherman.cloud. It enables Authentik Forward Auth via the +# goauthentik.io outpost, so every request is gated before hitting the +# frontend, WHEP signaling, HLS, or the MediaMTX API. +# +# Requires an Authentik Proxy Provider of type "Forward auth (single +# application)" with external host https://stream.hetherman.cloud and an +# Application bound to the `stream-viewers` group. + +# Forward every incoming request to the Authentik outpost for validation. +auth_request /outpost.goauthentik.io/auth/nginx; +error_page 401 = @goauthentik_proxy_signin; + +# Propagate user identity headers set by the outpost back to the browser +# (and optionally to upstream if you ever want to read the user in MediaMTX). +auth_request_set $auth_cookie $upstream_http_set_cookie; +add_header Set-Cookie $auth_cookie; +auth_request_set $authentik_username $upstream_http_x_authentik_username; +auth_request_set $authentik_groups $upstream_http_x_authentik_groups; +auth_request_set $authentik_email $upstream_http_x_authentik_email; + +# The outpost endpoint itself must be reachable un-gated so that the +# auth_request subrequest and the sign-in redirect can complete. +location /outpost.goauthentik.io { + # All traffic to /outpost.goauthentik.io is proxied to the Authentik host. + # Point this at your Authentik outpost URL. + proxy_pass https://auth.hetherman.cloud/outpost.goauthentik.io; + proxy_set_header Host $host; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Forwarded-Ssl on; + add_header Set-Cookie $auth_cookie; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; +} + +# When auth_request returns 401, send the browser to the outpost sign-in page +# and preserve the original request URL so the user lands back where they +# started after logging in. +location @goauthentik_proxy_signin { + internal; + add_header Set-Cookie $auth_cookie; + return 302 /outpost.goauthentik.io/start?rd=$request_uri; +} diff --git a/docs/authentik-setup.md b/docs/authentik-setup.md new file mode 100644 index 0000000..21de816 --- /dev/null +++ b/docs/authentik-setup.md @@ -0,0 +1,87 @@ +# Authentik setup + +Creates a Forward Auth Proxy Provider that NPM will consult before allowing +any request to `https://stream.hetherman.cloud`. + +Prerequisites: you already run Authentik at `https://auth.hetherman.cloud` and +have admin access. + +## 1. Create a group + +1. Authentik admin -> **Directory -> Groups -> Create** +2. Name: `stream-viewers` +3. Save. +4. Add each friend's user account to this group. **Do not** add yourself / + your admin account - your access comes from your existing admin group. + +## 2. Create a Proxy Provider + +1. **Applications -> Providers -> Create** +2. Select **Proxy Provider** and click Next. +3. Fill in: + - **Name**: `game-stream-forward-auth` + - **Authorization flow**: `default-provider-authorization-implicit-consent` + (skips the "Authorize application" prompt so friends get a one-click login) + - **Type**: **Forward auth (single application)** + - **External host**: `https://stream.hetherman.cloud` + - **Token validity**: default (24 hours) is fine +4. Save. + +## 3. Create an Application + +1. **Applications -> Applications -> Create** +2. Fill in: + - **Name**: `Game Stream` + - **Slug**: `game-stream` + - **Provider**: `game-stream-forward-auth` (the provider from step 2) + - **Launch URL**: `https://stream.hetherman.cloud` +3. Save. + +## 4. Bind the group policy + +Restrict who can authenticate to this application: + +1. Open the `Game Stream` application you just created. +2. Go to the **Policy / Group / User Bindings** tab -> **Create binding** +3. **Group**: `stream-viewers` +4. Leave the rest as default; Save. + +After this step, only members of `stream-viewers` (and Authentik superusers) +can authenticate to the game stream. + +## 5. Ensure the outpost serves this application + +Authentik runs an "outpost" that exposes `/outpost.goauthentik.io/auth/nginx` +for the nginx `auth_request` forward-auth pattern. + +1. **Applications -> Outposts** +2. Edit the default `authentik Embedded Outpost` (or create one if there is + not one already). +3. Under **Applications**, make sure `Game Stream` is checked. +4. Save. The outpost reloads automatically. + +Verify the outpost endpoint is reachable: + +``` +curl -I https://auth.hetherman.cloud/outpost.goauthentik.io/ping +``` + +A 200 or 204 means the outpost is up. + +## 6. Verify end-to-end + +After finishing `docs/npm-setup.md`: + +1. Open `https://stream.hetherman.cloud` in a private/incognito browser. +2. You should be redirected to Authentik to log in. +3. Log in as a member of `stream-viewers`. You should be redirected back to + the stream page. +4. Log out, clear cookies, try logging in as a non-member. You should be + denied with a "You do not have access" message. + +## Revoking access + +Remove the friend's user from the `stream-viewers` group. Their Authentik +session remains valid until it expires, but the next `auth_request` forward +auth check (evaluated on every HTTP request NPM sees) will fail the group +policy and they will be locked out within seconds. diff --git a/docs/npm-setup.md b/docs/npm-setup.md new file mode 100644 index 0000000..6c94163 --- /dev/null +++ b/docs/npm-setup.md @@ -0,0 +1,101 @@ +# Nginx Proxy Manager setup + +Configures NPM to: + +1. Serve `https://stream.hetherman.cloud` with TLS + Authentik forward auth, + reverse-proxying HTTP traffic to the Windows gaming PC. +2. Forward public UDP 8189 (WebRTC media) to the gaming PC via an NPM + **Stream** (L4 UDP proxy). + +Replace `` with the LAN IP of the Windows gaming PC +(e.g., `192.168.50.10`). + +## 1. DNS + +Create an A / CNAME record for `stream.hetherman.cloud` pointing to the same +DDNS hostname / public IP your other NPM-hosted services use. + +## 2. Router port forwarding + +Make sure your router forwards these to NPM (not to the PC directly): + +| Proto | External port | Internal target | +|-------|--------------|-------------------| +| TCP | 443 | NPM host, 443 | +| UDP | 8189 | NPM host, 8189 | + +(TCP 443 is probably already forwarded for your other services; UDP 8189 is +the new one for this app.) + +## 3. NPM Proxy Host (HTTP) + +In NPM, **Hosts -> Proxy Hosts -> Add Proxy Host**. + +**Details tab:** + +| Field | Value | +|------------------|------------------------------------| +| Domain Names | `stream.hetherman.cloud` | +| Scheme | `http` | +| Forward Hostname | `` | +| Forward Port | `8080` | +| Cache Assets | off | +| Block Common Exploits | on | +| Websockets Support | **on** (WebRTC signaling works without this, but it costs nothing) | + +**Custom locations tab:** add three entries so WHEP, HLS, and the MediaMTX +API are reverse-proxied to the right MediaMTX ports (and inherit the same +forward-auth gating). + +| Location | Scheme | Forward Hostname | Forward Port | +|----------|--------|------------------|--------------| +| `/whep` | `http` | `` | `8889` | +| `/hls` | `http` | `` | `8888` | +| `/v3` | `http` | `` | `9997` | + +**SSL tab:** + +- SSL Certificate: **Request a new SSL Certificate with Let's Encrypt** +- Force SSL: **on** +- HTTP/2 Support: **on** +- HSTS Enabled: optional + +**Advanced tab:** paste the entire contents of +[`config/npm-advanced.conf`](../config/npm-advanced.conf). This installs the +Authentik forward-auth subrequest and the sign-in redirect. + +Save the proxy host. Wait for the Let's Encrypt certificate to be issued. + +## 4. NPM Stream (UDP L4 proxy) + +In NPM, **Hosts -> Streams -> Add Stream**. + +| Field | Value | +|-------------------|---------------| +| Incoming Port | `8189` | +| Forward Host | `` | +| Forward Port | `8189` | +| TCP | **off** | +| UDP | **on** | + +Save. NPM (nginx `stream` module) now forwards public UDP 8189 to MediaMTX +on the gaming PC. This is the path WebRTC media takes after ICE negotiation. + +## 5. Verify + +1. **HTTP + auth:** from an incognito browser on a different network, visit + `https://stream.hetherman.cloud`. You should be redirected to + `auth.hetherman.cloud` to log in. Log in as a `stream-viewers` member - + you should land back at the stream page (video container + "Stream + offline" overlay, assuming you haven't started OBS yet). +2. **Certificate:** the padlock icon should show the Let's Encrypt cert you + requested. +3. **/whep, /hls, /v3:** once you start streaming in OBS, open DevTools on + the stream page and confirm requests to `/whep/game/whep`, + `/hls/game/index.m3u8`, and `/v3/paths/get/game` all return 200 (and not + 401/302). +4. **UDP stream:** with OBS streaming, tail the NPM container logs - you + should see entries from the stream module for UDP connections on 8189. + Alternatively, from the NPM host run + `tcpdump -n -i any udp port 8189` and confirm packets flow while a + viewer is connected. diff --git a/docs/obs-setup.md b/docs/obs-setup.md new file mode 100644 index 0000000..0ebf1c4 --- /dev/null +++ b/docs/obs-setup.md @@ -0,0 +1,132 @@ +# OBS setup + +Configures OBS Studio on the Windows gaming PC to capture the game, encode +with NVENC, and publish via WHIP to the local MediaMTX instance that the OBS +script spawns. + +Prerequisites: + +- OBS Studio 30.0 or newer (WHIP output is built in from 30.x onward). +- You already ran `.\scripts\install.ps1` in an elevated PowerShell, so + `bin\mediamtx.exe` exists and the `GameStream-UDP-8189` firewall rule is + registered (in the disabled state). + +## 1. Load the OBS script + +1. OBS -> **Tools -> Scripts -> +** +2. Select `obs-script/game_stream.py` from this repo. +3. In the properties panel on the right, set: + + | Setting | Value | + |-----------------------|---------------------------------------------------------------| + | MediaMTX binary | `\bin\mediamtx.exe` | + | MediaMTX config | `\config\mediamtx.yml` | + | Frontend directory | `\frontend` | + | Frontend HTTP port | `8080` (default) | + | Firewall rule name | `GameStream-UDP-8189` (must match the rule created by install.ps1) | + | Public URL | `https://stream.hetherman.cloud` | + | MediaMTX API URL | `http://127.0.0.1:9997` | + +4. Check the **Script Log** at the bottom - you should see + `[game_stream] game_stream.py loaded`. + +## 2. OBS output settings + +**Settings -> Output**, set **Output Mode** to **Advanced**. + +### Streaming tab + +| Setting | Value | +|------------------|---------------------------------------------------------| +| Audio Encoder | Opus (or FFmpeg AAC if Opus is unavailable - Opus is preferred for WebRTC) | +| Video Encoder | **NVIDIA NVENC HEVC** or **NVIDIA NVENC H.264** | + +Use H.264 for maximum browser compatibility (all browsers). HEVC works in +Safari and recent Chrome but not Firefox - stick with H.264 unless you have a +specific reason. + +**Encoder settings (H.264):** + +| Setting | Value | +|---------------------|-------------------| +| Rate Control | CBR | +| Bitrate | 8000 Kbps | +| Keyframe Interval | 2 s | +| Preset | P5 (Quality) | +| Tuning | Ultra Low Latency | +| Multipass | Two Passes (Quarter Resolution) | +| Profile | high | +| Look-ahead | off | +| Psycho Visual Tuning | on | +| GPU | 0 | +| Max B-frames | **0** (required for low-latency WebRTC) | + +With a 600 Mbps upload and up to 6 viewers at 8 Mbps each, 8000 Kbps leaves +generous headroom. Push to 12000-15000 Kbps if you want higher quality. + +### Audio tab + +| Setting | Value | +|-----------------|------------| +| Audio Bitrate | 128 Kbps | +| Sample Rate | 48 kHz | + +## 3. OBS stream settings + +**Settings -> Stream** + +| Setting | Value | +|----------|-----------------------------------------------| +| Service | Custom | +| Protocol | **WHIP** | +| Server | `http://localhost:8889/game/whip` | +| Bearer Token | (leave blank) | + +Save. + +## 4. First stream + +1. Click **Start Streaming**. +2. Check the OBS Script Log - you should see: + - `Firewall rule 'GameStream-UDP-8189' ENABLED` + - `MediaMTX started (pid=...)` + - `Frontend HTTP server listening on 0.0.0.0:8080` + - `Viewers can watch at: https://stream.hetherman.cloud` +3. Open `https://stream.hetherman.cloud` from another device, log in with + Authentik, and verify video plays. + +## 5. Stopping + +Click **Stop Streaming** in OBS. The script will: + +- Stop the MediaMTX subprocess +- Stop the frontend HTTP server +- Disable the firewall rule (`GameStream-UDP-8189` -> disabled) + +Verify the firewall state from PowerShell: + +```powershell +Get-NetFirewallRule -DisplayName "GameStream-UDP-8189" | Select-Object Enabled +``` + +Should report `False` while not streaming, `True` while streaming. + +## Troubleshooting + +- **"MediaMTX binary not found"** in the script log: the path in the script + properties panel is wrong. Re-select it with the file picker. +- **OBS cannot connect to WHIP**: MediaMTX did not start. Check the script + log for the actual reason; most commonly a port conflict on 8889 or 8189 + (another process is already using them). +- **Viewers see "Stream offline"** even after you click Start Streaming: + - Check that the MediaMTX API returns `ready: true`: + `curl http://localhost:9997/v3/paths/get/game` + - Check OBS's own streaming indicator - if it's red, OBS is not actually + sending to WHIP. Verify the URL and that the custom service / WHIP + protocol is selected. +- **Viewers connect but playback freezes after a few seconds:** the UDP port + path is broken. Verify the firewall rule is enabled (`Get-NetFirewallRule`), + the router port-forward to NPM for UDP 8189 is correct, and the NPM Stream + entry points at `:8189`. +- **Autoplay is blocked / no audio:** browsers start the video muted so + autoplay works. There is a "Click to unmute" button in the status bar. diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..9b90357 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,203 @@ +/* Game Stream App - dark theme, video fills viewport, minimal chrome. */ + +:root { + --bg: #0a0a0b; + --fg: #e6e6e6; + --fg-dim: #8a8a8a; + --accent: #4da3ff; + --live: #34c759; + --offline: #8a8a8a; + --error: #ff453a; + --card-bg: rgba(20, 20, 22, 0.92); + --border: rgba(255, 255, 255, 0.08); + --status-bar-height: 36px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; + font-size: 14px; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; +} + +/* --- video stage ------------------------------------------------------- */ + +#stage { + position: relative; + flex: 1 1 auto; + background: #000; + overflow: hidden; +} + +#video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +/* --- offline / loading overlay ---------------------------------------- */ + +.overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 10; + transition: opacity 200ms ease; +} + +.overlay[hidden] { + display: none; +} + +.overlay-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px 44px; + text-align: center; + min-width: 280px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); +} + +.overlay-card h1 { + margin: 0 0 8px 0; + font-size: 20px; + font-weight: 600; +} + +.overlay-card p { + margin: 0 0 20px 0; + color: var(--fg-dim); + font-size: 13px; +} + +.spinner { + width: 28px; + height: 28px; + margin: 0 auto; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 900ms linear infinite; +} + +.overlay-card.error .spinner { + display: none; +} + +.overlay-card.error h1 { + color: var(--error); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* --- status bar ------------------------------------------------------- */ + +#status-bar { + flex: 0 0 var(--status-bar-height); + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + background: #111113; + border-top: 1px solid var(--border); + font-size: 12px; + color: var(--fg-dim); + font-variant-numeric: tabular-nums; +} + +#status-bar b { + color: var(--fg); + font-weight: 600; +} + +#status-bar .separator { + color: #333; +} + +#status-bar .spacer { + flex: 1 1 auto; +} + +.status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + color: #000; +} + +.status::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; + opacity: 0.4; +} + +.status.live { + background: var(--live); + color: #003d15; +} + +.status.live::before { + background: #003d15; + animation: pulse 1.4s ease-in-out infinite; +} + +.status.offline { + background: #2a2a2d; + color: var(--fg-dim); +} + +.status.error { + background: var(--error); + color: #3d0000; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } +} + +#unmute-btn { + background: var(--accent); + color: #00224d; + border: none; + border-radius: 6px; + padding: 5px 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +#unmute-btn:hover { + background: #66b5ff; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..caef9f9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,39 @@ + + + + + + + Game Stream + + + + +
+ + +
+
+

Stream offline

+

Waiting for the streamer to start…

+ +
+
+
+ +
+ OFFLINE + | + Viewers: 0 + | + Latency: -- + | + Transport: -- + + +
+ + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..be58fb7 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,157 @@ +/* + * app.js - Glue between the DOM and StreamPlayer. + * + * - Polls the MediaMTX API (/v3/paths/get/game) to determine if the stream + * is live, and updates the status bar accordingly. + * - Starts/stops StreamPlayer based on that status. + * - Wires up the "Click to unmute" button (browsers autoplay muted). + * - Authentik forward auth is handled at the NPM layer, so by the time + * this JS runs the user is already authenticated. No auth logic here. + */ + +(function () { + const PATH_NAME = 'game'; + const API_URL = `/v3/paths/get/${PATH_NAME}`; + const POLL_INTERVAL_MS = 5000; + + const els = { + video: document.getElementById('video'), + overlay: document.getElementById('overlay'), + overlayTitle: document.getElementById('overlay-title'), + overlayMessage: document.getElementById('overlay-message'), + overlayCard: document.querySelector('.overlay-card'), + statusIndicator: document.getElementById('status-indicator'), + viewerCount: document.getElementById('viewer-count'), + latency: document.getElementById('latency'), + transport: document.getElementById('transport'), + unmuteBtn: document.getElementById('unmute-btn'), + }; + + const state = { + pathReady: false, + playerState: 'offline', + }; + + // ---- overlay helpers ------------------------------------------------- + + function showOverlay(title, message, isError) { + els.overlayTitle.textContent = title; + els.overlayMessage.textContent = message; + els.overlayCard.classList.toggle('error', !!isError); + els.overlay.hidden = false; + } + + function hideOverlay() { + els.overlay.hidden = true; + } + + // ---- status bar helpers --------------------------------------------- + + function setStatusIndicator(label, cls) { + els.statusIndicator.textContent = label; + els.statusIndicator.className = 'status ' + cls; + } + + function setViewerCount(n) { + els.viewerCount.textContent = String(n); + } + + function setLatency(ms) { + els.latency.textContent = ms == null ? '--' : `${ms} ms`; + } + + function setTransport(label) { + els.transport.textContent = label || '--'; + } + + // ---- MediaMTX API polling ------------------------------------------- + + async function pollPathStatus() { + try { + const res = await fetch(API_URL, { credentials: 'include' }); + if (res.status === 404) { + applyPathStatus(false, 0); + return; + } + if (!res.ok) { + // 401/403 means session expired - force reload so NPM can redirect to Authentik. + if (res.status === 401 || res.status === 403) { + window.location.reload(); + } + return; + } + const data = await res.json(); + applyPathStatus(Boolean(data.ready), (data.readers || []).length); + } catch (_) { + applyPathStatus(false, 0); + } + } + + function applyPathStatus(ready, viewers) { + setViewerCount(viewers); + + if (ready && !state.pathReady) { + state.pathReady = true; + setStatusIndicator('LIVE', 'live'); + showOverlay('Connecting to stream…', 'Negotiating WebRTC', false); + window.StreamPlayer.start(els.video); + } else if (!ready && state.pathReady) { + state.pathReady = false; + setStatusIndicator('OFFLINE', 'offline'); + setTransport('--'); + setLatency(null); + showOverlay('Stream offline', 'Waiting for the streamer to start…', false); + window.StreamPlayer.stop(); + } + } + + // ---- Unmute button -------------------------------------------------- + // + // Browsers require a user gesture to play audio. The