Skip to content

Adopt media-streamer's live TV settings, and refill the restart budget on a picture - #4

Merged
ralyodio merged 1 commit into
mainfrom
worktree-media-streamer-config
Aug 30, 2026
Merged

Adopt media-streamer's live TV settings, and refill the restart budget on a picture#4
ralyodio merged 1 commit into
mainfrom
worktree-media-streamer-config

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

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

configFor returned opposite settings per screen. 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 1.8.2, live-latency-chaser.ts, whose own first line calls it "not recommended":

if (buffered_end - current_time > this._config.liveBufferLatencyMaxLatency) {
    let target_time = buffered_end - this._config.liveBufferLatencyMinRemain;
    this._on_direct_seek(target_time);
}

That is a hard currentTime seek, MSE rebuilds the decode pipeline on each one, and notifyBufferedRangeUpdate calls it on every appended fragment. It leaves only MinRemain — 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:

was (desktop) now
enableWorker unset (false) true
enableStashBuffer false true
stashInitialSize 128 384 * 1024
liveBufferLatencyChasing true false
lazyLoad false false (+ 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 after MAX_RESTARTS, because nothing ever fires it.

MAX_RESTARTS 3 → 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 a playing leaves the stream alive through five more, where the old rule ended it.

configFor and MAX_RESTARTS are exported for those tests; not part of the public API.

Note for hosts with a strict CSP

enableWorker: true makes mpegts.js build its worker from a blob URL, so a host serving a strict CSP wants worker-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

…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>
@ralyodio
ralyodio merged commit 5d6899b into main Aug 30, 2026
3 checks passed
@ralyodio
ralyodio deleted the worktree-media-streamer-config 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