fix(portals): twenty-fourth review round on the cinematic details

- A forced Xtream episode launch returns its promise to the menu, which
  reports a rejection; the bookkeeping consumes a settled copy.
- Launch bookkeeping is guarded by a page token (series + visit), checked
  again after the asynchronous save, so neither a return to the same
  series nor a navigation during the save contaminates another page.
- A Stalker launch re-anchors to the host's request id after the host's
  own inline teardown: only a later start supersedes it, and that still
  silences the old launch's failure.
- The shared movie page disables Play and Start Over while a start is
  pending, so nothing starts beside an in-flight launch.
- Episode sublines honour duration_secs before the formatted duration.
- The external session → episode ids derivation moves into the launch
  helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:57:51 +02:00
1 parent d71a92cec8
commit d087cf4d1c
15 files changed
+209 -71

No files matched your search

@@ -43,6 +43,7 @@ describe('createStalkerVodDetailActions openExternal', () => {
const beginPendingStart = jest.fn().mockReturnValue({
settle: settlePendingStart,
isCurrent: () => launchCurrent,
rebase: () => (launchCurrent = true),
});
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
@@ -227,6 +228,27 @@ describe('createStalkerVodDetailActions openExternal', () => {
expect(t.logError).not.toHaveBeenCalled();
});
it('stays silent when a newer start superseded a launch already running', async () => {
const t = setup(() => 42);
let rejectLaunch: (error: unknown) => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise((_, reject) => (rejectLaunch = reject))
);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await new Promise((resolve) => setTimeout(resolve));
// Play pressed while the IPC was pending, then the old launch fails.
t.supersede();
rejectLaunch(new Error('no player'));
await launch;
expect(t.open).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
@@ -19,6 +19,8 @@ import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
export interface PendingExternalLaunch {
settle(): void;
isCurrent(): boolean;
/** Re-anchors to the host's current request id after the host's own teardown bumped it. */
rebase(): void;
}
/** Builds a `PendingExternalLaunch` on a host that tracks its own request ids. */
@@ -30,7 +32,7 @@ export function beginTrackedExternalLaunch(host: {
playbackOwnerKey(): string;
playbackRequestId: number;
}): PendingExternalLaunch {
const requestId = ++host.playbackRequestId;
let requestId = ++host.playbackRequestId;
const ownerKey = host.playbackOwnerKey();
const startId = host.pendingStart.begin(ownerKey);
return {
@@ -38,6 +40,9 @@ export function beginTrackedExternalLaunch(host: {
isCurrent: () =>
requestId === host.playbackRequestId &&
host.playbackOwnerKey() === ownerKey,
rebase: () => {
requestId = host.playbackRequestId;
},
};
}
@@ -141,10 +146,6 @@ 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,
@@ -167,7 +168,9 @@ export function createStalkerVodDetailActions(
return;
}
deps.beforeExternalLaunch?.();
launched = true;
// The host's inline teardown retired the request id too;
// only a start made after this point supersedes the launch.
pending?.rebase();
await deps.portalPlayer.openExternalPlayback(
playback,
event.player
@@ -175,7 +178,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() || (!launched && superseded())) {
if (!stillSelected() || superseded()) {
return;
}
deps.logError('External VOD playback failed', error);
@@ -49,13 +49,16 @@ export class StalkerVodPlaybackController {
* an earlier start and is dropped once a later one begins.
*/
beginPendingStart(): PendingExternalLaunch {
const requestId = ++this.playbackRequestId;
let requestId = ++this.playbackRequestId;
const playbackOwnerKey = this.config.playbackOwnerKey?.();
const startId = this.pendingStart.begin(playbackOwnerKey);
return {
settle: () => this.pendingStart.settle(startId),
isCurrent: () =>
this.isPlaybackRequestCurrent(requestId, playbackOwnerKey),
rebase: () => {
requestId = this.playbackRequestId;
},
};
}
@@ -4,11 +4,46 @@ import type {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
externalEpisodeSessionIds,
isEpisodeLaunchPending,
openEpisodeExternally,
queueEpisodeChoice,
} from './serial-details-external-launch';
describe('externalEpisodeSessionIds', () => {
it('reports the launching or playing episode of the shown series only', () => {
expect(externalEpisodeSessionIds(null, '103', 'xtream-1')).toEqual({
opening: null,
active: null,
});
expect(
externalEpisodeSessionIds(
session({ status: 'launching' }),
'103',
'xtream-1'
)
).toEqual({ opening: 1001, active: null });
expect(externalEpisodeSessionIds(session(), '103', 'xtream-1')).toEqual(
{
opening: null,
active: 1001,
}
);
expect(externalEpisodeSessionIds(session(), '104', 'xtream-1')).toEqual(
{
opening: null,
active: null,
}
);
expect(externalEpisodeSessionIds(session(), '103', 'xtream-2')).toEqual(
{
opening: null,
active: null,
}
);
});
});
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'http://xtream.example/series/1002.mp4',
title: 'Episode 2',
@@ -8,8 +8,46 @@ import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util';
import { closeRunningExternalSession } from '../vod-details/vod-details-external-session';
/** The episode ids an external session contributes to the series page. */
export interface ExternalEpisodeSessionIds {
readonly opening: number | null;
readonly active: number | null;
}
/**
* Which episode of the shown series the active external session is
* opening or playing; both null when the session belongs to something
* else or has ended.
*/
export function externalEpisodeSessionIds(
session: ExternalPlayerSession | null,
seriesXtreamId: string | number | undefined,
playlistId: string
): ExternalEpisodeSessionIds {
const info = session?.contentInfo;
if (
!session ||
!info ||
!seriesXtreamId ||
!playlistId ||
info.contentType !== 'episode' ||
info.playlistId !== playlistId ||
info.seriesXtreamId !== Number(seriesXtreamId)
) {
return { opening: null, active: null };
}
if (session.status === 'launching') {
return { opening: info.contentXtreamId, active: null };
}
if (isLiveExternalPlayerSession(session)) {
return { opening: null, active: info.contentXtreamId };
}
return { opening: null, active: null };
}
/** What a forced MPV/VLC episode launch reads from the series page. */
export interface SeriesExternalLaunchHost {
readonly portalPlayer: Pick<PortalPlayer, 'openExternalPlayback'>;
@@ -44,7 +44,7 @@ interface SerialDetailsMenuBindings {
readonly openEpisodeExternally: (
episode: XtreamSerieEpisode,
player: ExternalPlayerName
) => void;
) => Promise<unknown> | void;
}
/**
@@ -193,7 +193,7 @@ export class SerialDetailsMenuService {
await container?.downloadPresenter.enqueueSeason();
return;
case SERIES_MENU_ACTION.ExternalPlayer:
this.openExternal();
await this.openExternal();
return;
case SERIES_MENU_ACTION.CopyUrl:
await this.copyStreamUrl();
@@ -236,13 +236,21 @@ export class SerialDetailsMenuService {
};
}
private openExternal(): void {
private async openExternal(): Promise<void> {
const bindings = this.bindings();
const episode = bindings?.quickStart()?.episode;
if (!bindings || !episode) {
return;
}
bindings.openEpisodeExternally(episode, this.externalPlayer());
try {
await bindings.openEpisodeExternally(
episode,
this.externalPlayer()
);
} catch (error) {
this.logger.warn('External episode launch failed', error);
this.notify('PORTALS.PLAYBACK_ERROR');
}
}
private async copyStreamUrl(): Promise<void> {
@@ -151,7 +151,9 @@ export class SerialDetailsPlaybackPositionState {
save: (
playlistId: string,
position: PlaybackPositionData
) => Promise<void>
) => Promise<void>,
/** The page still shows the series; checked again after the save. */
stillShown: () => boolean = () => true
): Promise<void> {
const session = await launch;
if (!session) {
@@ -178,6 +180,9 @@ export class SerialDetailsPlaybackPositionState {
};
await save(contentInfo.playlistId, position);
if (!stillShown()) {
return;
}
this.update(position);
}
@@ -10,7 +10,6 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -43,6 +42,7 @@ import {
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import {
externalEpisodeSessionIds,
isEpisodeLaunchPending,
openEpisodeExternally,
queueEpisodeChoice,
@@ -131,38 +131,13 @@ export class SerialDetailsPlaybackService {
// A launch still closing its predecessor must not outlive the page.
inject(DestroyRef).onDestroy(() => this.bindings.set(null));
effect(() => {
const session = this.externalPlayback.activeSession();
const selectedItem = this.selectedItem();
const playlistId = this.currentPlaylistId();
if (
!session?.contentInfo ||
!selectedItem?.series_id ||
!playlistId ||
session.contentInfo.contentType !== 'episode' ||
session.contentInfo.playlistId !== playlistId ||
session.contentInfo.seriesXtreamId !==
Number(selectedItem.series_id)
) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'launching') {
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
this.activeEpisodeId.set(null);
return;
}
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
}
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
const ids = externalEpisodeSessionIds(
this.externalPlayback.activeSession(),
this.selectedItem()?.series_id,
this.currentPlaylistId()
);
this.openingEpisodeId.set(ids.opening);
this.activeEpisodeId.set(ids.active);
});
effect(() => {
@@ -216,6 +191,7 @@ export class SerialDetailsPlaybackService {
/** Clears all playback state when switching to another series. */
resetForNewSeries(): void {
this.pageGeneration += 1;
this.closeInlinePlayer();
this.playbackPositionState.reset();
this.openingEpisodeId.set(null);
@@ -226,7 +202,7 @@ export class SerialDetailsPlaybackService {
playEpisode(
episode: XtreamSerieEpisode,
player?: ExternalPlayerName
): void {
): Promise<ExternalPlayerSession | void> | void {
// A forced launch still settling owns the next start: the latest
// choice made meanwhile starts once it settled, never beside it.
const owner = this.launchOwner();
@@ -275,7 +251,7 @@ export class SerialDetailsPlaybackService {
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
this.startPlayback(playback, episodeState, player);
return this.startPlayback(playback, episodeState, player);
}
playQuickStartEpisode(): void {
@@ -332,10 +308,12 @@ export class SerialDetailsPlaybackService {
request.player
);
request.trackLaunch(launch);
const token = this.pageToken();
void this.playbackPositionState.recordExternalLaunch(
request.playback,
launch,
this.savePosition
this.savePosition,
() => this.pageToken() === token
);
}
@@ -427,16 +405,16 @@ export class SerialDetailsPlaybackService {
isEpisodeLaunchPending(this.launchOwner())
);
/** Bumped for every series the page shows: a return to the same series is a new visit. */
private pageGeneration = 0;
/**
* 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.
* Identifies the series AND the visit: a launch's bookkeeping applies
* only while the page still shows what it showed when the launch
* started, not after leaving and coming back.
*/
private ownedLaunch(launch: Promise<ExternalPlayerSession | void>) {
const owner = this.launchOwner();
return launch.then((session) =>
this.launchOwner() === owner ? session : undefined
);
private pageToken(): string {
return `${this.launchOwner()}#${this.pageGeneration}`;
}
/** `playlist:series` of the page, null once it is gone or shows another series. */
@@ -449,7 +427,7 @@ export class SerialDetailsPlaybackService {
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
player?: ExternalPlayerName
): void {
): Promise<ExternalPlayerSession | void> | void {
this.lastSaveTime = 0;
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlaybackSessionEpisodeState.set(episodeState);
@@ -458,14 +436,26 @@ export class SerialDetailsPlaybackService {
}
this.closeInlinePlayer();
const launch = player
? openEpisodeExternally(this, playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true);
// The bookkeeping never rejects; a forced launch's failure is the
// menu's to report, which awaits the launch it asked for.
const token = this.pageToken();
const stillShown = () => this.pageToken() === token;
const settled = launch.catch((error) => {
console.warn(
'[SerialDetailsPlayback] External launch failed',
error
);
return undefined;
});
void this.playbackPositionState.recordExternalLaunch(
playback,
this.ownedLaunch(
player
? openEpisodeExternally(this, playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true)
),
this.savePosition
settled.then((session) => (stillShown() ? session : undefined)),
this.savePosition,
stillShown
);
return launch;
}
}
@@ -674,8 +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();
// The launch settles through its page checks before the save.
await new Promise((resolve) => setTimeout(resolve));
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
@@ -51,3 +51,13 @@ export function formatEpisodePositionText(
.join(':');
return `${formatted}${suffix}`;
}
/** The episode runtime: the provider's `duration_secs` first, then its formatted `duration`. */
export function episodeRuntimeSeconds(
info: { duration_secs?: number; duration?: string } | null | undefined
): number {
const seconds = Number(info?.duration_secs);
return Number.isFinite(seconds) && seconds > 0
? Math.floor(seconds)
: parseDuration(info?.duration);
}
@@ -1,8 +1,23 @@
import {
episodeRuntimeSeconds,
formatEpisodePositionText,
parseDuration,
} from './episode-progress.util';
describe('episodeRuntimeSeconds', () => {
it('prefers the provider seconds over the formatted string', () => {
expect(episodeRuntimeSeconds({ duration_secs: 2520 })).toBe(2520);
expect(
episodeRuntimeSeconds({ duration_secs: 2520, duration: '00:10:00' })
).toBe(2520);
expect(episodeRuntimeSeconds({ duration: '00:42:00' })).toBe(2520);
expect(
episodeRuntimeSeconds({ duration_secs: 0, duration: '00:42:00' })
).toBe(2520);
expect(episodeRuntimeSeconds(undefined)).toBe(0);
});
});
describe('episode-progress.util', () => {
it('parses duration strings', () => {
expect(parseDuration('01:00:30')).toBe(3630);
@@ -26,7 +26,6 @@ import {
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
parseDurationSeconds,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
@@ -39,7 +38,10 @@ import {
EpisodeInfoDialogComponent,
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import { formatEpisodePositionText } from './episode-progress.util';
import {
formatEpisodePositionText,
episodeRuntimeSeconds,
} from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import { SeasonTabsComponent } from './season-tabs.component';
@@ -433,8 +435,7 @@ export class SeasonContainerComponent implements OnInit {
/** "42 min · 18m left", "42 min · watched", "42 min" — or null. */
getEpisodeSubline(episode: XtreamSerieEpisode): string | null {
const info = this.getEpisodeInfo(episode);
const seconds = parseDurationSeconds(info?.duration);
const duration = formatDurationLabel(seconds);
const duration = formatDurationLabel(episodeRuntimeSeconds(info));
const position = this.playbackPositions().get(Number(episode.id));
const remaining = formatRemainingLabel(position);
const parts = [
@@ -39,6 +39,13 @@ describe('buildVodMenuSections', () => {
expect(reset?.disabled).toBeFalsy();
});
it('disables Start Over while a start still resolves', () => {
const restart = rows({ startPending: true }).find(
(row) => row.id === VOD_DETAILS_MENU_ACTION.StartOver
);
expect(restart?.disabled).toBe(true);
});
it('disables the external launch while a start still resolves', () => {
const external = rows({ startPending: true }).find(
(row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer
@@ -200,6 +200,7 @@ export function buildVodMenuSections(input: {
id: VOD_DETAILS_MENU_ACTION.StartOver,
labelKey: 'XTREAM.RESTART',
icon: 'replay',
disabled: input.startPending,
testId: 'vod-menu-start-over',
});
}
@@ -105,7 +105,7 @@
[meta]="primary.meta"
[icon]="primary.icon"
[state]="primary.state"
[disabled]="isExternalLaunchPending()"
[disabled]="isExternalLaunchPending() || playbackStartPending()"
testId="vod-primary-action"
(pressed)="onPrimaryAction()"
/>