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/cast-chromecast.js | |
| 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/cast-chromecast.js')
| -rw-r--r-- | packages/meshbay-client/src/cast-chromecast.js | 89 |
1 files changed, 67 insertions, 22 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; |