fix(portals): ninth review round and the spec typecheck

- The Favorites/Recent Stalker detail wires the menu's external launch
  and progress reset to collection-owned actions.
- The Stalker search host passes its pending-start state to the detail,
  so the reset row is disabled while a launch resolves.
- A rejected forced Stalker episode launch is logged and reported.
- The spec mocks of the external playback handle are signals, as the
  contract types them (CI spec typecheck).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 12:27:15 +02:00
1 parent 23b70c524f
commit fad77eda18
9 files changed
+110 -7

No files matched your search

@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { replaceOwnedExternalSession } from './replace-owned-external-session';
@@ -21,7 +22,7 @@ function session(
describe('replaceOwnedExternalSession', () => {
function playback(active: ExternalPlayerSession | null) {
return {
activeSession: () => active,
activeSession: signal(active),
closeSession: jest.fn().mockResolvedValue(undefined),
};
}
@@ -79,6 +79,12 @@ class StubStalkerInlineDetailComponent {
readonly inlinePlaybackClosed = output<void>();
readonly streamUrlCopied = output<void>();
readonly inlineExternalFallbackRequested = output<unknown>();
readonly externalPlayRequested = output<{
item: VodDetailsItem;
player: 'mpv' | 'vlc';
positionSeconds: number | null;
}>();
readonly resetProgressRequested = output<VodDetailsItem>();
}
describe('StalkerCollectionDetailComponent', () => {
@@ -622,6 +628,45 @@ describe('StalkerCollectionDetailComponent', () => {
);
});
it('routes the menu launch and reset through the collection actions', async () => {
const sourceItem = createSourceItem();
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'movie',
categoryId: 'vod',
stalkerItem: sourceItem,
})
);
await settleDetail(fixture);
await settleDetail(fixture);
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
const item = createStalkerVodItem(sourceItem, playlist._id);
stalkerStore.resolveVodPlayback.mockResolvedValue({
streamUrl: 'http://cdn/1701.mp4',
title: 'Movie',
});
detail.externalPlayRequested.emit({
item,
player: 'vlc',
positionSeconds: null,
});
await settleDetail(fixture);
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({ streamUrl: 'http://cdn/1701.mp4' }),
'vlc'
);
detail.resetProgressRequested.emit(item);
await settleDetail(fixture);
expect(
playbackPositions.clearPlaybackPositionOrThrow
).toHaveBeenCalledWith('stalker-1', 1701, 'vod');
});
it('blocks the watched toggle while a collection Play is still resolving', async () => {
const sourceItem = createSourceItem();
let resolve!: (value: ResolvedPortalPlayback) => void;
@@ -89,6 +89,12 @@ import {
(inlineExternalFallbackRequested)="
handleExternalFallbackRequest($event)
"
(externalPlayRequested)="
playback.vodDetailActions.openExternal($event)
"
(resetProgressRequested)="
playback.vodDetailActions.resetProgress($event)
"
/>
} @else {
<app-portal-detail-shell
@@ -152,6 +158,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff {
stalkerStore: this.stalkerStore,
playbackPositions: this.playbackPositions,
portalPlayer: this.portalPlayer,
externalPlayback: this.externalPlayback,
snackBar: this.snackBar,
translateService: this.translateService,
logger: this.logger,
@@ -3,6 +3,7 @@ import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type {
Logger,
PortalExternalPlayback,
PortalPlaybackPositions,
PortalPlayer,
UnifiedCollectionItem,
@@ -18,6 +19,7 @@ import {
ResolvedPortalPlayback,
VodDetailsItem,
} from '@iptvnator/shared/interfaces';
import { createStalkerVodDetailActions } from './stalker-vod-detail-actions';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
import { createStalkerVodWatchedToggle } from './stalker-vod-watched-toggle';
@@ -32,6 +34,10 @@ interface StalkerCollectionPlaybackControllerConfig {
stalkerStore: InstanceType<typeof StalkerStore>;
playbackPositions: PortalPlaybackPositions;
portalPlayer: PortalPlayer;
externalPlayback: Pick<
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
snackBar: MatSnackBar;
translateService: TranslateService;
logger: Logger;
@@ -107,6 +113,32 @@ export class StalkerCollectionPlaybackController {
});
}
/** The "…" menu's external launch and progress reset of the shown movie. */
readonly vodDetailActions = createStalkerVodDetailActions({
resolvePlayback: (cmd, title, thumbnail, startTime) =>
this.config.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
undefined,
undefined,
startTime
),
portalPlayer: this.config.portalPlayer,
externalPlayback: this.config.externalPlayback,
playbackPositions: this.config.playbackPositions,
playlistId: () => this.playbackOwner()?.sourceId,
selectedVodId: () => Number(this.playbackOwner()?.contentId) || null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () =>
this.vodPlayback.discardPendingPositionLoad(),
beginPendingStart: () => this.vodPlayback.beginPendingStart(),
beforeExternalLaunch: () => this.closeInlinePlayer(),
snackBar: this.config.snackBar,
translate: this.config.translateService,
logError: (message, error) => this.config.logger.error(message, error),
});
onVodPlay(item: VodDetailsItem): void {
if (item.type === 'stalker') {
void this.startVodPlayback(
@@ -28,6 +28,7 @@
[sourceLabel]="sourceLabel()"
[inlinePlayback]="inlinePlayback()"
[externalPlayback]="externalPlayback.activeSession()"
[playbackStartPending]="playbackStartPending()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
@@ -213,6 +213,10 @@ export class StalkerSearchComponent {
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
);
/** A Play/Resume or menu launch still resolving its stream. */
readonly playbackStartPending = computed(() =>
this.vodPlayback.playbackStartPending()
);
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
@@ -1247,7 +1247,19 @@ export class StalkerSeriesViewComponent implements OnDestroy {
) {
return;
}
await this.portalPlayer.openExternalPlayback(playback, player);
try {
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.
if (generation !== this.seriesPlaybackRequestGeneration) return;
this.logger.error('External episode launch failed', error);
this.snackBar.open(
this.translateService.instant('PORTALS.PLAYBACK_ERROR'),
undefined,
{ duration: 3000 }
);
}
}
private async startPlayback(
@@ -30,13 +30,13 @@ describe('createStalkerVodDetailActions openExternal', () => {
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const beforeExternalLaunch = jest.fn();
const closeSession = jest.fn().mockResolvedValue(undefined);
let running: ExternalPlayerSession | null = null;
const running = signal<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 },
externalPlayback: { activeSession: running, closeSession },
beginPendingStart,
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
playlistId: () => 'portal-1',
@@ -55,7 +55,7 @@ describe('createStalkerVodDetailActions openExternal', () => {
beginPendingStart,
settlePendingStart,
setRunning: (session: ExternalPlayerSession | null) =>
(running = session),
running.set(session),
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
};
}
@@ -146,7 +146,7 @@ describe('createStalkerVodDetailActions resetProgress', () => {
resolvePlayback: jest.fn(),
portalPlayer: { openExternalPlayback: jest.fn() },
externalPlayback: {
activeSession: () => null,
activeSession: signal(null),
closeSession: jest.fn(),
},
playbackPositions: { clearPlaybackPositionOrThrow },
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
@@ -45,7 +46,7 @@ describe('openEpisodeExternally', () => {
host: {
portalPlayer: { openExternalPlayback },
externalPlayback: {
activeSession: () => active,
activeSession: signal(active),
closeSession,
},
launchOwner,