aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js153
1 files changed, 145 insertions, 8 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 66099de..0fed88c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -10,13 +10,44 @@ 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.
+// How far past the playhead we are willing to pull, at the least. 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;
+// …but a bound written in seconds has to be sized for the highest-bitrate file
+// in the library, and every ordinary file then holds a fraction of what the
+// same browser would have accepted. Measured on harness/mse_harness.mjs
+// against a 100 MB ceiling: a 1.0 Mbit/s film reached its ninety seconds on
+// 20.7 MB, and a 2.3 Mbit/s one on 45.6 MB — four fifths and a half of that
+// ceiling left unused, which is the whole of the margin a phone on a mobile
+// network has to ride out a dead spot on.
+//
+// The quantity the browser limits is BYTES, so that is what is budgeted here
+// and converted to seconds with the file's own average bitrate. It can only
+// ever RAISE the bound: `BUFFER_AHEAD_S` stays as the floor, so a file whose
+// bitrate spends the budget in under ninety seconds keeps exactly the bound it
+// had before and nothing that worked pulls less than it used to.
+//
+// **The budget is discovered, not declared.** The real ceiling is per-engine
+// and per-device and cannot be known up front, and there are only two ways to
+// find it: overshoot it, or walk up to it. Overshooting costs a refused append
+// every time it is attempted — a flat 48 MB budget against a 40 MB ceiling was
+// measured here at 1386 refusals on a film that had none before it, which is a
+// regression however well the player recovers from each one. So the budget
+// starts at zero, which `aheadLimit()` reads as the fixed bound that shipped
+// before, and thereafter is never more than one step past the most read-ahead
+// this SourceBuffer has actually been seen to accept.
+const BUFFER_AHEAD_STEP_BYTES = 4 * 1024 * 1024;
+// Where the walk stops even if nothing ever refuses. Memory a phone has to
+// find, and past this the gain is a read-ahead nobody needs.
+const BUFFER_AHEAD_MAX_BYTES = 48 * 1024 * 1024;
+// A sanity bound on what the budget may convert to. A file whose size or
+// duration is wrong must not be able to ask for the whole film.
+const BUFFER_AHEAD_MAX_S = 600;
// 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;
@@ -285,6 +316,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const appendingRef = useRef(false);
const endedRef = useRef(false);
const durationRef = useRef(0);
+ // Bytes of film per second of film, the byte budget the read-ahead is
+ // currently allowed, and the ceiling that budget may grow to. Zero bitrate
+ // means "not known yet" — before `stream_init` there is no duration — and a
+ // zero budget means "nothing learnt yet"; `aheadLimit()` reads either as the
+ // fixed `BUFFER_AHEAD_S` bound, which is what the player always had.
+ const bitrateRef = useRef(0);
+ const aheadBytesRef = useRef(0);
+ const aheadCapRef = useRef(BUFFER_AHEAD_MAX_BYTES);
+ // True while an append is known to be refusable and nothing has freed room
+ // since. See `flushQueue`.
+ const quotaHoldRef = useRef(false);
// 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);
@@ -414,6 +456,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const start = range ? range[0] : sb.buffered.start(0);
if (keepFrom - start < 10) return false;
sb.remove(start, keepFrom);
+ // Room is about to appear, which is the one event that makes an append
+ // refused for quota worth trying again — see `flushQueue`.
+ quotaHoldRef.current = false;
return true;
} catch {
return false;
@@ -428,6 +473,21 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
return Math.max(0, range[1] - v.currentTime);
}, [currentRange]);
+ /**
+ * Seconds past the playhead this film may be pulled to, right now.
+ *
+ * The byte budget is what the browser actually limits; the seconds are what
+ * the player can compare `bufferedAhead()` against. `BUFFER_AHEAD_S` is the
+ * floor and never the ceiling — see the constants — so this only ever gives
+ * a file more than the fixed bound did, never less.
+ */
+ const aheadLimit = useCallback(() => {
+ const rate = bitrateRef.current;
+ if (!(rate > 0)) return BUFFER_AHEAD_S;
+ return Math.max(BUFFER_AHEAD_S,
+ Math.min(BUFFER_AHEAD_MAX_S, aheadBytesRef.current / rate));
+ }, []);
+
const flushQueue = useCallback(() => {
const sb = sbRef.current;
if (!sb || appendingRef.current || sb.updating) return;
@@ -437,6 +497,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
return;
}
+ // An append refused for quota that could evict nothing will be refused
+ // again until something frees room, and pump() calls this on a clock: the
+ // retry cost a refused append several times a second for as long as it
+ // took the film to play far enough for eviction to have anything to drop —
+ // minutes of it, measured on the harness. `evictBehind` is the only thing
+ // that ever gives a SourceBuffer room back, so its success is what lifts
+ // this rather than a timer or the playhead, which moves long before the
+ // answer changes. It cannot deadlock: a buffer is full of film the
+ // playhead has not reached, so playback always advances into it and
+ // eviction always comes.
+ if (quotaHoldRef.current) return;
appendingRef.current = true;
const chunk = queueRef.current[0];
try {
@@ -446,10 +517,21 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
appendingRef.current = false;
if (e.name === 'QuotaExceededError') {
quotaRef.current += 1;
+ // This refusal is the browser stating where its own ceiling is — the
+ // one number the budget could not be given up front. Take the ceiling
+ // down below where it broke and leave it there, rather than backing
+ // off and letting the walk in `pump()` climb to the same place again:
+ // that sawtooth is a refused append every time round. `aheadLimit()`
+ // floors at `BUFFER_AHEAD_S`, so the worst this converges to is the
+ // fixed bound that shipped before.
+ aheadCapRef.current = Math.max(
+ 0, aheadBytesRef.current - BUFFER_AHEAD_STEP_BYTES);
+ aheadBytesRef.current = aheadCapRef.current;
// 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()) {
+ quotaHoldRef.current = true;
console.warn('[MSE] buffer full and nothing to evict yet');
}
return;
@@ -494,7 +576,32 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
if (endedRef.current && queueRef.current.length === 0) return;
if (!transport || !transport.connected) return;
- if (bufferedAhead() > BUFFER_AHEAD_S
+ // Walk the budget up to one step past the most read-ahead this
+ // SourceBuffer has been seen to hold without refusing an append — never
+ // further, so an engine with a small ceiling is overshot by one step
+ // instead of by the whole budget, and only once, because the refusal takes
+ // `aheadCapRef` down below where it broke. Read-ahead rather than total
+ // occupancy, because read-ahead is what this bound governs and what
+ // eviction leaves alone.
+ //
+ // Only while the film is actually playing. What the budget buys is a film
+ // that survives a dead spot, and a film nobody has started cannot be
+ // interrupted: growing it there filled a small browser's whole buffer for
+ // a film left on the loading screen — 40 MB and nine minutes of read-ahead
+ // where the fixed bound took 8 MB — on a mobile connection paying for it.
+ // Autoplay is blocked on a phone more often than not, so that is the
+ // ordinary case and not a corner. Pausing does not *shrink* the budget:
+ // the lead already paid for is the viewer's.
+ const vp = videoRef.current;
+ const rate = (vp && !vp.paused) ? bitrateRef.current : 0;
+ if (rate > 0) {
+ aheadBytesRef.current = Math.min(
+ aheadCapRef.current,
+ Math.max(aheadBytesRef.current,
+ bufferedAhead() * rate + BUFFER_AHEAD_STEP_BYTES));
+ }
+
+ if (bufferedAhead() > aheadLimit()
|| 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
@@ -517,7 +624,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
lastPokeRef.current = Date.now();
transport.grantStreamCredit(room);
}
- }, [evictBehind, flushQueue, bufferedAhead]);
+ }, [evictBehind, flushQueue, bufferedAhead, aheadLimit]);
useEffect(() => {
let cancelled = false;
@@ -538,6 +645,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
lastPokeRef.current = Date.now();
quotaRef.current = 0;
stalledRef.current = false;
+ // The same rule: a new stream starts from a known state. Both are per-film
+ // — the bitrate obviously, and the budget because what a browser refused
+ // for a nine-megabit film says nothing about the next one. A budget left
+ // at the floor by the film before would silently cap every film after it
+ // for the life of the page.
+ bitrateRef.current = 0;
+ aheadBytesRef.current = 0;
+ aheadCapRef.current = BUFFER_AHEAD_MAX_BYTES;
+ quotaHoldRef.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
@@ -683,6 +799,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
appendingRef.current = false;
endedRef.current = false;
quotaRef.current = 0;
+ // The buffer this was holding against has just been emptied, so there is
+ // room again — and a seek *backwards* would otherwise put the playhead
+ // below the held position and stop every append for the rest of the
+ // film. The budget is deliberately kept: what this browser refused is
+ // still true of it after a seek.
+ quotaHoldRef.current = false;
awaitingInitRef.current = false;
seekTargetRef.current = start;
console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length);
@@ -772,6 +894,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
durationRef.current = msg.duration || 0;
+ // What the read-ahead budget is spent at. The file's own average, not
+ // the stream's: on the copy path they are the same thing, and where
+ // the node re-encodes it sends fewer bytes per second than the source
+ // holds — so this errs towards buffering less than the budget allows,
+ // never more. A film whose size or duration is missing keeps the fixed
+ // bound, which is what `aheadLimit()` does with a zero here.
+ bitrateRef.current = (entry.size > 0 && durationRef.current > 0)
+ ? entry.size / durationRef.current
+ : 0;
// Recorded before either branch below: both restart the relay, and the
// subtitle sent with it has to be shifted by *this* start, not the one
// the previous stream had.
@@ -1071,6 +1202,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
t.sendStreamDiag({
t: +v.currentTime.toFixed(1),
ahead: +bufferedAhead().toFixed(1),
+ // What the read-ahead is allowed to be for this film, and the budget
+ // it came from. `ahead` on its own cannot say whether a short buffer
+ // is the gate holding or the network not keeping up, and the node's
+ // log is the only window there is into a phone.
+ limit: +aheadLimit().toFixed(0),
+ budgetMB: +(aheadBytesRef.current / 1048576).toFixed(1),
ranges: ranges.trim(),
ready: v.readyState, // 0 = nothing, 4 = enough to play through
paused: v.paused,