fix(portals): a reopened series can relaunch an episode an earlier visit still settles

The duplicate guard of forced episode launches is keyed by the page
visit, not the owner, so a launch left behind by an earlier visit of the
same series no longer swallows the reopened page's click; that click
queues behind the stale launch on the owner's chain instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 15:14:08 +02:00
1 parent 023c3444af
commit 3cdf1bd3ea
2 files changed
+31 -2

No files matched your search

@@ -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<void>((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'));
@@ -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<string>();
/** Per owner, the tail of its launch chain: a second episode waits its turn. */
const launchChains = new Map<string, Promise<unknown>>();
@@ -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;
}