mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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,
|
||||
|
||||
+13
-1
@@ -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 },
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user