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 b82a6549b..28752d8ab 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 @@ -24,9 +24,16 @@ const POSITION = { describe('createStalkerVodDetailActions openExternal', () => { function setup(selectedVodId: () => number | null) { let resolveLink: (playback: unknown) => void = () => undefined; + let rejectLink: (error: unknown) => void = () => undefined; const resolvePlayback = jest.fn( - () => new Promise((resolve) => (resolveLink = resolve)) + () => + new Promise((resolve, reject) => { + resolveLink = resolve; + rejectLink = reject; + }) ); + const open = jest.fn(); + const logError = jest.fn(); const openExternalPlayback = jest.fn().mockResolvedValue(undefined); const beforeExternalLaunch = jest.fn(); const closeSession = jest.fn().mockResolvedValue(undefined); @@ -47,15 +54,18 @@ describe('createStalkerVodDetailActions openExternal', () => { selectedVodId, selectedVodPosition: signal(null), beforeExternalLaunch, - snackBar: { open: jest.fn() }, + snackBar: { open }, translate: { instant: (key: string) => key }, - logError: jest.fn(), + logError, }); return { actions, openExternalPlayback, beforeExternalLaunch, closeSession, + open, + logError, + rejectLink: (error: unknown) => rejectLink(error), beginPendingStart, settlePendingStart, supersede: () => (launchCurrent = false), @@ -202,6 +212,21 @@ describe('createStalkerVodDetailActions openExternal', () => { expect(t.settlePendingStart).toHaveBeenCalledTimes(1); }); + it('stays silent when a superseded launch fails', async () => { + const t = setup(() => 42); + const launch = t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + t.supersede(); + t.rejectLink(new Error('create_link timed out')); + await launch; + + expect(t.open).not.toHaveBeenCalled(); + expect(t.logError).not.toHaveBeenCalled(); + }); + 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 c5b82cb18..8eaeed6be 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 @@ -168,10 +168,13 @@ export function createStalkerVodDetailActions( event.player ); } catch (error) { - deps.logError('External VOD playback failed', error); - if (stillSelected()) { - notify('PORTALS.PLAYBACK_ERROR'); + // A launch a newer start superseded fails on its own; the + // newer one reports for the movie now. + if (!stillSelected() || superseded()) { + return; } + deps.logError('External VOD playback failed', error); + notify('PORTALS.PLAYBACK_ERROR'); } finally { externalLaunchesInFlight.delete(launchKey); pending?.settle(); 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 6d612df56..8d7814c0b 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 @@ -92,6 +92,20 @@ describe('openEpisodeExternally', () => { expect(t.openExternalPlayback).not.toHaveBeenCalled(); }); + it('ignores a repeat before the first launch settled', async () => { + const t = host(null); + let settleLaunch: () => void = () => undefined; + t.openExternalPlayback.mockImplementation( + () => new Promise((resolve) => (settleLaunch = resolve)) + ); + const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + await openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + settleLaunch(); + await first; + + expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); + }); + it('keeps the running player when closing it fails', async () => { const t = host(session()); t.closeSession.mockRejectedValue(new Error('still busy')); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts index 4108a23c8..c6a664208 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -20,12 +20,16 @@ export interface SeriesExternalLaunchHost { launchOwner(): string | null; } +/** Owners (`playlist:series`) whose launch has not settled yet. */ +const launchesInFlight = new Set(); + /** * The "…" menu's MPV/VLC launch of an episode. An episode of this series * still running externally is closed first: with instance reuse off a * second detached player would start beside it. When that close fails, or * the user moved on while it ran, the running player stays and nothing new - * launches. + * launches. A repeat before the first launch settled (Electron publishes + * the session only afterwards) is ignored. */ export async function openEpisodeExternally( host: SeriesExternalLaunchHost, @@ -33,9 +37,23 @@ export async function openEpisodeExternally( player: ExternalPlayerName ): Promise { const owner = host.launchOwner(); - if (!owner) { + if (!owner || launchesInFlight.has(owner)) { return; } + launchesInFlight.add(owner); + try { + return await launchEpisode(host, owner, playback, player); + } finally { + launchesInFlight.delete(owner); + } +} + +async function launchEpisode( + host: SeriesExternalLaunchHost, + owner: string, + playback: ResolvedPortalPlayback, + player: ExternalPlayerName +): Promise { const session = host.externalPlayback.activeSession(); const info = session?.contentInfo; const ownSession =