summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-17 02:16:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-17 02:16:17 +0200
commitf5c4c058aa7e91fbdbbdd8cf34042535a05df433 (patch)
tree7100fbb9a51e26827169d3e2a018bd0b26322b24 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58 (diff)
downloadmeshbay-f5c4c058aa7e91fbdbbdd8cf34042535a05df433.tar.gz
feat(hub): move about in a film, and pick it up where it was left
Clicking the scrubber now restarts the stream there. A seek inside what is already buffered never reaches the node. Dragging is debounced at 350 ms, because `seeking` fires continuously and each one acted on kills an ffmpeg and spawns another. `SourceBuffer.mode` changes from 'sequence' to 'segments', and each stream sets `timestampOffset` to where the node says it started — otherwise a stream beginning at forty minutes is buffered at zero and the scrubber lies about everything. Proved in Chrome before any of it was written: abort, remove, offset 600, append, and playback resumes at 600 with `readyState` 4. A seek makes the buffer discontinuous, and everything reading `buffered` then has to mean a particular range. "The last one" stops being "the one playing": the read-ahead would report a full buffer across a gap while the player starves, and the eviction would take out everything between the first range and the playhead, including what is showing. Both work from the range around the playhead now. `reinitAt` also clears the buffer outright rather than keeping two ranges, and calls `abort()` first — ffmpeg was killed mid-fragment, so the parser holds half of one and the next header would land on top of it. The position is kept in localStorage, per file: no protocol, no storage anyone else has to keep, and nothing new learns what you watch. Not below thirty seconds, not past 97% of the film, and a pill offers the beginning back. Two things this had to get right and did not at first, both found by running it rather than reading it. Between asking for a seek and its `stream_init`, everything on the channel belongs to the film being left — same file, so `file_id` cannot separate them, but ordering can. And a segment that arrived is no longer in flight whatever is then done with it: discarding one before decrementing the window leaked a slot every time, and `reinitAt` is asynchronous, so a whole window could arrive while it waited. The player then believed eight segments were in flight, granted nothing further, and the node waited for credit that could not come — a race, which is why the same seek worked twice and hung on the third.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js339
1 files changed, 317 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a9559c6..198bc78 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1004,6 +1004,15 @@ const CREDIT_KEEPALIVE_MS = 20000;
// 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;
const PIPELINE_WINDOW = 8;
@@ -2673,6 +2682,43 @@ function _mseSupported(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 browser 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.
+ */
+function readResumePosition(fileId) {
+ try {
+ const raw = localStorage.getItem(`mb:pos:${fileId}`);
+ 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 {
+ if (!Number.isFinite(at) || at < RESUME_MIN_S
+ || (duration && at > duration * RESUME_MAX_FRACTION)) {
+ localStorage.removeItem(`mb:pos:${fileId}`);
+ return;
+ }
+ localStorage.setItem(`mb:pos:${fileId}`, String(Math.floor(at)));
+ } catch { /* nothing to be done, and nothing worth failing over */ }
+}
+
function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
@@ -2692,6 +2738,43 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// 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 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.
@@ -2705,7 +2788,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
const v = videoRef.current;
if (!sb || !v || sb.updating || !sb.buffered.length) return false;
const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
- const start = sb.buffered.start(0);
+ // 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);
@@ -2713,19 +2801,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
} catch {
return false;
}
- }, []);
+ }, [currentRange]);
/** Seconds of film held past the playhead. */
const bufferedAhead = useCallback(() => {
- const sb = sbRef.current;
const v = videoRef.current;
- if (!sb || !v || !sb.buffered.length) return 0;
- try {
- return sb.buffered.end(sb.buffered.length - 1) - v.currentTime;
- } catch {
- return 0;
- }
- }, []);
+ const range = currentRange();
+ if (!v || !range) return 0;
+ return Math.max(0, range[1] - v.currentTime);
+ }, [currentRange]);
const flushQueue = useCallback(() => {
const sb = sbRef.current;
@@ -2819,6 +2903,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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'));
@@ -2829,14 +2923,152 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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');
+ 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) {
+ 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;
+ setPhase('streaming');
+ pump();
+ };
+
const onSeeking = () => {
const v = videoRef.current;
- if (!v || !v.buffered.length) return;
+ if (!v || cancelled) return;
const target = v.currentTime;
- const end = v.buffered.end(v.buffered.length - 1);
- const start = v.buffered.start(0);
- if (target > end) v.currentTime = Math.max(end - 0.5, start);
- else if (target < start) v.currentTime = start;
+ // Inside what is buffered, the browser handles it and the node need not
+ // hear about it at all.
+ 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 () => {
@@ -2861,6 +3093,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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') {
+ reinitAt(msg.start || 0).catch(() => {
+ setError(t('video.err_transport'));
+ setPhase('error');
+ });
+ return;
+ }
+
const ms = new MediaSource();
msRef.current = ms;
const url = URL.createObjectURL(ms);
@@ -2873,7 +3118,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
}
const sb = ms.addSourceBuffer(mime);
sbRef.current = sb;
- sb.mode = 'sequence';
+ // '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()`
@@ -2881,6 +3137,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// evictions. What may be in flight is decided from the buffer, in
// pump(), and nowhere else.
appendingRef.current = false;
+ landPlayhead();
pump();
});
setPhase('streaming');
@@ -2902,16 +3159,27 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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);
+ // 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;
- // One of the in-flight segments landed, whatever becomes of it: the
- // window has room again. Counted before the decrypt so that a segment
- // we fail to read still frees its slot rather than shrinking the
- // window by one for the rest of the film.
- outstandingRef.current = Math.max(0, outstandingRef.current - 1);
try {
const plaintext = await window.MeshBayCrypto.decryptChunkBin(
gekRef.current, entry.id, msg.segment_index, msg.nonce, msg.ct);
@@ -2928,6 +3196,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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;
flushQueue();
};
@@ -2937,7 +3208,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
// 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;
- transport.requestStream(entry.id, 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
@@ -2973,6 +3246,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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++) {
@@ -3004,6 +3282,13 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
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);
+ }
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
if (videoRef.current) {
@@ -3080,6 +3365,16 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) {
${(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>
`}