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';
import { castSession } from './cast-session.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, at the least. 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;
// …but a bound written in seconds has to be sized for the highest-bitrate file
// in the library, and every ordinary file then holds a fraction of what the
// same browser would have accepted. Measured on harness/mse_harness.mjs
// against a 100 MB ceiling: a 1.0 Mbit/s film reached its ninety seconds on
// 20.7 MB, and a 2.3 Mbit/s one on 45.6 MB — four fifths and a half of that
// ceiling left unused, which is the whole of the margin a phone on a mobile
// network has to ride out a dead spot on.
//
// The quantity the browser limits is BYTES, so that is what is budgeted here
// and converted to seconds with the file's own average bitrate. It can only
// ever RAISE the bound: `BUFFER_AHEAD_S` stays as the floor, so a file whose
// bitrate spends the budget in under ninety seconds keeps exactly the bound it
// had before and nothing that worked pulls less than it used to.
//
// **The budget is discovered, not declared.** The real ceiling is per-engine
// and per-device and cannot be known up front, and there are only two ways to
// find it: overshoot it, or walk up to it. Overshooting costs a refused append
// every time it is attempted — a flat 48 MB budget against a 40 MB ceiling was
// measured here at 1386 refusals on a film that had none before it, which is a
// regression however well the player recovers from each one. So the budget
// starts at zero, which `aheadLimit()` reads as the fixed bound that shipped
// before, and thereafter is never more than one step past the most read-ahead
// this SourceBuffer has actually been seen to accept.
const BUFFER_AHEAD_STEP_BYTES = 4 * 1024 * 1024;
// Where the walk stops even if nothing ever refuses. Memory a phone has to
// find, and past this the gain is a read-ahead nobody needs.
const BUFFER_AHEAD_MAX_BYTES = 48 * 1024 * 1024;
// A sanity bound on what the budget may convert to. A file whose size or
// duration is wrong must not be able to ask for the whole film.
const BUFFER_AHEAD_MAX_S = 600;
// 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;
// How much read-ahead is worth carrying across a reconnection. Under this
// there is nothing much to lose, and restarting at the playhead — the path
// that has always run — is simpler and no worse.
const RECONNECT_KEEP_MIN_S = 10;
// 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 || '',
};
}
/**
* What a reconnection should ask the node for, and on which terms.
*
* The old stream died with the connection, so something has to be asked for
* again; the question is *where*, and the answer used to be "the playhead",
* unconditionally. That goes through `reinitAt`, which empties the
* SourceBuffer — so a reconnection spent the entire read-ahead at the one
* moment it was worth most: the link is back, the film never stopped because
* the buffer was carrying it, and emptying the buffer is what finally stops
* it. Harmless while the read-ahead was ninety seconds and a dead spot had
* already drained it; with a budget of minutes it is the thing that stops the
* budget paying for anything.
*
* So where there is read-ahead worth keeping, carry on from the *end* of it
* and leave what is there alone. The two modes are not interchangeable:
* resuming keeps the buffer and must not move the playhead, seeking discards
* the buffer and must move it.
*
* Pure, and at module scope, so it can be tested by being run rather than by
* being read.
*/
function reconnectPlan(playhead, range, ended, castActive) {
// A stream that already ended has nothing left to fetch, and asking for a
// position at the end of the film would start an ffmpeg to serve nothing.
// Seeking is what this did before and is left exactly as it was.
//
// A cast is the same answer for a quite different reason: **the receiver
// does not share this buffer.** The relay is fed from segments as they
// arrive off the wire, so everything sitting in the SourceBuffer is material
// it never saw — carrying on from the end of it would restart the relay
// there and jump the receiver forward by the whole read-ahead, which with a
// budget of minutes is minutes of film silently skipped on somebody's
// television. What this protects is the *local* buffer; where playback is
// not local, restarting at the playhead is the correct answer and not merely
// the cautious one.
if (!ended && !castActive
&& range && range[1] - playhead >= RECONNECT_KEEP_MIN_S) {
return { mode: 'resume', at: range[1] };
}
return { mode: 'seek', at: playhead };
}
/** 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}`;
}
/**
* The player as a remote, while a television plays the film.
*
* Only what it is told: the receiver's position on the film's timeline (null
* until the receiver has answered), its state, and whether a seek is on its
* way. The scrubber reports a drag as it moves and a seek once let go.
*/
function CastRemote({ device, position, duration, playerState, moving,
onDrag, onSeek, onToggle, onStop }) {
const known = position !== null;
const paused = playerState === 'PAUSED';
const busy = !known || moving || playerState === 'BUFFERING' || playerState === 'LOADING';
const pct = known && duration > 0 ? Math.min(100, (position / duration) * 100) : 0;
return html`
<${Icon} name="cast" />
${t('cast.casting_to')}
${device}
${busy ? html` ${' '}${t('cast.waiting')}` : ''}
onSeek(position - 30)}
title=${t('cast.back30')} aria-label=${t('cast.back30')}>
<${Icon} name="skipback" />30
<${Icon} name=${paused ? 'play' : 'pause'} />
onSeek(position + 30)}
title=${t('cast.fwd30')} aria-label=${t('cast.fwd30')}>
<${Icon} name="skipfwd" />30
<${Icon} name="close" /> ${t('cast.stop')}
`;
}
/**
* 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, onCopyLink }) {
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);
// Bytes of film per second of film, the byte budget the read-ahead is
// currently allowed, and the ceiling that budget may grow to. Zero bitrate
// means "not known yet" — before `stream_init` there is no duration — and a
// zero budget means "nothing learnt yet"; `aheadLimit()` reads either as the
// fixed `BUFFER_AHEAD_S` bound, which is what the player always had.
const bitrateRef = useRef(0);
const aheadBytesRef = useRef(0);
const aheadCapRef = useRef(BUFFER_AHEAD_MAX_BYTES);
// True while an append is known to be refusable and nothing has freed room
// since. See `flushQueue`.
const quotaHoldRef = useRef(false);
// 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);
// True between asking the node to carry the stream on from the end of the
// buffer and that request's `stream_init` arriving. It is what tells the two
// landings apart: one keeps the buffer, the other empties it.
const resumingRef = useRef(false);
// 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 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);
// 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);
// A television already chosen this session (cast-session.js) takes the film
// from its first segment: the cast starts as a restart would, pending until
// the init segment arrives, and the player opens as its remote. Read once —
// a television chosen while this film plays is chosen here, by the picker.
const [autoCast] = useState(() => (castSession.supported ? castSession.device : null));
const [castActive, setCastActive] = useState(Boolean(autoCast));
const [castUrl, setCastUrl] = useState(null);
const [castPickerOpen, setCastPickerOpen] = useState(false);
const [castDevices, setCastDevices] = useState([]);
const [castScanning, setCastScanning] = useState(false);
const [castDeviceName, setCastDeviceName] = useState(autoCast ? autoCast.name : null);
const castActiveRef = useRef(Boolean(autoCast));
const castCodecRef = useRef(null);
const initSegmentRef = useRef(null);
const castRestartPendingRef = useRef(Boolean(autoCast));
const castDeviceRef = useRef(autoCast);
const castRestartGenRef = useRef(0);
// The receiver as last polled: { playerState, position } with the position
// on the film's timeline, or null before it has answered.
const [remote, setRemote] = useState(null);
// Where the viewer sent the television and it has not arrived yet. Shown in
// place of the receiver's position, which until the reload still belongs to
// the stream being abandoned and would make the scrubber jump back.
const [remoteTarget, setRemoteTarget] = useState(null);
const [remoteDrag, setRemoteDrag] = useState(null);
const remoteFilmPosRef = useRef(null);
// Segments of the *new* stream that arrive while its seek is still landing
// (`reinitAt`/`resumeAt` wait on the SourceBuffer). Null when not holding.
const heldRef = useRef(null);
const holdGenRef = useRef(0);
const landingPlayheadRef = useRef(false);
// The current Screen Wake Lock sentinel, if the browser granted one — see
// the effect below. Null on any platform/context that does not support it,
// which playback has never depended on.
const wakeLockRef = useRef(null);
/**
* The buffered range the playhead is actually in, or null.
*
* Seeking makes the buffer discontinuous, and "the last range" stops meaning
* "the one being watched" the moment there is more than one: measuring the
* read-ahead against a range on the far side of a gap reports a full buffer
* while the player starves.
*/
const currentRange = useCallback(() => {
const sb = sbRef.current;
const v = videoRef.current;
if (!sb || !v) return null;
try {
const t = v.currentTime;
for (let i = 0; i < sb.buffered.length; i++) {
// Half a second of slack: the playhead sits exactly on a boundary
// often enough, and a strict test there reports nothing buffered.
if (t >= sb.buffered.start(i) - 0.5 && t <= sb.buffered.end(i) + 0.5) {
return [sb.buffered.start(i), sb.buffered.end(i)];
}
}
} catch { /* the SourceBuffer went away under us */ }
return null;
}, []);
/**
* Drop what has already been watched.
*
* A SourceBuffer is not a file: browsers cap it at a few hundred megabytes
* and refuse the append that goes past. Keeping a minute behind the playhead
* is enough for a small seek backwards and bounded for a three-hour film.
*/
const evictBehind = useCallback(() => {
const sb = sbRef.current;
const v = videoRef.current;
if (!sb || !v) return false;
// `.buffered`/`.updating` throw InvalidStateError once the SourceBuffer
// has been removed from its MediaSource — same reasoning as currentRange
// and describeRanges just above, which already guard the same read. This
// one did not, and an uncaught throw here skips flushQueue right after it
// in pump() too, since nothing between them catches it. Found live:
// fired on every incoming segment once the SourceBuffer went stale,
// which pump() runs on a 1s timer regardless of whether new data is
// arriving — an unguarded read here is not a rare corner, it repeats
// forever.
try {
if (sb.updating || !sb.buffered.length) return false;
const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
// The range being watched, not the first one: after a seek backwards the
// first range is somewhere else entirely, and removing from its start to
// just behind the playhead would take out everything in between —
// including what is playing.
const range = currentRange();
const start = range ? range[0] : sb.buffered.start(0);
if (keepFrom - start < 10) return false;
sb.remove(start, keepFrom);
// Room is about to appear, which is the one event that makes an append
// refused for quota worth trying again — see `flushQueue`.
quotaHoldRef.current = false;
return true;
} catch {
return false;
}
}, [currentRange]);
/** Seconds of film held past the playhead. */
const bufferedAhead = useCallback(() => {
const v = videoRef.current;
const range = currentRange();
if (!v || !range) return 0;
return Math.max(0, range[1] - v.currentTime);
}, [currentRange]);
/**
* Seconds past the playhead this film may be pulled to, right now.
*
* The byte budget is what the browser actually limits; the seconds are what
* the player can compare `bufferedAhead()` against. `BUFFER_AHEAD_S` is the
* floor and never the ceiling — see the constants — so this only ever gives
* a file more than the fixed bound did, never less.
*/
const aheadLimit = useCallback(() => {
const rate = bitrateRef.current;
if (!(rate > 0)) return BUFFER_AHEAD_S;
return Math.max(BUFFER_AHEAD_S,
Math.min(BUFFER_AHEAD_MAX_S, aheadBytesRef.current / rate));
}, []);
const flushQueue = useCallback(() => {
const sb = sbRef.current;
if (!sb || appendingRef.current || sb.updating) return;
if (queueRef.current.length === 0) {
if (endedRef.current && msRef.current?.readyState === 'open') {
try { msRef.current.endOfStream(); } catch {}
}
return;
}
// An append refused for quota that could evict nothing will be refused
// again until something frees room, and pump() calls this on a clock: the
// retry cost a refused append several times a second for as long as it
// took the film to play far enough for eviction to have anything to drop —
// minutes of it, measured on the harness. `evictBehind` is the only thing
// that ever gives a SourceBuffer room back, so its success is what lifts
// this rather than a timer or the playhead, which moves long before the
// answer changes. It cannot deadlock: a buffer is full of film the
// playhead has not reached, so playback always advances into it and
// eviction always comes.
if (quotaHoldRef.current) return;
appendingRef.current = true;
const chunk = queueRef.current[0];
try {
sb.appendBuffer(chunk);
queueRef.current.shift();
} catch (e) {
appendingRef.current = false;
if (e.name === 'QuotaExceededError') {
quotaRef.current += 1;
// This refusal is the browser stating where its own ceiling is — the
// one number the budget could not be given up front. Take the ceiling
// down below where it broke and leave it there, rather than backing
// off and letting the walk in `pump()` climb to the same place again:
// that sawtooth is a refused append every time round. `aheadLimit()`
// floors at `BUFFER_AHEAD_S`, so the worst this converges to is the
// fixed bound that shipped before.
aheadCapRef.current = Math.max(
0, aheadBytesRef.current - BUFFER_AHEAD_STEP_BYTES);
aheadBytesRef.current = aheadCapRef.current;
// The segment stays at the head of the queue and is tried again once
// there is room. Dropping it — which is what this used to do — leaves a
// hole in the middle of the film and no error anywhere.
if (!evictBehind()) {
quotaHoldRef.current = true;
console.warn('[MSE] buffer full and nothing to evict yet');
}
return;
}
queueRef.current.shift();
console.error('[MSE] appendBuffer error:', e);
// Anything else here does not get better by retrying: a SourceBuffer
// removed from its MediaSource stays removed. Discarding the segment
// and continuing left pump()'s credit grant running unchecked — it is
// driven by how much is successfully buffered, which never grows when
// nothing is actually appending — so the node kept sending and this
// kept discarding, forever. Found live: over 2 GB and 8000+ segments
// fetched for a picture that never appeared. Stop asking instead of
// spinning.
queueRef.current = [];
endedRef.current = true;
const transport = transportRef.current;
if (transport) transport.stopStream();
setError(t('video.err_transport'));
setPhase('error');
}
}, [evictBehind, transportRef]);
/**
* Decide whether the node may send more, and keep the pipeline moving.
*
* This is the only place credit is granted, and the only thing that can
* restart a pipeline the buffer ceiling has stopped. That second job is why
* it exists: an append refused for quota fires no `updateend`, so it grants
* no credit, so the node sends nothing, so no segment arrives to call
* `flushQueue` again. Every wakeup the append path had was downstream of the
* append that just failed — the player deadlocked against itself and sat on
* "buffering" for good, which is what a 500 MB film did at around 100 MB.
*
* So the clock drives this, not the data.
*/
const pump = useCallback(() => {
if (awaitingInitRef.current) return;
const transport = transportRef.current;
evictBehind();
flushQueue();
if (endedRef.current && queueRef.current.length === 0) return;
if (!transport || !transport.connected) return;
// Walk the budget up to one step past the most read-ahead this
// SourceBuffer has been seen to hold without refusing an append — never
// further, so an engine with a small ceiling is overshot by one step
// instead of by the whole budget, and only once, because the refusal takes
// `aheadCapRef` down below where it broke. Read-ahead rather than total
// occupancy, because read-ahead is what this bound governs and what
// eviction leaves alone.
//
// Only while the film is actually playing. What the budget buys is a film
// that survives a dead spot, and a film nobody has started cannot be
// interrupted: growing it there filled a small browser's whole buffer for
// a film left on the loading screen — 40 MB and nine minutes of read-ahead
// where the fixed bound took 8 MB — on a mobile connection paying for it.
// Autoplay is blocked on a phone more often than not, so that is the
// ordinary case and not a corner. Pausing does not *shrink* the budget:
// the lead already paid for is the viewer's.
const vp = videoRef.current;
const rate = (vp && !vp.paused) ? bitrateRef.current : 0;
if (rate > 0) {
aheadBytesRef.current = Math.min(
aheadCapRef.current,
Math.max(aheadBytesRef.current,
bufferedAhead() * rate + BUFFER_AHEAD_STEP_BYTES));
}
if (bufferedAhead() > aheadLimit()
|| queueRef.current.length > QUEUE_HIGH_WATER) {
// Far enough ahead. Grant nothing, but do not go silent: two minutes of
// silence is how the node decides nobody is watching, and pausing a film
// for two minutes is an ordinary thing to do.
const now = Date.now();
if (now - lastPokeRef.current > CREDIT_KEEPALIVE_MS) {
lastPokeRef.current = now;
transport.grantStreamCredit(0);
}
return;
}
// Top the window back up to what is allowed in flight, rather than paying
// off everything owed at once. Called on every arriving segment as well as
// on the clock, so credit trickles out as room appears instead of being
// released in one gulp when the buffer finally drains.
const room = STREAM_WINDOW - outstandingRef.current;
if (room > 0) {
outstandingRef.current += room;
lastPokeRef.current = Date.now();
transport.grantStreamCredit(room);
}
}, [evictBehind, flushQueue, bufferedAhead, aheadLimit]);
useEffect(() => {
let cancelled = false;
// Held so the teardown below can drop *this* listener and no one else's
// (transport.js's addReconnectListener).
let offReconnect = null;
// Reset here, not in the teardown of the run before: switching video while
// an append was in flight left `appendingRef` true, and flushQueue bails
// out on it. The new SourceBuffer then never appended anything, so no
// `updateend` ever cleared the flag, no credit went back to the node, and
// the player sat on "buffering" for good. `endedRef` surviving is the same
// shape of bug — the next stream would call endOfStream() the first time
// its queue ran dry and truncate the film.
appendingRef.current = false;
endedRef.current = false;
queueRef.current = [];
outstandingRef.current = 0;
lastPokeRef.current = Date.now();
quotaRef.current = 0;
stalledRef.current = false;
// The same rule: a new stream starts from a known state. Both are per-film
// — the bitrate obviously, and the budget because what a browser refused
// for a nine-megabit film says nothing about the next one. A budget left
// at the floor by the film before would silently cap every film after it
// for the life of the page.
bitrateRef.current = 0;
aheadBytesRef.current = 0;
aheadCapRef.current = BUFFER_AHEAD_MAX_BYTES;
quotaHoldRef.current = false;
// The same shape again, and the seek refs are worse than the others.
// Switching film while a seek was in flight leaves `awaitingInit` true,
// and only reinitAt() ever lowers it — which the next film does not go
// through, because it builds a new SourceBuffer. Every segment of the new
// film is then dropped as though it belonged to the one we left, for good.
// A stale `seekTarget` is milder: the new film jumps to a position from
// the old one the moment that much is buffered.
awaitingInitRef.current = false;
seekTargetRef.current = null;
// And the same trap once more: a resume asked for on the film being left
// would have the new film's first stream_init keep a buffer belonging to
// the old one, on a SourceBuffer built for neither.
resumingRef.current = false;
clearTimeout(seekTimerRef.current);
const transport = transportRef.current;
if (!transport || !transport.connected) {
setError(t('video.err_transport'));
setPhase('error');
return;
}
const onStarved = () => { stalledRef.current = true; pump(); };
const onFed = () => { stalledRef.current = false; };
/** The buffered ranges, short enough for a log line. */
const describeRanges = () => {
const sb = sbRef.current;
if (!sb) return '(no buffer)';
try {
let s = '';
for (let i = 0; i < sb.buffered.length; i++) {
s += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
}
return s.trim() || '(empty)';
} catch {
return '?';
}
};
/**
* Ask the node to restart the film somewhere else.
*
* Debounced, because dragging the scrubber fires `seeking` continuously and
* each request kills an ffmpeg and spawns another. Only the position the
* finger stops on is worth acting on.
*/
const requestSeek = (target) => {
clearTimeout(seekTimerRef.current);
seekTimerRef.current = setTimeout(() => {
const t = transportRef.current;
if (cancelled || !t || !t.connected) return;
// Everything arriving from here until the new `stream_init` belongs to
// the stream being abandoned. The channel is ordered, so this flag is
// enough to tell them apart without a sequence number in the protocol.
// Rare enough to report every time, and the node logs it at INFO. A
// seek nobody asked for is the kind of thing only this line can show:
// from the node's side it is indistinguishable from a viewer dragging
// the scrubber.
t.sendStreamDiag({
event: 'seek', target: +target.toFixed(1),
t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
ready: videoRef.current ? videoRef.current.readyState : null,
offset: sbRef.current ? sbRef.current.timestampOffset : null,
ranges: describeRanges(),
});
awaitingInitRef.current = true;
heldRef.current = null; holdGenRef.current++;
// A seek supersedes a resume that had been asked for and not landed:
// this one empties the buffer, and taking the resume branch on its
// `stream_init` would leave the film we navigated away from in place.
resumingRef.current = false;
seekTargetRef.current = target;
outstandingRef.current = STREAM_WINDOW;
setPhase('loading');
console.log('[seek] request', +target.toFixed(1), 'outstanding:', STREAM_WINDOW);
t.requestStream(entry.id, STREAM_WINDOW, target, audioTrackRef.current);
}, SEEK_DEBOUNCE_MS);
};
requestSeekRef.current = requestSeek;
/**
* Ask the node to carry the stream on from `target`, keeping the buffer.
*
* Not debounced, unlike `requestSeek`: the 350 ms there is for a finger on
* the scrubber, and a reconnection happens once. Nor does it show the
* loading screen — the film is still playing out of the buffer, and there
* is nothing for the viewer to wait for.
*/
const requestResume = (target) => {
clearTimeout(seekTimerRef.current);
const t = transportRef.current;
if (cancelled || !t || !t.connected) return;
t.sendStreamDiag({
event: 'resume-request', target: +target.toFixed(1),
t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
ready: videoRef.current ? videoRef.current.readyState : null,
offset: sbRef.current ? sbRef.current.timestampOffset : null,
ranges: describeRanges(),
});
resumingRef.current = true;
awaitingInitRef.current = true;
heldRef.current = null; holdGenRef.current++;
seekTargetRef.current = null;
outstandingRef.current = STREAM_WINDOW;
console.log('[resume] request', +target.toFixed(1));
t.requestStream(entry.id, STREAM_WINDOW, target, audioTrackRef.current);
};
/**
* Move the playhead onto a seek once the data for it has arrived.
*
* Setting `currentTime` into a region that is not buffered yet leaves the
* element waiting with nothing to show, and on a seek backwards it would
* be overwritten by the playhead the browser restores. So the position is
* remembered and applied on the first append that actually covers it.
*/
const landPlayhead = () => {
const target = seekTargetRef.current;
const v = videoRef.current, sb = sbRef.current;
if (target === null || !v || !sb) return;
try {
for (let i = 0; i < sb.buffered.length; i++) {
const a = sb.buffered.start(i), b = sb.buffered.end(i);
if (target >= a - 1 && target < b) {
seekTargetRef.current = null;
// ffmpeg lands on the keyframe at or before what we asked for, so
// the range can begin slightly later than the target; never seek
// behind what is actually there.
if (Math.abs(v.currentTime - target) > 0.5) {
landingPlayheadRef.current = true;
v.currentTime = Math.max(target, a);
}
v.play().catch(() => {});
return;
}
}
} catch { /* the SourceBuffer went away */ }
};
/** Wait for whatever the SourceBuffer is doing to finish. */
const settled = (sb) => new Promise((resolve) => {
if (!sb.updating) return resolve();
sb.addEventListener('updateend', resolve, { once: true });
});
/**
* Put the SourceBuffer back to an empty state that starts at `start`.
*
* Everything buffered is dropped rather than kept alongside the new
* material. A discontinuous buffer is legal and every piece of code that
* reads `buffered` then has to reason about which range it means — the
* eviction, the read-ahead, the seek test — for the sake of a few
* megabytes of film the viewer has just navigated away from.
*
* `abort()` first: ffmpeg was killed mid-fragment, so the parser is
* holding half of one, and appending the next stream's header on top of
* that is a decode error.
*/
const reinitAt = async (start) => {
const sb = sbRef.current;
if (!sb) return;
try { sb.abort(); } catch { /* not in a state that needs it */ }
await settled(sb);
try {
sb.remove(0, Infinity);
await settled(sb);
} catch { /* nothing buffered */ }
// ffmpeg restarts its timestamps at zero however far in we asked it to
// seek, so this is what puts the fragments back on the film's timeline.
try { sb.timestampOffset = start; } catch { /* older browsers */ }
const tr = transportRef.current;
if (tr) {
tr.sendStreamDiag({
event: 'reinit', target: start, offset: sb.timestampOffset,
t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
ranges: describeRanges(),
});
}
queueRef.current = [];
appendingRef.current = false;
endedRef.current = false;
quotaRef.current = 0;
// The buffer this was holding against has just been emptied, so there is
// room again — and a seek *backwards* would otherwise put the playhead
// below the held position and stop every append for the rest of the
// film. The budget is deliberately kept: what this browser refused is
// still true of it after a seek.
quotaHoldRef.current = false;
awaitingInitRef.current = false;
seekTargetRef.current = start;
console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
setPhase('streaming');
pump();
};
/**
* Carry the stream on at `start`, keeping everything already buffered.
*
* `reinitAt`'s counterpart, for the one case where the buffer is the thing
* worth saving rather than the thing in the way — see `reconnectPlan`. The
* node lands on a keyframe at or before what was asked for, so the new
* material overlaps the tail of what is there and the coded frame
* processing replaces it; the range stays continuous and the playhead
* never notices.
*/
const resumeAt = async (start) => {
const sb = sbRef.current;
if (!sb) return;
// If the node could not start at or before where the buffer ends, the
// new material would not join what is there and the film would stall on
// the gap for good. It should not happen — a keyframe at or before the
// request, and clamping only ever moves it earlier — but a permanent
// silent stall is not a risk worth taking on reasoning alone, and the
// old path is right there and proven.
const range = currentRange();
if (!range || start > range[1] + 0.5) {
console.warn('[resume] node started at', start, 'past the buffer end',
range ? range[1] : null, '— falling back to reinit');
return reinitAt(start);
}
// Same reason as reinitAt: ffmpeg was killed mid-fragment, so the parser
// holds half of one and the next stream's header on top of that is a
// decode error. `abort()` resets the parser and leaves what is buffered
// alone, which is exactly what this path needs.
try { sb.abort(); } catch { /* not in a state that needs it */ }
await settled(sb);
// ffmpeg restarts its timestamps at zero however far in it was asked to
// seek, so this is what puts the new fragments back on the film's
// timeline — beside the ones already there rather than on top of them.
try { sb.timestampOffset = start; } catch { /* older browsers */ }
const tr = transportRef.current;
if (tr) {
tr.sendStreamDiag({
event: 'resume', target: start, offset: sb.timestampOffset,
t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
ranges: describeRanges(),
});
}
queueRef.current = [];
appendingRef.current = false;
endedRef.current = false;
awaitingInitRef.current = false;
// No seek target. The playhead is already where it belongs and has not
// stopped; setting one would have `landPlayhead` jump the film forward
// to the end of the buffer — over the very minutes this path exists to
// keep. `quotaHold` is left alone for the mirror-image reason: nothing
// was emptied, so a buffer that had no room still has none.
seekTargetRef.current = null;
console.log('[resume] resumeAt done, start:', start,
'kept:', describeRanges());
setPhase('streaming');
pump();
};
const onSeeking = () => {
if (landingPlayheadRef.current) {
landingPlayheadRef.current = false;
return;
}
const v = videoRef.current;
if (!v || cancelled) return;
const target = v.currentTime;
// Inside what is buffered, the browser handles it and the node need not
// hear about it at all — unless a cast is active, because the relay
// cannot seek within its HTTP stream and must be restarted.
if (!castActiveRef.current) {
const sb = sbRef.current;
if (sb) {
try {
for (let i = 0; i < sb.buffered.length; i++) {
if (target >= sb.buffered.start(i) && target <= sb.buffered.end(i) - 0.5) {
return;
}
}
} catch { /* fall through and ask the node */ }
}
}
requestSeek(target);
};
const startStream = async () => {
transport.onStreamError = (msg) => {
if (cancelled) return;
// Say what the node said. Sitting on "buffering" with the reason
// already delivered is the worst of both.
setError(msg.detail || t('video.err_transport'));
setPhase('error');
};
// The old stream died with the connection (the node retires it the
// moment its session goes away — see webrtc_server.py's
// on_state_change), so there is nothing to resume on the wire, only a
// reason to ask again. Where to ask from is `reconnectPlan`: the end of
// the buffer when there is one worth keeping, the playhead otherwise.
offReconnect = transport.addReconnectListener(() => {
if (cancelled) return;
const v = videoRef.current;
const seek = requestSeekRef.current;
if (!v || !seek) return;
const plan = reconnectPlan(v.currentTime, currentRange(),
endedRef.current, castActiveRef.current);
console.log('[MeshBay] transport reconnected —', plan.mode, 'at',
plan.at.toFixed(1), 'from', v.currentTime.toFixed(1));
if (plan.mode === 'resume') requestResume(plan.at);
else seek(plan.at);
});
transport.onStreamInit = (msg) => {
if (cancelled) return;
if (msg.file_id && msg.file_id !== entry.id) return;
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 : []);
// 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);
}
// An iPhone has no MediaSource, only ManagedMediaSource (iOS 17.1+):
// testing for the first alone refused every film there, and blamed
// the codec for it.
const MS = window.MediaSource || window.ManagedMediaSource;
if (!MS) {
setError(t('video.err_no_mse'));
setPhase('error');
return;
}
if (!MS.isTypeSupported(mime)) {
setError(t('video.err_mse', { codec: msg.codec }));
setPhase('error');
return;
}
durationRef.current = msg.duration || 0;
// What the read-ahead budget is spent at. The file's own average, not
// the stream's: on the copy path they are the same thing, and where
// the node re-encodes it sends fewer bytes per second than the source
// holds — so this errs towards buffering less than the budget allows,
// never more. A film whose size or duration is missing keeps the fixed
// bound, which is what `aheadLimit()` does with a zero here.
bitrateRef.current = (entry.size > 0 && durationRef.current > 0)
? entry.size / durationRef.current
: 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
// element's src, blank the picture and throw away the duration the
// scrubber is drawn from.
if (sbRef.current && msRef.current
&& msRef.current.readyState === 'open') {
console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current);
// Read and cleared together: whichever landing runs, the next
// `stream_init` is a fresh question and must not inherit this one's
// answer.
const resuming = resumingRef.current;
resumingRef.current = false;
initSegmentRef.current = null;
if (castActiveRef.current && platform.cast.available) {
platform.cast.stop().catch(() => {});
castRestartPendingRef.current = true;
}
const land = resuming ? resumeAt : reinitAt;
// What follows this message on the ordered channel is the new
// stream, its header first. The landing waits on the SourceBuffer,
// and those segments used to arrive in that gap and be dropped as
// the old film's: the player kept its header from the start of the
// film and never noticed, but a cast relay restarted here got a
// stream beginning at a moof — no ftyp, no moov — and the receiver
// gave up on it within a second (measured on a phone). So they are
// held, and taken in order once the landing is done.
const hold = ++holdGenRef.current;
heldRef.current = [];
land(msg.start || 0).then(async () => {
while (holdGenRef.current === hold && heldRef.current && heldRef.current.length) {
await consumeSegment(heldRef.current.shift());
}
if (holdGenRef.current === hold) heldRef.current = null;
}).catch(() => {
if (holdGenRef.current === hold) heldRef.current = null;
setError(t('video.err_transport'));
setPhase('error');
});
return;
}
// If we reach here during a seek (readyState was 'ended' after the
// previous stream finished), the seek-landing path above could not run.
// A fresh MediaSource is needed, but the seek state must still be reset
// or awaitingInit stays true and every segment is dropped forever.
awaitingInitRef.current = false;
endedRef.current = false;
appendingRef.current = false;
// This branch builds a new SourceBuffer, so there is no buffer left to
// carry anything on from — whatever asked for a resume has had its
// answer, and a flag left standing here would send the *next*
// stream_init down a path whose precondition is gone.
resumingRef.current = false;
queueRef.current = [];
sbRef.current = null;
initSegmentRef.current = null;
if (castActiveRef.current && platform.cast.available) {
platform.cast.stop().catch(() => {});
castRestartPendingRef.current = true;
}
const ms = new MS();
msRef.current = ms;
if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current);
const url = URL.createObjectURL(ms);
blobUrlRef.current = url;
ms.addEventListener('sourceopen', () => {
if (cancelled) return;
if (durationRef.current > 0) {
ms.duration = durationRef.current;
}
const sb = ms.addSourceBuffer(mime);
sbRef.current = sb;
// 'segments', not 'sequence': the fragments must land where they
// belong on the film's timeline rather than one after another, or a
// stream that started at 40 minutes would be buffered at zero and
// the scrubber would lie about everything.
sb.mode = 'segments';
try { sb.timestampOffset = msg.start || 0; } catch { /* older browsers */ }
if (msg.start) seekTargetRef.current = msg.start;
transport.sendStreamDiag({
event: 'first-init', target: msg.start || 0,
offset: sb.timestampOffset, duration: durationRef.current,
t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null,
});
sb.addEventListener('updateend', () => {
// No credit is granted here, deliberately. Appending is not the
// same question as having room, and tying the two meant `remove()`
// — which fires this event too — paid the node for the player's own
// evictions. What may be in flight is decided from the buffer, in
// pump(), and nowhere else.
appendingRef.current = false;
landPlayhead();
pump();
});
setPhase('streaming');
flushQueue();
});
// Neither fires for the ordinary end-of-stream (that's `endOfStream()`
// succeeding, no event needed) — only for the browser's own decoder
// giving up on what was appended. Logged, not acted on: by the time
// this fires the MediaSource is already unusable and every SourceBuffer
// call from here on throws, which the existing catches already handle.
ms.addEventListener('sourceclose', () => {
console.error('[MSE] sourceclose — MediaSource left "open" on its own',
'readyState:', ms.readyState);
});
if (videoRef.current) {
// A ManagedMediaSource never opens on an element AirPlay could take
// over, unless it is given a second source to hand AirPlay instead.
if (MS !== window.MediaSource) videoRef.current.disableRemotePlayback = true;
videoRef.current.src = url;
videoRef.current.addEventListener('seeking', onSeeking);
videoRef.current.addEventListener('timeupdate', pump);
videoRef.current.addEventListener('error', () => {
const err = videoRef.current && videoRef.current.error;
console.error('[MSE] video element error, code:', err && err.code,
'message:', err && err.message);
if (transportRef.current) {
transportRef.current.sendStreamDiag({
event: 'video-element-error',
code: err ? err.code : null,
message: err ? err.message : null,
});
}
});
// The element's own verdict. "buffering" on screen is this, and it
// is the one thing the node cannot infer from a stream it is feeding.
videoRef.current.addEventListener('waiting', onStarved);
videoRef.current.addEventListener('stalled', onStarved);
videoRef.current.addEventListener('playing', onFed);
videoRef.current.addEventListener('canplay', onFed);
}
};
transport.onStreamData = async (msg) => {
if (cancelled) return;
// A segment arrived, so it is no longer in flight — whatever we go on
// to do with it. This has to come before every early return below, and
// it did not: skipping the count for segments we discard leaks a slot
// out of the window each time, and the window never grows back.
//
// `reinitAt` is asynchronous — it waits for two `updateend` events —
// and a seek's first segments arrive during that gap and are dropped
// by the flag below. Lose all eight and the player believes a full
// window is in flight, grants nothing ever again, and the node waits
// for credit that cannot come. A race, which is why the same seek
// worked twice and hung on the third.
outstandingRef.current = Math.max(0, outstandingRef.current - 1);
// The new stream, arriving while its seek lands: kept, not dropped.
// Counted above already, so the replay must not count it again.
if (heldRef.current) {
if (!msg.file_id || msg.file_id === entry.id) heldRef.current.push(msg);
return;
}
if (awaitingInitRef.current) {
console.log('[seek] dropping segment (awaitingInit), outstanding:', outstandingRef.current);
}
// Between asking for a seek and its `stream_init`, everything on the
// channel is the film we just left. Same file, so `file_id` cannot
// tell them apart — ordering can.
if (awaitingInitRef.current) return;
// Late segments from the stream we just left. The DataChannel is
// ordered, so they arrive before the new stream's first segment and
// would otherwise be decrypted against the wrong file — which fails,
// loudly, in the console, for something that is simply not ours.
if (msg.file_id && msg.file_id !== entry.id) return;
await consumeSegment(msg);
};
// Everything a segment goes through once it is known to belong to the
// stream being played: by arrival, or replayed after a landing.
const consumeSegment = async (msg) => {
if (cancelled) return;
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
if (initSegmentRef.current === null) {
initSegmentRef.current = plaintext;
if (castRestartPendingRef.current && castActiveRef.current
&& platform.cast.available) {
castRestartPendingRef.current = false;
const gen = ++castRestartGenRef.current;
const device = castDeviceRef.current;
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;
if (device || castSession.device) castSession.claim('video');
setCastUrl(result.url);
const status = await platform.cast.status();
if (status && status.chromecast && status.chromecast.connected) {
await platform.cast.chromecastReload({ mediaUrl: result.url });
} else if (device) {
await platform.cast.chromecastConnect({
deviceId: device.id, mediaUrl: result.url,
});
setCastDeviceName(device.name);
} else {
navigator.clipboard.writeText(result.url).catch(() => {});
}
if (castRestartGenRef.current === gen) setRemoteTarget(null);
}).catch((err) => {
if (castRestartGenRef.current !== gen) return;
console.error('[cast] restart failed:', err);
platform.cast.stop().catch(() => {});
setCastActive(false); castActiveRef.current = false;
setCastUrl(null); setCastDeviceName(null);
});
}
}
if (castActiveRef.current && !castRestartPendingRef.current
&& platform.cast.available) {
platform.cast.push(plaintext).catch(() => {});
}
queueRef.current.push(plaintext);
// pump(), not flushQueue(): arriving data is the moment to top the
// window back up, and that is what keeps the stream continuous.
pump();
} catch (e) {
console.error('[MSE] decrypt error:', e);
}
};
transport.onStreamEnd = (msg) => {
if (cancelled) return;
// The end of the previous film is not the end of this one.
if (msg && msg.file_id && msg.file_id !== entry.id) return;
// Nor is the end of the stream we abandoned by seeking: taking it
// would call endOfStream() and truncate the film at the seek point.
if (awaitingInitRef.current) return;
endedRef.current = true;
if (castActiveRef.current && platform.cast.available) {
platform.cast.finish().catch(() => {});
}
flushQueue();
};
// The opening window, and the count that tracks it. Asking for more here
// than pump() maintains would leave the node holding credit this side
// does not know about, which is the whole window's worth of overshoot on
// the very first breath of the stream.
outstandingRef.current = STREAM_WINDOW;
const resumeAt = readResumePosition(entry.id);
if (resumeAt) setResumedFrom(resumeAt);
transport.requestStream(entry.id, STREAM_WINDOW, resumeAt, audioTrackRef.current);
};
// Closing the tab, or backgrounding it on a phone, never runs a React
// cleanup — so the node hears nothing and keeps transcoding. `pagehide`
// fires in both cases and is the one event mobile browsers honour on the
// way out; `visibilitychange` covers switching apps. The node stops the
// stream by itself when the connection drops, but that costs a round of
// detection, and this message is a single datagram already in flight.
const leave = (why) => {
const t = transportRef.current;
console.log('[MeshBay] stopStream:', why);
if (t && t.connected) t.stopStream();
};
const onPageHide = () => leave('pagehide');
// Screen Wake Lock: keeps the display on while this page is open and
// visible, purely so the phone stops auto-locking mid-film on its own
// idle timer — the commonest real-world trigger for the WebRTC-drop
// recovery above, and the one case it can sidestep entirely rather than
// recover from. Unrelated to streaming/transport in every direction:
// requesting, holding, or losing this lock touches no DataChannel, no
// SourceBuffer, no playback state, so it cannot itself cause a stall or
// a regression in the existing pipeline. It also does nothing at all on
// a phone the user locks with the power button, or once the tab is
// backgrounded (the spec releases it automatically) — the reconnect path
// above is still the one that has to handle those.
const releaseWakeLock = () => {
const wl = wakeLockRef.current;
wakeLockRef.current = null;
if (wl) { try { wl.release(); } catch { /* already released */ } }
};
const acquireWakeLock = async () => {
if (!('wakeLock' in navigator)) return;
try {
const wl = await navigator.wakeLock.request('screen');
// The effect may have torn down while this was in flight.
if (cancelled) { try { wl.release(); } catch { /* ignore */ } return; }
wakeLockRef.current = wl;
wl.addEventListener('release', () => { wakeLockRef.current = null; });
} catch (e) {
// Battery saver, no permission, an insecure context — playback has
// never depended on this, so there is nothing to fall back to.
console.warn('[MeshBay] Wake lock request failed:', e.message);
}
};
acquireWakeLock();
// NOT wired to stopStream. Android fires visibilitychange when a video goes
// fullscreen, so cutting the stream here killed the film the moment it was
// watched properly. Logged only, until that is confirmed or ruled out.
const onVisibility = () => {
console.log('[MeshBay] visibilitychange:', document.visibilityState);
// The lock is released automatically the moment the page goes hidden
// (spec behaviour, not something to undo) — re-requesting it here is
// what makes it hold again once the film is actually back on screen,
// including the fullscreen transition this handler already exists for.
if (document.visibilityState === 'visible') acquireWakeLock();
};
window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibility);
// `timeupdate` is silent while the film is paused, and the append path
// cannot wake itself once the ceiling has refused a segment. This is the
// clock that guarantees something is still driving the pipeline.
const pumpTimer = setInterval(pump, 1000);
// What the player sees, into the node's log. A hang on a phone shows the
// node feeding a stream quite happily; the half that says otherwise is in
// here, and there is no console to read it from.
const diagTimer = setInterval(() => {
const v = videoRef.current, sb = sbRef.current;
const t = transportRef.current;
if (!t || !v) return;
// Cheap, and the only thing that makes "resume where I stopped" work
// when the tab is closed rather than the player.
if (!v.paused) {
writeResumePosition(entry.id, v.currentTime, durationRef.current);
}
let ranges = '';
try {
for (let i = 0; sb && i < sb.buffered.length; i++) {
ranges += `${sb.buffered.start(i).toFixed(0)}-${sb.buffered.end(i).toFixed(0)} `;
}
} catch { ranges = '?'; }
t.sendStreamDiag({
t: +v.currentTime.toFixed(1),
ahead: +bufferedAhead().toFixed(1),
// What the read-ahead is allowed to be for this film, and the budget
// it came from. `ahead` on its own cannot say whether a short buffer
// is the gate holding or the network not keeping up, and the node's
// log is the only window there is into a phone.
limit: +aheadLimit().toFixed(0),
budgetMB: +(aheadBytesRef.current / 1048576).toFixed(1),
ranges: ranges.trim(),
ready: v.readyState, // 0 = nothing, 4 = enough to play through
paused: v.paused,
stalled: stalledRef.current,
q: queueRef.current.length,
inflight: outstandingRef.current,
appending: appendingRef.current,
updating: sb ? sb.updating : null,
quota: quotaRef.current,
ms: msRef.current ? msRef.current.readyState : null,
err: v.error ? `${v.error.code}:${v.error.message}` : null,
});
}, 5000);
startStream().catch(err => {
if (!cancelled) { setError(err.message); setPhase('error'); }
});
return () => {
cancelled = true;
clearInterval(pumpTimer);
clearInterval(diagTimer);
clearTimeout(seekTimerRef.current);
releaseWakeLock();
// Closing the player is the commonest way to stop watching, so this is
// the write that matters most.
if (videoRef.current) {
writeResumePosition(entry.id, videoRef.current.currentTime,
durationRef.current);
}
if (castActiveRef.current && platform.cast.available) {
platform.cast.chromecastDisconnect().catch(() => {});
platform.cast.stop().catch(() => {});
castActiveRef.current = false;
}
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
if (videoRef.current) {
videoRef.current.removeEventListener('seeking', onSeeking);
videoRef.current.removeEventListener('timeupdate', pump);
videoRef.current.removeEventListener('waiting', onStarved);
videoRef.current.removeEventListener('stalled', onStarved);
videoRef.current.removeEventListener('playing', onFed);
videoRef.current.removeEventListener('canplay', onFed);
}
if (transport) {
// Tell the node first: dropping the handlers only makes us deaf, and a
// stream nobody is listening to still occupies a transcode slot.
transport.stopStream();
transport.onStreamInit = null;
transport.onStreamData = null;
transport.onStreamEnd = null;
transport.onStreamError = null;
}
if (offReconnect) { offReconnect(); offReconnect = null; }
// The queue can hold several megabytes of decrypted video.
queueRef.current = [];
const ms = msRef.current;
if (ms && ms.readyState === 'open') {
try { ms.endOfStream(); } catch { /* already ended */ }
}
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
sbRef.current = null;
msRef.current = null;
};
}, [entry, flushQueue, pump]);
useEffect(() => {
if (phase === 'streaming' && videoRef.current) {
videoRef.current.play().catch(() => {});
}
}, [phase]);
// The scan lives as long as the picker is open: closing it, or picking a
// receiver, stops the updates. Receivers are listed as they answer, and the
// spinner sits below them so one landing never moves the row being aimed at.
// While casting, the film plays on the receiver; the local element keeps
// playing — its playhead is what paces the stream the relay forwards — but
// silently. It went on with its sound, so a phone in the room doubled the
// television's audio, screen off included. What the viewer had set comes
// back when the cast ends.
const mutedBeforeCastRef = useRef(null);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (castActive) {
if (mutedBeforeCastRef.current === null) mutedBeforeCastRef.current = v.muted;
v.muted = true;
} else if (mutedBeforeCastRef.current !== null) {
v.muted = mutedBeforeCastRef.current;
mutedBeforeCastRef.current = null;
}
}, [castActive]);
useEffect(() => {
if (!castPickerOpen) return undefined;
setCastDevices([]);
setCastScanning(true);
return platform.cast.discover((devices, scanning) => {
setCastDevices(devices || []);
setCastScanning(scanning);
});
}, [castPickerOpen]);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
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.
*/
/**
* 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) {
URL.revokeObjectURL(subtitleUrlRef.current);
subtitleUrlRef.current = null;
}
setSubtitleUrl(null);
setSubtitleError(false);
if (track === null) {
setSubtitleTrack(null);
setSubtitleBusy(false);
subtitleTextRef.current = null;
sendSubtitleToCast(null);
return;
}
const transport = transportRef.current;
if (!transport) return;
setSubtitleTrack(track.i);
setSubtitleBusy(true);
// Traced end to end on purpose. Every step here happens on someone else's
// machine, over a link, against a file that may be gigabytes: when this
// does not finish, the only question worth asking is *which* step did not,
// and no other record of that exists.
const t0 = performance.now();
console.log('[MeshBay] subtitle: asking for track', track.i, 'of', entry.id.slice(0, 12));
try {
const info = await transport.requestSubtitle(entry.id, track.i);
console.log('[MeshBay] subtitle: node answered in',
Math.round(performance.now() - t0), 'ms —',
'hash=', String(info.hash).slice(0, 12), 'size=', info.size,
'mime=', info.mime);
const chunks = await pipelinedDownload(
transport, gekRef.current, info.hash, Math.ceil(info.size / CHUNK_SIZE));
console.log('[MeshBay] subtitle: blob fetched in',
Math.round(performance.now() - t0), 'ms —',
chunks.length, 'chunk(s)');
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) {
console.log('[MeshBay] subtitle: superseded, blob dropped');
URL.revokeObjectURL(url); return;
}
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',
Math.round(performance.now() - t0), 'ms:', err);
setSubtitleTrack(null);
setSubtitleError(true);
} finally {
if (subtitleGenRef.current === gen) setSubtitleBusy(false);
}
}, [entry, transportRef, gekRef, sendSubtitleToCast]);
// The mode is set here rather than left to the `default` attribute.
//
// Chrome does honour `default` on a track appended long after playback
// started — measured in a headless run, where the TextTrack read back
// "showing" before this effect had touched it, and its cues were parsed.
// That was worth checking and is not what this exists for: `default` has
// nothing to say about turning subtitles *off* again, which is the other
// half of this effect, and a mode assigned here means the same thing in
// every engine whatever each one decides the attribute implies.
useEffect(() => {
const v = videoRef.current;
if (!v) return;
for (let i = 0; i < v.textTracks.length; i++) {
v.textTracks[i].mode = subtitleUrl ? 'showing' : 'disabled';
}
console.log('[MeshBay] subtitle: textTracks =', v.textTracks.length,
'mode =', v.textTracks[0] && v.textTracks[0].mode,
'cues =', v.textTracks[0] && v.textTracks[0].cues
? v.textTracks[0].cues.length : 'none');
}, [subtitleUrl]);
useEffect(() => {
return () => {
if (subtitleUrlRef.current) {
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;
};
}, []);
// ── Remote ────────────────────────────────────────────────────────────
//
// While a television plays the film, this player is its remote. The phone
// and the receiver do not play in step — they start apart and drift — so a
// scrubber on the local playhead lied about where the film was, and a seek
// from it landed off by that much. Everything here reads the receiver and
// tells it what to do; seeking is the ordinary seek, which restarts the
// relay and reloads the receiver at the new position.
const remoteMode = castActive && castDeviceName !== null && platform.cast.canControl;
useEffect(() => {
if (!remoteMode) {
setRemote(null); setRemoteTarget(null); setRemoteDrag(null);
remoteFilmPosRef.current = null;
return undefined;
}
let stopped = false;
const poll = async () => {
try {
const s = await platform.cast.status();
const c = s && s.chromecast;
if (stopped) return;
if (c && c.connected && c.position != null && !castRestartPendingRef.current) {
const position = streamStartRef.current + c.position;
remoteFilmPosRef.current = position;
setRemote({ playerState: c.playerState, position });
} else {
setRemote((r) => (r && c && c.connected ? { ...r, playerState: c.playerState } : r));
}
} catch { /* asked again on the next tick */ }
};
poll();
const id = setInterval(poll, 1000);
return () => { stopped = true; clearInterval(id); };
}, [remoteMode]);
// Stopping here is stopping for the session: the next film or photo stays
// on this device until a television is chosen again.
const stopCast = async () => {
castSession.forget();
await platform.cast.chromecastDisconnect().catch(() => {});
await platform.cast.stop();
setCastActive(false); castActiveRef.current = false;
setCastUrl(null);
setCastDeviceName(null);
castDeviceRef.current = null;
};
const remoteShown = remoteDrag ?? remoteTarget ?? (remote ? remote.position : null);
const remoteSeek = (to) => {
const duration = durationRef.current;
const target = Math.max(0, duration > 0 ? Math.min(to, duration - 1) : to);
setRemoteTarget(target);
if (requestSeekRef.current) requestSeekRef.current(target);
};
const remoteToggle = async () => {
const v = videoRef.current;
try {
if (remote && remote.playerState === 'PAUSED') {
await platform.cast.chromecastPlay();
if (v) v.play().catch(() => {});
} else {
await platform.cast.chromecastPause();
// The local copy only paces the download: left running, it would go
// on fetching for a television that is not watching.
if (v) v.pause();
}
setRemote((r) => (r ? { ...r, playerState: r.playerState === 'PAUSED' ? 'PLAYING' : 'PAUSED' } : r));
} catch (err) {
console.warn('[cast] remote command failed:', err);
}
};
return html`
{
if (e.target.classList.contains('video-overlay')) onClose();
}}>
${entry.name} (${formatSize(entry.size)})
${audioTracks.length > 1 && html`
${audioMenuOpen && html`
${audioTracks.map((track) => html`
{
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;
// Where the television is, when one plays the film: the
// phone's playhead has drifted from it.
const at = remoteFilmPosRef.current ?? (v && v.currentTime);
if (at != null && seek) seek(at);
}}>
${track.i === audioTrack
? html`<${Icon} name="check" />`
: html` `}
${' '}${audioTrackLabel(track)}
`)}
`}
`}
${subtitleTracks.length > 0 && html`
setSubtitleMenuOpen(!subtitleMenuOpen)}
title="${t('video.subtitles')}">
${subtitleBusy
? html` `
: html`<${Icon} name="subtitles" />`}
${subtitleMenuOpen && html`
{
setSubtitleMenuOpen(false);
selectSubtitle(null);
}}>
${subtitleTrack === null
? html`<${Icon} name="check" />`
: html` `}
${' '}${t('video.subtitles_off')}
${subtitleTracks.map((track) => html`
{
setSubtitleMenuOpen(false);
if (track.i === subtitleTrack) return;
selectSubtitle(track);
}}>
${track.i === subtitleTrack
? html`<${Icon} name="check" />`
: html` `}
${' '}${subtitleTrackLabel(track)}
`)}
${subtitleError && html`
${t('video.err_subtitle')}
`}
`}
`}
${platform.capabilities.lanCast && html`
{
if (castActive) {
await stopCast();
} else if (castCodecRef.current && initSegmentRef.current) {
if (castPickerOpen) {
setCastPickerOpen(false);
} else {
setCastPickerOpen(true);
}
}
}}
title="${castActive ? t('cast.stop') : t('cast.start')}">
<${Icon} name="cast" />
${castPickerOpen && html`
${castDevices.map(d => html`
{
setCastPickerOpen(false);
castSession.choose(d);
setCastDeviceName(d.name);
setCastActive(true); castActiveRef.current = true;
castRestartPendingRef.current = true;
castDeviceRef.current = d;
if (videoRef.current && requestSeekRef.current) {
requestSeekRef.current(videoRef.current.currentTime);
}
}}>
<${Icon} name="cast" /> ${d.name}
`)}
${castScanning && html`
${t('cast.scanning')}
`}
${!castScanning && castDevices.length === 0 && html`
${t('cast.no_devices')}
`}
{
setCastPickerOpen(false);
setCastActive(true); castActiveRef.current = true;
castRestartPendingRef.current = true;
if (videoRef.current && requestSeekRef.current) {
requestSeekRef.current(videoRef.current.currentTime);
}
}}>
<${Icon} name="clip" /> ${t('cast.copy_url')}
`}
`}
${castUrl && html`
${castDeviceName
? castDeviceName
: html` {
e.target.select();
navigator.clipboard.writeText(castUrl).catch(() => {});
}}
title="${t('cast.copy_url')}" />`
}
`}
${onCopyLink && html`
<${Icon} name="link" />
`}
${onDownload && html`
{
if (!dlBusy) {
setDlBusy(true);
onDownload();
setTimeout(() => setDlBusy(false), 1500);
}
}}
title="${t('group.download')}">
${dlBusy
? html` `
: html`<${Icon} name="download" />`}
`}
<${Icon} name="close" />
${(phase === 'streaming' || phase === 'loading') && html`
${subtitleUrl && html`
s.i === subtitleTrack) || {}).lang || ''}
label=${subtitleTrack === null ? ''
: subtitleTrackLabel(
subtitleTracks.find((s) => s.i === subtitleTrack) || { i: 0 })}
default />
`}
${remoteMode && html`
<${CastRemote}
device=${castDeviceName}
position=${remoteShown}
duration=${durationRef.current}
playerState=${remote ? remote.playerState : null}
moving=${remoteTarget !== null}
onDrag=${setRemoteDrag}
onSeek=${(to) => { setRemoteDrag(null); remoteSeek(to); }}
onToggle=${remoteToggle}
onStop=${stopCast} />
`}
${phase === 'loading' && html`
${' '}${t('video.buffering')}
`}
${resumedFrom > 0 && html`
${t('video.resumed_at', { time: formatClock(resumedFrom) })}
{
setResumedFrom(0);
writeResumePosition(entry.id, 0, durationRef.current);
if (requestSeekRef.current) requestSeekRef.current(0);
}}>${t('video.from_start')}
`}
`}
${phase === 'error' && html`
${error}
`}
`;
}
// ── Search Page (cross-group file search) ───────────────────────────────────
/**
* "3 hours ago", in the reader's language.
*
* The search page needs it because its results come from a cache: a file that
* was deleted an hour ago is still listed until the group is opened again, and
* the honest thing is to say how old the answer is rather than to imply it is
* live.
*/
export { VideoPlayer, CastRemote };