summaryrefslogtreecommitdiffstats
path: root/docs/cast-smart-tv.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/cast-smart-tv.md')
-rw-r--r--docs/cast-smart-tv.md176
1 files changed, 176 insertions, 0 deletions
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
+<?xml version="1.0"?>
+<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
+ s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
+ <s:Body>
+ <u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
+ <InstanceID>0</InstanceID>
+ <CurrentURI>{mediaUrl}</CurrentURI>
+ <CurrentURIMetaData>{didlLite}</CurrentURIMetaData>
+ </u:SetAVTransportURI>
+ </s:Body>
+</s:Envelope>
+```
+
+### 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 <url>` 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