diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-18 09:47:01 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-18 09:49:27 +0200 |
| commit | c03512aeab576a06f8d5026e5eb484897ec45f99 (patch) | |
| tree | 81a2f319273d01610b1851da6e3ba20b5d2c0886 /packages/meshbay-client/src | |
| parent | 4742aa685129cf790d3f7510238919ffde15a949 (diff) | |
| download | meshbay-c03512aeab576a06f8d5026e5eb484897ec45f99.tar.gz | |
feat(cast): carry subtitles to a Chromecast, on the relay's clock
The relay forwards the node's fragments untouched, and those begin at zero
at the seek point. The player never notices because its SourceBuffer is given
`timestampOffset = start`; a receiver has no equivalent, so the cues are
shifted by `-start` before they leave, recomputed at every restart of the
relay. Sent as they are, a subtitle would be out by the whole seek.
The document is served from the relay's own port at /subs.vtt, behind the same
token as the stream and with CORS: a receiver fetches a side-loaded track with
XHR from its own origin, and without the headers it fails as a network error
with nothing on screen to say so. The URL carries a version because a track is
cached by address — changing the cues behind a fixed URL leaves the previous
language showing.
Cues that end before the stream begins are dropped rather than clamped, so a
line from before the seek cannot appear over the first frames after it.
The relay is plain Node, so the tests start it and fetch from it rather than
reading its source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
Diffstat (limited to 'packages/meshbay-client/src')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 89 | ||||
| -rw-r--r-- | packages/meshbay-client/src/cast-relay.js | 114 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 19 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 1 |
4 files changed, 194 insertions, 29 deletions
diff --git a/packages/meshbay-client/src/cast-chromecast.js b/packages/meshbay-client/src/cast-chromecast.js index 50f79fd..2dd210f 100644 --- a/packages/meshbay-client/src/cast-chromecast.js +++ b/packages/meshbay-client/src/cast-chromecast.js @@ -6,6 +6,56 @@ const DefaultMediaReceiver = require('castv2-client').DefaultMediaReceiver; const SCAN_DURATION_MS = 6000; +// The one track the receiver is ever told about. A side-loaded track cannot +// have its cues replaced in place, so changing subtitles means loading again +// with a new URL under this same id rather than adding a second track. +const TEXT_TRACK_ID = 1; + +// White on nothing is unreadable over a bright scene, and the receiver's +// default style is exactly that. An outline costs no bandwidth. +const TEXT_TRACK_STYLE = Object.freeze({ + backgroundColor: '#00000000', + foregroundColor: '#FFFFFFFF', + edgeType: 'OUTLINE', + edgeColor: '#000000FF', + fontScale: 1.0, + fontFamily: 'SANS_SERIF', +}); + +/** + * What to hand `player.load()` for a stream, with or without subtitles. + * + * `streamType: 'LIVE'` because the relay has no beginning to seek back to — + * the film's own timeline lives on this side, and a seek restarts the relay. + */ +function mediaFor(mediaUrl, subtitle) { + const media = { + contentId: mediaUrl, + contentType: 'video/mp4', + streamType: 'LIVE', + }; + if (subtitle && subtitle.url) { + media.tracks = [{ + trackId: TEXT_TRACK_ID, + type: 'TEXT', + trackContentId: subtitle.url, + trackContentType: 'text/vtt', + subtype: 'SUBTITLES', + name: subtitle.label || 'Subtitles', + language: subtitle.language || 'und', + }]; + media.textTrackStyle = TEXT_TRACK_STYLE; + } + return media; +} + +function loadOptionsFor(subtitle) { + return { + autoplay: true, + activeTrackIds: subtitle && subtitle.url ? [TEXT_TRACK_ID] : [], + }; +} + class CastChromecast { constructor() { this._bonjour = null; @@ -56,7 +106,7 @@ class CastChromecast { }); } - async connect(deviceId, mediaUrl) { + async connect(deviceId, mediaUrl, subtitle) { const device = this._devices.get(deviceId); if (!device) throw new Error(`Unknown device: ${deviceId}`); @@ -91,36 +141,29 @@ class CastChromecast { console.log(`[cast-chromecast] player status: ${status.playerState}`); }); - const media = { - contentId: mediaUrl, - contentType: 'video/mp4', - streamType: 'LIVE', - }; - + console.log(`[cast-chromecast] loading with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { - player.load(media, { autoplay: true }, (err, s) => { - if (err) return reject(err); - resolve(s); - }); + player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), + (err, s) => { + if (err) return reject(err); + resolve(s); + }); }); console.log(`[cast-chromecast] loaded on "${device.name}", state: ${status.playerState}`); return { deviceName: device.name, playerState: status.playerState }; } - async reload(mediaUrl) { + async reload(mediaUrl, subtitle) { if (!this._player) throw new Error('Not connected'); - console.log(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"`); - const media = { - contentId: mediaUrl, - contentType: 'video/mp4', - streamType: 'LIVE', - }; + console.log(`[cast-chromecast] reloading stream on "${this._connectedDevice?.name}"` + + ` with ${subtitle?.url ? 'subtitles' : 'no subtitles'}`); const status = await new Promise((resolve, reject) => { - this._player.load(media, { autoplay: true }, (err, s) => { - if (err) return reject(err); - resolve(s); - }); + this._player.load(mediaFor(mediaUrl, subtitle), loadOptionsFor(subtitle), + (err, s) => { + if (err) return reject(err); + resolve(s); + }); }); console.log(`[cast-chromecast] reloaded, state: ${status.playerState}`); return { playerState: status.playerState }; @@ -155,3 +198,5 @@ class CastChromecast { } module.exports = CastChromecast; +module.exports.mediaFor = mediaFor; +module.exports.loadOptionsFor = loadOptionsFor; diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js index 12939c4..71c07a4 100644 --- a/packages/meshbay-client/src/cast-relay.js +++ b/packages/meshbay-client/src/cast-relay.js @@ -10,10 +10,16 @@ * properly framed fMP4 fragments. The BoxAccumulator reassembles the byte * stream and emits complete moof+mdat pairs. * + * Subtitles ride alongside the video as a side-loaded WebVTT file at + * `/subs.vtt`. A receiver fetches that one with XHR rather than handing it to + * a media element, so unlike the stream it needs CORS headers to be readable + * at all. + * * Mitigations: * · Bind to the LAN interface, never 0.0.0.0 * · Fixed port range (19550-19553), opened only during active cast * · Unguessable token in the URL (32 hex chars) + * · CORS is granted on the subtitle path only, and still behind the token * · Cache-Control: no-store on every response * · Server destroyed when playback stops — zero residual surface */ @@ -30,6 +36,16 @@ const MOOF = 0x6d6f6f66; const PORT_BASE = 19550; const PORT_COUNT = 4; +// A receiver reads a side-loaded subtitle with XHR, from its own origin, so +// the three headers it sends have to be allowed by name — `Range` included, +// which it sends even for a document it will read whole. +const CORS_HEADERS = Object.freeze({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept-Encoding, Range', + 'Access-Control-Expose-Headers': 'Content-Length, Content-Range', +}); + function lanAddress() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { @@ -122,6 +138,8 @@ class CastRelay { this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; + this._subtitle = null; + this._subtitleVersion = 0; } get active() { return this._server !== null; } @@ -131,7 +149,54 @@ class CastRelay { return `http://${this._lanIP}:${this._port}/stream.mp4?t=${this._token}`; } - async start({ codec, initSegment }) { + /** + * Where the current subtitle can be fetched, or null when there is none. + * + * The version is part of the URL because a receiver caches a side-loaded + * track by its address: changing the cues behind a fixed URL would leave the + * old ones on screen. + */ + get subtitleUrl() { + if (!this._server || !this._subtitle) return null; + return `http://${this._lanIP}:${this._port}/subs.vtt` + + `?t=${this._token}&v=${this._subtitleVersion}`; + } + + get subtitle() { + if (!this._subtitle) return null; + return { + url: this.subtitleUrl, + language: this._subtitle.language, + label: this._subtitle.label, + }; + } + + /** + * Carry a WebVTT document for the receiver to side-load. + * + * The cues must already be expressed on the *stream's* timeline, which + * starts at zero at the seek point — not on the film's. The renderer shifts + * them before they get here; the relay only serves bytes. + */ + setSubtitle(sub) { + if (!sub || !sub.vtt) { + this._subtitle = null; + this._subtitleVersion++; + console.log('[cast-relay] subtitle cleared'); + return null; + } + this._subtitle = { + vtt: Buffer.from(sub.vtt, 'utf8'), + language: sub.language || '', + label: sub.label || '', + }; + this._subtitleVersion++; + console.log(`[cast-relay] subtitle set: ${this._subtitle.vtt.length} bytes` + + `, lang "${this._subtitle.language}", v${this._subtitleVersion}`); + return this.subtitle; + } + + async start({ codec, initSegment, subtitle }) { if (this._server) await this.stop(); this._token = crypto.randomBytes(16).toString('hex'); @@ -143,6 +208,8 @@ class CastRelay { this._fragCount = 0; this._chunkCount = 0; this._bytesSent = 0; + this._subtitle = null; + this.setSubtitle(subtitle); const server = http.createServer((req, res) => this._handle(req, res)); @@ -173,7 +240,12 @@ class CastRelay { this._server = server; console.log(`[cast-relay] started on ${this.url}`); console.log(`[cast-relay] init segment: ${this._initSegment ? this._initSegment.length + ' bytes' : 'none'}`); - return { url: this.url, port: this._port, token: this._token }; + return { + url: this.url, + port: this._port, + token: this._token, + subtitle: this.subtitle, + }; } pushSegment(data) { @@ -227,6 +299,7 @@ class CastRelay { this._port = null; this._token = null; this._initSegment = null; + this._subtitle = null; this._ring = []; this._accum.reset(); this._fragCount = 0; @@ -235,7 +308,7 @@ class CastRelay { } _handle(req, res) { - if (req.method !== 'GET') { + if (req.method !== 'GET' && req.method !== 'OPTIONS') { res.writeHead(405); res.end(); return; @@ -250,12 +323,26 @@ class CastRelay { return; } + // The preflight is answered before the token is examined: a browser sends + // it without credentials and rejecting it here would read, on the receiver, + // as a network failure rather than as a refusal. + if (req.method === 'OPTIONS') { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + if (url.searchParams.get('t') !== this._token) { res.writeHead(403); res.end(); return; } + if (url.pathname === '/subs.vtt') { + this._handleSubtitle(res); + return; + } + if (url.pathname !== '/stream.mp4') { res.writeHead(404); res.end(); @@ -264,6 +351,11 @@ class CastRelay { let sent = 0; res.writeHead(200, { + // A receiver that has been given a side-loaded track reads the media + // through the same CORS-checked path as the track, so the headers go on + // both or neither. They widen nothing: the URL is already unguessable, + // and a page that has it could embed it in a media element regardless. + ...CORS_HEADERS, 'Content-Type': 'video/mp4', 'Cache-Control': 'no-store', 'Accept-Ranges': 'none', @@ -288,6 +380,22 @@ class CastRelay { console.log(`[cast-relay] client disconnected, ${this._clients.size} remaining`); }); } + + _handleSubtitle(res) { + if (!this._subtitle) { + res.writeHead(404, CORS_HEADERS); + res.end(); + return; + } + res.writeHead(200, { + ...CORS_HEADERS, + 'Content-Type': 'text/vtt; charset=utf-8', + 'Content-Length': this._subtitle.vtt.length, + 'Cache-Control': 'no-store', + }); + res.end(this._subtitle.vtt); + console.log(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`); + } } module.exports = CastRelay; diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f201a21..122a302 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1770,9 +1770,17 @@ function registerBridge() { return castRelay.start({ codec: opts.codec, initSegment: opts.initSegment ? Buffer.from(opts.initSegment) : null, + subtitle: opts.subtitle || null, }); }); + // Carried separately from `cast:start` for the viewer who turns subtitles on + // without seeking: the relay keeps serving the same video while the receiver + // is told to load again with the new track. + ipcMain.handle('cast:subtitle', async (_e, sub) => { + return castRelay.setSubtitle(sub || null); + }); + ipcMain.handle('cast:push', async (_e, data) => { castRelay.pushSegment(Buffer.from(data)); return true; @@ -1791,6 +1799,7 @@ function registerBridge() { ipcMain.handle('cast:status', async () => ({ active: castRelay.active, url: castRelay.url, + subtitle: castRelay.subtitle, chromecast: castChromecast.getStatus(), })); @@ -1800,12 +1809,14 @@ function registerBridge() { return castChromecast.discover(); }); - ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl }) => { - return castChromecast.connect(deviceId, mediaUrl); + ipcMain.handle('cast:chromecast:connect', async (_e, { deviceId, mediaUrl, subtitle }) => { + return castChromecast.connect(deviceId, mediaUrl, + subtitle === undefined ? castRelay.subtitle : subtitle); }); - ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl }) => { - return castChromecast.reload(mediaUrl); + ipcMain.handle('cast:chromecast:reload', async (_e, { mediaUrl, subtitle }) => { + return castChromecast.reload(mediaUrl, + subtitle === undefined ? castRelay.subtitle : subtitle); }); ipcMain.handle('cast:chromecast:disconnect', async () => { diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index c9c9fe0..c2a2bd4 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -139,6 +139,7 @@ contextBridge.exposeInMainWorld('meshbay', { start: (opts) => ipcRenderer.invoke('cast:start', opts), push: (data) => ipcRenderer.invoke('cast:push', data), stop: () => ipcRenderer.invoke('cast:stop'), + subtitle: (sub) => ipcRenderer.invoke('cast:subtitle', sub), finish: () => ipcRenderer.invoke('cast:finish'), status: () => ipcRenderer.invoke('cast:status'), discover: () => ipcRenderer.invoke('cast:discover'), |