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 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); const [castActive, setCastActive] = useState(false); const [castUrl, setCastUrl] = useState(null); const [castPickerOpen, setCastPickerOpen] = useState(false); const [castDevices, setCastDevices] = useState([]); const [castScanning, setCastScanning] = useState(false); const [castDeviceName, setCastDeviceName] = useState(null); const castActiveRef = useRef(false); const castCodecRef = useRef(null); const initSegmentRef = useRef(null); const castRestartPendingRef = useRef(false); const castDeviceRef = useRef(null); const castRestartGenRef = 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); 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]); 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; } 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; // 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()) { 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; if (bufferedAhead() > BUFFER_AHEAD_S || 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]); 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 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; 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; 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; /** * 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; awaitingInitRef.current = false; seekTargetRef.current = start; console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); 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. requestSeek already knows how to land a new // stream_init on the live SourceBuffer without resetting playback — // exactly what dragging the scrubber does — so reusing it here means a // screen-lock reconnect looks like a seek to where the film already // was, not a reload. offReconnect = transport.addReconnectListener(() => { if (cancelled) return; const v = videoRef.current; const seek = requestSeekRef.current; if (!v || !seek) return; console.log('[MeshBay] transport reconnected — resuming stream at', v.currentTime.toFixed(1)); seek(v.currentTime); }); 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); } if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) { setError(t('video.err_mse', { codec: msg.codec })); setPhase('error'); return; } durationRef.current = msg.duration || 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); initSegmentRef.current = null; if (castActiveRef.current && platform.cast.available) { platform.cast.stop().catch(() => {}); castRestartPendingRef.current = true; } reinitAt(msg.start || 0).catch(() => { 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; queueRef.current = []; sbRef.current = null; initSegmentRef.current = null; if (castActiveRef.current && platform.cast.available) { platform.cast.stop().catch(() => {}); castRestartPendingRef.current = true; } const ms = new MediaSource(); 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) { 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); 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; 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; 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(() => {}); } }).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), 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]); 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; }; }, []); 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` `)}
`}
`} ${subtitleTracks.length > 0 && html`
${subtitleMenuOpen && html`
${subtitleTracks.map((track) => html` `)} ${subtitleError && html`
${t('video.err_subtitle')}
`}
`}
`} ${platform.capabilities.lanCast && html`
${castPickerOpen && html`
${castScanning && html`
${t('cast.scanning')}
`} ${castDevices.map(d => html` `)} ${!castScanning && castDevices.length === 0 && html`
${t('cast.no_devices')}
`}
`}
`} ${castUrl && html` ${castDeviceName ? castDeviceName : html` { e.target.select(); navigator.clipboard.writeText(castUrl).catch(() => {}); }} title="${t('cast.copy_url')}" />` } `} ${onDownload && html` `}
${(phase === 'streaming' || phase === 'loading') && html`
${phase === 'loading' && html`
${' '}${t('video.buffering')}
`} ${resumedFrom > 0 && html`
${t('video.resumed_at', { time: formatClock(resumedFrom) })}
`}
`} ${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 };