diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts index 6fae579be..7ea37562d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-menu.service.ts @@ -65,6 +65,7 @@ export class StalkerSeriesMenuService { (watch?.seriesActionDisabled() ?? true), hasProgress: this.bindings()?.hasProgress() ?? false, playbackActive: this.bindings()?.playbackActive() ?? false, + startPending: this.bindings()?.startPending() ?? false, sourcesCount: 0, externalPlayerHint: quickStart?.action && diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 4b9c41805..d6937ca07 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -39,6 +39,7 @@ describe('StalkerSeriesViewComponent', () => { const resolveVodPlayback = jest.fn(); const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]); const openResolvedPlayback = jest.fn(); + const openExternalPlayback = jest.fn(); const isEmbeddedPlayer = jest.fn(); const tmdbGetSeason = jest.fn(); const fetchLinkToPlay = jest.fn(); @@ -54,6 +55,13 @@ describe('StalkerSeriesViewComponent', () => { await fixture.whenStable(); } + /** Lets a start's awaited continuations run, then renders their state. */ + async function settleStart(): Promise { + await stabilize(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + } + beforeEach(async () => { selectedContentType.set('series'); selectedItem.set({ @@ -104,6 +112,7 @@ describe('StalkerSeriesViewComponent', () => { getSeriesPlaybackPositions.mockClear(); getSeriesPlaybackPositions.mockResolvedValue([]); openResolvedPlayback.mockClear(); + openExternalPlayback.mockReset(); isEmbeddedPlayer.mockReset(); isEmbeddedPlayer.mockReturnValue(false); tmdbGetSeason.mockReset(); @@ -159,6 +168,7 @@ describe('StalkerSeriesViewComponent', () => { useValue: { isEmbeddedPlayer, openResolvedPlayback, + openExternalPlayback, }, }, { @@ -269,6 +279,62 @@ describe('StalkerSeriesViewComponent', () => { ); }); + it('holds the quick-start button until the start settles', async () => { + let finishResolve!: (playback: unknown) => void; + resolveVodPlayback.mockImplementationOnce( + () => new Promise((resolve) => (finishResolve = resolve)) + ); + await stabilize(); + fixture.detectChanges(); + const button = (): HTMLButtonElement | null => + fixture.nativeElement.querySelector( + '[data-testid="series-quick-start"]' + ); + + button()?.click(); + fixture.detectChanges(); + // A second press could not cancel the first start. + expect(button()?.disabled).toBe(true); + + finishResolve({ streamUrl: 'http://stalker.example/episode.mpg' }); + await settleStart(); + expect(button()?.disabled).toBe(false); + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + }); + + it('plays an episode chosen during a forced launch once that launch settles', async () => { + let finishLaunch!: () => void; + openExternalPlayback.mockImplementationOnce( + () => new Promise((resolve) => (finishLaunch = resolve)) + ); + await stabilize(); + const [first, second] = fixture.componentInstance.mappedSeasons()['1']; + + fixture.componentInstance.onEpisodeClicked(first, undefined, 'mpv'); + await settleStart(); + expect(openExternalPlayback).toHaveBeenCalledTimes(1); + + // The launch IPC cannot be cancelled: the choice waits for it instead + // of opening a second player next to the first. + fixture.componentInstance.onEpisodeClicked(second); + await settleStart(); + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + + finishLaunch(); + await settleStart(); + expect(resolveVodPlayback).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + '/media/file_30001.mpg', + 'Regular Series', + 'poster.jpg', + 2, + expect.any(Number), + undefined + ); + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + }); + it('keeps provider episodes playable while hiding download presentation', async () => { fixture.componentRef.setInput('providerOnly', true); 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 aea1aa101..d174a3205 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 @@ -289,11 +289,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { * starting. Provider series ids are playlist-scoped. */ private readonly pendingStartSeriesIds = signal([]); + /** + * `playlist:series` keys whose forced MPV/VLC launch is mid-flight. The + * launch IPC cannot be cancelled, so an episode chosen meanwhile waits + * in `queuedEpisodeChoice` instead of opening a second player. + */ + private readonly launchingSeriesIds = new Set(); + private queuedEpisodeChoice: (() => void) | null = null; /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => `${this.stalkerStore.currentPlaylist()?._id ?? ''}:${this.displayItem()?.id ?? ''}` ); + /** A start of the series on screen that has not settled. */ + readonly startPending = computed(() => + this.pendingStartSeriesIds().includes(this.currentSeriesKey()) + ); readonly seasonWatchBatchRunning = signal(false); /** @@ -397,7 +408,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { constructor() { this.heroPresenter.bind({ displayItem: this.displayItem, - quickStart: this.quickStartAction, + quickStart: this.quickStartButton, yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate), similarInPortals: this.similarInPortals, openSimilarInPortals: (item) => this.openSimilarInPortals(item), @@ -410,16 +421,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { ), playbackActive: computed( () => - this.pendingStartSeriesIds().includes( - this.currentSeriesKey() - ) || + this.startPending() || this.inlinePlayback() !== null || this.openingEpisodeId() !== null || this.activeEpisodeId() !== null ), - startPending: computed(() => - this.pendingStartSeriesIds().includes(this.currentSeriesKey()) - ), + startPending: this.startPending, resetProgress: () => this.resetProgress(), openExternal: (player) => this.openQuickStartExternally(player), }); @@ -765,6 +772,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { vodSeriesSeasons: this.vodSeriesSeasons(), }); }); + /** The hero's button: held while a start is pending, a second press would double it. */ + readonly quickStartButton = computed(() => { + const button = this.quickStartAction(); + return button && this.startPending() + ? { ...button, disabled: true } + : button; + }); readonly inlineEpisodeState = computed(() => { const identity = this.inlinePlaybackEpisodeIdentity(); const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? ''; @@ -1021,6 +1035,12 @@ export class StalkerSeriesViewComponent implements OnDestroy { startTimeOverride?: number, forcePlayer?: ExternalPlayerName ) { + if (this.launchingSeriesIds.has(this.currentSeriesKey())) { + // The last choice made during the launch plays once it settles. + this.queuedEpisodeChoice = () => + this.onEpisodeClicked(episode, startTimeOverride, forcePlayer); + return; + } const item = this.displayItem(); const episodeState = resolveSelectedStalkerEpisodeState({ episodesBySeason: this.mappedSeasons(), @@ -1330,11 +1350,17 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (forcePlayer) { // Awaited so the start stays pending through the close of the // previous player and the launch itself. - await this.openEpisodeExternally( - resolvedPlayback, - forcePlayer, - request - ); + this.launchingSeriesIds.add(pendingSeriesId); + try { + await this.openEpisodeExternally( + resolvedPlayback, + forcePlayer, + request + ); + } finally { + this.launchingSeriesIds.delete(pendingSeriesId); + this.playQueuedEpisodeChoice(pendingSeriesId); + } } else { void this.portalPlayer.openResolvedPlayback( resolvedPlayback, @@ -1363,6 +1389,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + /** Plays the choice held during a launch, unless the page moved on meanwhile. */ + private playQueuedEpisodeChoice(seriesKey: string): void { + const queued = this.queuedEpisodeChoice; + this.queuedEpisodeChoice = null; + if (queued && this.currentSeriesKey() === seriesKey) queued(); + } + private isPlaybackRequestCurrent( request: StalkerSeriesPlaybackRequestContext ): boolean { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index 6ec898c1f..fb1972259 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -162,6 +162,7 @@ export class SerialDetailsMenuService { (watch?.seriesActionDisabled() ?? true), hasProgress: this.hasProgress(), playbackActive: this.bindings()?.playbackActive() ?? false, + startPending: this.bindings()?.startPending() ?? false, sourcesCount: 0, externalPlayerHint: quickStart && this.runtime.supportsManagedExternalPlayers diff --git a/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts b/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts index ea43a945e..d300225fc 100644 --- a/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts +++ b/libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts @@ -15,8 +15,9 @@ const BASE = { seriesActionDisabled: false, hasProgress: true, playbackActive: false, + startPending: false, sourcesCount: 0, - externalPlayerHint: null, + externalPlayerHint: 'MPV' as const, copyUrlEpisodeCode: null, downloadVisible: false, downloadCount: 0, @@ -26,10 +27,14 @@ const BASE = { inContinueWatching: false, }; -function resetRow(input: Partial) { +function row(input: Partial, id: string) { return buildSeriesMenuSections({ ...BASE, ...input }) .flatMap((section) => section.items) - .find((row) => row.id === SERIES_MENU_ACTION.ResetProgress); + .find((item) => item.id === id); +} + +function resetRow(input: Partial) { + return row(input, SERIES_MENU_ACTION.ResetProgress); } describe('buildSeriesMenuSections', () => { @@ -47,4 +52,11 @@ describe('buildSeriesMenuSections', () => { it('hides the reset once every episode is watched', () => { expect(resetRow({ seriesFullyWatched: true })).toBeUndefined(); }); + + it('holds the external-player row while a start has not settled', () => { + const external = SERIES_MENU_ACTION.ExternalPlayer; + expect(row({}, external)?.disabled).toBeFalsy(); + // A second launch could not cancel the first: both players would open. + expect(row({ startPending: true }, external)?.disabled).toBe(true); + }); }); diff --git a/libs/ui/components/src/lib/detail-ui/series-hero.state.ts b/libs/ui/components/src/lib/detail-ui/series-hero.state.ts index 10ab231ea..3285d42f4 100644 --- a/libs/ui/components/src/lib/detail-ui/series-hero.state.ts +++ b/libs/ui/components/src/lib/detail-ui/series-hero.state.ts @@ -193,6 +193,8 @@ export interface SeriesMenuInput { readonly hasProgress: boolean; /** An episode plays or launches: its next position tick would undo a reset. */ readonly playbackActive: boolean; + /** A start has not settled: another launch would double it. */ + readonly startPending: boolean; readonly sourcesCount: number; readonly externalPlayerHint: 'MPV' | 'VLC' | null; readonly copyUrlEpisodeCode: string | null; @@ -261,6 +263,7 @@ export function buildSeriesMenuSections( labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER', icon: 'open_in_new', hint: input.externalPlayerHint, + disabled: input.startPending, testId: 'vod-menu-external', }); }