diff options
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.js | 173 |
1 files changed, 164 insertions, 9 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 index 0fed88c..6eaacc5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -63,6 +63,10 @@ const STREAM_WINDOW = 8; // kills an ffmpeg and spawns another. Only where the finger stops is worth a // restart. const SEEK_DEBOUNCE_MS = 350; +// How much read-ahead is worth carrying across a reconnection. Under this +// there is nothing much to lose, and restarting at the playhead — the path +// that has always run — is simpler and no worse. +const RECONNECT_KEEP_MIN_S = 10; // A position is remembered per file, in this browser. Below the first threshold // there is nothing to resume; above the second the film is finished and // offering to resume thirty seconds before the credits is a nuisance. @@ -224,6 +228,48 @@ function castSubtitleFor(sub, start) { }; } +/** + * What a reconnection should ask the node for, and on which terms. + * + * The old stream died with the connection, so something has to be asked for + * again; the question is *where*, and the answer used to be "the playhead", + * unconditionally. That goes through `reinitAt`, which empties the + * SourceBuffer — so a reconnection spent the entire read-ahead at the one + * moment it was worth most: the link is back, the film never stopped because + * the buffer was carrying it, and emptying the buffer is what finally stops + * it. Harmless while the read-ahead was ninety seconds and a dead spot had + * already drained it; with a budget of minutes it is the thing that stops the + * budget paying for anything. + * + * So where there is read-ahead worth keeping, carry on from the *end* of it + * and leave what is there alone. The two modes are not interchangeable: + * resuming keeps the buffer and must not move the playhead, seeking discards + * the buffer and must move it. + * + * Pure, and at module scope, so it can be tested by being run rather than by + * being read. + */ +function reconnectPlan(playhead, range, ended, castActive) { + // A stream that already ended has nothing left to fetch, and asking for a + // position at the end of the film would start an ffmpeg to serve nothing. + // Seeking is what this did before and is left exactly as it was. + // + // A cast is the same answer for a quite different reason: **the receiver + // does not share this buffer.** The relay is fed from segments as they + // arrive off the wire, so everything sitting in the SourceBuffer is material + // it never saw — carrying on from the end of it would restart the relay + // there and jump the receiver forward by the whole read-ahead, which with a + // budget of minutes is minutes of film silently skipped on somebody's + // television. What this protects is the *local* buffer; where playback is + // not local, restarting at the playhead is the correct answer and not merely + // the cautious one. + if (!ended && !castActive + && range && range[1] - playhead >= RECONNECT_KEEP_MIN_S) { + return { mode: 'resume', at: range[1] }; + } + return { mode: 'seek', at: playhead }; +} + function _mseSupported(codec) { if (!window.MediaSource) return false; const mime = `video/mp4; codecs="${codec}"`; @@ -343,6 +389,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const awaitingInitRef = useRef(false); const seekTargetRef = useRef(null); const seekTimerRef = useRef(null); + // True between asking the node to carry the stream on from the end of the + // buffer and that request's `stream_init` arriving. It is what tells the two + // landings apart: one keeps the buffer, the other empties it. + const resumingRef = useRef(false); // The seek is built inside the effect, where the transport and `cancelled` // live; the render needs to reach it for "start from the beginning". const requestSeekRef = useRef(null); @@ -663,6 +713,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // the old one the moment that much is buffered. awaitingInitRef.current = false; seekTargetRef.current = null; + // And the same trap once more: a resume asked for on the film being left + // would have the new film's first stream_init keep a buffer belonging to + // the old one, on a SourceBuffer built for neither. + resumingRef.current = false; clearTimeout(seekTimerRef.current); const transport = transportRef.current; if (!transport || !transport.connected) { @@ -716,6 +770,10 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { ranges: describeRanges(), }); awaitingInitRef.current = true; + // A seek supersedes a resume that had been asked for and not landed: + // this one empties the buffer, and taking the resume branch on its + // `stream_init` would leave the film we navigated away from in place. + resumingRef.current = false; seekTargetRef.current = target; outstandingRef.current = STREAM_WINDOW; setPhase('loading'); @@ -726,6 +784,33 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { requestSeekRef.current = requestSeek; /** + * Ask the node to carry the stream on from `target`, keeping the buffer. + * + * Not debounced, unlike `requestSeek`: the 350 ms there is for a finger on + * the scrubber, and a reconnection happens once. Nor does it show the + * loading screen — the film is still playing out of the buffer, and there + * is nothing for the viewer to wait for. + */ + const requestResume = (target) => { + clearTimeout(seekTimerRef.current); + const t = transportRef.current; + if (cancelled || !t || !t.connected) return; + t.sendStreamDiag({ + event: 'resume-request', target: +target.toFixed(1), + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ready: videoRef.current ? videoRef.current.readyState : null, + offset: sbRef.current ? sbRef.current.timestampOffset : null, + ranges: describeRanges(), + }); + resumingRef.current = true; + awaitingInitRef.current = true; + seekTargetRef.current = null; + outstandingRef.current = STREAM_WINDOW; + console.log('[resume] request', +target.toFixed(1)); + t.requestStream(entry.id, STREAM_WINDOW, target, audioTrackRef.current); + }; + + /** * Move the playhead onto a seek once the data for it has arrived. * * Setting `currentTime` into a region that is not buffered yet leaves the @@ -812,6 +897,65 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { pump(); }; + /** + * Carry the stream on at `start`, keeping everything already buffered. + * + * `reinitAt`'s counterpart, for the one case where the buffer is the thing + * worth saving rather than the thing in the way — see `reconnectPlan`. The + * node lands on a keyframe at or before what was asked for, so the new + * material overlaps the tail of what is there and the coded frame + * processing replaces it; the range stays continuous and the playhead + * never notices. + */ + const resumeAt = async (start) => { + const sb = sbRef.current; + if (!sb) return; + // If the node could not start at or before where the buffer ends, the + // new material would not join what is there and the film would stall on + // the gap for good. It should not happen — a keyframe at or before the + // request, and clamping only ever moves it earlier — but a permanent + // silent stall is not a risk worth taking on reasoning alone, and the + // old path is right there and proven. + const range = currentRange(); + if (!range || start > range[1] + 0.5) { + console.warn('[resume] node started at', start, 'past the buffer end', + range ? range[1] : null, '— falling back to reinit'); + return reinitAt(start); + } + // Same reason as reinitAt: ffmpeg was killed mid-fragment, so the parser + // holds half of one and the next stream's header on top of that is a + // decode error. `abort()` resets the parser and leaves what is buffered + // alone, which is exactly what this path needs. + try { sb.abort(); } catch { /* not in a state that needs it */ } + await settled(sb); + // ffmpeg restarts its timestamps at zero however far in it was asked to + // seek, so this is what puts the new fragments back on the film's + // timeline — beside the ones already there rather than on top of them. + try { sb.timestampOffset = start; } catch { /* older browsers */ } + const tr = transportRef.current; + if (tr) { + tr.sendStreamDiag({ + event: 'resume', target: start, offset: sb.timestampOffset, + t: videoRef.current ? +videoRef.current.currentTime.toFixed(1) : null, + ranges: describeRanges(), + }); + } + queueRef.current = []; + appendingRef.current = false; + endedRef.current = false; + awaitingInitRef.current = false; + // No seek target. The playhead is already where it belongs and has not + // stopped; setting one would have `landPlayhead` jump the film forward + // to the end of the buffer — over the very minutes this path exists to + // keep. `quotaHold` is left alone for the mirror-image reason: nothing + // was emptied, so a buffer that had no room still has none. + seekTargetRef.current = null; + console.log('[resume] resumeAt done, start:', start, + 'kept:', describeRanges()); + setPhase('streaming'); + pump(); + }; + const onSeeking = () => { if (landingPlayheadRef.current) { landingPlayheadRef.current = false; @@ -850,19 +994,19 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { // 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. + // reason to ask again. Where to ask from is `reconnectPlan`: the end of + // the buffer when there is one worth keeping, the playhead otherwise. offReconnect = transport.addReconnectListener(() => { if (cancelled) return; const v = videoRef.current; const seek = requestSeekRef.current; if (!v || !seek) return; - console.log('[MeshBay] transport reconnected — resuming stream at', - v.currentTime.toFixed(1)); - seek(v.currentTime); + const plan = reconnectPlan(v.currentTime, currentRange(), + endedRef.current, castActiveRef.current); + console.log('[MeshBay] transport reconnected —', plan.mode, 'at', + plan.at.toFixed(1), 'from', v.currentTime.toFixed(1)); + if (plan.mode === 'resume') requestResume(plan.at); + else seek(plan.at); }); transport.onStreamInit = (msg) => { @@ -915,12 +1059,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { if (sbRef.current && msRef.current && msRef.current.readyState === 'open') { console.log('[seek] stream_init landed, start:', msg.start, 'awaitingInit:', awaitingInitRef.current); + // Read and cleared together: whichever landing runs, the next + // `stream_init` is a fresh question and must not inherit this one's + // answer. + const resuming = resumingRef.current; + resumingRef.current = false; initSegmentRef.current = null; if (castActiveRef.current && platform.cast.available) { platform.cast.stop().catch(() => {}); castRestartPendingRef.current = true; } - reinitAt(msg.start || 0).catch(() => { + const land = resuming ? resumeAt : reinitAt; + land(msg.start || 0).catch(() => { setError(t('video.err_transport')); setPhase('error'); }); @@ -934,6 +1084,11 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { awaitingInitRef.current = false; endedRef.current = false; appendingRef.current = false; + // This branch builds a new SourceBuffer, so there is no buffer left to + // carry anything on from — whatever asked for a resume has had its + // answer, and a flag left standing here would send the *next* + // stream_init down a path whose precondition is gone. + resumingRef.current = false; queueRef.current = []; sbRef.current = null; initSegmentRef.current = null; |