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
6 changed files with 533 additions and 8 deletions
+28
View File
@@ -643,6 +643,34 @@ 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).** `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**, so a second clear in `onPlaying` would be a guard no test could
distinguish. That holds because the panel's paths back to `starting` either clear the flag themselves
(Retry, a channel change) or cannot be reached while it is set — the forced-preview opt-in re-enters
`starting` without clearing, but its button renders only while the panel has not started, and the
only thing that un-starts it is the channel reset that clears the flag. 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
deployment it appends the short-lived `?access_token=` the `/iptv/*` scheme requires (the `ctv-session`
+175 -1
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 = {
@@ -105,4 +105,178 @@ describe('HlsPlayer', () => {
const video = container.querySelector('video');
expect(() => video?.dispatchEvent(new Event('playing'))).not.toThrow();
});
// #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. 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 by autoplay policy', async () => {
const onAutoplayBlocked = vi.fn();
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
render(<HlsPlayer onAutoplayBlocked={onAutoplayBlocked} src="/iptv/channel/12.1.m3u8" />);
const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1];
manifestHandler();
// The rejection settles on a microtask; flush it before asserting.
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).toHaveBeenCalledTimes(1);
} finally {
playSpy.mockRestore();
}
});
// 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" />);
const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1];
manifestHandler();
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).not.toHaveBeenCalled();
});
it('is safe with no onAutoplayBlocked provided', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
render(<HlsPlayer src="/iptv/channel/12.1.m3u8" />);
const manifestHandler = hlsMock.on.mock.calls.find((call) => call[0] === 'hlsManifestParsed')?.[1];
expect(() => manifestHandler()).not.toThrow();
await Promise.resolve();
await Promise.resolve();
} finally {
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');
});
});
});
+47 -7
View File
@@ -20,6 +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 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
@@ -27,7 +50,16 @@ export interface HlsPlayerProps {
// segments exist, so the manifest request must tolerate an unbounded time-to-first-byte), and falls
// back to the browser's native HLS support (Safari) otherwise. Reusable across any screen that needs
// to preview an ErsatzTV HLS stream.
export function HlsPlayer({ src, playToken = 0, className, style, onError, onPlaying }: HlsPlayerProps) {
export function HlsPlayer({
src,
playToken = 0,
className,
style,
onError,
onPlaying,
onAutoplayBlocked,
muted = false
}: HlsPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const hlsRef = useRef<Hls | null>(null);
@@ -63,8 +95,11 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
void video.play().catch(() => {
// Autoplay may be blocked; the user can press play manually.
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) => {
@@ -86,8 +121,11 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
const onCanPlay = () => {
void video.play().catch(() => {
// Autoplay may be blocked; the user can press play manually.
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);
@@ -110,7 +148,7 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla
return undefined;
// playToken forces re-attachment for repeat plays of an identical src (see HlsPlayerProps).
}, [src, playToken, onError, onPlaying]);
}, [src, playToken, onError, onPlaying, onAutoplayBlocked]);
// Final unmount safety net (covers the hls.js instance in every path).
useEffect(
@@ -120,5 +158,7 @@ export function HlsPlayer({ src, playToken = 0, className, style, onError, onPla
[]
);
return <video className={className} controls 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 };
@@ -211,6 +211,198 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText(/networkError: manifestLoadError/)).toBeInTheDocument();
});
// #554: on a slow-starting channel MANIFEST_PARSED can arrive past the browser's transient
// user-activation window, so HlsPlayer's own play() kick is rejected as blocked autoplay. The
// panel must tell the operator to press play rather than sit silently at "starting".
it('shows a hint when autoplay is blocked', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
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.getByText(/autoplay was blocked/i)).toBeInTheDocument();
// Still honest: playback has not actually started, so the State fact stays "starting".
expect(screen.getByText('starting')).toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
// 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', 'NotAllowedError'));
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.getByText(/autoplay was blocked/i)).toBeInTheDocument();
// SlideOver portals its content, so query the whole document rather than the render container.
const video = document.querySelector('video');
await act(async () => {
video?.dispatchEvent(new Event('playing'));
await Promise.resolve();
});
expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
// The `state === 'starting'` guard is the hint's ONLY guard, so every path back to `starting` has
// to clear the flag itself or be unreachable while it is set. Retry is one of the clearing ones:
// it is a fresh attempt that was never blocked, and a hint carried over from the previous attempt
// would name a cause that did not happen this time.
it('clicking Retry clears the autoplay-blocked hint', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
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.getByText(/autoplay was blocked/i)).toBeInTheDocument();
// The retried attempt is not blocked.
playSpy.mockResolvedValue(undefined);
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
await flush();
// The player really did re-mount, so the hint is absent because the flag was cleared — not
// because the whole `resolvedSrc` block is still unrendered.
expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
expect(document.querySelector('video')).not.toBeNull();
expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument();
// Retry returns to `starting`, so the render guard is not what hid the hint here.
expect(screen.getByText('starting')).toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
// The forced-preview opt-in is the third path back to `starting`, and the one that does NOT clear
// the flag. It does not have to: its button is gone the moment `started` flips true, which is
// before any player — the only thing that can set the flag — has mounted. Pin that unreachability,
// since it is what makes the missing clear correct rather than an oversight.
it('retires the forced-preview opt-in once the player is up, so it cannot re-enter starting with the hint set', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
await renderPanel(
<ChannelPreviewPanel
channel={channel(forced, 'MPEG-TS')}
nowPlaying={null}
onClose={vi.fn()}
open
/>
);
fireEvent.click(screen.getByRole('button', { name: /preview via hls anyway/i }));
await flush();
const manifestHandler = getManifestHandler();
await act(async () => {
manifestHandler();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText(/autoplay was blocked/i)).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /preview via hls anyway/i })
).not.toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
// 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 />
);
const manifestHandler = getManifestHandler();
await act(async () => {
manifestHandler();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument();
});
it('shows the manifest url so a failure can be reproduced with curl', async () => {
await renderPanel(
<ChannelPreviewPanel channel={channel(available)} nowPlaying={null} onClose={vi.fn()} open />
@@ -381,6 +573,47 @@ describe('ChannelPreviewPanel', () => {
expect(screen.getByText('playing')).toBeInTheDocument();
});
// The other path back to `starting`, and the other half of the hint's single-guard invariant: a
// switch must not leave the previous channel's hint standing over a new channel that was never
// blocked. The render-phase reset owns this clear — the guard cannot help, since the new channel
// starts in `starting` too.
it('clears the autoplay-blocked hint when switching to a different channel', async () => {
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockRejectedValue(new DOMException('blocked', 'NotAllowedError'));
try {
const { rerender } = await renderPanel(
<ChannelPreviewPanel channel={channel(available, undefined, 1)} nowPlaying={null} onClose={vi.fn()} open />
);
const manifestHandler = getManifestHandler();
await act(async () => {
manifestHandler();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText(/autoplay was blocked/i)).toBeInTheDocument();
// The new channel's attempt is not blocked.
playSpy.mockResolvedValue(undefined);
rerender(
<ChannelPreviewPanel channel={channel(available2, undefined, 2)} nowPlaying={null} onClose={vi.fn()} open />
);
await flush();
// The new channel's player really is mounted, so the hint is absent because the flag was
// cleared — not because the `resolvedSrc` block is still unrendered.
expect(hlsMock.loadSource).toHaveBeenLastCalledWith('/iptv/channel/34.1.m3u8');
expect(document.querySelector('video')).not.toBeNull();
expect(screen.queryByText(/autoplay was blocked/i)).not.toBeInTheDocument();
expect(screen.getByText('starting')).toBeInTheDocument();
} finally {
playSpy.mockRestore();
}
});
it('does not carry the retry play token over to the next channel', async () => {
// Both channels share a manifest URL so the only thing that could re-trigger the attach
// effect across the switch is playToken. If the reset failed to zero it out, the token
@@ -45,6 +45,17 @@ 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'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, so a second
// clear in onPlaying would be unreachable as a guard and could not be pinned by a test. Of the
// three paths back to 'starting', two clear this flag themselves (Retry, and the render-phase
// channel reset below). The third — onOptIn — deliberately does not, because it cannot be reached
// while the flag is set: its button renders only while `started` is false, `started` only goes
// false in that same reset (two lines above the reset's own clear), and no player exists to set
// the flag in between. A clear there would be dead code no test could distinguish either.
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
// no-op; HlsPlayer tears down and re-attaches whenever this changes, even with the same src.
@@ -74,6 +85,7 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
setStarted(availability === 'Available');
setState(availability === 'Available' ? 'starting' : 'idle');
setError(null);
setAutoplayBlocked(false);
setPlayToken(0);
setResolvedSrc(null);
}
@@ -105,6 +117,11 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
setState('playing');
}, []);
// Stable identity for the same reason as onError/onPlaying above.
const onAutoplayBlocked = useCallback(() => {
setAutoplayBlocked(true);
}, []);
const onOptIn = useCallback(() => {
setStarted(true);
setState('starting');
@@ -125,6 +142,7 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
setResolvedSrc(null);
failedRef.current = false;
setError(null);
setAutoplayBlocked(false);
setState('starting');
setPlayToken((current) => current + 1);
}, []);
@@ -181,12 +199,19 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
<>
<HlsPlayer
className="ctv-preview-video"
muted
onAutoplayBlocked={onAutoplayBlocked}
onError={onError}
onPlaying={onPlaying}
playToken={playToken}
src={resolvedSrc}
style={{ aspectRatio: '16 / 9', background: '#000' }}
/>
{autoplayBlocked && state === 'starting' && (
<p className="ctv-settings-warn-callout ctv-preview-caveat" role="status">
Autoplay was blocked press play on the video to start the preview.
</p>
)}
<div className="ctv-detail-actions">
<Button onClick={onRetry} size="sm" variant="secondary">
Retry