fix(554): mute the channel preview, and name blocked autoplay only when that is the cause #910
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user