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 8d7814c0b..cc739c248 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 @@ -34,6 +34,9 @@ function session( } as ExternalPlayerSession; } +/** Lets the launch chain's microtasks run up to the player call. */ +const flush = () => new Promise((resolve) => setTimeout(resolve)); + describe('openEpisodeExternally', () => { function host(active: ExternalPlayerSession | null) { const openExternalPlayback = jest.fn().mockResolvedValue(undefined); @@ -100,12 +103,47 @@ describe('openEpisodeExternally', () => { ); const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); await openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + await flush(); + expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); settleLaunch(); await first; expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); }); + it('queues another episode behind the first launch instead of dropping it', async () => { + const t = host(null); + let settleLaunch: () => void = () => undefined; + t.openExternalPlayback.mockImplementationOnce( + () => new Promise((resolve) => (settleLaunch = resolve)) + ); + const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + const second = openEpisodeExternally( + t.host, + { + ...PLAYBACK, + streamUrl: 'http://xtream.example/series/1003.mp4', + contentInfo: { + ...PLAYBACK.contentInfo!, + contentXtreamId: 1003, + }, + }, + 'mpv' + ); + await flush(); + expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); + + settleLaunch(); + await first; + await second; + expect(t.openExternalPlayback).toHaveBeenCalledTimes(2); + expect(t.openExternalPlayback.mock.calls[1][0]).toEqual( + expect.objectContaining({ + streamUrl: 'http://xtream.example/series/1003.mp4', + }) + ); + }); + 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 c6a664208..12faf5274 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 @@ -20,16 +20,20 @@ export interface SeriesExternalLaunchHost { launchOwner(): string | null; } -/** Owners (`playlist:series`) whose launch has not settled yet. */ +/** `owner: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>(); /** * The "…" menu's MPV/VLC launch of an episode. An episode of this series * still running externally is closed first: with instance reuse off a * second detached player would start beside it. When that close fails, or * the user moved on while it ran, the running player stays and nothing new - * launches. A repeat before the first launch settled (Electron publishes - * the session only afterwards) is ignored. + * launches. A repeat of the same episode before its launch settled + * (Electron publishes the session only afterwards) is ignored; another + * episode of the series waits for that launch to settle and then replaces + * it like any later start. */ export async function openEpisodeExternally( host: SeriesExternalLaunchHost, @@ -37,14 +41,27 @@ export async function openEpisodeExternally( player: ExternalPlayerName ): Promise { const owner = host.launchOwner(); - if (!owner || launchesInFlight.has(owner)) { + if (!owner) { return; } - launchesInFlight.add(owner); + const key = `${owner}:${playback.contentInfo?.contentXtreamId ?? playback.streamUrl}`; + if (launchesInFlight.has(key)) { + return; + } + launchesInFlight.add(key); + const previous = launchChains.get(owner) ?? Promise.resolve(); + const run = previous.then(() => + launchEpisode(host, owner, playback, player) + ); + const tail = run.catch(() => undefined); + launchChains.set(owner, tail); try { - return await launchEpisode(host, owner, playback, player); + return await run; } finally { - launchesInFlight.delete(owner); + launchesInFlight.delete(key); + if (launchChains.get(owner) === tail) { + launchChains.delete(owner); + } } }