fix(554): mute the channel preview, and name blocked autoplay only when that is the cause #910

Merged
timothy merged 5 commits from 554-preview-autoplay-blocked-hint into main 2026-09-05 04:23:11 +02:00
5 Commits
Author SHA1 Message Date
timothyandClaude Fable 5.1 350509f8bb docs(554): the opt-in's unreachability is about started, not about mount order
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 22s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
PR Gates / Docs update reminder (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 35s
review-verdict/h10 Review-verdict: MERGEABLE @ 350509f (base: main)
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m49s
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 33s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m20s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m1s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
"its button renders only before the player has ever mounted" is loose: a channel
switch to a forced channel re-renders the button after a player has mounted for the
previous channel. The load-bearing fact is the one the code comment states — the
button renders only while `started` is false, and the only thing that un-starts the
panel is the channel reset that clears the flag in the same batch.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:45:42 +02:00
timothyandClaude Fable 5.1 f119bfcb29 docs(554): the opt-in is a third path back to starting, and the invariant must say so
The hint's single-guard invariant was stated as "every path back to `starting` clears
the flag itself", enumerating Retry and a channel change. `onOptIn` is a third such
path and clears nothing, so the sentence was false as written — in the code comment,
in the Retry test's comment, and in docs/spa-conventions.md §5b.

Adding a clear to `onOptIn` would be dead code no test could distinguish, which is the
exact shape this branch removed from `onPlaying`. The omission is correct for a reason
none of the three places stated: the opt-in button renders only while `started` is
false, `started` only goes false in the render-phase reset that clears the flag two
lines later, and no player exists to set the flag while `started` is false. State that
exception, and pin the reachability premise it rests on with a test that fails if the
opt-in button outlives the mounted player.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:42:39 +02:00
timothyandClaude Fable 5.1 b6b4fb6619 test(554): pin both clears the hint's single guard depends on
Dropping the onPlaying clear made the `state === 'starting'` render guard the
hint's only guard, which moves the burden onto the two paths back to
`starting`: each has to clear `autoplayBlocked` itself. Both were assertable
but unasserted — either `setAutoplayBlocked(false)` could be deleted with the
whole panel suite green, so the invariant the code comment and
docs/spa-conventions.md §5b both state was unpinned in both of its named paths.

Add one test per path (Retry; a channel switch), each asserting the hint is
gone while the panel is back at `starting` — so the render guard cannot be
what hid it. Each also asserts the player really re-mounted (loadSource count
/ last URL, plus a non-null <video>), so the hint cannot be absent merely
because the `resolvedSrc` block is unrendered.

Measured on this tree, each mutation caught by exactly one test:
deleting the onRetry clear reds only 'clicking Retry clears the
autoplay-blocked hint' (Tests 1 failed | 25 passed); deleting the
render-phase reset clear reds only 'clears the autoplay-blocked hint when
switching to a different channel' (1 failed | 25 passed); replacing
`autoplayBlocked && state === 'starting' &&` with `autoplayBlocked &&` reds
one test too. Unmutated: 26 passed.

Refs #554

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 02:08:36 +02:00
timothyandClaude Fable 5.1 4e042fb7de fix(554): the hint's cause must be measured, and muting is the consumer's call
Round one measured three defects in the first commit.

onAutoplayBlocked fired on ANY rejected video.play(), so the panel could
say "autoplay was blocked" when it was not. A play() interrupted by
teardown rejects with AbortError — which is exactly what the panel's own
Retry produces while the MANIFEST_PARSED play() is still pending — and
because the element was muted, a genuine NotAllowedError is the rare
case, so the realistic firings were the mislabelled ones. Report only a
DOMException named NotAllowedError, on both the MSE and native paths.

`muted` was applied to the shared player unconditionally, which silently
muted the playback-troubleshooting screen — the tool whose job includes
verifying the audio side of an FFmpeg profile, and which the legacy
Blazor player never muted. Make it an opt-in `muted` prop defaulting to
false; the channel preview passes it, troubleshooting does not, and a
test on each side pins its own value.

The two clauses hiding the hint once playback starts masked each other:
removing either alone left the panel suite green. Every path back to
'starting' (Retry, a channel change) already clears the flag itself, so
the clear in onPlaying could never be the load-bearing guard — drop it
and let the `state === 'starting'` render guard be the single pinned one.

Also cover the native-HLS (Safari) branch, which no test had ever
executed: its play() kick, its playing/error wiring, and both autoplay
rejection names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:46:37 +02:00
timothyandClaude Fable 5.1 02e1c583e9 fix(554): mute the preview player so autoplay is never blocked
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 9s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 24s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 17m7s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m47s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
review-verdict/h10 Awaiting review verdict for 02e1c58
Review verdict / Set review-verdict status (pull_request_target) Successful in 33s
HlsPlayer's manifest GET can block until segments exist (unbounded
maxTimeToFirstByteMs), so MANIFEST_PARSED can arrive past the
browser's transient user-activation window and video.play() gets
rejected as blocked autoplay — the channel preview panel then sat at
"starting" over a black frame with no hint the operator just needed
to press play.

Render the <video> element muted (browsers permit autoplay of muted
media without user activation) so the common case starts on its own,
and add an optional onAutoplayBlocked callback for the residual case
(stricter policy/extension) that the channel preview panel wires to a
"press play" hint shown only while still starting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 01:12:39 +02:00