diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 339 |
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> `} |