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-hub/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-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/platform.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 124 |
2 files changed, 127 insertions, 1 deletions
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; }; }, []); |