Skip to content

Match media-streamer's live TV player, and go fullscreen on Play - #15

Merged
ralyodio merged 2 commits into
mainfrom
worktree-player-buffering
Aug 30, 2026
Merged

Match media-streamer's live TV player, and go fullscreen on Play#15
ralyodio merged 2 commits into
mainfrom
worktree-player-buffering

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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 after MAX_RESTARTS, because nothing ever fires it.

MAX_RESTARTS 3 → 5, backoff base 1.5s → 2s, to match.

2. The desktop buffering profile was the opposite of the one that works

was (desktop) now (every screen)
enableWorker unset (false) true
enableStashBuffer false true
stashInitialSize 128 (bytes) 384 * 1024
liveBufferLatencyChasing true false
lazyLoad false false (+ explicit durations)

liveBufferLatencyChasing closes drift by assigning to currentTime — 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 only MinRemain — 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.

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.

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 the webkit spellings 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.js needed 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, immediately
  • a channel that recovers every time is never given up on — twelve hiccups, no error; this stream was dead on the fourth under the old rule
  • a clock that moves is not the same as a picture — progress alone does not refill, so a channel that never resumes still runs out

vendor-mpegts.js rebuilt (the Dockerfile rebuilds it at image build; bun run dev serves 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

ralyodio and others added 2 commits August 30, 2026 02:23
…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
@ralyodio ralyodio changed the title Stop the desktop player throwing away the buffer that would have smoothed it Match media-streamer's live TV player, and go fullscreen on Play Aug 30, 2026
@ralyodio
ralyodio merged commit 1446218 into main Aug 30, 2026
2 checks passed
@ralyodio
ralyodio deleted the worktree-player-buffering branch August 30, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant