From ad4ca3229002997934ccb5b2eaeb553c13b8888f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 17 Sep 2026 13:39:23 +0200 Subject: feat: embedded subtitles in the video player (MNP 3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MSE decodes no in-band text track, so a subtitle cannot ride inside the fragmented MP4 the player is fed. The node extracts one track whole, converts it to WebVTT and caches it under its own hash; the client pulls that blob through the ordinary file_req/chunk path and hangs a on the video element — the same indirection as a TMDB poster or an audio transcode, which is what makes a film's subtitles extracted once in the life of the file rather than once per viewing. Whole-file also makes the cues absolute, so a seek and an audio-language change both leave the track untouched. **The ordinal counts every subtitle stream, including the ones never listed.** Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a real library) has no path to WebVTT without OCR, and one extracted anyway yields a header with no cues, which is a menu entry that shows nothing and reports no error. Numbering the survivors of that filter would give a PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0` would then extract the PGS — the same trap `AudioTrack.ordinal` exists for, one level deeper. A fixture whose first subtitle stream cannot be decoded pins it, and the handler checks membership of the probed list, never a range. Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's own `stream_init` and from no version number, so `subtitle_req` is never sent to a peer that would not answer it. The floor stays at 3.0. Also here: a failed extraction never touches playback, a superseded reply cannot install its blob over a newer choice, and `_languageName` is shared with the audio labels — lifted by both label harnesses, since a lift that names one function stops covering the rule the moment logic moves out of it. Tests: 9 node (tracks told apart by the words in the extracted cues, not by tags), 10 client. Full suite green: 1545 node/common, 1252 hub. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc --- .../src/meshbay_hub/static/video-player.js | 176 ++++++++++++++++++++- 1 file changed, 172 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js') 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 23b0699..5725fc4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -3,7 +3,7 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale } from './i18n.js'; import { Icon } from './icon.js'; -import { formatSize } from './file-utils.js'; +import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js'; import { loadAuth } from './hub-client.js'; import * as platform from './platform.js'; @@ -68,8 +68,8 @@ const _ISO639 = { * otherwise indistinguishable in the menu — which is common, since a stereo * downmix usually sits beside the surround track it came from. */ -function audioTrackLabel(track) { - const code = (track.lang || '').toLowerCase(); +function _languageName(lang) { + const code = (lang || '').toLowerCase(); let name = null; const iso = _ISO639[code] || (code.length === 2 ? code : null); if (iso) { @@ -84,6 +84,11 @@ function audioTrackLabel(track) { } catch { /* no Intl.DisplayNames, or a code it does not know */ } } if (!name && code && code !== 'und') name = code; + return name; +} + +function audioTrackLabel(track) { + let name = _languageName(track.lang); if (!name) name = t('video.audio_track_n', { n: track.i + 1 }); // Two tracks in the same language are one menu entry repeated without // this, and a library where a stereo downmix sits beside the surround @@ -96,6 +101,21 @@ function audioTrackLabel(track) { return detail ? `${name} — ${detail}` : name; } +/** + * What to call one subtitle track. + * + * Same shape as `audioTrackLabel`, minus the channel layout, which subtitles + * have no equivalent of. The container's title still wins where there is one: + * "Forced", "SDH" and "Signs & Songs" are all the same language tag as the + * ordinary track they sit beside, and picking the wrong one of those is the + * difference between a full translation and three lines in a whole film. + */ +function subtitleTrackLabel(track) { + const name = _languageName(track.lang) + || t('video.subtitle_track_n', { n: track.i + 1 }); + return track.title ? `${name} — ${track.title}` : name; +} + function _mseSupported(codec) { if (!window.MediaSource) return false; const mime = `video/mp4; codecs="${codec}"`; @@ -218,6 +238,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // Read inside the effect's closures, which are built once and would // otherwise capture the first track forever. const audioTrackRef = useRef(null); + // Subtitles. The node lists only the tracks it can turn into WebVTT, so an + // empty list means "nothing showable here" whatever the container holds, + // and draws no selector — the same discovery-from-the-answer shape as the + // audio tracks above. `null` is off, and off is where a film opens. + // + // None of this is torn down by a seek or a language change: the extraction + // is whole-file, so the cues are absolute and the outlives every + // restart of the MediaSource underneath it. + const [subtitleTracks, setSubtitleTracks] = useState([]); + const [subtitleTrack, setSubtitleTrack] = useState(null); + const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false); + const [subtitleUrl, setSubtitleUrl] = useState(null); + const [subtitleBusy, setSubtitleBusy] = useState(false); + const [subtitleError, setSubtitleError] = useState(false); + const subtitleUrlRef = useRef(null); + // Two extractions can be in flight when the viewer changes their mind, and + // the first one asked for is not necessarily the first one answered. Only + // the newest request may install its blob. + const subtitleGenRef = useRef(0); const [castActive, setCastActive] = useState(false); const [castUrl, setCastUrl] = useState(null); const [castPickerOpen, setCastPickerOpen] = useState(false); @@ -629,6 +668,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // was drawn falls back to the first track, and the selector must show // the truth rather than the request. setAudioTracks(Array.isArray(msg.audio_tracks) ? msg.audio_tracks : []); + // Re-stated on every stream_init, including the ones a seek and an + // audio-language change produce. Deliberately does not touch + // `subtitleTrack` or the blob: the cues are absolute, so the track + // showing before the restart is still the right one after it. + setSubtitleTracks( + Array.isArray(msg.subtitle_tracks) ? msg.subtitle_tracks : []); if (Number.isInteger(msg.audio_track)) { audioTrackRef.current = msg.audio_track; setAudioTrack(msg.audio_track); @@ -1025,6 +1070,80 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { return () => window.removeEventListener('keydown', onKey); }, [onClose]); + /** + * Show one subtitle track, or none. + * + * The node extracts the whole track to WebVTT and caches it under its own + * hash; what comes back here is that hash, pulled through the ordinary + * chunk path like any other file. So this is slow exactly once per film per + * track, and instant every time after — including in a later sitting, which + * is the part a per-seek extraction could never have given. + * + * A failure here never touches playback. Subtitles are an addition to a + * 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. + */ + const selectSubtitle = useCallback(async (track) => { + const gen = ++subtitleGenRef.current; + if (subtitleUrlRef.current) { + URL.revokeObjectURL(subtitleUrlRef.current); + subtitleUrlRef.current = null; + } + setSubtitleUrl(null); + setSubtitleError(false); + if (track === null) { + setSubtitleTrack(null); + setSubtitleBusy(false); + return; + } + const transport = transportRef.current; + if (!transport) return; + setSubtitleTrack(track.i); + setSubtitleBusy(true); + try { + const info = await transport.requestSubtitle(entry.id, track.i); + const chunks = await pipelinedDownload( + transport, gekRef.current, info.hash, Math.ceil(info.size / CHUNK_SIZE)); + const url = URL.createObjectURL( + new Blob(chunks, { type: info.mime || 'text/vtt' })); + // Someone changed their mind while this was in flight. Dropping the blob + // rather than installing it is the whole point of the generation: the + // reply that arrives last is not the choice that was made last. + if (subtitleGenRef.current !== gen) { URL.revokeObjectURL(url); return; } + subtitleUrlRef.current = url; + setSubtitleUrl(url); + } catch (err) { + if (subtitleGenRef.current !== gen) return; + console.warn('[MeshBay] subtitle track', track.i, 'failed:', err); + setSubtitleTrack(null); + setSubtitleError(true); + } finally { + if (subtitleGenRef.current === gen) setSubtitleBusy(false); + } + }, [entry, transportRef, gekRef]); + + // A added to a media element after it started playing is not shown + // by the `default` attribute — that one is read when the element is first + // parsed, and by then this track did not exist. The mode has to be set on + // the live TextTrack, which only appears once the element has adopted the + // child preact just rendered. + useEffect(() => { + const v = videoRef.current; + if (!v) return; + for (let i = 0; i < v.textTracks.length; i++) { + v.textTracks[i].mode = subtitleUrl ? 'showing' : 'disabled'; + } + }, [subtitleUrl]); + + useEffect(() => { + return () => { + if (subtitleUrlRef.current) { + URL.revokeObjectURL(subtitleUrlRef.current); + subtitleUrlRef.current = null; + } + }; + }, []); + return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); @@ -1065,6 +1184,46 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { `}
`} + ${subtitleTracks.length > 0 && html` +
+ + ${subtitleMenuOpen && html` +
+ + ${subtitleTracks.map((track) => html` + + `)} + ${subtitleError && html` +
+ ${t('video.err_subtitle')} +
+ `} +
+ `} +
+ `} ${platform.capabilities.lanCast && html`