From cf191eb1a0b27eb6bc25766de53bfe397775c4f6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 16:15:54 +0200 Subject: [PATCH] fix(portals): a choice made while the opened player closes is the one that starts The queued entry stays in place while the player the launch opened closes, so a choice made meanwhile replaces it instead of being stranded (Stalker) or registering a second replay that starts beside the first (Xtream). The series launch helpers log through the shared redacting logger. Co-Authored-By: Claude Fable 5.1 --- .../stalker-series-launch-queue.spec.ts | 26 +++++++++++++++ .../stalker-series-launch-queue.ts | 10 ++++-- .../serial-details-external-launch.spec.ts | 32 +++++++++++++++++++ .../serial-details-external-launch.ts | 26 +++++++++++---- .../serial-details-playback.service.ts | 12 +++---- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts index ade5f4a4b..74950f537 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts @@ -72,6 +72,32 @@ describe('StalkerSeriesLaunchQueue', () => { expect(queue.isLaunching(A)).toBe(false); }); + it('starts the choice held while the player closed, not the earlier one', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const replace = deferred(); + const run = queue.run( + A, + () => Promise.resolve(), + release({ replacePlayer: () => replace.promise }) + ); + const earlier = jest.fn(); + const latest = jest.fn(); + queue.hold(A, earlier); + await Promise.resolve(); + await Promise.resolve(); + + // Still launching while the player closes: the viewer's newer pick + // must not be stranded behind the one that triggered the close. + expect(queue.isLaunching(A)).toBe(true); + queue.hold(A, latest); + + replace.settle(true); + await run; + expect(earlier).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); + expect(queue.isLaunching(A)).toBe(false); + }); + it('keeps the player and drops the choice when it cannot be replaced', async () => { const queue = new StalkerSeriesLaunchQueue(); const choice = jest.fn(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts index 0b12975e6..6333b4fae 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts @@ -59,10 +59,16 @@ export class StalkerSeriesLaunchQueue { seriesKey: string, release: StalkerSeriesLaunchRelease ): Promise { + if (!this.choices.has(seriesKey)) return; + if (!release.stillShown()) { + this.choices.delete(seriesKey); + return; + } + const replaced = await release.replacePlayer(); + // The latest choice wins, including one held while the player closed. const choice = this.choices.get(seriesKey); this.choices.delete(seriesKey); - if (!choice || !release.stillShown()) return; - if ((await release.replacePlayer()) && release.stillShown()) choice(); + if (replaced && release.stillShown()) choice?.(); } private count(seriesKey: string, delta: number): void { 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 962d61153..ce282f730 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 @@ -287,6 +287,38 @@ describe('openEpisodeExternally', () => { expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false); }); + it('starts only the choice made while the opened player closed', async () => { + const t = host(null); + const opened = session({ id: 'mpv-2' }); + t.openExternalPlayback.mockImplementation(async () => { + t.host.externalPlayback.activeSession.set(opened); + return opened; + }); + let settleClose: () => void = () => undefined; + t.closeSession.mockImplementation( + () => new Promise((resolve) => (settleClose = resolve)) + ); + const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + const earlier = jest.fn(); + const latest = jest.fn(); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', earlier); + await launch; + await flush(); + expect(t.closeSession).toHaveBeenCalledTimes(1); + + // Still pending while the player closes: the newer choice replaces + // the queued one instead of starting beside it afterwards. + expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-4', latest); + + settleClose(); + await flush(); + expect(earlier).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); + expect(latest).toHaveBeenCalledWith('episode-4'); + expect(t.closeSession).toHaveBeenCalledTimes(1); + }); + it('drops the queued choice when the opened player cannot be closed', async () => { const t = host(null); const opened = session({ id: 'mpv-2' }); 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 b5612a6bd..2ec2f77b0 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 @@ -8,9 +8,14 @@ import type { ExternalPlayerSession, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util'; +import { + createLogger, + isLiveExternalPlayerSession, +} from '@iptvnator/portal/shared/util'; import { closeRunningExternalSession } from '../vod-details/vod-details-external-session'; +const logger = createLogger('SerialDetailsPlayback'); + /** The episode ids an external session contributes to the series page. */ export interface ExternalEpisodeSessionIds { readonly opening: number | null; @@ -139,8 +144,11 @@ export function queueEpisodeChoice( */ async function replayQueuedChoice(owner: string): Promise { const queued = queuedChoices.get(owner); - queuedChoices.delete(owner); - if (!queued || queued.host.launchOwner() !== owner) { + if (!queued) { + return; + } + if (queued.host.launchOwner() !== owner) { + queuedChoices.delete(owner); return; } countPending(owner, 1); @@ -150,8 +158,13 @@ async function replayQueuedChoice(owner: string): Promise { } finally { countPending(owner, -1); } - if (replaced && queued.host.launchOwner() === owner) { - queued.start(queued.episode as never); + // The entry stayed in the map while the player closed, so a choice made + // meanwhile replaced it instead of registering a second replay: the + // latest choice wins. + const latest = queuedChoices.get(owner); + queuedChoices.delete(owner); + if (latest && replaced && latest.host.launchOwner() === owner) { + latest.start(latest.episode as never); } } @@ -176,8 +189,7 @@ function closeOwnedEpisodeSession( return closeRunningExternalSession( ownSession, (running) => host.externalPlayback.closeSession(running), - (message, error) => - console.warn(`[SerialDetailsPlayback] ${message}`, error) + (message, error) => logger.warn(message, error) ); } 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 41311cdb7..023dd595e 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 @@ -13,6 +13,7 @@ import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, + createLogger, getSeriesQuickStartAction, inlineProgressPosition, } from '@iptvnator/portal/shared/util'; @@ -82,6 +83,7 @@ export class SerialDetailsPlaybackService { private readonly recordRecentItem = injectXtreamRecentHistory(); private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET); private readonly seasonWatch = inject(SerialDetailsSeasonWatchService); + private readonly logger = createLogger('SerialDetailsPlayback'); private readonly savePosition = ( playlistId: string, position: PlaybackPositionData @@ -380,10 +382,7 @@ export class SerialDetailsPlaybackService { } catch (error) { // The toggle itself succeeded; a failed refresh keeps the store // populated-but-stale, which beats wiping it with a bad read. - console.warn( - '[SerialDetailsPlayback] Store position refresh failed', - error - ); + this.logger.warn('Store position refresh failed', error); } } @@ -449,10 +448,7 @@ export class SerialDetailsPlaybackService { const token = this.pageToken(); const stillShown = () => this.pageToken() === token; const settled = launch.catch((error) => { - console.warn( - '[SerialDetailsPlayback] External launch failed', - error - ); + this.logger.warn('External launch failed', error); return undefined; }); void this.playbackPositionState.recordExternalLaunch(