summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/video-player.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with no way to add another group-level app without touching the shell itself. It is now app.js (routing, non-group pages) plus nine focused files — apps.js (the registry), chat-app.js, files-app.js, video-player.js, group-page.js (the shell), group-settings.js, hub-client.js, icon.js and file-utils.js — with docs/apps.md as the checklist for adding one (Videos/Music/Photos are sketched there, not built). Node side gained the matching enablement mechanism, mirroring member_upload exactly: a roster setting, a signed apps_enabled op enforced by _has_admin_authority, exposed in the handshake ack. Operators toggle applications per group from Settings, which also gained a small reorder: Invite, Pairing, Applications, Shared directories, Uploads, danger zone, Your devices, Members. Two bugs surfaced during the split, both missing an import across the new file boundary and invisible to node --check or a module-load probe since they only throw when the code path actually runs: - group-page.js called onRefreshAuth on a stale-token handshake rejection, but app.js never imported refreshAccessToken from hub-client.js — so a brand new member (including a group's own creator) hit "Not a member of this group" and the retry silently failed, throwing before it could refresh the token. - chat-app.js called getLocale() for message timestamps without importing it from i18n.js. Opening Chat on a group with real messages threw mid- render; uncaught, that appears to wedge Preact's render scheduler, so every button on the page stopped responding until reload. Caught the second class of bug with a proper no-undef audit across all split files (a temporarily installed ESLint 9, since the system one is too old to parse this codebase's syntax) rather than trusting grep. 827 tests pass; 6 new ones cover the apps_enabled policy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-player.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js992
1 files changed, 992 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
new file mode 100644
index 0000000..42842d2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -0,0 +1,992 @@
+import {
+ html, useState, useEffect, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+import { formatSize } 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;
+
+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:<file>` is the old shape; `mb:pos:<user>:<file>` 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);
+ 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 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 || 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;
+ try {
+ 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);
+ }
+ }, [evictBehind]);
+
+ /**
+ * 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;
+ // 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);
+ }, 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');
+ };
+
+ 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;
+
+ if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
+ setError(t('video.err_mse', { codec: msg.codec }));
+ setPhase('error');
+ return;
+ }
+
+ durationRef.current = msg.duration || 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();
+ });
+
+ if (videoRef.current) {
+ videoRef.current.src = url;
+ videoRef.current.addEventListener('seeking', onSeeking);
+ videoRef.current.addEventListener('timeupdate', pump);
+ // 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,
+ }).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);
+ };
+
+ // 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');
+ // 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);
+ };
+ 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);
+ // 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;
+ }
+ // 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]);
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ ${platform.capabilities.lanCast && html`
+ <div class="cast-wrapper" style="position:relative">
+ <button class="video-close ${castActive ? 'cast-active' : ''}"
+ onClick=${async () => {
+ if (castActive) {
+ await platform.cast.chromecastDisconnect().catch(() => {});
+ await platform.cast.stop();
+ setCastActive(false); castActiveRef.current = false;
+ setCastUrl(null);
+ setCastDeviceName(null);
+ castDeviceRef.current = null;
+ } else if (castCodecRef.current && initSegmentRef.current) {
+ if (castPickerOpen) {
+ setCastPickerOpen(false);
+ } else {
+ setCastPickerOpen(true);
+ setCastScanning(true);
+ setCastDevices([]);
+ platform.cast.discover().then((devices) => {
+ setCastDevices(devices || []);
+ setCastScanning(false);
+ }).catch(() => setCastScanning(false));
+ }
+ }
+ }}
+ title="${castActive ? t('cast.stop') : t('cast.start')}">
+ <${Icon} name="cast" /></button>
+ ${castPickerOpen && html`
+ <div class="cast-picker">
+ ${castScanning && html`
+ <div class="cast-picker-item cast-picker-scanning">
+ <span class="spinner" style="width:14px;height:14px"></span>
+ ${t('cast.scanning')}
+ </div>
+ `}
+ ${castDevices.map(d => html`
+ <button class="cast-picker-item" onClick=${() => {
+ setCastPickerOpen(false);
+ 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}
+ </button>
+ `)}
+ ${!castScanning && castDevices.length === 0 && html`
+ <div class="cast-picker-item cast-picker-empty">
+ ${t('cast.no_devices')}
+ </div>
+ `}
+ <div class="cast-picker-sep"></div>
+ <button class="cast-picker-item" onClick=${async () => {
+ 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')}
+ </button>
+ </div>
+ `}
+ </div>
+ `}
+ ${castUrl && html`
+ <span class="cast-status-label">
+ ${castDeviceName
+ ? castDeviceName
+ : html`<input class="cast-url-input" readOnly value=${castUrl}
+ onClick=${(e) => {
+ e.target.select();
+ navigator.clipboard.writeText(castUrl).catch(() => {});
+ }}
+ title="${t('cast.copy_url')}" />`
+ }
+ </span>
+ `}
+ ${onDownload && html`
+ <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
+ onClick=${() => {
+ if (!dlBusy) {
+ setDlBusy(true);
+ onDownload();
+ setTimeout(() => setDlBusy(false), 1500);
+ }
+ }}
+ title="${t('group.download')}">
+ ${dlBusy
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
+ `}
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">
+ <${Icon} name="close" /></button>
+ </div>
+
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">
+ <span class="spinner"></span>${' '}${t('video.buffering')}
+ </div>
+ </div>
+ `}
+
+ ${(phase === 'streaming' || phase === 'loading') && html`
+ <div class="video-container">
+ <video ref=${videoRef} controls autoplay />
+ ${resumedFrom > 0 && html`
+ <div class="video-resumed">
+ ${t('video.resumed_at', { time: formatClock(resumedFrom) })}
+ <button class="linklike" onClick=${() => {
+ setResumedFrom(0);
+ writeResumePosition(entry.id, 0, durationRef.current);
+ if (requestSeekRef.current) requestSeekRef.current(0);
+ }}>${t('video.from_start')}</button>
+ </div>
+ `}
+ </div>
+ `}
+
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
+// ── 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 };