1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
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
|