diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
index 72e66ccc0..c40831ad9 100644
--- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx
+++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
@@ -273,6 +273,45 @@ describe('ChannelPreviewPanel', () => {
}
});
+ // The `state === 'starting'` guard is the hint's ONLY guard, so every path back to `starting` has
+ // to clear the flag itself. Retry is one: 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();
+ }
+ });
+
// 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.
@@ -495,6 +534,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