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 8a50d4fc9..3d40fae93 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 @@ -112,6 +112,25 @@ describe('createStalkerVodDetailActions openExternal', () => { ); }); + it('ignores a second launch while the first is still resolving', async () => { + const t = setup(() => 42); + const first = t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + await t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + t.resolveLink(); + await first; + + expect(t.beginPendingStart).toHaveBeenCalledTimes(1); + expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); + }); + it('drops the stream once another movie was selected meanwhile', async () => { let selected = 42; const t = setup(() => selected); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts index 35798d12e..2796f63c0 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-detail-actions.ts @@ -71,6 +71,9 @@ export function createStalkerVodDetailActions( deps.snackBar.open(deps.translate.instant(key), undefined, { duration: 3000, }); + // A second "Open in external player" during a slow `create_link` would + // resolve beside the first and start a second player. + let externalLaunchInFlight = false; return { /** The inline player copied the stream URL. */ @@ -93,9 +96,10 @@ export function createStalkerVodDetailActions( }, async openExternal(event: StalkerVodExternalPlayEvent): Promise { - if (event.item.type !== 'stalker') { + if (event.item.type !== 'stalker' || externalLaunchInFlight) { return; } + externalLaunchInFlight = true; // The `create_link` round trip may outlive the selection: a // stream resolved for a movie the user left is dropped, and its // failure is not reported over the new one. @@ -137,6 +141,7 @@ export function createStalkerVodDetailActions( notify('PORTALS.PLAYBACK_ERROR'); } } finally { + externalLaunchInFlight = false; settlePendingStart?.(); } }, diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts index a95d55ae1..9afaec367 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-hero.state.ts @@ -125,6 +125,7 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) { playbackActive: deps.externalState() !== 'idle' || deps.playbackStartPending(), + startPending: deps.playbackStartPending(), }) ), }; diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts index ce0880ccd..1105f564c 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.spec.ts @@ -20,6 +20,7 @@ const BASE = { hasPlaybackPosition: true, hasStoredProgress: true, playbackActive: false, + startPending: false, }; function rows(input: Partial) { @@ -38,6 +39,18 @@ describe('buildVodMenuSections', () => { expect(reset?.disabled).toBeFalsy(); }); + it('disables the external launch while a start still resolves', () => { + const external = rows({ startPending: true }).find( + (row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer + ); + expect(external?.disabled).toBe(true); + expect( + rows({}).find( + (row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer + )?.disabled + ).toBeFalsy(); + }); + it('disables the progress reset while playback owns the position', () => { // A running player, or a start still resolving its stream, would // write the position right back. diff --git a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts index 79cab76e0..48c6c2148 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details-presentation.ts @@ -180,6 +180,8 @@ export function buildVodMenuSections(input: { hasStoredProgress: boolean; /** An external player owns the row: its next tick would undo a reset. */ playbackActive: boolean; + /** A start still resolving its stream: a second launch would double it. */ + startPending: boolean; }): VodMoreMenuSection[] { const sourceRows: VodMoreMenuSection['items'][number][] = []; if (input.externalPlayerAvailable) { @@ -188,6 +190,7 @@ export function buildVodMenuSections(input: { labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER', icon: 'open_in_new', hint: input.externalPlayerHint, + disabled: input.startPending, testId: 'vod-menu-external', }); }