import {
html, useState, useEffect, useCallback, useRef,
} from './vendor/htm-preact.js';
import { t, getLocale } from './i18n.js';
import { Icon } from './icon.js';
import { formatSize, CHUNK_SIZE, pipelinedDownload } from './file-utils.js';
import { loadAuth } from './hub-client.js';
import * as platform from './platform.js';
// Seconds of already-watched video kept in the SourceBuffer, and the queue depth
// past which we start making room before being forced to.
const BUFFER_BEHIND_S = 60;
// How far past the playhead we are willing to pull. The browser caps a video
// SourceBuffer at a few hundred megabytes and refuses the append that goes
// past, so "as fast as the network allows" is not a strategy for a film: the
// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a
// ten-megabit second fills the ceiling in the first minute. Buffering by time
// rather than by bytes keeps a two-hour film and a two-minute clip alike.
const BUFFER_AHEAD_S = 90;
// While we deliberately hold credit back, the node must still hear from us: its
// own stall timeout is two minutes, and a paused film is not a gone viewer.
const CREDIT_KEEPALIVE_MS = 20000;
// Segments allowed in flight while there is room to put them. This is a window,
// topped up as segments land, and not a debt released in one go: accumulating a
// credit per append and handing the lot over when the buffer finally had room
// sent 6 MB in a burst, overshot the target by a minute of film, and then said
// nothing for the next forty-six seconds. Measured in Chrome against real
// fragmented MP4. A stream that arrives in gulps has no margin for a network
// that hesitates, and looks like a hang while it is quiet.
const STREAM_WINDOW = 8;
// Dragging the scrubber fires `seeking` continuously, and every seek we act on
// kills an ffmpeg and spawns another. Only where the finger stops is worth a
// restart.
const SEEK_DEBOUNCE_MS = 350;
// A position is remembered per file, in this browser. Below the first threshold
// there is nothing to resume; above the second the film is finished and
// offering to resume thirty seconds before the credits is a nuisance.
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 _languageName(lang) {
const code = (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;
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
// 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;
}
/**
* 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 });
// The disposition wins over the container's title, and is translated, so a
// forced track reads as one in the viewer's own language rather than as the
// English word a muxer happened to type — or as nothing at all, which is
// what a forced track with no title tag looked like.
const kind = track.forced ? t('video.subtitles_forced')
: track.sdh ? t('video.subtitles_sdh')
: null;
const detail = kind || track.title;
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}"`;
return MediaSource.isTypeSupported(mime);
}
/** Seconds as h:mm:ss, or m:ss under an hour. */
function formatClock(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
}
/**
* Where *this account on this device* last left off in a given file.
*
* localStorage rather than the node: it needs no protocol, no storage anyone
* else has to keep, and nothing new learns what you watch. The cost is that
* the position does not follow you from the laptop to the phone.
*
* The account has to be in the key. Without it the position is per *device* —
* so a second person signing in on the same machine was offered "resume where
* you left off" in a film they had never opened, which is both wrong and a
* small disclosure of what someone else watches. Found by signing in with a
* fresh account and being offered a resume point.
*/
function resumeKey(fileId) {
const auth = loadAuth();
return auth && auth.userId ? `mb:pos:${auth.userId}:${fileId}` : null;
}
function readResumePosition(fileId) {
try {
const key = resumeKey(fileId);
if (!key) return 0;
const raw = localStorage.getItem(key);
const at = raw ? parseFloat(raw) : 0;
return Number.isFinite(at) && at > RESUME_MIN_S ? at : 0;
} catch {
return 0; // private browsing, or storage disabled
}
}
function writeResumePosition(fileId, at, duration) {
try {
const key = resumeKey(fileId);
if (!key) return;
if (!Number.isFinite(at) || at < RESUME_MIN_S
|| (duration && at > duration * RESUME_MAX_FRACTION)) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, String(Math.floor(at)));
} catch { /* nothing to be done, and nothing worth failing over */ }
}
/**
* Drop the positions written before they were scoped to an account.
*
* Re-keying them is not possible — there is no record of whose they were, and
* guessing would hand them to whoever signs in next, which is the bug. They go.
*/
function purgeUnscopedResumePositions() {
try {
const stale = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
// `mb:pos:` is the old shape; `mb:pos::` is current.
if (key && key.startsWith('mb:pos:') && key.split(':').length === 3) {
stale.push(key);
}
}
stale.forEach((key) => localStorage.removeItem(key));
} catch { /* storage disabled: nothing was written either */ }
}
purgeUnscopedResumePositions();
function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const [dlBusy, setDlBusy] = useState(false);
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const videoRef = useRef(null);
const msRef = useRef(null);
const sbRef = useRef(null);
const blobUrlRef = useRef(null);
const queueRef = useRef([]);
const appendingRef = useRef(false);
const endedRef = useRef(false);
const durationRef = useRef(0);
// Segments the node is allowed to have in flight but has not sent yet, and
// when we last said anything to it at all.
const outstandingRef = useRef(0);
const lastPokeRef = useRef(0);
// Diagnostics reported to the node: how many appends the browser refused for
// want of room, and whether the element itself says it is starved.
const quotaRef = useRef(0);
const stalledRef = useRef(false);
// Seeking. `awaitingInit` is true from the moment we ask the node to restart
// somewhere else until its new `stream_init` arrives: the channel is ordered,
// so everything in between belongs to the stream we just abandoned and would
// otherwise be appended on top of the new one. `seekTarget` is where to put
// the playhead once the buffer actually covers it.
const awaitingInitRef = useRef(false);
const seekTargetRef = useRef(null);
const seekTimerRef = useRef(null);
// The seek is built inside the effect, where the transport and `cancelled`
// 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);
// 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