aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js176
1 files changed, 172 insertions, 4 deletions
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 <track> 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 <track> 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`
<div class="video-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
@@ -1065,6 +1184,46 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
`}
</div>
`}
+ ${subtitleTracks.length > 0 && html`
+ <div class="cast-wrapper" style="position:relative">
+ <button class="video-close ${subtitleUrl ? 'cast-active' : ''}"
+ onClick=${() => setSubtitleMenuOpen(!subtitleMenuOpen)}
+ title="${t('video.subtitles')}">
+ ${subtitleBusy
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="subtitles" />`}</button>
+ ${subtitleMenuOpen && html`
+ <div class="cast-picker">
+ <button class="cast-picker-item" onClick=${() => {
+ setSubtitleMenuOpen(false);
+ selectSubtitle(null);
+ }}>
+ ${subtitleTrack === null
+ ? html`<${Icon} name="check" />`
+ : html`<span style="display:inline-block;width:14px"></span>`}
+ ${' '}${t('video.subtitles_off')}
+ </button>
+ ${subtitleTracks.map((track) => html`
+ <button class="cast-picker-item" onClick=${() => {
+ setSubtitleMenuOpen(false);
+ if (track.i === subtitleTrack) return;
+ selectSubtitle(track);
+ }}>
+ ${track.i === subtitleTrack
+ ? html`<${Icon} name="check" />`
+ : html`<span style="display:inline-block;width:14px"></span>`}
+ ${' '}${subtitleTrackLabel(track)}
+ </button>
+ `)}
+ ${subtitleError && html`
+ <div class="cast-picker-item cast-picker-empty">
+ ${t('video.err_subtitle')}
+ </div>
+ `}
+ </div>
+ `}
+ </div>
+ `}
${platform.capabilities.lanCast && html`
<div class="cast-wrapper" style="position:relative">
<button class="video-close ${castActive ? 'cast-active' : ''}"
@@ -1167,7 +1326,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
${(phase === 'streaming' || phase === 'loading') && html`
<div class="video-container">
- <video ref=${videoRef} controls autoplay />
+ <video ref=${videoRef} controls autoplay>
+ ${subtitleUrl && html`
+ <track key=${subtitleUrl} kind="subtitles" src=${subtitleUrl}
+ srclang=${(subtitleTracks.find((s) => s.i === subtitleTrack) || {}).lang || ''}
+ label=${subtitleTrack === null ? ''
+ : subtitleTrackLabel(
+ subtitleTracks.find((s) => s.i === subtitleTrack) || { i: 0 })}
+ default />
+ `}
+ </video>
${phase === 'loading' && html`
<div class="video-loading">
<div class="video-loading-label">