diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts new file mode 100644 index 000000000..8b96125bc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.spec.ts @@ -0,0 +1,79 @@ +import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue'; + +function deferred(): { promise: Promise; settle: () => void } { + let settle!: () => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +} + +describe('StalkerSeriesLaunchQueue', () => { + const A = 'playlist-1:100'; + const B = 'playlist-1:200'; + + it('plays the last choice held during a launch once it settles', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const launch = deferred(); + const run = queue.run( + A, + () => launch.promise, + () => true + ); + const first = jest.fn(); + const second = jest.fn(); + + expect(queue.isLaunching(A)).toBe(true); + queue.hold(A, first); + queue.hold(A, second); + expect(second).not.toHaveBeenCalled(); + + launch.settle(); + await run; + + expect(queue.isLaunching(A)).toBe(false); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('keeps a choice for one series when a launch of another settles first', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const launchA = deferred(); + const launchB = deferred(); + const runA = queue.run( + A, + () => launchA.promise, + () => false + ); + const runB = queue.run( + B, + () => launchB.promise, + () => true + ); + const choiceB = jest.fn(); + queue.hold(B, choiceB); + + // The page shows B now: A settling must not touch B's choice. + launchA.settle(); + await runA; + expect(choiceB).not.toHaveBeenCalled(); + expect(queue.isLaunching(B)).toBe(true); + + launchB.settle(); + await runB; + expect(choiceB).toHaveBeenCalledTimes(1); + }); + + it('drops the choice when the page moved on, even if the launch failed', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const choice = jest.fn(); + const run = queue.run( + A, + () => Promise.reject(new Error('launch failed')), + () => false + ); + queue.hold(A, choice); + + await expect(run).rejects.toThrow('launch failed'); + expect(choice).not.toHaveBeenCalled(); + expect(queue.isLaunching(A)).toBe(false); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts new file mode 100644 index 000000000..3af05de1c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-launch-queue.ts @@ -0,0 +1,43 @@ +/** + * Episode choices made while a forced MPV/VLC launch of their series is + * mid-flight. The launch IPC cannot be cancelled by a later request, so the + * choice waits and plays once the launch settles instead of opening a second + * player; the last choice per series wins. + * + * Keyed by `playlist:series`: the series view is reused across series, so a + * launch of one series settling must not drop what another series holds. + */ +export class StalkerSeriesLaunchQueue { + private readonly launching = new Set(); + private readonly choices = new Map void>(); + + /** True while a launch of `seriesKey` is mid-flight. */ + isLaunching(seriesKey: string): boolean { + return this.launching.has(seriesKey); + } + + /** Holds `choice` until the launch of `seriesKey` settles, replacing an earlier one. */ + hold(seriesKey: string, choice: () => void): void { + this.choices.set(seriesKey, choice); + } + + /** + * Runs `launch`, then the choice held for `seriesKey` meanwhile, unless + * `stillShown` reports that the page moved on to another series. + */ + async run( + seriesKey: string, + launch: () => Promise, + stillShown: () => boolean + ): Promise { + this.launching.add(seriesKey); + try { + await launch(); + } finally { + this.launching.delete(seriesKey); + const choice = this.choices.get(seriesKey); + this.choices.delete(seriesKey); + if (choice && stillShown()) choice(); + } + } +} 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 d174a3205..892a46fe2 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 @@ -100,6 +100,7 @@ import { } from '@iptvnator/services'; import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; import { StalkerSeriesHeroPresenter } from './stalker-series-hero.presenter'; +import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue'; import { StalkerSeriesMenuService } from './stalker-series-menu.service'; import { getStalkerSeriesQuickStartButton, @@ -289,13 +290,8 @@ 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; + /** Episode choices made while a forced MPV/VLC launch is mid-flight. */ + private readonly launchQueue = new StalkerSeriesLaunchQueue(); /** `playlist:series` of the series on screen; provider ids collide across playlists. */ readonly currentSeriesKey = computed( () => @@ -1035,10 +1031,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); + const seriesKey = this.currentSeriesKey(); + if (this.launchQueue.isLaunching(seriesKey)) { + // The launch cannot be cancelled: the choice plays once it settles. + this.launchQueue.hold(seriesKey, () => + this.onEpisodeClicked(episode, startTimeOverride, forcePlayer) + ); return; } const item = this.displayItem(); @@ -1350,17 +1348,16 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (forcePlayer) { // Awaited so the start stays pending through the close of the // previous player and the launch itself. - this.launchingSeriesIds.add(pendingSeriesId); - try { - await this.openEpisodeExternally( - resolvedPlayback, - forcePlayer, - request - ); - } finally { - this.launchingSeriesIds.delete(pendingSeriesId); - this.playQueuedEpisodeChoice(pendingSeriesId); - } + await this.launchQueue.run( + pendingSeriesId, + () => + this.openEpisodeExternally( + resolvedPlayback, + forcePlayer, + request + ), + () => this.currentSeriesKey() === pendingSeriesId + ); } else { void this.portalPlayer.openResolvedPlayback( resolvedPlayback, @@ -1389,13 +1386,6 @@ 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 {