Channel preview can sit at "starting" when autoplay is blocked, with no hint to press play #554
Closed
opened 2026-07-21 23:22:21 +02:00 by timothy
·
4 comments
No Branch/Tag Specified
main
913-scripted-playout-401
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.16.0
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#554
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #60 (PR #551) — a known limitation shipped knowingly, not a regression.
Problem
HlsPlayersetsmanifestLoadPolicy.maxTimeToFirstByteMs: Infinitybecause ErsatzTV's manifest GET blocks until segments exist (inherited from the troubleshooting use case, where that blocking is intended and correct).Consequence for the preview panel: on a slow-starting channel,
MANIFEST_PARSEDcan arrive well past the browser's transient user-activation window (~5s in Chrome).video.play()is then rejected as blocked autoplay, noplayingevent fires, and the panel sits at statestartingover a black frame — with no indication that the operator simply needs to press play.This is honest (it does not claim to be playing — that lie was fixed in #60), and the native
<video controls>play button is right there. But for a tool whose job is answering "does this channel work?", "starting forever" is an unhelpful answer that looks identical to a genuine stall.Options
muted+autoPlayto the<video>element so autoplay is permitted (muted autoplay is allowed without user activation). Preview is a diagnostic, so muted-by-default is arguably correct anyway — though it makes audio-presence unverifiable until unmuted.play()promise and surface an explicit "autoplay blocked — press play" hint.starting, show what to check.The first two are complementary; the muted-autoplay route is the smaller change and removes the failure mode rather than annotating it.
Done-when
docs/spa-conventions.md§5b updated ifHlsPlayer's element attributes changeClaiming #554. Working on branch
554-preview-autoplay-blocked-hint, worktree/Users/timothy/orca/workspaces/ersatztv/wt-554.Closing record
Outcome: PR #910 (#910) —
HlsPlayernow renders<video>mutedunconditionally so autoplay is never blocked in the common case, plus an optionalonAutoplayBlocked()callback for the residual case (stricter browser policy/extension);ChannelPreviewPanelwires it to a "press play" hint shown only whilestateis stillstarting.Root cause:
manifestLoadPolicy.maxTimeToFirstByteMs: Infinity(needed because ErsatzTV's manifest GET blocks until segments exist, #60/PR#551) meansMANIFEST_PARSEDcan arrive past the browser's ~5s transient user-activation window on a slow-starting channel, sovideo.play()is rejected as blocked autoplay with noplayingevent and no hint to the operator.Decisions/conventions changed: none new — extends the existing
HlsPlayeroptional-callback convention (onError/onPlaying) documented indocs/spa-conventions.md§5b.Reusable knowledge: muted autoplay removes the failure mode outright rather than annotating it (per the issue's own analysis) — the residual
onAutoplayBlockedpath exists only for policies stricter than "blocks autoplay of unmuted media", which is the common browser default this fix targets.Verification:
npm run check:api && npm run lint && npm run typecheck && npm run buildclean;npm test121 files / 1332 tests passed (2026-09-05), including 4 newHlsPlayertests and 3 newChannelPreviewPaneltests pinning the muted attribute, the callback firing/not-firing, and the hint showing/clearing;check-doc-narrative.py --diff origin/main0 warnings; live-E2E viascripts/e2e-local.shon port 8440 — server reached readiness,/app/returned 200, built bundle confirmed to contain the "Autoplay was blocked" hint string, server stopped cleanly. No.cs/scripts//workflow files touched, so the .NET and Python gates don't apply.Deferred: none.
Docs updated:
docs/spa-conventions.md§5b (HlsPlayer'smutedattribute and theonAutoplayBlockedcontract).Done-when evidence:
HlsPlayer.tsxrenders<video muted>(starts anyway in the common case) and callsonAutoplayBlockedon rejection, wired to the "Autoplay was blocked — press play on the video to start the preview." hint inChannelPreviewPanel.tsx(residual case).web/src/media/HlsPlayer.test.tsx(4 new tests: muted attribute, callback fires on rejection, callback silent on success, safe when omitted) andweb/src/screens/channels/ChannelPreviewPanel.test.tsx(3 new tests: hint shown on blocked autoplay, hint clears onplaying, hint absent whenplay()succeeds) —npm testrun confirming 1332/1332 passed.Closing record (supersedes the record posted 2026-09-05 01:39)
This replaces the earlier closing record on this issue. That one described only the branch's first commit and asserted three things the two later commits reverse:
<video>muted unconditionally, a clear inonPlaying, and "1 review round … findings folded into the diff" (at the time, none of round one's findings were in the diff). Itsnpm testand live-E2E numbers were measurements of that first commit. Everything below is measured on the branch as it stands (3 commits).Outcome: PR #910 —
HlsPlayertakes an optionalmutedprop, defaulting tofalse, plus an optionalonAutoplayBlocked()callback that fires only on an autoplay-policy rejection (aDOMExceptionnamedNotAllowedError). The channel-preview panel opts intomuted— muted media autoplays without user activation, which removes the failure mode rather than annotating it — and wiresonAutoplayBlockedto a "press play" hint for the residual case (a stricter browser policy or an extension). The playback-troubleshooting screen deliberately stays unmuted: verifying the audio side of an FFmpeg profile is part of its job, and the legacy Blazor player was never muted.Root cause:
manifestLoadPolicy.maxTimeToFirstByteMs: Infinity(needed because ErsatzTV's manifest GET blocks until segments exist, #60/PR#551) meansMANIFEST_PARSEDcan arrive past the browser's ~5s transient user-activation window on a slow-starting channel, sovideo.play()is rejected as blocked autoplay with noplayingevent and no hint to the operator.Decisions/conventions changed: none new — extends the existing
HlsPlayeroptional-callback convention (onError/onPlaying) documented indocs/spa-conventions.md§5b.Reusable knowledge:
play()says why in the DOMException name.AbortError(a pending play() interrupted by teardown — exactly what this panel's own Retry produces) is not blocked autoplay, and reporting it as such puts a cause in front of the operator that did not happen. Because the element is muted, a genuineNotAllowedErroris the rare case, so the unfiltered firings would have been mostly the mislabelled ones.onPlayingclear gone, the hint's only guard isstate === 'starting', so every path back tostartingmust clear the flag itself — an invariant that has to be pinned per path, not asserted in prose.Verification (branch head, 2026-09-05):
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build— clean.npm test— 121 test files / 1342 tests passed.setAutoplayBlocked(false)fromonRetry→Tests 1 failed | 25 passed; deleting it from the render-phase channel reset →1 failed | 25 passed; replacingautoplayBlocked && state === 'starting' &&withautoplayBlocked &&→1 failed | 25 passed. Unmutated:26 passed.python3 scripts/check-doc-narrative.py --diff origin/main— 0 advisory warnings.ETV_UI_PORT=8440 scripts/e2e-local.sh <fresh CONFIG_DIR>— readiness reached,/app/200, and the served bundle carries the end state:HlsPlayercompiled asmuted:s=!1renderingmuted:s, the preview panel the only call site passingmuted:!0, theNotAllowedErrorfilter and the "Autoplay was blocked" string both present. Server stopped by the PID the launcher printed..cs,scripts/or workflow files touched, so the .NET and Python gates do not apply.Deferred: none.
Docs updated:
docs/spa-conventions.md§5b — themutedprop and why it is per-consumer, theNotAllowedError-only contract foronAutoplayBlocked, and the single-guard invariant.Done-when evidence:
muted, so the common case starts on its own; when a stricter policy still refuses,onAutoplayBlockedraises "Autoplay was blocked — press play on the video to start the preview." while the panel stays honestly atstarting.web/src/media/HlsPlayer.test.tsx(themutedprop default and opt-in, the callback firing only onNotAllowedError, silence onAbortErrorand on success, safe when omitted, and the previously untested native-HLS branch) andweb/src/screens/channels/ChannelPreviewPanel.test.tsx(hint shown when blocked, hidden onceplaying, absent on a non-autoplay rejection, absent whenplay()succeeds, cleared by Retry, cleared by a channel switch), plusPlaybackTroubleshootingScreen.test.tsxpinning that screen unmuted. Suite: 1342 passed.docs/spa-conventions.md§5b updated ifHlsPlayer's element attributes change: the "Autoplay (#554)" subsection in the PR diff.fix(554): the hint's cause must be measured, and muting is the consumer's call; round 2 →test(554): pin both clears the hint's single guard depends on). Boxes are left unticked pending the verdict on the final head.Still required before merge: PR #910's head is still the first commit — the two fix commits are on the branch but not yet pushed. Push the branch, then take CI and the
review-verdict/h10status on the pushed head; this record describes that head, not the one the PR currently points at.🤖 Generated with Claude Code
https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
This supersedes the earlier
## Closing recordcomments on this issue (comment ids 26468, 26478)— both were written against an earlier state of the branch and their counts are stale now that
4e042fb7d,b6b4fb661,f119bfcb2, and350509f8bhave landed on top of02e1c583e.Closing record
Outcome: PR #910 (branch
554-preview-autoplay-blocked-hint, head350509f8b, 5 commits)mutes the channel-preview player (opt-in
mutedprop onHlsPlayer, defaultfalse) andfilters
onAutoplayBlockedto fire only on aDOMExceptionnamedNotAllowedError, so thepanel shows an explicit "autoplay blocked" hint only when that is actually why playback hasn't
started. Not yet merged.
Root cause:
HlsPlayer's<video>played unmuted, and ErsatzTV's manifest GET blocks untilsegments exist (
manifestLoadPolicy.maxTimeToFirstByteMs: Infinity, inherited from thetroubleshooting-screen use case) — so on a slow-starting channel
MANIFEST_PARSEDcan arrive pastthe browser's transient user-activation window,
video.play()rejects as blocked autoplay, andthe panel sat at
startingover a black frame with no indication the operator just needed topress play.
Decisions/conventions changed:
docs/spa-conventions.md§5b updated to document the opt-inmutedprop, theNotAllowedError-only filter ononAutoplayBlocked, and the hint's singlerender guard (
autoplayBlocked && state === 'starting') together with the three paths back tostarting(Retry, channel change, the mute opt-in) and why only two of the three need to clearthe flag themselves.
Reusable knowledge: the hint's visibility has exactly one guard
(
state === 'starting') — every path that returns the panel tostartingis independentlyresponsible for clearing
autoplayBlocked, since nothing else does. The mute opt-in button isthe one path that doesn't clear it and is provably safe not to: the button only renders while
startedisfalse, andstartedonly goesfalsein the same render-phase reset that clearsthe flag two lines later, so no player exists to have set the flag while the button is visible.
Verification (measured on
350509f8b, 2026-09-05):npm run check:api,npm run lint,npm run typecheck,npm run build— all clean.npm test: 121 test files, 1343 tests, all passed.HlsPlayer.test.tsx— 15 passed;ChannelPreviewPanel.test.tsx— 27 passed; those two plus
PlaybackTroubleshootingScreen.test.tsxtogether — 55 passed.python3 scripts/check-doc-narrative.py --diff origin/main— 0 warnings.dotnet build ErsatzTV.slnsucceeded;ETV_UI_PORT=8440 scripts/e2e-local.sh <fresh mktemp dir>reported "Server ready";curl /app/returned HTTP 200; the launched PIDwas killed after the check.
b6b4fb661): deleting the Retry-path clear reds exactly the Retrytest; deleting the render-phase-reset clear reds exactly the channel-change test; widening the
render guard to drop
state === 'starting'reds a third, distinct test.1 nit, answered by
4e042fb7d; round two — 1 should-fix (the opt-in third path), answered byf119bfcb2+350509f8b; round three — measurement staleness only (this record and the PRbody), answered by this comment and the PR body rewrite. Cross-family review: not required
(routine risk class under
process.independent-review-rubric).Deferred: none.
Docs updated:
docs/spa-conventions.md§5b (in4e042fb7d,f119bfcb2).Done-when evidence
evidenced by
HlsPlayer.tsx's opt-inmutedprop +NotAllowedError-filteredonAutoplayBlocked, andChannelPreviewPanel.tsx's hint rendered onautoplayBlocked && state === 'starting'(commit4e042fb7d); confirmed live via the E2E runabove (
/app/200 on a built preview panel).HlsPlayer.test.tsx(15 passed) andChannelPreviewPanel.test.tsx(27 passed, including the two path-specific tests added inb6b4fb661and the opt-in-reachability test added inf119bfcb2), plus the mutation-testingresults in
b6b4fb661's commit message.docs/spa-conventions.md§5b updated ifHlsPlayer's element attributes change —evidenced by
git show --stat 4e042fb7d f119bfcb2 350509f8b -- docs/spa-conventions.md.answered by commits
4e042fb7d,b6b4fb661,f119bfcb2,350509f8b; noReview-verdict:comment /
review-verdict/h10status has been posted yet for this head, which is why thisbox is left unticked and the issue stays open.
None of the four boxes above are being ticked by this comment — that is left for the merge-gate
flow (
Review-verdict:+ CI) to derive, perprocess.done-when-boxes-can-be-circularand theroot
CLAUDE.md's merge-consent convention.