From 39ec84811075ed325d9185f7c00299335d8ccc31 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 14:28:56 +0200 Subject: [PATCH] fix(portals): a queued episode choice stays with its series The choice made while a forced launch settles is kept per playlist:series owner by the launch helper and only starts if the page still shows that series, so a Similar-rail navigation cannot play it under another title. Co-Authored-By: Claude Fable 5.1 --- .../serial-details-external-launch.spec.ts | 21 +++++++++++++++++ .../serial-details-external-launch.ts | 23 +++++++++++++++++++ .../serial-details-playback.service.ts | 22 +++++------------- 3 files changed, 50 insertions(+), 16 deletions(-) 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 64c2a99da..f115c4b90 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 @@ -6,6 +6,7 @@ import type { import { isEpisodeLaunchPending, openEpisodeExternally, + queueEpisodeChoice, } from './serial-details-external-launch'; const PLAYBACK: ResolvedPortalPlayback = { @@ -195,6 +196,26 @@ describe('openEpisodeExternally', () => { expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); }); + it('starts only the latest queued choice once the launch settled', async () => { + const t = host(null); + let settleLaunch: () => void = () => undefined; + t.openExternalPlayback.mockImplementation( + () => new Promise((resolve) => (settleLaunch = resolve)) + ); + const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + const start = jest.fn(); + queueEpisodeChoice('xtream-1:103', 'episode-1', start); + queueEpisodeChoice('xtream-1:103', 'episode-2', start); + await flush(); + expect(start).not.toHaveBeenCalled(); + + settleLaunch(); + await launch; + await flush(); + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith('episode-2'); + }); + 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 a55739a27..ef7bd3730 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 @@ -52,6 +52,29 @@ export function whenEpisodeLaunchesSettle(owner: string | null): Promise { return tail ? tail.then(() => undefined) : Promise.resolve(); } +/** Per owner, the latest Play/episode choice made while its forced launch settled. */ +const queuedChoices = new Map(); + +/** + * Keeps the latest choice made while the owner's forced launch settles and + * hands it to `start` once that launch settled; the caller decides whether + * the page still shows the owner by then. + */ +export function queueEpisodeChoice( + owner: string, + episode: TEpisode, + start: (episode: TEpisode) => void +): void { + queuedChoices.set(owner, episode); + void whenEpisodeLaunchesSettle(owner).then(() => { + const queued = queuedChoices.get(owner) as TEpisode | undefined; + if (queued !== undefined) { + queuedChoices.delete(owner); + start(queued); + } + }); +} + /** * 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 diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 480b144d7..c78513353 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -44,7 +44,7 @@ import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.toke import { isEpisodeLaunchPending, openEpisodeExternally, - whenEpisodeLaunchesSettle, + queueEpisodeChoice, } from './serial-details-external-launch'; import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; import { @@ -228,11 +228,11 @@ export class SerialDetailsPlaybackService { ): void { // A forced launch still settling owns the next start: the latest // choice made meanwhile starts once it settled, never beside it. - if (!player && this.forcedLaunchPending()) { - this.queuedEpisode = episode; - void whenEpisodeLaunchesSettle(this.launchOwner()).then(() => - this.playQueuedEpisode() - ); + const owner = this.launchOwner(); + if (!player && owner && this.forcedLaunchPending()) { + queueEpisodeChoice(owner, episode, (queued) => { + if (this.launchOwner() === owner) this.playEpisode(queued); + }); return; } const playlist = this.xtreamStore.currentPlaylist(); @@ -425,16 +425,6 @@ export class SerialDetailsPlaybackService { readonly forcedLaunchPending = computed(() => isEpisodeLaunchPending(this.launchOwner()) ); - /** The choice made while a forced launch settled; only the latest one starts. */ - private queuedEpisode: XtreamSerieEpisode | null = null; - - private playQueuedEpisode(): void { - const episode = this.queuedEpisode; - this.queuedEpisode = null; - if (episode && !this.forcedLaunchPending()) { - this.playEpisode(episode); - } - } /** `playlist:series` of the page, null once it is gone or shows another series. */ launchOwner(): string | null {