diff --git a/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts b/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts index 6b57833a2..1511c29ea 100644 --- a/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts +++ b/libs/portal/shared/util/src/lib/replace-owned-external-session.spec.ts @@ -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), }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 2c72e4b5b..870b72ea2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -79,6 +79,12 @@ class StubStalkerInlineDetailComponent { readonly inlinePlaybackClosed = output(); readonly streamUrlCopied = output(); readonly inlineExternalFallbackRequested = output(); + readonly externalPlayRequested = output<{ + item: VodDetailsItem; + player: 'mpv' | 'vlc'; + positionSeconds: number | null; + }>(); + readonly resetProgressRequested = output(); } 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 2034e9d63..4df4824de 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -89,6 +89,12 @@ import { (inlineExternalFallbackRequested)=" handleExternalFallbackRequest($event) " + (externalPlayRequested)=" + playback.vodDetailActions.openExternal($event) + " + (resetProgressRequested)=" + playback.vodDetailActions.resetProgress($event) + " /> } @else { ; 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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index edcb707ad..83fe416f2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -28,6 +28,7 @@ [sourceLabel]="sourceLabel()" [inlinePlayback]="inlinePlayback()" [externalPlayback]="externalPlayback.activeSession()" + [playbackStartPending]="playbackStartPending()" (playClicked)="onVodPlay($event)" (resumeClicked)="onVodResume($event)" (inlineTimeUpdated)="handleInlineTimeUpdate($event)" diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index cd4c1b982..ff1842956 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -213,6 +213,10 @@ export class StalkerSearchComponent { readonly selectedVodPlaybackPosition = computed( () => 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, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 2415033ef..d0c9219ec 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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( diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts index cd42ec6a2..8a50d4fc9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.spec.ts @@ -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(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 }, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts index f113274a7..6d612df56 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts @@ -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,