Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions apps/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1163,6 +1163,40 @@ async function checkOwnChannels(section, onLive, signal) {
* because the line permits one connection and two <video> elements pulling at
* once is exactly what the ceiling exists to prevent.
*/
/**
* Fill the screen, because that is what pressing Play on a match means.
*
* Requested on the stage rather than the <video> so the element keeps its own
* controls and anything else the stage holds stays with it; a bare
* `video.requestFullscreen()` hands the browser its native chrome instead and
* loses the surrounding markup.
*
* Every part of this is allowed to fail without consequence. Fullscreen needs
* transient activation, and by the time we get here the player bundle may have
* been fetched over a slow link and spent it -- so the request is rejected and
* the reader simply watches in the page, which is what used to happen anyway.
* A rejection is a Promise rejection in modern browsers and a synchronous throw
* in older ones, hence both guards.
*
* `webkitEnterFullscreen` is the iOS spelling and only exists on the video
* element. iPhone Safari has no Media Source Extensions so it never reaches
* this player at all, but an iPad that does should not be the one device where
* the button quietly does nothing.
*/
function goFullscreen(stage, video) {
try {
const request = stage.requestFullscreen ?? stage.webkitRequestFullscreen;
if (request) {
request.call(stage)?.catch?.(() => {});
return;
}
video.webkitEnterFullscreen?.();
} catch {
// Denied, unsupported, or no longer in a gesture. The stream plays in the
// page regardless, so there is nothing to tell the reader about.
}
}

function initInlinePlayer(root = document) {
const sections = [...root.querySelectorAll('[data-player-src]')].filter(
(el) => !el.dataset.player,
Expand Down Expand Up @@ -1386,15 +1420,49 @@ function initPlayerSection(section) {
video.controls = true;
video.autoplay = true;
video.playsInline = true;
// Muted, and not as a preference. Every browser refuses to autoplay audible
// video without a gesture, and the refusal arrives as a rejected play() that
// leaves a black rectangle -- which reads as a broken stream rather than a
// blocked one. The reader unmutes with the control.

/*
* Starts muted, ends audible, and the order is the whole trick.
*
* Every browser refuses to autoplay audible video without a user gesture,
* and the refusal arrives as a rejected play() that leaves a black
* rectangle -- which reads as a broken stream rather than a blocked one.
* Pressing Play IS a gesture, but this handler has already awaited the
* player bundle by now, and on a cold cache that download can outlast the
* activation the click granted. Starting muted is the one way to be sure
* a picture appears.
*
* So the sound is turned up on the first `playing` instead, when there is
* demonstrably a stream to turn up, and `volume` is set before `muted` is
* lifted so nobody gets a frame of full-volume broadcast before the
* element knows what level to use.
*/
video.muted = true;
video.volume = 1;
video.addEventListener(
'playing',
() => {
video.volume = 1;
video.muted = false;
/*
* If the browser disagrees it says so by pausing rather than by
* throwing, so the paused element is the only signal there is. Going
* back to muted keeps a picture on the screen, which is strictly
* better than being correct about the audio and showing nothing.
*/
if (video.paused) {
video.muted = true;
video.play().catch(() => {});
}
},
{ once: true },
);

stage.append(video);
button.closest('li')?.after(stage);

stop = player.attach(video, button.dataset.play, fail, notice);
goFullscreen(stage, video);
button.dataset.playing = '1';
button.textContent = 'Stop';
});
Expand Down
6 changes: 3 additions & 3 deletions apps/web/public/vendor-mpegts.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/web/public/vendor-webauthn.js

Large diffs are not rendered by default.

60 changes: 32 additions & 28 deletions apps/web/src/client/player-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,18 @@ function supported() {
/**
* How many times a stream is rebuilt before the reader is told it failed.
*
* Three, with the wait doubling, so the whole sequence is over in about eleven
* seconds. It has to be bounded and it has to be short: every restart is a fresh
* connection to the provider, and the line permits one.
* Five, doubling from two seconds, which is what media-streamer's live TV
* player uses on these same provider lines. It still has to be bounded --
* every restart is a fresh connection and the line permits one -- but the
* number matters far less than when the budget refills; see `onPlaying`.
*/
const MAX_RESTARTS = 3;
const RESTART_BASE_MS = 1500;
const MAX_RESTARTS = 5;
const RESTART_BASE_MS = 2000;

/** How a stall is noticed: the clock is read this often, this many times. */
const STALL_CHECK_MS = 5000;
const STALL_LIMIT = 3;

/**
* Playback this long since the last restart means the trouble is over, and the
* budget goes back to full. Without it a channel that breaks once an hour spends
* its three restarts over an afternoon and then fails for good.
*/
const RECOVERED_AFTER_MS = 30_000;

/**
* Attach a stream to a <video> and start it.
*
Expand All @@ -78,7 +72,6 @@ function attach(video, url, onError, onNotice = () => {}) {
let restarts = 0;
let restartTimer = null;
let stallTimer = null;
let startedAt = 0;
let lastTime = -1;
let stalls = 0;

Expand Down Expand Up @@ -171,20 +164,14 @@ function attach(video, url, onError, onNotice = () => {}) {
}
return;
}
// It is moving. If it has been moving for a while, the earlier trouble is
// over and this counts as a healthy stream again.
// It is moving, so nothing is wrong right now. The restart budget is not
// touched here -- that is `onPlaying`'s job, for the reason given there.
lastTime = video.currentTime;
stalls = 0;
if (restarts > 0 && Date.now() - startedAt > RECOVERED_AFTER_MS) {
restarts = 0;
onNotice(null);
}
}, STALL_CHECK_MS);
};

function start() {
startedAt = Date.now();

/*
* The buffering profile is picked per screen, not once for the site.
*
Expand Down Expand Up @@ -292,15 +279,32 @@ function attach(video, url, onError, onNotice = () => {}) {
}

/*
* A picture is the only proof worth acting on.
* A picture is the only proof worth acting on -- and it refills the budget.
*
* "Reconnecting…" comes off the page the moment the stream is back, and the
* event that means it is back is this one, not the absence of another error,
* which is also what a permanently frozen player looks like.
*
* "Reconnecting…" has to come off the page the moment the stream is back, and
* the event that means it is back is this one -- not the absence of another
* error, which is also what a permanently frozen player looks like. The restart
* budget is NOT cleared here: a second of playback between two failures is not
* a recovery, and thirty are (see the stall watcher).
* The budget used to be deliberately NOT cleared here, on the reasoning that
* a second of playback between two failures is not a recovery and thirty are.
* That reasoning is what killed streams. Thirty unbroken seconds, measured
* from the last restart and checked only from inside the stall watcher, meant
* three hiccups inside half a minute spent the entire allowance -- and the
* channel was given up on for good, even though all three restarts had worked
* and the picture was back within seconds each time. On a provider line that
* drops a connection now and then, which is all of them, that is a hard
* ceiling of three recoveries per stream, and reaching it takes about a
* minute. "It dies after a minute or two" was this line.
*
* media-streamer's live TV player resets on every `playing`, and it is right:
* the budget should be spent by failures to RECOVER, not by failures. A
* channel that never plays still gives up after MAX_RESTARTS, because nothing
* ever fires this.
*/
const onPlaying = () => onNotice(null);
const onPlaying = () => {
restarts = 0;
onNotice(null);
};
video.addEventListener('playing', onPlaying);

start();
Expand Down
128 changes: 68 additions & 60 deletions apps/web/src/client/tv.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
* Asked for one reason: a Fire TV stick and a laptop want opposite things from
* the same live stream, and the player has been tuned for the laptop.
*
* On a desktop the connection is fast and steady, so reading ahead is wasted
* latency and chasing the live edge keeps a match from drifting minutes behind.
* On a stick behind household wifi, decoding a transport stream on a CPU an order
* of magnitude slower, both of those choices are actively harmful: with no
* read-ahead buffer every jitter spike is a stall, and latency chasing answers a
* stall by seeking forward, which is a stall the reader can see. The stream stops
* and starts until it is abandoned, which is what "it does not play on Silk"
* looks like from the sofa.
* A stick behind household wifi, decoding a transport stream on a CPU an order of
* magnitude slower, needs a read-ahead buffer to survive a jitter spike, and needs
* whatever closes the gap to the live edge to not do it by seeking -- a seek during
* a live stream is a rebuffer the viewer sees. Without both it stops and starts
* until it is abandoned, which is what "it does not play on Silk" looks like from
* the sofa.
*
* A desktop wants a smaller version of the same thing, not the opposite of it. It
* was given the opposite -- no buffer at all, and drift closed by seeking -- and
* that was its own kind of stutter: see the desktop branch below, where the whole
* sawtooth is written out. Both screens now read ahead and neither seeks; they
* differ in how much they hold and how close they sit to the edge, which is the
* only thing the device should have been deciding.
*
* So the buffering profile is picked per screen. There is no feature to detect
* here -- the difference is the device, not the API surface -- so this is a user
Expand Down Expand Up @@ -69,69 +74,72 @@ export function isTvBrowser(userAgent) {
/**
* mpegts.js settings for one screen or the other.
*
* Everything here except the two buffering decisions is the same on both, and the
* comments on those live with the values rather than in the caller, because the
* reason a value differs is the only interesting thing about it.
* The comments live with the values rather than in the caller, because the reason
* a value differs between the two is the only interesting thing about it.
*
* @param {boolean} isTv
*/
export function playerConfig(isTv) {
const shared = {
// lazyLoad pauses the download once enough is buffered, which for a live
// stream means dropping the connection mid-match and reconnecting.
lazyLoad: false,
export function playerConfig(_isTv) {
return {
/*
* Drop what has already been watched.
* Demux on a worker thread.
*
* A football match is three hours. Without this the source buffer keeps
* every second of it in memory and the tab is killed somewhere in the
* second half -- on a Fire TV, considerably sooner than that.
* A transport stream at broadcast bitrate is real work, and on the main
* thread it competes with rendering the page it is playing on -- which
* shows up as dropped frames rather than as an error. mpegts.js builds the
* worker from a blob URL; we serve no CSP, so there is nothing to allow.
*/
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
};
enableWorker: true,

if (isTv) {
return {
...shared,
/*
* Read ahead, and do not chase.
*
* The stash is a read-ahead buffer. On a desktop it is pure added latency;
* on a stick going through the proxy it is the only thing standing between
* a wifi hiccup and a stall, so it is on and generously sized. 384KB is
* mpegts.js's own default and roughly a second of a broadcast bitrate.
*
* Latency chasing is off for the same reason it is on below. Its answer to
* drift is to seek the media element forward; on a link that drifts because
* it is struggling, that is a seek every few seconds, and a seek during a
* live stream is a rebuffer. Being ten seconds behind is not a complaint
* anybody makes. Stopping every ten seconds is.
*/
enableStashBuffer: true,
stashInitialSize: 384 * 1024,
liveBufferLatencyChasing: false,
liveBufferLatencyMaxLatency: 12,
liveBufferLatencyMinRemain: 2,
};
}
/*
* Read ahead, on every screen.
*
* The stash sits in front of the demuxer. A transport stream arrives in
* bursts -- the provider's pacing, not the viewer's bandwidth -- so with
* nothing buffered each gap between bursts is an underrun however fast the
* connection is. 384KB is mpegts.js's own default, roughly a second.
*/
enableStashBuffer: true,
stashInitialSize: 384 * 1024,

return {
...shared,
/*
* Live settings for a screen with a real connection, and each one is
* load-bearing.
* Never close drift by seeking.
*
* This is the line that made a desktop stutter and then killed the stream.
* mpegts.js implements chasing by assigning to `currentTime`; that is a
* hard seek, MSE rebuilds the decode pipeline on every one, it is evaluated
* on every appended fragment, and it leaves only `MinRemain` seconds of
* buffer behind -- one second, as this used to be set. One second is a
* single jitter spike from an underrun, the underrun refills past the
* ceiling, and it seeks again. Each hitch was also a chance to spend a
* restart, which is how a stutter became a stream that ended.
*
* The stash exists to smooth a seekable file; here it is pure added latency,
* so it is off and the initial chunk is small. Latency chasing skips the
* player forward when it drifts behind -- without it a stall during a goal is
* never recovered from, the stream just plays permanently late.
* The two bounds are inert while chasing is off. They are kept as the bound
* anyone re-enabling it would want, rather than left to a library default.
*/
enableStashBuffer: false,
stashInitialSize: 128,
liveBufferLatencyChasing: true,
liveBufferLatencyMaxLatency: 6,
liveBufferLatencyChasing: false,
liveBufferLatencyMaxLatency: 5,
liveBufferLatencyMinRemain: 1,

/*
* Drop what has already been watched. Without this the source buffer keeps
* every second of a three-hour match in memory and the tab is killed -- on
* a Fire TV, considerably sooner than that.
*/
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,

/*
* lazyLoad pauses the download once enough is buffered, which on a live
* stream means dropping the provider connection mid-match and reconnecting
* -- on a line that permits one connection, the worst available way to
* idle. Off, with both durations stated so there is no default to inherit.
*/
lazyLoad: false,
lazyLoadMaxDuration: 60,
lazyLoadRecoverDuration: 30,

seekType: 'range',
};
}
Loading