diff options
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'), |