diff --git a/docs/spa-conventions.md b/docs/spa-conventions.md
index 10d7b9935..9ce223c0e 100644
--- a/docs/spa-conventions.md
+++ b/docs/spa-conventions.md
@@ -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`
diff --git a/web/src/media/HlsPlayer.test.tsx b/web/src/media/HlsPlayer.test.tsx
index 1337d4841..c8ed7fd16 100644
--- a/web/src/media/HlsPlayer.test.tsx
+++ b/web/src/media/HlsPlayer.test.tsx
@@ -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();
+
+ const video = container.querySelector('video');
+ expect(video?.muted).toBe(false);
+ });
+
+ it('renders the video element muted when the caller opts in', () => {
+ const { container } = render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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;
+
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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');
+ });
+ });
});
diff --git a/web/src/media/HlsPlayer.tsx b/web/src/media/HlsPlayer.tsx
index 96c3cdce4..25fdcc874 100644
--- a/web/src/media/HlsPlayer.tsx
+++ b/web/src/media/HlsPlayer.tsx
@@ -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(null);
const hlsRef = useRef(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 ;
+ // `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 ;
}
diff --git a/web/src/screens/PlaybackTroubleshootingScreen.test.tsx b/web/src/screens/PlaybackTroubleshootingScreen.test.tsx
index cdd720e52..dfaf9cfbe 100644
--- a/web/src/screens/PlaybackTroubleshootingScreen.test.tsx
+++ b/web/src/screens/PlaybackTroubleshootingScreen.test.tsx
@@ -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();
+ 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 };
diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
index ce46e11e3..c44b64ab4 100644
--- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx
+++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ 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(
@@ -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(
+
+ );
+
+ 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(
+
+ );
+ 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
diff --git a/web/src/screens/channels/ChannelPreviewPanel.tsx b/web/src/screens/channels/ChannelPreviewPanel.tsx
index dcc4b5fc8..0180fc79b 100644
--- a/web/src/screens/channels/ChannelPreviewPanel.tsx
+++ b/web/src/screens/channels/ChannelPreviewPanel.tsx
@@ -45,6 +45,17 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
const [started, setStarted] = useState(availability === 'Available');
const [state, setState] = useState(availability === 'Available' ? 'starting' : 'idle');
const [error, setError] = useState(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
<>
+ {autoplayBlocked && state === 'starting' && (
+
+ Autoplay was blocked — press play on the video to start the preview.
+