fix(portals): eighth review round on the cinematic details

- The external player rows of every details menu follow the managed
  external player capability instead of the downloads availability.
- An explicit Stalker movie launch is a pending start for the selected
  movie while its stream resolves, so the reset row is disabled meanwhile.
- The resume clock formatter moves into the presentation helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 12:03:01 +02:00
1 parent 4e17c0533b
commit d0fe593f92
13 files changed
+81 -42

No files matched your search

@@ -22,7 +22,7 @@
[watchedToggleReady]="positionLoaded()"
[playbackStartPending]="playbackStartPending()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(resumeClicked)="onVodPlay($event.item, $event.positionSeconds)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(watchedToggled)="onVodWatchedToggled($event)"
(downloadRequested)="vodDetailActions.download($event)"
@@ -245,13 +245,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
}
onVodResume(event: {
item: VodDetailsItem;
positionSeconds: number;
}): void {
this.onVodPlay(event.item, event.positionSeconds);
}
onVodFavoriteToggled(event: {
item: VodDetailsItem;
isFavorite: boolean;
@@ -285,6 +278,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
beforeExternalLaunch: () => this.closeInlinePlayer(),
beginPendingStart: () => {
const startId = this.pendingStart.begin(this.playbackOwnerKey());
return () => this.pendingStart.settle(startId);
},
afterProgressReset: (playlistId) =>
void this.catalog.refreshPositions(playlistId),
download: (item) =>
@@ -922,6 +922,7 @@ export class StalkerSearchComponent {
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () =>
this.vodPlayback.discardPendingPositionLoad(),
beginPendingStart: () => this.vodPlayback.beginPendingStart(),
snackBar: this.snackBar,
translate: this.translateService,
logError: () => undefined,
@@ -1,5 +1,5 @@
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type ExternalPlayerName,
@@ -28,7 +28,7 @@ interface StalkerSeriesMenuBindings {
*/
@Injectable()
export class StalkerSeriesMenuService {
private readonly downloadsService = inject(DownloadsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly bindings = signal<StalkerSeriesMenuBindings | null>(null);
@@ -62,7 +62,8 @@ export class StalkerSeriesMenuService {
playbackActive: this.bindings()?.playbackActive() ?? false,
sourcesCount: 0,
externalPlayerHint:
quickStart?.action && this.downloadsService.isAvailable()
quickStart?.action &&
this.runtime.supportsManagedExternalPlayers
? this.externalPlayer() === 'vlc'
? 'VLC'
: 'MPV'
@@ -31,10 +31,13 @@ describe('createStalkerVodDetailActions openExternal', () => {
const beforeExternalLaunch = jest.fn();
const closeSession = jest.fn().mockResolvedValue(undefined);
let running: ExternalPlayerSession | null = null;
const settlePendingStart = jest.fn();
const beginPendingStart = jest.fn().mockReturnValue(settlePendingStart);
const actions = createStalkerVodDetailActions({
resolvePlayback: resolvePlayback as never,
portalPlayer: { openExternalPlayback },
externalPlayback: { activeSession: () => running, closeSession },
beginPendingStart,
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
playlistId: () => 'portal-1',
selectedVodId,
@@ -49,6 +52,8 @@ describe('createStalkerVodDetailActions openExternal', () => {
openExternalPlayback,
beforeExternalLaunch,
closeSession,
beginPendingStart,
settlePendingStart,
setRunning: (session: ExternalPlayerSession | null) =>
(running = session),
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
@@ -98,6 +103,13 @@ describe('createStalkerVodDetailActions openExternal', () => {
{ streamUrl: 'http://cdn/42.mp4' },
'mpv'
);
// Pending from the click until the launch settled: a reset meanwhile
// would be undone by the start.
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
expect(t.beginPendingStart.mock.invocationCallOrder[0]).toBeLessThan(
t.openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('drops the stream once another movie was selected meanwhile', async () => {
@@ -51,6 +51,8 @@ interface StalkerVodDetailActionsDeps {
/** Retires a stored-position read in flight, which would restore the row. */
readonly discardPendingPositionLoad?: () => void;
readonly beforeExternalLaunch?: () => void;
/** Marks the movie's start pending while the stream resolves; returns the settle. */
readonly beginPendingStart?: () => () => void;
readonly afterProgressReset?: (playlistId: string) => void;
readonly snackBar: Pick<MatSnackBar, 'open'>;
readonly translate: Pick<TranslateService, 'instant'>;
@@ -102,6 +104,7 @@ export function createStalkerVodDetailActions(
const stillSelected = () =>
deps.selectedVodId() === vodId &&
deps.playlistId() === playlistId;
const settlePendingStart = deps.beginPendingStart?.();
try {
const playback = await deps.resolvePlayback(
event.item.cmd,
@@ -133,6 +136,8 @@ export function createStalkerVodDetailActions(
if (stillSelected()) {
notify('PORTALS.PLAYBACK_ERROR');
}
} finally {
settlePendingStart?.();
}
},
@@ -43,6 +43,14 @@ export class StalkerVodPlaybackController {
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
/** An explicit launch resolving its stream; the closure settles it. */
beginPendingStart(): () => void {
const startId = this.pendingStart.begin(
this.config.playbackOwnerKey?.()
);
return () => this.pendingStart.settle(startId);
}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
@@ -11,7 +11,7 @@ import {
XTREAM_DATA_SOURCE,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type ExternalPlayerName,
@@ -58,7 +58,7 @@ export class SerialDetailsMenuService {
private readonly dataSource = inject(XTREAM_DATA_SOURCE, {
optional: true,
});
private readonly downloadsService = inject(DownloadsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
@@ -157,7 +157,7 @@ export class SerialDetailsMenuService {
playbackActive: this.bindings()?.playbackActive() ?? false,
sourcesCount: 0,
externalPlayerHint:
quickStart && this.downloadsService.isAvailable()
quickStart && this.runtime.supportsManagedExternalPlayers
? this.externalPlayer() === 'vlc'
? 'VLC'
: 'MPV'
@@ -7,7 +7,7 @@ import {
PORTAL_PLAYBACK_POSITIONS,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
type ExternalPlayerName,
@@ -48,7 +48,7 @@ export class VodDetailsMenuService {
private readonly msUi = inject(VodDetailsMultiSourceUiService);
private readonly multiSource = inject(VodMultiSourceHostService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly downloadsService = inject(DownloadsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
@@ -80,7 +80,7 @@ export class VodDetailsMenuService {
testId: 'vod-menu-sources',
});
}
if (item && this.downloadsService.isAvailable()) {
if (item && this.runtime.supportsManagedExternalPlayers) {
sourceRows.push({
id: VOD_MENU_ACTION.ExternalPlayer,
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
@@ -21,6 +21,7 @@ import {
buildVodMenuSections,
labelOf,
vodDurationSeconds,
formatPlaybackClock,
} from './vod-details-presentation';
export interface VodDetailsHeroStateDeps {
@@ -29,9 +30,9 @@ export interface VodDetailsHeroStateDeps {
readonly playbackPosition: Signal<number | null>;
readonly playbackDurationSeconds: Signal<number | null>;
readonly hasPlaybackPosition: Signal<boolean>;
readonly formattedPosition: Signal<string>;
readonly isWatched: Signal<boolean>;
readonly isElectron: Signal<boolean>;
/** Managed MPV/VLC launches: the bridge serves openInMpv/openInVlc. */
readonly supportsExternalPlayers: () => boolean;
/** Play/Resume clicked, stream still resolving (Stalker `create_link`). */
readonly playbackStartPending: Signal<boolean>;
readonly isOfflinePrimary: Signal<boolean>;
@@ -98,7 +99,9 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
hasPlaybackPosition: deps.hasPlaybackPosition(),
positionSeconds: deps.playbackPosition(),
durationSeconds: durationSeconds(),
formattedPosition: deps.formattedPosition(),
formattedPosition: formatPlaybackClock(
deps.playbackPosition()
),
},
translate
)
@@ -111,7 +114,7 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
buildVodMenuSections({
// Provider-only mode hides local/download controls, not the
// provider's own stream in MPV/VLC.
externalPlayerAvailable: deps.isElectron(),
externalPlayerAvailable: deps.supportsExternalPlayers(),
externalPlayerHint: externalPlayer() === 'vlc' ? 'VLC' : 'MPV',
hasPlaybackPosition: deps.hasPlaybackPosition(),
hasStoredProgress:
@@ -1,8 +1,19 @@
import {
buildVodMenuSections,
formatPlaybackClock,
VOD_DETAILS_MENU_ACTION,
} from './vod-details-presentation';
describe('formatPlaybackClock', () => {
it('formats a resume point as a clock', () => {
expect(formatPlaybackClock(754)).toBe('12:34');
expect(formatPlaybackClock(5025)).toBe('1:23:45');
expect(formatPlaybackClock(65)).toBe('1:05');
expect(formatPlaybackClock(0)).toBe('');
expect(formatPlaybackClock(null)).toBe('');
});
});
const BASE = {
externalPlayerAvailable: true,
externalPlayerHint: 'MPV' as const,
@@ -159,6 +159,20 @@ export function buildPrimaryAction(
};
}
/** "12:34" or "1:23:45" for a resume point; empty without one. */
export function formatPlaybackClock(
seconds: number | null | undefined
): string {
const total = Math.floor(seconds ?? 0);
if (total <= 0) {
return '';
}
const minutes = Math.floor((total % 3600) / 60);
const rest = `${minutes.toString().padStart(2, '0')}:${(total % 60).toString().padStart(2, '0')}`;
const hours = Math.floor(total / 3600);
return hours > 0 ? `${hours}:${rest}` : `${minutes}:${rest.slice(3)}`;
}
export function buildVodMenuSections(input: {
externalPlayerAvailable: boolean;
externalPlayerHint: 'MPV' | 'VLC';
@@ -47,6 +47,7 @@ import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
@@ -194,6 +195,8 @@ export class VodDetailsComponent {
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly router = inject(Router);
@@ -264,25 +267,9 @@ export class VodDetailsComponent {
* Whether there's a playback position to resume from. A watched movie
* shows Play, not "Resume 1:32:00" from its final seconds.
*/
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
return pos !== null && pos > 0 && !this.isWatched();
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
readonly formattedPosition = computed(() => {
const pos = this.playbackPosition();
if (!pos || pos <= 0) return '';
const hours = Math.floor(pos / 3600);
const minutes = Math.floor((pos % 3600) / 60);
const seconds = Math.floor(pos % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
readonly hasPlaybackPosition = computed(
() => (this.playbackPosition() ?? 0) > 0 && !this.isWatched()
);
private readonly downloadState = createVodDownloadState(
this.downloadsService,
@@ -322,9 +309,9 @@ export class VodDetailsComponent {
playbackPosition: this.playbackPosition,
playbackDurationSeconds: this.playbackDurationSeconds,
hasPlaybackPosition: this.hasPlaybackPosition,
formattedPosition: this.formattedPosition,
isWatched: this.isWatched,
isElectron: this.isElectron,
supportsExternalPlayers: () =>
this.runtime.supportsManagedExternalPlayers,
playbackStartPending: this.playbackStartPending,
isOfflinePrimary: this.isOfflinePrimary,
externalLabel: this.externalPrimaryLabel,