diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
index e470862bf..ce46e11e3 100644
--- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx
+++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx
@@ -235,6 +235,29 @@ describe('ChannelPreviewPanel', () => {
expect(resetIptvTokenCacheMock).toHaveBeenCalled();
});
+ it('on Retry re-mints the token and never reloads the stale-token URL', async () => {
+ // withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache).
+ let n = 0;
+ withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`));
+
+ await renderPanel(
+
+ );
+
+ expect(hlsMock.loadSource).toHaveBeenCalledTimes(1);
+ expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1');
+
+ fireEvent.click(screen.getByRole('button', { name: /retry/i }));
+ await flush();
+
+ // Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be
+ // reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that
+ // kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL.
+ expect(hlsMock.loadSource).toHaveBeenCalledTimes(2);
+ expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2');
+ expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1);
+ });
+
it('clicking Retry clears a prior error', async () => {
await renderPanel(
diff --git a/web/src/screens/channels/ChannelPreviewPanel.tsx b/web/src/screens/channels/ChannelPreviewPanel.tsx
index f08d36279..dcc4b5fc8 100644
--- a/web/src/screens/channels/ChannelPreviewPanel.tsx
+++ b/web/src/screens/channels/ChannelPreviewPanel.tsx
@@ -118,6 +118,11 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan
// token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge
// playback. An actually-expired token would refresh on its own, but this also covers those edges (#552).
resetIptvTokenCache();
+ // Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken
+ // bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this
+ // would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even
+ // after the fresh stream succeeds).
+ setResolvedSrc(null);
failedRef.current = false;
setError(null);
setState('starting');