diff options
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; }; }, []); |