Adopt media-streamer's live TV settings, and refill the restart budget on a picture - #4
Merged
Merged
Conversation
…icture Streams played through this engine died after a minute or two on provider lines that media-streamer plays all evening. Two causes, and the second is the one that actually ended them. **The desktop profile fought the stream.** `configFor` returned opposite settings per screen, and the desktop half ran with no stash at all (`stashInitialSize: 128` -- bytes) and `liveBufferLatencyChasing: true`, on the reasoning that a laptop has bandwidth to spare and should sit close to the live edge. Chasing is not polite about it: mpegts.js implements it by assigning to `currentTime`, MSE tears down and rebuilds the decode pipeline on every such seek, it is evaluated on every appended fragment, and it leaves only `MinRemain` seconds behind -- one second, as configured. One second is a single jitter spike from an underrun; the underrun refills past the ceiling; it seeks again. That sawtooth is the stutter, and each hitch is a chance to spend a restart. There is now one profile for every screen, and it is media-streamer's live TV player's: worker on, 384KB stash, chasing off, lazyLoad off. A television was already getting most of that, which is why only desktops complained. **The restart budget almost never came back.** It refilled only after thirty unbroken seconds, measured from the last restart and checked solely from inside the stall watcher. So three hiccups within half a minute spent the whole allowance and the stream was given up on permanently -- even though every one of those restarts had worked and the picture was back within seconds. 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 hitting it takes about a minute. It now refills on `playing`, which is what media-streamer does and is the right event: it fires when the media element genuinely resumed, so the budget is spent by failures to *recover* rather than by failures. A channel that never plays still gives up after MAX_RESTARTS, because nothing ever fires it. MAX_RESTARTS also goes 3 -> 5 and the backoff base 1.5s -> 2s, to match. `configFor` and `MAX_RESTARTS` are exported for the tests, which state the rules rather than restating the object. 91 tests pass, typecheck clean. Minor bump to 0.4.0: same API, materially different playback behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4
ralyodio
added a commit
to profullstack/tipoffwatch.com
that referenced
this pull request
Aug 30, 2026
Streams died after a minute or two here while media-streamer played the same provider lines all evening. Comparing the two players says why, and it was not the demuxer. **The restart budget almost never came back, and that is what killed them.** It refilled only after thirty unbroken seconds, measured from the last restart and checked solely from inside the stall watcher. Three hiccups within half a minute therefore spent the whole allowance and the channel was given up on permanently -- 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 now refills on `playing`, which is what media-streamer's live TV player does and is the right event: it fires when the media element genuinely resumed, so the budget is spent by failures to RECOVER rather than by failures. A channel that never plays still gives up after MAX_RESTARTS, because nothing ever fires it. MAX_RESTARTS goes 3 -> 5 and the backoff base 1.5s -> 2s to match. **The desktop buffering profile was the opposite of the one that works.** One profile now, media-streamer's, on every screen: worker on, 384KB stash, chasing off, lazyLoad off. The desktop half used to run with no stash (`stashInitialSize: 128` -- bytes) and `liveBufferLatencyChasing: true`, which closes drift by assigning to `currentTime`. That is a hard seek, MSE rebuilds the decode pipeline on each one, it is evaluated on every appended fragment, and it leaves one second of buffer behind -- so a jitter spike underruns, the refill passes the ceiling, and it seeks again. Every hitch in that sawtooth was also a chance to spend a restart, which is how a stutter became a stream that ended. A television already had all three settings right, which is why only desktops complained. This supersedes the liveSync approach in the first commit on this branch: liveSync is arguably gentler, but "the same as media-streamer" is the thing worth having, and chasing is off either way. **Pressing Play now fills the screen with the sound up.** The video still starts muted -- autoplay policy refuses audible video without a gesture and the refusal is a rejected play() that leaves a black rectangle, and this handler has already awaited the player bundle, which on a cold cache can outlast the activation the click granted. So the sound goes up on the first `playing` instead, and if the browser answers that by pausing (which is how Chrome disagrees) it goes back to muted and keeps the picture. Fullscreen is requested on the stage rather than the <video> so the element keeps its own controls, and every part of it is allowed to fail silently. Not done here: consuming @profullstack/player, which carries a copy of this same engine. profullstack/player#4 fixes it there identically; this repo can switch to it once that is published. 939 tests pass. `vendor-mpegts.js` rebuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4
ralyodio
added a commit
to profullstack/tipoffwatch.com
that referenced
this pull request
Aug 30, 2026
* Stop the desktop player throwing away the buffer that would have smoothed it The web player stutters on a desktop, and it is the player doing it rather than the connection or the provider. `liveBufferLatencyChasing` was on, with `MinRemain` at one second. The library's own source calls that option "not recommended" in the first line of the file implementing it, and the implementation says why: it closes drift by assigning to `currentTime`. That is a hard seek, MSE tears down and rebuilds the decode pipeline on one, and it is evaluated on every appended fragment. So: buffer passes six seconds ahead, seek down to one second of headroom, one jitter spike underruns it, the refill passes six seconds, seek again. A visible hitch on every cycle. The stash made it worse. It was off with `stashInitialSize` at 128 bytes, on the reasoning that a laptop has bandwidth to spare. It does, but a transport stream still arrives through the proxy in bursts -- the provider's pacing, not ours -- and with nothing in front of the demuxer each gap between bursts had nothing to draw on. So the desktop now reads ahead 128KB (a third of the television's) and closes drift with `liveSync` instead: over six seconds behind it plays at 1.1x until it is back within three, then returns to 1x. No seek, nothing discarded, nothing rebuffered, and 1.1x is below where the pitch shift is noticeable -- 1.2, the library default, is not. Chasing is now off on both profiles. The television gets no liveSync: it is behind because it is struggling, and asking a CPU that is barely keeping up to decode faster is how a slow stream becomes a stopped one. `vendor-mpegts.js` is rebuilt in the same commit. The Dockerfile builds it at image build so production would have picked the change up regardless, but `bun run dev` serves public/ as committed, so leaving it stale is a fix nobody working locally can see. Not addressed here: @profullstack/player 0.3.1 carries a byte-identical copy of the old desktop profile, so every other consumer still has this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4 * Match media-streamer's live TV player, and go fullscreen on Play Streams died after a minute or two here while media-streamer played the same provider lines all evening. Comparing the two players says why, and it was not the demuxer. **The restart budget almost never came back, and that is what killed them.** It refilled only after thirty unbroken seconds, measured from the last restart and checked solely from inside the stall watcher. Three hiccups within half a minute therefore spent the whole allowance and the channel was given up on permanently -- 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 now refills on `playing`, which is what media-streamer's live TV player does and is the right event: it fires when the media element genuinely resumed, so the budget is spent by failures to RECOVER rather than by failures. A channel that never plays still gives up after MAX_RESTARTS, because nothing ever fires it. MAX_RESTARTS goes 3 -> 5 and the backoff base 1.5s -> 2s to match. **The desktop buffering profile was the opposite of the one that works.** One profile now, media-streamer's, on every screen: worker on, 384KB stash, chasing off, lazyLoad off. The desktop half used to run with no stash (`stashInitialSize: 128` -- bytes) and `liveBufferLatencyChasing: true`, which closes drift by assigning to `currentTime`. That is a hard seek, MSE rebuilds the decode pipeline on each one, it is evaluated on every appended fragment, and it leaves one second of buffer behind -- so a jitter spike underruns, the refill passes the ceiling, and it seeks again. Every hitch in that sawtooth was also a chance to spend a restart, which is how a stutter became a stream that ended. A television already had all three settings right, which is why only desktops complained. This supersedes the liveSync approach in the first commit on this branch: liveSync is arguably gentler, but "the same as media-streamer" is the thing worth having, and chasing is off either way. **Pressing Play now fills the screen with the sound up.** The video still starts muted -- autoplay policy refuses audible video without a gesture and the refusal is a rejected play() that leaves a black rectangle, and this handler has already awaited the player bundle, which on a cold cache can outlast the activation the click granted. So the sound goes up on the first `playing` instead, and if the browser answers that by pausing (which is how Chrome disagrees) it goes back to muted and keeps the picture. Fullscreen is requested on the stage rather than the <video> so the element keeps its own controls, and every part of it is allowed to fail silently. Not done here: consuming @profullstack/player, which carries a copy of this same engine. profullstack/player#4 fixes it there identically; this repo can switch to it once that is published. 939 tests pass. `vendor-mpegts.js` rebuilt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Streams played through the mpegts engine died after a minute or two on the same provider lines media-streamer plays all evening. Two causes; the second is what actually ended them.
1. The desktop profile fought the stream
configForreturned opposite settings per screen. The desktop half ran with no stash at all (stashInitialSize: 128— bytes) andliveBufferLatencyChasing: true, on the reasoning that a laptop has bandwidth to spare and should sit close to the live edge.Chasing is not polite about it — mpegts.js 1.8.2,
live-latency-chaser.ts, whose own first line calls it "not recommended":That is a hard
currentTimeseek, MSE rebuilds the decode pipeline on each one, andnotifyBufferedRangeUpdatecalls it on every appended fragment. It leaves onlyMinRemain— one second — of buffer behind. One second is a single jitter spike from an underrun; the underrun refills past the ceiling; it seeks again. Every cycle is a visible hitch, and every hitch is a chance to spend a restart.Now one profile for every screen, taken from media-streamer's live TV player:
enableWorkertrueenableStashBufferfalsetruestashInitialSize128384 * 1024liveBufferLatencyChasingtruefalselazyLoadfalsefalse(+ explicit durations)A television was already getting most of this, which is why only desktops complained.
2. The restart budget almost never came back — this is the one that killed streams
It refilled only after thirty unbroken seconds, measured from the last restart and checked solely from inside the stall watcher. So three hiccups within half a minute spent the whole allowance and the stream was given up on permanently — even though every one of those restarts had worked and the picture was back within seconds.
On a provider line that drops a connection now and then (all of them), that is a hard ceiling of three recoveries per stream. Hitting it takes about a minute.
It now refills on
playing, which is what media-streamer does and is the right event: it fires when the media element genuinely resumed, so the budget is spent by failures to recover rather than by failures. A channel that never plays still gives up afterMAX_RESTARTS, because nothing ever fires it.MAX_RESTARTS3 → 5 and the backoff base 1.5s → 2s, to match.Tests
91 pass, typecheck clean, prettier clean. Two new files:
test/mpegts-config.test.ts— pins the profile as rules ("is the same on every screen", "never closes drift by seeking") rather than restating the object.test/mpegts-restart.test.ts— drives the engine against a mocked mpegts.js and proves the refill: four failures then aplayingleaves the stream alive through five more, where the old rule ended it.configForandMAX_RESTARTSare exported for those tests; not part of the public API.Note for hosts with a strict CSP
enableWorker: truemakes mpegts.js build its worker from a blob URL, so a host serving a strict CSP wantsworker-src blob:. Without it the library falls back and the only thing lost is the contention it was avoiding.Minor bump to 0.4.0: same API, materially different playback behaviour.
🤖 Generated with Claude Code
https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4