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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:46:37 +02:00
1 parent 6de998094a
commit d71a92cec8
4 files changed
+48 -4

No files matched your search

@@ -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);
@@ -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);
@@ -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<ExternalPlayerSession | void>) {
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
);
}
@@ -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',