Match media-streamer's live TV player, and go fullscreen on Play - #15
Merged
Conversation
…thed 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
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
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 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 connection, or the provider.
1. The restart budget almost never came back — this is what killed them
The engine rebuilds the player rather than giving up, because a live transport stream changes shape mid-broadcast and MSE throws when it does. The budget for that 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 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 (all of them), that is a hard ceiling of three recoveries per stream. 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 afterMAX_RESTARTS, because nothing ever fires it.MAX_RESTARTS3 → 5, backoff base 1.5s → 2s, to match.2. The desktop buffering profile was the opposite of the one that works
enableWorkertrueenableStashBufferfalsetruestashInitialSize128(bytes)384 * 1024liveBufferLatencyChasingtruefalselazyLoadfalsefalse(+ explicit durations)liveBufferLatencyChasingcloses drift by assigning tocurrentTime— mpegts.js's own source calls it "not recommended" on the first line of the file implementing it. That is a hard seek, MSE rebuilds the decode pipeline on each one, it is evaluated on every appended fragment, and it leaves onlyMinRemain— one second — of buffer behind. A jitter spike underruns it, the refill passes the ceiling, 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. There is one profile now, and it is media-streamer's.
3. Pressing Play fills the screen with the sound up
The video still starts muted, deliberately: autoplay policy refuses audible video without a gesture, 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, when there is demonstrably a stream to turn up. If the browser answers an unwanted unmute by pausing (which is how Chrome disagrees — it does not throw), 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, with thewebkitspellings behind it. Every part of it is allowed to fail silently: the stream plays in the page regardless.Tests
939 pass, 0 fail.
test/player-restart.test.jsneeded real rewriting rather than renumbering — it pinned the old rule directly (half a minute of real playback earns it back,a second of playback between two failures does not). The replacements state the new rule and keep the bound honest:a picture earns it back, immediatelya channel that recovers every time is never given up on— twelve hiccups, no error; this stream was dead on the fourth under the old rulea clock that moves is not the same as a picture— progress alone does not refill, so a channel that never resumes still runs outvendor-mpegts.jsrebuilt (the Dockerfile rebuilds it at image build;bun run devserves it as committed).Not done here
Consuming
@profullstack/player, which carries a copy of this same engine — its mpegts engine was ported from this file, bad desktop profile and thirty-second rule included. profullstack/player#4 fixes it there identically and bumps to 0.4.0. This repo can switch to the package once that is published; the behaviour is already the same either way.🤖 Generated with Claude Code
https://claude.ai/code/session_01S5KxNHPQPY9Do9eWfJnGP4