diff options
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.js | 117 |
1 files changed, 114 insertions, 3 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 4f8a4e4..4970262 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -1,7 +1,7 @@ import { html, useState, useEffect, useCallback, useRef, } from './vendor/htm-preact.js'; -import { t } from './i18n.js'; +import { t, getLocale } from './i18n.js'; import { Icon } from './icon.js'; import { formatSize } from './file-utils.js'; import { loadAuth } from './hub-client.js'; @@ -39,6 +39,63 @@ const RESUME_MIN_S = 30; const RESUME_MAX_FRACTION = 0.97; const QUEUE_HIGH_WATER = 12; +// ffprobe reports a container's language tag as ISO 639-2, and in either of +// its two variants for the dozen languages that have both — a bibliographic +// one (fre, ger, dut) and a terminological one (fra, deu, nld), with real +// files in this library using each. `Intl.DisplayNames` wants 639-1, so both +// variants are folded onto the same two-letter code here. Only what a media +// container actually carries is listed; anything unmapped falls through to the +// raw tag, which is more useful than "Unknown". +const _ISO639 = { + ara: 'ar', ben: 'bn', bul: 'bg', cat: 'ca', ces: 'cs', cze: 'cs', + chi: 'zh', dan: 'da', deu: 'de', dut: 'nl', ell: 'el', eng: 'en', + est: 'et', fas: 'fa', fin: 'fi', fra: 'fr', fre: 'fr', ger: 'de', + gle: 'ga', gre: 'el', heb: 'he', hin: 'hi', hrv: 'hr', hun: 'hu', + ice: 'is', ind: 'id', isl: 'is', ita: 'it', jpn: 'ja', kor: 'ko', + lav: 'lv', lit: 'lt', may: 'ms', msa: 'ms', nld: 'nl', nor: 'no', + per: 'fa', pol: 'pl', por: 'pt', ron: 'ro', rum: 'ro', rus: 'ru', + slk: 'sk', slo: 'sk', slv: 'sl', spa: 'es', srp: 'sr', swe: 'sv', + tam: 'ta', tha: 'th', tur: 'tr', ukr: 'uk', urd: 'ur', vie: 'vi', + zho: 'zh', +}; + +/** + * What to call one audio track, in the reader's language. + * + * The container's own `title` tag is preferred when there is one: a muxer that + * bothered to write "VFQ" or "Director's commentary" has said something the + * language code cannot, and two tracks tagged with the same language are + * 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(); + let name = null; + const iso = _ISO639[code] || (code.length === 2 ? code : null); + if (iso) { + try { + name = new Intl.DisplayNames([getLocale()], { type: 'language' }).of(iso); + // `Intl.DisplayNames` follows each locale's prose convention, which is + // lower case in French, Spanish and Italian among others. A menu entry + // is not prose, and "français" beside "AC3 5.1" reads like a bug. Only + // this branch needs it: a raw tag is a code and is shown as written, + // and the numbered fallback comes from the catalogues already cased. + if (name) name = name.charAt(0).toUpperCase() + name.slice(1); + } catch { /* no Intl.DisplayNames, or a code it does not know */ } + } + if (!name && code && code !== 'und') name = code; + 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 + // track it came from is the ordinary case. The container's title wins; the + // channel layout is the fallback, written in the "5.1" notation that needs + // no catalogue entry in any of the ten languages. + let detail = track.title; + if (!detail && track.ch > 2) detail = `${track.ch - 1}.1`; + else if (!detail && track.ch) detail = `${track.ch}.0`; + return detail ? `${name} — ${detail}` : name; +} + function _mseSupported(codec) { if (!window.MediaSource) return false; const mime = `video/mp4; codecs="${codec}"`; @@ -151,6 +208,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // live; the render needs to reach it for "start from the beginning". const requestSeekRef = useRef(null); const [resumedFrom, setResumedFrom] = useState(0); + // The audio tracks this node reported for this file, and which one is + // playing. An empty list means either a file with one track or a node too + // old to enumerate them — both draw no selector, which is why nothing here + // needs to know which of the two it is. + const [audioTracks, setAudioTracks] = useState([]); + const [audioTrack, setAudioTrack] = useState(0); + const [audioMenuOpen, setAudioMenuOpen] = useState(false); + // Read inside the effect's closures, which are built once and would + // otherwise capture the first track forever. + const audioTrackRef = useRef(null); const [castActive, setCastActive] = useState(false); const [castUrl, setCastUrl] = useState(null); const [castPickerOpen, setCastPickerOpen] = useState(false); @@ -412,7 +479,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { outstandingRef.current = STREAM_WINDOW; setPhase('loading'); console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW); - t.requestStream(entry.id, STREAM_WINDOW, target); + t.requestStream(entry.id, STREAM_WINDOW, target, audioTrackRef.current); }, SEEK_DEBOUNCE_MS); }; requestSeekRef.current = requestSeek; @@ -557,6 +624,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const mime = `video/mp4; codecs="${msg.codec}"`; castCodecRef.current = msg.codec; + // What the node offered, and what it actually used — which is not + // always what was asked for: a file replaced on disk since the list + // 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 : []); + if (Number.isInteger(msg.audio_track)) { + audioTrackRef.current = msg.audio_track; + setAudioTrack(msg.audio_track); + } + if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { setError(t('video.err_mse', { codec: msg.codec })); setPhase('error'); @@ -769,7 +846,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { outstandingRef.current = STREAM_WINDOW; const resumeAt = readResumePosition(entry.id); if (resumeAt) setResumedFrom(resumeAt); - transport.requestStream(entry.id, STREAM_WINDOW, resumeAt); + transport.requestStream(entry.id, STREAM_WINDOW, resumeAt, audioTrackRef.current); }; // Closing the tab, or backgrounding it on a phone, never runs a React @@ -954,6 +1031,40 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { }}> <div class="video-top-bar"> <span class="video-title">${entry.name} (${formatSize(entry.size)})</span> + ${audioTracks.length > 1 && html` + <div class="cast-wrapper" style="position:relative"> + <button class="video-close ${audioMenuOpen ? 'cast-active' : ''}" + onClick=${() => setAudioMenuOpen(!audioMenuOpen)} + title="${t('video.audio_track')}"> + <${Icon} name="volume" /></button> + ${audioMenuOpen && html` + <div class="cast-picker"> + ${audioTracks.map((track) => html` + <button class="cast-picker-item" onClick=${() => { + setAudioMenuOpen(false); + if (track.i === audioTrackRef.current) return; + // A different track is a different ffmpeg, so this is the + // seek path verbatim — and it has to be, because the new + // stream opens with an init segment the SourceBuffer can + // only accept after the abort/remove that `reinitAt` does. + // Resuming where the film already was is the whole point: + // the viewer changed language, not position. + audioTrackRef.current = track.i; + setAudioTrack(track.i); + const v = videoRef.current; + const seek = requestSeekRef.current; + if (v && seek) seek(v.currentTime); + }}> + ${track.i === audioTrack + ? html`<${Icon} name="check" />` + : html`<span style="display:inline-block;width:14px"></span>`} + ${' '}${audioTrackLabel(track)} + </button> + `)} + </div> + `} + </div> + `} ${platform.capabilities.lanCast && html` <div class="cast-wrapper" style="position:relative"> <button class="video-close ${castActive ? 'cast-active' : ''}" |