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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:34:51 +02:00
1 parent e0b6de8372
commit d8a34570a3
4 files changed
+153 -9

No files matched your search

@@ -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
)
);
}
}
@@ -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<void>((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) =>
@@ -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;
@@ -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;
}