From d8a34570a38aa2611ea8631ac43a18e62f736516 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 19:34:51 +0200 Subject: [PATCH] fix(portals): a series launch that outlived its page closes its player, and a held choice stays with its series Both portals' forced episode launches dropped the session the player IPC returned, so leaving the series while it was pending left that player beside whatever the viewer started next; the session is kept and closed when the page no longer shows the series. The Stalker choice held during a watched batch is tied to its series and dropped after a switch, since episode identities overlap across series. Co-Authored-By: Claude Fable 5.1 --- .../stalker-series-view.component.ts | 55 ++++++++++++-- .../stalker-series-view.season-watch.spec.ts | 73 +++++++++++++++++++ .../serial-details-external-launch.spec.ts | 17 +++++ .../serial-details-external-launch.ts | 17 ++++- 4 files changed, 153 insertions(+), 9 deletions(-) diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 04178964f..3c250b61b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -45,6 +45,7 @@ import { } from '@iptvnator/ui/components'; import { ExternalPlayerName, + ExternalPlayerSession, PlaybackPositionData, PlayerContentInfo, ResolvedPortalPlayback, @@ -295,9 +296,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { private readonly launchQueue = new StalkerSeriesLaunchQueue(); /** * The episode chosen while a watched/reset batch still rewrote the rows - * a start resumes from; the last choice plays once the batch settled. + * a start resumes from; the last choice plays once the batch settled, + * and only on the series it was made for. */ - private choiceHeldForBatch: (() => void) | null = null; + private choiceHeldForBatch: { + readonly seriesKey: string; + readonly play: () => void; + } | null = null; /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => @@ -1040,8 +1045,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (this.seasonWatchBatchRunning()) { // The batch rewrites the very rows a start resumes from: the // choice waits for it, like the Reset and watched rows do. - this.choiceHeldForBatch = () => - this.onEpisodeClicked(episode, startTimeOverride, forcePlayer); + this.choiceHeldForBatch = { + seriesKey: this.currentSeriesKey(), + play: () => + this.onEpisodeClicked( + episode, + startTimeOverride, + forcePlayer + ), + }; return; } const seriesKey = this.currentSeriesKey(); @@ -1056,12 +1068,18 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.startEpisode(episode, startTimeOverride, forcePlayer); } - /** The batch settled: the choice held meanwhile goes through the usual gates. */ + /** + * The batch settled: the choice held meanwhile goes through the usual + * gates, unless the viewer switched series since. Episode identities + * overlap across series, so it must never resolve against another one. + */ private endWatchBatch(): void { this.seasonWatchBatchRunning.set(false); - const choice = this.choiceHeldForBatch; + const held = this.choiceHeldForBatch; this.choiceHeldForBatch = null; - choice?.(); + if (held && held.seriesKey === this.currentSeriesKey()) { + held.play(); + } } private startEpisode( @@ -1301,8 +1319,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { ) { return; } + let session: ExternalPlayerSession | void; try { - await this.portalPlayer.openExternalPlayback(playback, player); + session = await this.portalPlayer.openExternalPlayback( + playback, + player + ); } catch (error) { // The caller's catch would read the retired generation and stay // silent; the user chose this launch and gets its failure. @@ -1313,6 +1335,23 @@ export class StalkerSeriesViewComponent implements OnDestroy { undefined, { duration: 3000 } ); + return; + } + // The viewer left the series, or started something else, while the + // launch sat inside the player IPC: the player it opened must not + // stay beside what they chose since. + if ( + session && + !this.isPlaybackRequestCurrent({ ...request, generation }) + ) { + await this.externalPlayback + .closeSession(session) + .catch((error: unknown) => + this.logger.warn( + 'Closing a superseded external player failed', + error + ) + ); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts index a1ca00c77..dbf155245 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.season-watch.spec.ts @@ -420,6 +420,79 @@ describe('StalkerSeriesViewComponent season watched toggle', () => { expect(resolvePlayback).toHaveBeenCalledTimes(1); }); + it('drops an episode held during a batch once the viewer switched series', async () => { + const [firstId, secondId] = await startWithTwoLoadedEpisodes(); + const store = TestBed.inject(StalkerStore); + const resolvePlayback = jest + .spyOn(store, 'resolveVodPlayback') + .mockResolvedValue(null as never); + let finishSave: () => void = () => undefined; + savePlaybackPositionOrThrow.mockImplementationOnce( + () => new Promise((resolve) => (finishSave = resolve)) + ); + const batch = + fixture.componentInstance.handleSeasonPlaybackToggleRequested( + seasonToggleRequest([firstId, secondId], true) + ); + await Promise.resolve(); + const [first] = fixture.componentInstance.mappedSeasons()['1']; + fixture.componentInstance.onEpisodeClicked(first); + + // Episode identities overlap across series: the held choice must + // not resolve against the series now on screen. + selectedItem.set(createVodItem(SERIES_B_ID)); + await settle(); + finishSave(); + await batch; + + expect(resolvePlayback).not.toHaveBeenCalled(); + }); + + it('closes the player a forced launch opened after the viewer left the series', async () => { + await startWithTwoLoadedEpisodes(); + const store = TestBed.inject(StalkerStore); + jest.spyOn(store, 'resolveVodPlayback').mockResolvedValue({ + streamUrl: 'http://stalker.example/episode.mpg', + title: 'Episode', + contentInfo: { + playlistId: PLAYLIST_ID, + contentXtreamId: 1, + contentType: 'episode', + seriesXtreamId: SERIES_A_ID, + }, + } as never); + const player = TestBed.inject(PORTAL_PLAYER) as unknown as { + openExternalPlayback: jest.Mock; + }; + const external = TestBed.inject( + PORTAL_EXTERNAL_PLAYBACK + ) as unknown as { closeSession?: jest.Mock }; + external.closeSession = jest.fn().mockResolvedValue(undefined); + const opened = { id: 'mpv-9', status: 'launching' }; + let finishLaunch: () => void = () => undefined; + player.openExternalPlayback.mockImplementationOnce( + () => + new Promise((resolve) => { + finishLaunch = () => resolve(opened); + }) + ); + const [first] = fixture.componentInstance.mappedSeasons()['1']; + fixture.componentInstance.onEpisodeClicked(first, undefined, 'mpv'); + await settle(); + await new Promise((resolve) => setTimeout(resolve)); + expect(player.openExternalPlayback).toHaveBeenCalledTimes(1); + + // The viewer opens another series while the launch sits inside the + // player IPC: the player it opens must not stay beside that one. + selectedItem.set(createVodItem(SERIES_B_ID)); + await settle(); + finishLaunch(); + await settle(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(external.closeSession).toHaveBeenCalledWith(opened); + }); + it('keeps surviving clears and reports a partial season unwatch failure', async () => { const [firstId, secondId] = await startWithTwoLoadedEpisodes(); repositoryRows = [firstId, secondId].map((contentXtreamId, index) => 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 ce282f730..35068fa4e 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 @@ -137,6 +137,23 @@ describe('openEpisodeExternally', () => { expect(t.openExternalPlayback).not.toHaveBeenCalled(); }); + it('closes the player a launch opened after the page moved on', async () => { + const t = host(null); + const opened = session({ id: 'mpv-2', status: 'launching' }); + t.openExternalPlayback.mockImplementation(async () => { + // The viewer opens another title while the launch sits inside + // the player IPC. + t.launchOwner.mockReturnValue('xtream-1:999'); + return opened; + }); + + await expect( + openEpisodeExternally(t.host, PLAYBACK, 'mpv') + ).resolves.toBeUndefined(); + + expect(t.closeSession).toHaveBeenCalledWith(opened); + }); + it('ignores a repeat before the first launch settled', async () => { const t = host(null); let settleLaunch: () => void = () => undefined; 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 2ec2f77b0..1b7b16a75 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 @@ -253,5 +253,20 @@ async function launchEpisode( if (!replaced || host.launchOwner() !== owner) { return; } - return host.portalPlayer.openExternalPlayback(playback, player); + const session = await host.portalPlayer.openExternalPlayback( + playback, + player + ); + // The page moved on while the launch sat inside the player IPC: pending + // launches are owner-scoped, so the new title may already play, and the + // player this one opened must not stay beside it. + if (session && host.launchOwner() !== owner) { + try { + await host.externalPlayback.closeSession(session); + } catch (error) { + logger.warn('Closing a superseded external player failed', error); + } + return; + } + return session; }