From d71a92cec8563ecb99cd11e3c3853d18d74edc9e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 14:46:37 +0200 Subject: [PATCH] fix(portals): twenty-second review round on the cinematic details - A Stalker launch that already started reports its failure even though the host's own inline teardown retired the request id. - An Xtream episode launch's bookkeeping applies only while the page still shows the series it started on. Co-Authored-By: Claude Fable 5.1 --- .../lib/stalker-vod-detail-actions.spec.ts | 22 +++++++++++++++++++ .../src/lib/stalker-vod-detail-actions.ts | 7 +++++- .../serial-details-playback.service.ts | 21 +++++++++++++++--- .../serial-details.component.spec.ts | 2 ++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts index 28752d8ab..0fce2509f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -227,6 +227,28 @@ describe('createStalkerVodDetailActions openExternal', () => { expect(t.logError).not.toHaveBeenCalled(); }); + it('reports a failed launch although its own teardown retired the request', async () => { + const t = setup(() => 42); + // The host closes the inline player before the launch, bumping its + // request id like a start would. + t.beforeExternalLaunch.mockImplementation(() => t.supersede()); + t.openExternalPlayback.mockRejectedValue(new Error('no player')); + const launch = t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + t.resolveLink(); + await launch; + + expect(t.logError).toHaveBeenCalledTimes(1); + expect(t.open).toHaveBeenCalledWith( + 'PORTALS.PLAYBACK_ERROR', + undefined, + expect.anything() + ); + }); + it('drops the stream once another movie was selected meanwhile', async () => { let selected = 42; const t = setup(() => selected); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index 8eaeed6be..d3d801976 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -141,6 +141,10 @@ export function createStalkerVodDetailActions( deps.playlistId() === playlistId; const pending = deps.beginPendingStart?.(); const superseded = () => pending?.isCurrent() === false; + // Set once this launch is the one running: the host's inline + // teardown before it retires the request id as well, which is + // not a newer start. + let launched = false; try { const playback = await deps.resolvePlayback( event.item.cmd, @@ -163,6 +167,7 @@ export function createStalkerVodDetailActions( return; } deps.beforeExternalLaunch?.(); + launched = true; await deps.portalPlayer.openExternalPlayback( playback, event.player @@ -170,7 +175,7 @@ export function createStalkerVodDetailActions( } catch (error) { // A launch a newer start superseded fails on its own; the // newer one reports for the movie now. - if (!stillSelected() || superseded()) { + if (!stillSelected() || (!launched && superseded())) { return; } deps.logError('External VOD playback failed', 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 c78513353..28729ea2a 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 @@ -21,6 +21,7 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { ExternalPlayerName, + ExternalPlayerSession, PlaybackPositionData, PlayerContentInfo, ResolvedPortalPlayback, @@ -426,6 +427,18 @@ export class SerialDetailsPlaybackService { isEpisodeLaunchPending(this.launchOwner()) ); + /** + * The launch's bookkeeping applies only while the page still shows the + * series it started on; a session that lands after a navigation is not + * written into the next series' position map. + */ + private ownedLaunch(launch: Promise) { + const owner = this.launchOwner(); + return launch.then((session) => + this.launchOwner() === owner ? session : undefined + ); + } + /** `playlist:series` of the page, null once it is gone or shows another series. */ launchOwner(): string | null { const seriesId = this.selectedItem()?.series_id; @@ -447,9 +460,11 @@ export class SerialDetailsPlaybackService { this.closeInlinePlayer(); void this.playbackPositionState.recordExternalLaunch( playback, - player - ? openEpisodeExternally(this, playback, player) - : this.portalPlayer.openResolvedPlayback(playback, true), + this.ownedLaunch( + player + ? openEpisodeExternally(this, playback, player) + : this.portalPlayer.openResolvedPlayback(playback, true) + ), this.savePosition ); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index de178297a..207a790b3 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -674,6 +674,8 @@ describe('SerialDetailsComponent', () => { season: 2, } as never); await fixture.whenStable(); + // One more hop: the launch's owner check runs before the save. + await Promise.resolve(); expect(savePlaybackPosition).toHaveBeenCalledWith( 'xtream-1',