summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-18 09:47:01 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-18 09:49:27 +0200
commitc03512aeab576a06f8d5026e5eb484897ec45f99 (patch)
tree81a2f319273d01610b1851da6e3ba20b5d2c0886 /packages
parent4742aa685129cf790d3f7510238919ffde15a949 (diff)
downloadmeshbay-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')
-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
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js124
-rw-r--r--packages/meshbay-hub/tests/test_cast_subtitles.py393
-rw-r--r--packages/meshbay-hub/tests/test_video_subtitles.py13
8 files changed, 722 insertions, 35 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'),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 43b8ba8..07e5b6f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -441,6 +441,10 @@ export const cast = {
if (!bridge || !bridge.cast) return false;
return bridge.cast.stop();
},
+ async subtitle(sub) {
+ if (!bridge || !bridge.cast || !bridge.cast.subtitle) return null;
+ return bridge.cast.subtitle(sub);
+ },
async finish() {
if (!bridge || !bridge.cast) return false;
return bridge.cast.finish();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 1522539..a07558e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -124,6 +124,75 @@ function subtitleTrackLabel(track) {
return detail ? `${name} — ${detail}` : name;
}
+/**
+ * The same cues, moved onto a stream that begins somewhere else.
+ *
+ * The node extracts a subtitle whole, so its cues carry the film's own
+ * timeline. That is what the player wants — the SourceBuffer is given
+ * `timestampOffset = start`, so the element's `currentTime` is film time and
+ * the cues need no adjustment.
+ *
+ * A cast has no such offset. The relay hands the receiver the node's fragments
+ * untouched, and those are rebased to zero at the seek point, so film time and
+ * stream time differ by exactly `start`. Sending the unshifted document to a
+ * receiver would put the subtitles out by however far the viewer had seeked —
+ * an hour into a film, an hour wrong.
+ *
+ * Cues that end before the stream does are dropped rather than clamped: a cue
+ * pinned to 0 would show a line from before the seek over the first frames
+ * after it.
+ */
+function shiftWebVtt(text, delta) {
+ const TIMING = /^((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s*-->\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})(.*)$/;
+ const parse = (stamp) => {
+ const parts = stamp.replace(',', '.').split(':');
+ const secs = parseFloat(parts.pop());
+ const mins = parseInt(parts.pop() || '0', 10);
+ const hours = parseInt(parts.pop() || '0', 10);
+ return hours * 3600 + mins * 60 + secs;
+ };
+ const pad = (n, width) => String(n).padStart(width, '0');
+ const format = (t) => {
+ const ms = Math.round(t * 1000);
+ return `${pad(Math.floor(ms / 3600000), 2)}:${pad(Math.floor(ms / 60000) % 60, 2)}`
+ + `:${pad(Math.floor(ms / 1000) % 60, 2)}.${pad(ms % 1000, 3)}`;
+ };
+
+ const kept = [];
+ for (const block of String(text).split(/\r?\n\r?\n/)) {
+ const lines = block.split(/\r?\n/);
+ const at = lines.findIndex((line) => TIMING.test(line));
+ // The header, NOTE, STYLE and REGION blocks carry no timing and travel
+ // unchanged — dropping them would take the cue positioning with them.
+ if (at === -1) {
+ kept.push(block);
+ continue;
+ }
+ const m = lines[at].match(TIMING);
+ const from = parse(m[1]) + delta;
+ const to = parse(m[2]) + delta;
+ if (to <= 0) continue;
+ lines[at] = `${format(Math.max(0, from))} --> ${format(to)}${m[3]}`;
+ kept.push(lines.join('\n'));
+ }
+ return kept.join('\n\n');
+}
+
+/**
+ * What the cast relay should serve for the track now showing, or null.
+ *
+ * `start` is where the stream the relay is being fed begins, in film time, so
+ * the shift is its negation: film time minus start is stream time.
+ */
+function castSubtitleFor(sub, start) {
+ if (!sub || !sub.text) return null;
+ return {
+ vtt: shiftWebVtt(sub.text, -(start || 0)),
+ language: sub.language || '',
+ label: sub.label || '',
+ };
+}
+
function _mseSupported(codec) {
if (!window.MediaSource) return false;
const mime = `video/mp4; codecs="${codec}"`;
@@ -265,6 +334,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
// the first one asked for is not necessarily the first one answered. Only
// the newest request may install its blob.
const subtitleGenRef = useRef(0);
+ // The cues as text, on the film's own timeline. Kept because a cast needs
+ // them shifted onto the relay's, and that shift changes at every seek.
+ const subtitleTextRef = useRef(null);
+ // Where the stream the node is sending begins, in film time. The same number
+ // the SourceBuffer gets as its `timestampOffset`.
+ const streamStartRef = useRef(0);
const [castActive, setCastActive] = useState(false);
const [castUrl, setCastUrl] = useState(null);
const [castPickerOpen, setCastPickerOpen] = useState(false);
@@ -694,6 +769,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
durationRef.current = msg.duration || 0;
+ // Recorded before either branch below: both restart the relay, and the
+ // subtitle sent with it has to be shifted by *this* start, not the one
+ // the previous stream had.
+ streamStartRef.current = msg.start || 0;
// A second init on a live SourceBuffer is a seek landing, not a new
// film. Reuse what is there: rebuilding the MediaSource would reset the
@@ -841,6 +920,8 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
platform.cast.start({
codec: castCodecRef.current,
initSegment: plaintext,
+ subtitle: castSubtitleFor(
+ subtitleTextRef.current, streamStartRef.current),
}).then(async (result) => {
if (castRestartGenRef.current !== gen) return;
if (!result) return;
@@ -1091,6 +1172,36 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
* film that is already running, and taking the film down because a text
* track could not be read would be a worse answer than no subtitles.
*/
+ /**
+ * Put the chosen track in front of the receiver, when one is casting.
+ *
+ * A seek already carries the subtitle with it — the relay restarts and is
+ * handed the shifted cues. This covers the other case: the viewer turns
+ * subtitles on, off, or swaps languages while the picture keeps running. The
+ * relay keeps serving the same video; only the receiver has to be told, and
+ * a side-loaded track cannot be changed in place, so it is told by loading
+ * the same stream URL again with a new track address.
+ *
+ * Never allowed to disturb playback. A receiver that refuses the track keeps
+ * showing the film without subtitles, which is what it was doing anyway.
+ */
+ const sendSubtitleToCast = useCallback(async (sub) => {
+ if (!castActiveRef.current || !platform.cast.available) return;
+ try {
+ const payload = castSubtitleFor(sub, streamStartRef.current);
+ await platform.cast.subtitle(payload);
+ const status = await platform.cast.status();
+ if (status && status.chromecast && status.chromecast.connected
+ && status.url) {
+ await platform.cast.chromecastReload({ mediaUrl: status.url });
+ }
+ console.log('[cast] subtitle', payload ? 'sent' : 'cleared',
+ '— stream starts at', streamStartRef.current.toFixed(1));
+ } catch (err) {
+ console.warn('[cast] subtitle not sent:', err);
+ }
+ }, []);
+
const selectSubtitle = useCallback(async (track) => {
const gen = ++subtitleGenRef.current;
if (subtitleUrlRef.current) {
@@ -1102,6 +1213,8 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
if (track === null) {
setSubtitleTrack(null);
setSubtitleBusy(false);
+ subtitleTextRef.current = null;
+ sendSubtitleToCast(null);
return;
}
const transport = transportRef.current;
@@ -1137,6 +1250,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
subtitleUrlRef.current = url;
setSubtitleUrl(url);
console.log('[MeshBay] subtitle: track attached');
+ subtitleTextRef.current = {
+ text: await new Blob(chunks).text(),
+ language: track.lang || '',
+ label: subtitleTrackLabel(track),
+ };
+ sendSubtitleToCast(subtitleTextRef.current);
} catch (err) {
if (subtitleGenRef.current !== gen) return;
console.warn('[MeshBay] subtitle track', track.i, 'failed after',
@@ -1146,7 +1265,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
} finally {
if (subtitleGenRef.current === gen) setSubtitleBusy(false);
}
- }, [entry, transportRef, gekRef]);
+ }, [entry, transportRef, gekRef, sendSubtitleToCast]);
// The mode is set here rather than left to the `default` attribute.
//
@@ -1175,6 +1294,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
URL.revokeObjectURL(subtitleUrlRef.current);
subtitleUrlRef.current = null;
}
+ // A whole film's cues, held as a string for the cast path. Nothing else
+ // drops it, and the next film's are a different document.
+ subtitleTextRef.current = null;
};
}, []);
diff --git a/packages/meshbay-hub/tests/test_cast_subtitles.py b/packages/meshbay-hub/tests/test_cast_subtitles.py
new file mode 100644
index 0000000..722a56a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_cast_subtitles.py
@@ -0,0 +1,393 @@
+"""
+Subtitles on a cast receiver.
+
+Casting takes a different road than the player does, and the difference that
+matters is the clock. The node extracts a subtitle whole, so its cues carry the
+film's own timeline; the player feeds its SourceBuffer a `timestampOffset`
+equal to the stream's start, which puts the element's `currentTime` on that
+same timeline and lets the cues be used as they arrive.
+
+A receiver has no such offset. The relay hands it the node's fragments
+untouched, and those are rebased to zero at the seek point. Film time and
+stream time therefore differ by exactly the start, and a document sent across
+unshifted is wrong by however far the viewer had seeked — an hour into a film,
+an hour wrong, with nothing in any log to say so. `shiftWebVtt` is the whole
+correction and the first half of this file tests it by running it.
+
+The second half runs the relay itself. Unlike the Electron shell it is plain
+Node with no dependencies, so it can be started, served from and stopped here.
+Three of its properties are load-bearing and invisible when broken: the
+subtitle sits behind the same token as the stream, it carries CORS headers
+because a receiver fetches it with XHR rather than handing it to a media
+element, and its URL changes when its content does — a side-loaded track is
+cached by address, so a fixed URL would leave the old language on screen.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "video-player.js"
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+RELAY = CLIENT / "src" / "cast-relay.js"
+CHROMECAST = CLIENT / "src" / "cast-chromecast.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _lift(src: str, name: str) -> str:
+ """One top-level function, as text, for node to execute."""
+ start = src.index(f"function {name}(")
+ depth, i, seen = 0, start, False
+ while i < len(src):
+ if src[i] == "{":
+ depth += 1
+ seen = True
+ elif src[i] == "}":
+ depth -= 1
+ if seen and depth == 0:
+ return src[start:i + 1]
+ i += 1
+ raise AssertionError(f"{name} never closes")
+
+
+def _run(tmp_path, body: str, *args: str):
+ script = tmp_path / "cast.mjs"
+ app = APP.read_text(encoding="utf-8")
+ script.write_text(
+ _lift(app, "shiftWebVtt")
+ + "\n" + _lift(app, "castSubtitleFor")
+ + "\n" + body, encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(script), *args],
+ capture_output=True, text=True, timeout=30)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+VTT = """WEBVTT
+
+NOTE this file was converted from an embedded track
+
+1
+00:00:10.000 --> 00:00:12.500
+Before the seek.
+
+2
+01:23:45.000 --> 01:23:47.250 line:90%
+Hello.
+
+3
+01:30:00.000 --> 01:30:02.000
+Goodbye.
+"""
+
+
+def _timings(vtt: str):
+ return [line.strip() for line in vtt.splitlines() if "-->" in line]
+
+
+# ── The clock ────────────────────────────────────────────────────────────────
+
+def test_cues_move_back_by_the_streams_start(tmp_path):
+ """
+ A stream that begins at 01:20:00 makes a cue at 01:23:45 land at 00:03:45.
+
+ This is the correction the whole feature rests on. Without it the cues keep
+ the film's timeline while the receiver counts from zero, and the error is
+ the size of the seek rather than a small drift — invisible in code review,
+ unmistakable on screen.
+ """
+ out = _run(tmp_path, """
+ const shifted = shiftWebVtt(process.argv[2], -4800);
+ console.log(JSON.stringify(shifted));
+ """, VTT)
+ assert "00:03:45.000 --> 00:03:47.250 line:90%" in out
+ assert "00:10:00.000 --> 00:10:02.000" in out
+
+
+def test_a_stream_from_the_top_leaves_every_cue_where_it_was(tmp_path):
+ """No seek means no shift, and the document must come back unchanged."""
+ out = _run(tmp_path, """
+ console.log(JSON.stringify(shiftWebVtt(process.argv[2], 0)));
+ """, VTT)
+ assert _timings(out) == _timings(VTT)
+
+
+def test_cues_before_the_stream_are_dropped_not_clamped(tmp_path):
+ """
+ A cue that has already finished when the stream starts must disappear.
+
+ Clamping it to zero instead would print a line from before the seek over
+ the first frames after it — the one failure mode that looks like a bug in
+ the extraction rather than in the arithmetic.
+ """
+ out = _run(tmp_path, """
+ console.log(JSON.stringify(shiftWebVtt(process.argv[2], -4800)));
+ """, VTT)
+ assert "Before the seek." not in out
+ assert len(_timings(out)) == 2
+
+
+def test_a_cue_straddling_the_seek_survives_and_starts_at_zero(tmp_path):
+ """
+ Someone is mid-sentence when the viewer lands. The line is still owed to
+ them, so the cue is kept with its start pulled up to the stream's own.
+ """
+ out = _run(tmp_path, """
+ const vtt = 'WEBVTT\\n\\n1\\n00:00:08.000 --> 00:00:14.000\\nMid-sentence.\\n';
+ console.log(JSON.stringify(shiftWebVtt(vtt, -10)));
+ """)
+ assert "00:00:00.000 --> 00:00:04.000" in out
+ assert "Mid-sentence." in out
+
+
+def test_the_header_and_its_notes_travel_unchanged(tmp_path):
+ """
+ A document that loses its `WEBVTT` line is not a WebVTT document, and a
+ receiver rejects it whole rather than complaining about one cue.
+ """
+ out = _run(tmp_path, """
+ console.log(JSON.stringify(shiftWebVtt(process.argv[2], -4800)));
+ """, VTT)
+ assert out.startswith("WEBVTT")
+ assert "NOTE this file was converted" in out
+
+
+def test_comma_decimals_are_read_and_written_back_as_points(tmp_path):
+ """
+ SRT writes `00:00:10,000` and some converters leave the comma in place.
+ Reading it and emitting the WebVTT spelling costs nothing and saves a
+ document that would otherwise be silently dropped cue by cue.
+ """
+ out = _run(tmp_path, """
+ const vtt = 'WEBVTT\\n\\n1\\n00:01:00,500 --> 00:01:02,000\\nComma.\\n';
+ console.log(JSON.stringify(shiftWebVtt(vtt, -30)));
+ """)
+ assert "00:00:30.500 --> 00:00:32.000" in out
+
+
+def test_the_payload_negates_the_start_it_is_given(tmp_path):
+ """
+ `castSubtitleFor` takes where the stream *begins* and must subtract it.
+ Getting the sign wrong doubles the error instead of cancelling it, and both
+ directions produce a plausible-looking document.
+ """
+ out = _run(tmp_path, """
+ const sub = { text: process.argv[2], language: 'fr', label: 'French' };
+ const payload = castSubtitleFor(sub, 4800);
+ console.log(JSON.stringify(payload));
+ """, VTT)
+ assert "00:03:45.000 --> 00:03:47.250" in out["vtt"]
+ assert out["language"] == "fr"
+ assert out["label"] == "French"
+
+
+def test_no_track_showing_means_no_payload(tmp_path):
+ """
+ Subtitles off has to reach the relay as null, not as an empty document: an
+ empty WebVTT file is a track the receiver will happily display nothing
+ from, and its menu would still offer it.
+ """
+ out = _run(tmp_path, """
+ console.log(JSON.stringify([
+ castSubtitleFor(null, 0), castSubtitleFor({ text: '' }, 0),
+ ]));
+ """)
+ assert out == [None, None]
+
+
+# ── The relay ────────────────────────────────────────────────────────────────
+
+relay_only = pytest.mark.skipif(
+ not RELAY.exists(), reason="desktop client sources not present")
+
+RELAY_SCRIPT = """
+const CastRelay = require(process.argv[2]);
+
+(async () => {
+ const relay = new CastRelay();
+ const started = await relay.start({
+ codec: 'avc1.640028',
+ initSegment: Buffer.from([0, 0, 0, 8, 102, 116, 121, 112]),
+ subtitle: { vtt: 'WEBVTT\\n\\n1\\n00:00:01.000 --> 00:00:02.000\\nHi.\\n',
+ language: 'fr', label: 'French' },
+ });
+
+ const out = { streamUrl: started.url, subtitleUrl: started.subtitle.url };
+
+ const get = await fetch(out.subtitleUrl);
+ out.status = get.status;
+ out.contentType = get.headers.get('content-type');
+ out.allowOrigin = get.headers.get('access-control-allow-origin');
+ out.allowHeaders = get.headers.get('access-control-allow-headers');
+ out.body = await get.text();
+
+ const noToken = await fetch(out.subtitleUrl.replace(/t=[0-9a-f]+/, 't=0'));
+ out.withoutToken = noToken.status;
+
+ const preflight = await fetch(out.subtitleUrl, { method: 'OPTIONS' });
+ out.preflight = preflight.status;
+ out.preflightAllowOrigin =
+ preflight.headers.get('access-control-allow-origin');
+
+ const second = relay.setSubtitle({ vtt: 'WEBVTT\\n\\n', language: 'en' });
+ out.secondUrl = second.url;
+
+ relay.setSubtitle(null);
+ out.afterClear = relay.subtitleUrl;
+ out.afterClearStatus = (await fetch(out.secondUrl)).status;
+ const stream = await fetch(out.streamUrl);
+ out.streamStillServes = stream.status;
+ out.streamAllowOrigin = stream.headers.get('access-control-allow-origin');
+
+ await relay.stop();
+ console.log(JSON.stringify(out));
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
+"""
+
+
+@pytest.fixture(scope="module")
+def served(tmp_path_factory):
+ script = tmp_path_factory.mktemp("relay") / "serve.cjs"
+ script.write_text(RELAY_SCRIPT, encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(script), str(RELAY)],
+ capture_output=True, text=True, timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout.strip().splitlines()[-1])
+
+
+@relay_only
+def test_the_subtitle_is_served_as_webvtt(served):
+ assert served["status"] == 200
+ assert served["contentType"].startswith("text/vtt")
+ assert served["body"].startswith("WEBVTT")
+
+
+@relay_only
+def test_the_subtitle_sits_behind_the_same_token_as_the_stream(served):
+ """
+ The relay's only defence is that its URL cannot be guessed. A subtitle
+ path exempt from the token would hand the film's dialogue — often the
+ whole script — to anything on the Wi-Fi.
+ """
+ assert served["withoutToken"] == 403
+
+
+@relay_only
+def test_the_receiver_is_allowed_to_read_it(served):
+ """
+ A side-loaded track is fetched with XHR from the receiver's own origin, so
+ without CORS it fails as a network error and the film plays on with no
+ subtitles and no message. `Range` is named because the receiver sends it
+ even for a document it reads whole.
+ """
+ assert served["allowOrigin"] == "*"
+ for header in ("Content-Type", "Accept-Encoding", "Range"):
+ assert header in served["allowHeaders"]
+
+
+@relay_only
+def test_the_stream_carries_the_same_headers_as_its_subtitle(served):
+ """
+ A receiver given a side-loaded track reads the media through the same
+ CORS-checked path, so headers on one and not the other fails the load
+ entirely rather than losing the subtitles alone. They widen nothing the
+ token does not already govern: a page holding the URL could put it in a
+ media element with or without them.
+ """
+ assert served["streamAllowOrigin"] == "*"
+
+
+@relay_only
+def test_the_preflight_is_answered_before_the_token_is_checked(served):
+ """
+ A browser sends `OPTIONS` without the credentials that would let it pass a
+ token check, so refusing it there would deny every well-formed request.
+ """
+ assert served["preflight"] == 204
+ assert served["preflightAllowOrigin"] == "*"
+
+
+@relay_only
+def test_a_new_subtitle_gets_a_new_address(served):
+ """
+ A receiver caches a side-loaded track by its URL. Serving different cues
+ from a fixed address leaves the previous language on screen, which reads
+ as the switch having been ignored.
+ """
+ assert served["secondUrl"] != served["subtitleUrl"]
+
+
+@relay_only
+def test_turning_subtitles_off_takes_the_file_away_but_not_the_film(served):
+ """
+ Clearing the track must not disturb playback: the stream is the reason the
+ relay exists, and losing the picture to a subtitle change would be a far
+ worse failure than the one being fixed.
+ """
+ assert served["afterClear"] is None
+ assert served["afterClearStatus"] == 404
+ assert served["streamStillServes"] == 200
+
+
+# ── What the receiver is told ────────────────────────────────────────────────
+
+chromecast_only = pytest.mark.skipif(
+ not CHROMECAST.exists(), reason="desktop client sources not present")
+
+CHROMECAST_SCRIPT = """
+const m = require(process.argv[2]);
+const sub = { url: 'http://10.0.0.2:19550/subs.vtt?t=ab&v=3',
+ language: 'fr', label: 'French — Forced' };
+console.log(JSON.stringify({
+ with: { media: m.mediaFor('http://10.0.0.2:19550/stream.mp4?t=ab', sub),
+ options: m.loadOptionsFor(sub) },
+ without: { media: m.mediaFor('http://10.0.0.2:19550/stream.mp4?t=ab', null),
+ options: m.loadOptionsFor(null) },
+}));
+"""
+
+
+@pytest.fixture(scope="module")
+def loaded(tmp_path_factory):
+ script = tmp_path_factory.mktemp("cc") / "media.cjs"
+ script.write_text(CHROMECAST_SCRIPT, encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(script), str(CHROMECAST)],
+ capture_output=True, text=True, timeout=60)
+ if proc.returncode != 0:
+ pytest.skip(f"cast-chromecast.js is not loadable here: {proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+@chromecast_only
+def test_the_track_is_declared_and_switched_on(loaded):
+ """
+ Declaring a track without naming it in `activeTrackIds` loads it and shows
+ nothing, which is the same symptom as not declaring it at all.
+ """
+ track = loaded["with"]["media"]["tracks"][0]
+ assert track["type"] == "TEXT"
+ assert track["subtype"] == "SUBTITLES"
+ assert track["trackContentType"] == "text/vtt"
+ assert track["language"] == "fr"
+ assert loaded["with"]["options"]["activeTrackIds"] == [track["trackId"]]
+
+
+@chromecast_only
+def test_a_stream_without_subtitles_declares_none(loaded):
+ """
+ A stale id in `activeTrackIds` is a load error on the receiver, and the
+ load error takes the film with it.
+ """
+ assert "tracks" not in loaded["without"]["media"]
+ assert loaded["without"]["options"]["activeTrackIds"] == []
diff --git a/packages/meshbay-hub/tests/test_video_subtitles.py b/packages/meshbay-hub/tests/test_video_subtitles.py
index 6867076..7fdc9cd 100644
--- a/packages/meshbay-hub/tests/test_video_subtitles.py
+++ b/packages/meshbay-hub/tests/test_video_subtitles.py
@@ -259,9 +259,12 @@ def test_a_failed_extraction_does_not_take_the_film_down(app):
`setError` is the player's fatal path — it replaces the picture. A text
track that could not be read must not reach it.
"""
- body = _player(app)
- start = body.index("const selectSubtitle = useCallback(")
- end = body.index("}, [entry, transportRef, gekRef]);", start)
- assert "setError(" not in body[start:end], (
+ # Matched on braces rather than on the dependency list. The deps are the
+ # part of a callback most likely to change for reasons unrelated to what is
+ # asserted here, and a slice keyed to them stops at `.index()` raising
+ # rather than at the property being broken — a guard that reports the wrong
+ # thing is barely better than one that reports nothing.
+ body = _block(_player(app), "const selectSubtitle = useCallback(")
+ assert "setError(" not in body, (
"a subtitle failure takes the whole player down")
- assert "setSubtitleError(true)" in body[start:end]
+ assert "setSubtitleError(true)" in body