aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-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
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;
};
}, []);