diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts index 2ed46e85d..0785b3e89 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts @@ -81,10 +81,12 @@ describe('openEpisodeExternally', () => { const openExternalPlayback = jest.fn().mockResolvedValue(undefined); const closeSession = jest.fn().mockResolvedValue(undefined); const launchOwner = jest.fn().mockReturnValue('xtream-1:103'); + const pageToken = jest.fn().mockReturnValue('xtream-1:103#1'); return { openExternalPlayback, closeSession, launchOwner, + pageToken, host: { portalPlayer: { openExternalPlayback }, externalPlayback: { @@ -92,6 +94,7 @@ describe('openEpisodeExternally', () => { closeSession, }, launchOwner, + pageToken, }, }; } @@ -255,6 +258,27 @@ describe('openEpisodeExternally', () => { expect(startNew).toHaveBeenCalledWith('episode-2'); }); + it('lets a reopened series launch the episode an earlier visit still settles', async () => { + const first = host(session()); + let settleClose: () => void = () => undefined; + first.closeSession.mockImplementation( + () => new Promise((resolve) => (settleClose = resolve)) + ); + const stale = openEpisodeExternally(first.host, PLAYBACK, 'mpv'); + // The viewer left; this visit's page is gone. + first.launchOwner.mockReturnValue(null); + + const reopened = host(null); + reopened.pageToken.mockReturnValue('xtream-1:103#2'); + const fresh = openEpisodeExternally(reopened.host, PLAYBACK, 'mpv'); + settleClose(); + await stale; + await fresh; + + expect(first.openExternalPlayback).not.toHaveBeenCalled(); + expect(reopened.openExternalPlayback).toHaveBeenCalledTimes(1); + }); + it('keeps the running player when closing it fails', async () => { const t = host(session()); t.closeSession.mockRejectedValue(new Error('still busy')); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts index b45654df1..5f65c9d25 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -57,9 +57,11 @@ export interface SeriesExternalLaunchHost { >; /** `playlist:series` on screen, null once the page is gone. */ launchOwner(): string | null; + /** Owner plus visit: a reopened series is a new page for the duplicate guard. */ + pageToken(): string; } -/** `owner:episode` keys of launches that have not settled yet. */ +/** `page:episode` keys of launches that have not settled yet. */ const launchesInFlight = new Set(); /** Per owner, the tail of its launch chain: a second episode waits its turn. */ const launchChains = new Map>(); @@ -144,7 +146,10 @@ export async function openEpisodeExternally( if (!owner) { return; } - const key = `${owner}:${playback.contentInfo?.contentXtreamId ?? playback.streamUrl}`; + // Keyed by the visit, not the owner: a launch left behind by an earlier + // visit of the same series must not swallow the reopened page's click, + // which instead queues behind it on the owner's chain. + const key = `${host.pageToken()}:${playback.contentInfo?.contentXtreamId ?? playback.streamUrl}`; if (launchesInFlight.has(key)) { return; }