From 8730281d739d9ed1d6f2d366772582eea8ba0294 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 21 Aug 2026 13:52:11 +0200 Subject: feat: LAN Wi-Fi casting to Chromecast via local HTTP relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-serve decrypted fMP4 video over HTTP on the LAN so a Chromecast can play the stream. The relay runs in the Electron main process — same trust boundary as downloads and MSE playback. - cast-relay.js: HTTP server with BoxAccumulator (reassembles WebRTC chunks into moof+mdat pairs), ring buffer, backpressure, finish() for clean end-of-stream, fixed port range 19550-19553 - cast-chromecast.js: mDNS discovery (bonjour-service) + CASTV2 protocol (castv2-client), connect/reload/disconnect lifecycle - Seek-aware: relay restarts on every seek, Chromecast reloads new URL; generation counter prevents stale async errors from killing active restarts; landingPlayheadRef suppresses programmatic seeking events - Device picker in video top bar with scan, device selection, copy-URL fallback, and cast status indicator - IPC bridge (main/preload/platform) for start/push/stop/finish/status/ discover/chromecastConnect/chromecastReload/chromecastDisconnect - Phase 3 design doc for DLNA/Smart TV in docs/cast-smart-tv.md Co-Authored-By: Claude Opus 4.6 --- docs/cast-smart-tv.md | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/cast-smart-tv.md (limited to 'docs') diff --git a/docs/cast-smart-tv.md b/docs/cast-smart-tv.md new file mode 100644 index 0000000..e7a50be --- /dev/null +++ b/docs/cast-smart-tv.md @@ -0,0 +1,176 @@ +# Phase 3 — Smart TV casting via DLNA/UPnP + +## Context + +Phase 1 (HTTP relay) and Phase 2 (Chromecast) are shipped. The relay in +`cast-relay.js` already serves a standard fMP4 stream over HTTP — any +DLNA-capable TV can play from that URL. What is missing is *discovery* +(finding the TV) and *control* (telling it to play). + +## What exists + +| Layer | File | Role | +|-------|------|------| +| Relay | `cast-relay.js` | HTTP server, BoxAccumulator, ring buffer, `finish()` for clean EOS | +| Chromecast | `cast-chromecast.js` | mDNS discovery, CASTV2 connect/reload/disconnect | +| IPC | `main.js` lines 743-795 | `cast:*` + `cast:chromecast:*` handlers | +| Bridge | `preload.js` | `cast.start/push/stop/finish/status/discover` + chromecast methods | +| Platform | `platform.js` | `cast.*` namespace, `capabilities.lanCast` gate | +| UI | `app.js` VideoPlayer | Cast button, device picker, seek-aware restart, generation counter | + +The relay is device-agnostic — Chromecast and DLNA share it. Phase 3 adds a +second device backend alongside `cast-chromecast.js`, not a second relay. + +## Protocol + +DLNA media rendering uses two protocols: + +1. **SSDP** (Simple Service Discovery Protocol) — UDP multicast on + `239.255.255.250:1900`. The client sends `M-SEARCH` for + `urn:schemas-upnp-org:device:MediaRenderer:1` and collects responses + (location URL → XML device description → friendly name, icon, services). + +2. **UPnP AV** — SOAP over HTTP to the `AVTransport:1` service on the TV. + Key actions: + - `SetAVTransportURI(uri, metadata)` — point the TV at the relay URL. + `metadata` is a DIDL-Lite XML snippet with content type, title, etc. + - `Play` / `Stop` / `Pause` + - `Seek(target)` — if the TV supports it (many don't for live streams) + - `GetTransportInfo` — current state (PLAYING, STOPPED, etc.) + +## Implementation plan + +### 1. `cast-dlna.js` (~150-200 lines) + +``` +class CastDLNA { + constructor() + + async discover(): Device[] + // Send M-SEARCH, collect SSDP responses for 6 seconds + // Fetch each device's XML description + // Parse friendly name + AVTransport control URL + // Return [{ id, name, host, controlUrl }] + + async play(device, mediaUrl): void + // POST SOAP SetAVTransportURI to device.controlUrl + // POST SOAP Play + + async stop(): void + // POST SOAP Stop to connected device + + async getStatus(): { state, ... } + // POST SOAP GetTransportInfo + + getConnectedDevice(): { name } | null +} +``` + +**Dependencies:** None required. SSDP is raw UDP, UPnP AV is HTTP+SOAP — +both can be done with Node builtins (`dgram`, `http`, `xml` parsing with a +small helper or regex for the limited DIDL-Lite we generate). Avoid pulling +`node-ssdp` or `upnp-client` — they're heavy and unmaintained. Keep it +self-contained like `cast-chromecast.js`. + +SSDP implementation notes: +- Bind UDP socket to `0.0.0.0` (not the LAN IP — multicast responses come + back to the sending port) +- Send to `239.255.255.250:1900` +- `MX: 3` (max wait), `ST: urn:schemas-upnp-org:device:MediaRenderer:1` +- Parse `LOCATION:` header from responses, fetch the XML, extract + `friendlyName` and the `AVTransport` `controlURL` + +UPnP AV SOAP envelope (SetAVTransportURI): +```xml + + + + + 0 + {mediaUrl} + {didlLite} + + + +``` + +### 2. IPC channels + +Add to `main.js`: +- `cast:dlna:discover` → `castDLNA.discover()` +- `cast:dlna:play` → `castDLNA.play(device, mediaUrl)` +- `cast:dlna:stop` → `castDLNA.stop()` +- `cast:dlna:status` → `castDLNA.getStatus()` + +### 3. Bridge + platform + +Add to `preload.js` `cast` object: +- `dlnaDiscover`, `dlnaPlay`, `dlnaStop`, `dlnaStatus` + +Add to `platform.js` `cast` namespace: +- Same four methods + +### 4. UI changes + +The device picker already lists Chromecast devices. Extend it: +- Run `cast:discover` (Chromecast mDNS) and `cast:dlna:discover` (SSDP) in + parallel +- Display both in the picker, with a type indicator (cast icon vs TV icon) +- On click: if Chromecast → existing `chromecastConnect` flow; if DLNA → + `cast.start()` (relay) then `dlnaPlay(device, relayUrl)` +- Seek restart: same pattern as Chromecast — `cast.stop()`, `cast.start()` + with new init segment, then `dlnaPlay()` with the new URL (DLNA has no + `reload`, it's just SetAVTransportURI + Play again) +- `onStreamEnd` `finish()` should work as-is — the TV sees HTTP EOF + +### 5. Firewall + +SSDP needs UDP 1900 (outbound multicast + inbound responses). The relay +ports (TCP 19550-19553) are already opened for Chromecast. Add to the +firewall instructions: +``` +sudo firewall-cmd --zone=FedoraWorkstation --add-port=1900/udp +``` + +## Key differences from Chromecast + +| | Chromecast | DLNA/Smart TV | +|---|---|---| +| Discovery | mDNS (bonjour-service) | SSDP (raw UDP) | +| Control | CASTV2 (TLS, protobuf) | UPnP AV (HTTP, SOAP/XML) | +| Connection | Persistent (TLS socket) | Stateless (one SOAP call per action) | +| Reload | `player.load()` on same session | `SetAVTransportURI` + `Play` (new URL) | +| Dependencies | `castv2-client`, `bonjour-service` | None (Node builtins) | +| Status | Continuous (`player.on('status')`) | Polling (`GetTransportInfo`) | + +The stateless nature of UPnP AV is actually simpler — no persistent +connection to manage, no error handler that can call `_cleanup()` and lose +state. Each action is a self-contained HTTP request. + +## Edge cases to handle + +- **TV goes to sleep during playback** — `GetTransportInfo` returns + `STOPPED` or the HTTP request times out. Surface this in the UI. +- **Multiple AVTransport services** — some TVs expose more than one. Pick + the first, or the one whose `serviceId` contains `AVTransport`. +- **Content-Type negotiation** — some TVs reject `video/mp4` for live + streams. The DIDL-Lite metadata should specify + `protocolInfo="http-get:*:video/mp4:*"`. +- **No seek support** — most TVs don't support `Seek` on live/progressive + streams. The seek-restart pattern (stop → new URL → play) handles this. + +## Testing + +Samsung, LG, and Sony TVs are the most common DLNA renderers. VLC also +acts as a DLNA renderer (`vlc --play-and-exit ` or via its DLNA +renderer mode) and is useful for development without a physical TV. + +## Estimated scope + +- `cast-dlna.js`: ~150-200 lines (SSDP + UPnP AV, no deps) +- `main.js`: ~20 lines (4 IPC handlers) +- `preload.js`: ~5 lines +- `platform.js`: ~15 lines +- `app.js`: ~30 lines (parallel discovery, device type routing) +- Total: ~250 lines of new code -- cgit v1.2.3