summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client')
-rw-r--r--packages/meshbay-client/src/cast-chromecast.js89
-rw-r--r--packages/meshbay-client/src/cast-relay.js114
-rw-r--r--packages/meshbay-client/src/main.js19
-rw-r--r--packages/meshbay-client/src/preload.js1
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'),