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; }