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
This commit is contained in:
2026-09-05 01:46:37 +02:00
co-authored by Claude Fable 5.1
parent 02e1c583e9
commit 4e042fb7de
6 changed files with 252 additions and 46 deletions
+24 -14
View File
@@ -643,20 +643,30 @@ safe) and mirrors `onError`'s requirements exactly: pass a **stable** `onPlaying
since it too sits in the attach effect's dependency array and an unstable identity would restart the
stream every render.
**Autoplay (#554).** The `<video>` element is rendered `muted` unconditionally: browsers permit
autoplay of muted media without user activation, so the `video.play()` kick above succeeds even when
`MANIFEST_PARSED` arrives past the browser's transient user-activation window (~5s in Chrome) — the
failure mode a slow-starting channel (unbounded manifest `maxTimeToFirstByteMs`, see above) used to
hit, leaving the channel-preview panel sitting at "starting" over a black frame with no indication the
operator just needs to press play. `controls` stays on so the operator can unmute to verify audio.
For the residual case where autoplay is still rejected (a stricter browser policy or extension),
`HlsPlayer` also takes an optional `onAutoplayBlocked()`, called when the `video.play()` promise
rejects on either path; it is purely additive and does **not** need a stable identity requirement
beyond what the attach effect's dependency array already requires (same rule as `onError`/`onPlaying`
— pass a `useCallback`). The channel-preview panel (`ChannelPreviewPanel.tsx`) wires it to a
"press play" hint shown only while `state` is still `starting`, and clears it the moment `onPlaying`
actually fires — the panel never claims the fault fixed itself; the hint mirrors an honest, still-not-
playing state.
**Autoplay (#554).** `HlsPlayer` takes an optional `muted` prop, **defaulting to `false`**. Muted
media is the one thing browsers autoplay without user activation, so passing it makes the
`video.play()` kick above succeed even when `MANIFEST_PARSED` arrives past the browser's transient
user-activation window (~5s in Chrome) — the failure mode a slow-starting channel (unbounded manifest
`maxTimeToFirstByteMs`, see above) hits, leaving a player sitting at "starting" over a black frame
with no indication the operator just needs to press play. It is a **per-consumer** choice rather than
a player-wide default because muting costs audio-by-default: the channel-preview panel
(`ChannelPreviewPanel.tsx`) opts in — it answers "does this channel work right now", and starting
beats being audible — while the playback-troubleshooting screen
(`PlaybackTroubleshootingScreen.tsx`) must stay unmuted, since verifying the audio side of an FFmpeg
profile is part of its job. `controls` is always on either way, so a muted player can be unmuted by
hand.
For the residual case where autoplay is rejected anyway (a stricter browser policy or an extension),
`HlsPlayer` also takes an optional `onAutoplayBlocked()`. It fires **only** on an autoplay-policy
rejection — a `DOMException` named `NotAllowedError`; a `play()` promise rejected under any other
name (notably `AbortError`, which is what a pending `play()` interrupted by a teardown produces, and
a consumer's own Retry does exactly that) is not reported, or the caller would put a cause in front
of the operator that did not happen. Like `onError`/`onPlaying` it sits in the attach effect's
dependency array, so pass a `useCallback`; it is purely additive and omitting it is safe. The
channel-preview panel wires it to a "press play" hint whose visibility is the render guard
`state === 'starting'` **alone** — every path back to `starting` (Retry, a channel change) clears the
flag itself, so a second clear in `onPlaying` would be a guard no test could distinguish. The panel
never claims the fault fixed itself; the hint mirrors an honest, still-not-playing state.
**Resolving a `/iptv/*` src under JWT auth (#552).** Before feeding an `/iptv/*` manifest URL to
`HlsPlayer`, pass it through `withIptvToken(url)` (`web/src/media/iptvToken.ts`): under a JWT-enabled
+118 -7
View File
@@ -1,5 +1,5 @@
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HlsPlayer } from './HlsPlayer';
const hlsMock = {
@@ -107,20 +107,27 @@ describe('HlsPlayer', () => {
});
// #554: the panel used to sit at "starting" over a black frame with no hint when the browser
// rejected the manifest-parsed play() kick as blocked autoplay. Rendering the video muted lets
// browsers autoplay it without user activation in the common case.
it('renders the video element muted', () => {
// rejected the manifest-parsed play() kick as blocked autoplay. Muted media autoplays without user
// activation, but muting costs audio-by-default, so it is the caller's decision, not the player's.
it('renders the video element unmuted by default', () => {
const { container } = render(<HlsPlayer src="/iptv/channel/12.1.m3u8" />);
const video = container.querySelector('video');
expect(video?.muted).toBe(false);
});
it('renders the video element muted when the caller opts in', () => {
const { container } = render(<HlsPlayer muted src="/iptv/channel/12.1.m3u8" />);
const video = container.querySelector('video');
expect(video?.muted).toBe(true);
});
it('reports onAutoplayBlocked when the manifest-parsed play() kick is rejected', async () => {
it('reports onAutoplayBlocked when the manifest-parsed play() kick is rejected by autoplay policy', async () => {
const onAutoplayBlocked = vi.fn();
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked'));
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
render(<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />);
@@ -138,6 +145,30 @@ describe('HlsPlayer', () => {
}
});
// A play() interrupted by teardown rejects with AbortError — which is what a caller's own Retry
// produces while the manifest-parsed play() is still pending. Reporting that as blocked autoplay
// would put a false cause in front of the operator.
it('does not report onAutoplayBlocked when play() is rejected for another reason', async () => {
const onAutoplayBlocked = vi.fn();
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('The play() request was interrupted', 'AbortError'));
try {
render(<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />);
const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1];
manifestHandler();
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).not.toHaveBeenCalled();
} finally {
playSpy.mockRestore();
}
});
it('does not report onAutoplayBlocked when the play() kick succeeds', async () => {
const onAutoplayBlocked = vi.fn();
render(<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />);
@@ -154,7 +185,7 @@ describe('HlsPlayer', () => {
it('is safe with no onAutoplayBlocked provided', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked'));
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
render(<HlsPlayer src="/iptv/channel/12.1.m3u8" />);
@@ -168,4 +199,84 @@ describe('HlsPlayer', () => {
playSpy.mockRestore();
}
});
// The native-HLS (Safari) branch carries its own copies of the play() kick and of the playing/error
// wiring; without MediaSource in jsdom nothing else in this file executes it, so it is exercised
// here explicitly rather than assumed to mirror the MSE path.
describe('native HLS (Safari) path', () => {
let canPlayTypeSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
hlsMock.isSupported = false;
canPlayTypeSpy = vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('maybe');
});
afterEach(() => {
canPlayTypeSpy.mockRestore();
});
it('reports onAutoplayBlocked when the canplay play() kick is rejected by autoplay policy', async () => {
const onAutoplayBlocked = vi.fn();
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
const { container } = render(
<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />
);
// Confirm this really is the native branch: hls.js was never constructed.
expect(hlsMock.loadSource).not.toHaveBeenCalled();
const video = container.querySelector('video');
video?.dispatchEvent(new Event('canplay'));
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).toHaveBeenCalledTimes(1);
} finally {
playSpy.mockRestore();
}
});
it('does not report onAutoplayBlocked when the canplay play() kick is rejected for another reason', async () => {
const onAutoplayBlocked = vi.fn();
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('The play() request was interrupted', 'AbortError'));
try {
const { container } = render(
<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />
);
const video = container.querySelector('video');
video?.dispatchEvent(new Event('canplay'));
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).not.toHaveBeenCalled();
} finally {
playSpy.mockRestore();
}
});
it('reports onPlaying and onError from the element\'s own events', () => {
const onError = vi.fn();
const onPlaying = vi.fn();
const { container } = render(
<HlsPlayer onError={onError} onPlaying={onPlaying} src="/iptv/channel/12.1.m3u8" />
);
const video = container.querySelector('video');
video?.dispatchEvent(new Event('playing'));
expect(onPlaying).toHaveBeenCalledTimes(1);
video?.dispatchEvent(new Event('error'));
expect(onError).toHaveBeenCalledWith('The browser could not play this stream');
});
});
});
+37 -19
View File
@@ -20,13 +20,29 @@ export interface HlsPlayerProps {
// before any media has decoded (an HttpLiveStreamingDirect manifest always parses even over a
// black video). Optional and purely additive — omitting it is safe.
onPlaying?: () => void;
// Called when the browser rejects the player's own `video.play()` kick (autoplay blocked). The
// element is rendered `muted`, which lets Chromium/Firefox autoplay without user activation in
// the common case (#554), but a stricter policy (e.g. an extension, or a browser configured to
// block all autoplay) can still reject it — this is the fallback so a caller can tell the operator
// to press play rather than let the panel sit at "starting" with no explanation. Optional and
// purely additive.
// Called when the browser rejects the player's own `video.play()` kick under its autoplay policy
// — and only then: the promise is rejected with a `NotAllowedError` DOMException in that case, and
// with a different name (e.g. `AbortError`) when a pending play() is merely interrupted by
// teardown, which must not be reported as blocked autoplay or the caller names a cause that did
// not happen. Passing `muted` lets Chromium/Firefox autoplay without user activation in the common
// case (#554), but a stricter policy (an extension, or a browser configured to block all autoplay)
// can still reject it — this is the fallback so a caller can tell the operator to press play rather
// than let the panel sit at "starting" with no explanation. Optional and purely additive.
onAutoplayBlocked?: () => void;
// Render the element muted. Off by default: the playback-troubleshooting screen exists partly to
// verify the audio side of an FFmpeg profile, so it must stay audible. The channel preview opts in
// (#554) because browsers permit autoplay of muted media without user activation, which is what
// keeps a slow-starting preview from sitting at "starting" over a black frame. `controls` is always
// on, so a muted player can still be unmuted by hand.
muted?: boolean;
}
// A rejected video.play() says why in the DOMException's name. Only NotAllowedError is the autoplay
// policy refusing to start unprompted; AbortError (a pending play() interrupted by a teardown — what
// the preview panel's own Retry does while MANIFEST_PARSED's play() is still pending) and
// NotSupportedError are different faults and must not be reported as blocked autoplay.
function isAutoplayPolicyRejection(error: unknown): boolean {
return error instanceof DOMException && error.name === 'NotAllowedError';
}
// A small HLS video player. Uses hls.js when Media Source Extensions are available (config mirrors
@@ -41,7 +57,8 @@ export function HlsPlayer({
style,
onError,
onPlaying,
onAutoplayBlocked
onAutoplayBlocked,
muted = false
}: HlsPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
@@ -78,9 +95,11 @@ export function HlsPlayer({
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
void video.play().catch(() => {
// Autoplay may be blocked despite `muted` (#554); the user can press play manually.
onAutoplayBlocked?.();
void video.play().catch((error: unknown) => {
// Autoplay can be blocked even when muted (#554); the user can press play manually.
if (isAutoplayPolicyRejection(error)) {
onAutoplayBlocked?.();
}
});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
@@ -102,9 +121,11 @@ export function HlsPlayer({
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
const onCanPlay = () => {
void video.play().catch(() => {
// Autoplay may be blocked despite `muted` (#554); the user can press play manually.
onAutoplayBlocked?.();
void video.play().catch((error: unknown) => {
// Autoplay can be blocked even when muted (#554); the user can press play manually.
if (isAutoplayPolicyRejection(error)) {
onAutoplayBlocked?.();
}
});
};
video.addEventListener('canplay', onCanPlay);
@@ -137,10 +158,7 @@ export function HlsPlayer({
[]
);
// muted: browsers permit autoplay of muted media without user activation (#554) — without it,
// MANIFEST_PARSED can arrive past the ~5s transient-activation window on a slow-starting channel
// and video.play() is rejected as blocked autoplay, leaving the panel at "starting" over a black
// frame with no indication the operator just needs to press play. `controls` still lets the
// operator unmute to verify audio.
return <video className={className} controls muted ref={videoRef} style={style} />;
// `muted` is the caller's call (see HlsPlayerProps): muted media autoplays without user activation
// (#554), but that trades away audio-by-default, which the troubleshooting screen needs.
return <video className={className} controls muted={muted} ref={videoRef} style={style} />;
}
@@ -277,6 +277,31 @@ describe('PlaybackTroubleshootingScreen', () => {
}
});
// This screen exists partly to verify the audio side of an FFmpeg profile, so its player must stay
// audible. HlsPlayer's `muted` is opt-in for that reason (#554) — the channel preview opts in, this
// one must not, and a default flipped to muted here would be silent in both senses.
it('leaves the troubleshooting player unmuted', async () => {
setLocation('/app/troubleshooting/playback?mediaItem=5');
const statusRef = { current: IDLE };
installFetch(statusRef);
vi.useFakeTimers();
try {
render(<PlaybackTroubleshootingScreen />);
await vi.waitFor(() => expect(screen.getByText(/Movie Settings/i)).toBeTruthy());
statusRef.current = { state: 'running', exitCode: null, speed: null, logs: null };
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
await vi.waitFor(() => expect(hlsMock.loadSource).toHaveBeenCalledWith(LIVE_MANIFEST));
const video = document.querySelector('video');
expect(video).not.toBeNull();
expect(video?.muted).toBe(false);
} finally {
vi.useRealTimers();
}
});
it('surfaces the ProblemDetails detail and returns to idle when start fails (409)', async () => {
setLocation('/app/troubleshooting/playback?mediaItem=5');
const statusRef = { current: IDLE };
@@ -217,7 +217,7 @@ describe('ChannelPreviewPanel', () => {
it('shows a hint when autoplay is blocked', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked'));
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
await renderPanel(
@@ -239,10 +239,12 @@ describe('ChannelPreviewPanel', () => {
}
});
// The hint is hidden by the `state === 'starting'` render guard alone — removing that guard must
// redden this test, which it can only do because nothing else clears the flag on this path.
it('does not show the autoplay-blocked hint once playback actually starts', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked'));
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
await renderPanel(
@@ -271,6 +273,43 @@ describe('ChannelPreviewPanel', () => {
}
});
// A play() interrupted by teardown rejects with AbortError, which is exactly what Retry produces
// while the manifest-parsed play() is still pending. The hint names blocked autoplay as the cause,
// so it must not appear for a rejection that was not the autoplay policy.
it('does not show the autoplay-blocked hint when play() is rejected for another reason', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('The play() request was interrupted', 'AbortError'));
try {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
const manifestHandler = getManifestHandler();
await act(async () => {
manifestHandler();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
// The preview is the consumer that opts into a muted player (#554): muted media autoplays without
// user activation, which is what stops a slow-starting preview from sitting at "starting".
it('mutes the preview player', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
);
// SlideOver portals its content, so query the whole document rather than the render container.
expect(document.querySelector('video')?.muted).toBe(true);
});
it('does not show the autoplay-blocked hint when play() succeeds', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
@@ -45,9 +45,12 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
const [started, setStarted] = useState(availability === 'Available');
const [state, setState] = useState<PlaybackState>(availability === 'Available' ? 'starting' : 'idle');
const [error, setError] = useState<null | string>(null);
// Set when the browser rejects HlsPlayer's own play() kick despite the player being muted (#554).
// Purely informational — `state` stays 'starting' (still honest: playback has not actually begun)
// — so the operator knows to press the native play button instead of assuming a stall.
// Set when the browser's autoplay policy rejects HlsPlayer's own play() kick despite the player
// being muted (#554). Purely informational — `state` stays 'starting' (still honest: playback has
// not actually begun) — so the operator knows to press the native play button instead of assuming
// a stall. The hint's visibility is the render guard on `state === 'starting'` alone: every path
// back to 'starting' (Retry, a channel change) clears this flag itself, so a second clear in
// onPlaying would be unreachable as a guard and could not be pinned by a test.
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
// Bumped on every (re)play. The manifest GET itself starts a server-side session, so a repeat play
// of an identical URL — e.g. Retry after a failure — must re-issue the request rather than be a
@@ -107,7 +110,6 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
return;
}
setError(null);
setAutoplayBlocked(false);
setState('playing');
}, []);
@@ -193,6 +195,7 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
<>
<HlsPlayer
className="ctv-preview-video"
muted
onAutoplayBlocked={onAutoplayBlocked}
onError={onError}
onPlaying={onPlaying}