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 index 8b96125bc..ade5f4a4b 100644 --- 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 @@ -1,11 +1,27 @@ -import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue'; +import { + StalkerSeriesLaunchQueue, + type StalkerSeriesLaunchRelease, +} from './stalker-series-launch-queue'; -function deferred(): { promise: Promise; settle: () => void } { - let settle!: () => void; - const promise = new Promise((resolve) => (settle = resolve)); +function deferred(): { + promise: Promise; + settle: (value: T) => void; +} { + let settle!: (value: T) => void; + const promise = new Promise((resolve) => (settle = resolve)); return { promise, settle }; } +function release( + overrides: Partial = {} +): StalkerSeriesLaunchRelease { + return { + stillShown: () => true, + replacePlayer: () => Promise.resolve(true), + ...overrides, + }; +} + describe('StalkerSeriesLaunchQueue', () => { const A = 'playlist-1:100'; const B = 'playlist-1:200'; @@ -13,11 +29,7 @@ describe('StalkerSeriesLaunchQueue', () => { 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 run = queue.run(A, () => launch.promise, release()); const first = jest.fn(); const second = jest.fn(); @@ -34,6 +46,47 @@ describe('StalkerSeriesLaunchQueue', () => { expect(second).toHaveBeenCalledTimes(1); }); + it('replaces the player the launch opened before the held choice starts', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const replace = deferred(); + const replacePlayer = jest.fn().mockReturnValue(replace.promise); + const run = queue.run( + A, + () => Promise.resolve(), + release({ replacePlayer }) + ); + const choice = jest.fn(); + queue.hold(A, choice); + await Promise.resolve(); + await Promise.resolve(); + + // The choice must not play beside the opened player, and nothing + // else may start while that player closes. + expect(replacePlayer).toHaveBeenCalledTimes(1); + expect(choice).not.toHaveBeenCalled(); + expect(queue.isLaunching(A)).toBe(true); + + replace.settle(true); + await run; + expect(choice).toHaveBeenCalledTimes(1); + expect(queue.isLaunching(A)).toBe(false); + }); + + it('keeps the player and drops the choice when it cannot be replaced', async () => { + const queue = new StalkerSeriesLaunchQueue(); + const choice = jest.fn(); + const run = queue.run( + A, + () => Promise.resolve(), + release({ replacePlayer: () => Promise.resolve(false) }) + ); + queue.hold(A, choice); + + await run; + expect(choice).not.toHaveBeenCalled(); + expect(queue.isLaunching(A)).toBe(false); + }); + it('keeps a choice for one series when a launch of another settles first', async () => { const queue = new StalkerSeriesLaunchQueue(); const launchA = deferred(); @@ -41,13 +94,9 @@ describe('StalkerSeriesLaunchQueue', () => { const runA = queue.run( A, () => launchA.promise, - () => false - ); - const runB = queue.run( - B, - () => launchB.promise, - () => true + release({ stillShown: () => false }) ); + const runB = queue.run(B, () => launchB.promise, release()); const choiceB = jest.fn(); queue.hold(B, choiceB); @@ -65,14 +114,16 @@ describe('StalkerSeriesLaunchQueue', () => { it('drops the choice when the page moved on, even if the launch failed', async () => { const queue = new StalkerSeriesLaunchQueue(); const choice = jest.fn(); + const replacePlayer = jest.fn(); const run = queue.run( A, () => Promise.reject(new Error('launch failed')), - () => false + release({ stillShown: () => false, replacePlayer }) ); queue.hold(A, choice); await expect(run).rejects.toThrow('launch failed'); + expect(replacePlayer).not.toHaveBeenCalled(); 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 index 3af05de1c..0b12975e6 100644 --- 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 @@ -1,43 +1,76 @@ +/** What releasing a held choice needs from the page once its launch settled. */ +export interface StalkerSeriesLaunchRelease { + /** Whether the page still shows the series: a choice the viewer left behind is dropped. */ + readonly stillShown: () => boolean; + /** Closes the player the launch opened; false keeps it and drops the choice. */ + readonly replacePlayer: () => Promise; +} + /** * 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. + * choice waits and, once the launch settled, replaces the player it opened + * instead of playing beside it; 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 launching = new Map(); private readonly choices = new Map void>(); - /** True while a launch of `seriesKey` is mid-flight. */ + /** True while a launch of `seriesKey`, or the release of its held choice, is mid-flight. */ isLaunching(seriesKey: string): boolean { - return this.launching.has(seriesKey); + return (this.launching.get(seriesKey) ?? 0) > 0; } - /** Holds `choice` until the launch of `seriesKey` settles, replacing an earlier one. */ + /** + * Holds `choice` until the launch of `seriesKey` settles, replacing an + * earlier one. The choice must start the episode directly: the series + * still counts as launching when it runs. + */ 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. + * Runs `launch`, then the choice held for `seriesKey` meanwhile, after + * `replacePlayer` closed what the launch opened. The series counts as + * launching until that choice started, so nothing slips in between. */ async run( seriesKey: string, launch: () => Promise, - stillShown: () => boolean + release: StalkerSeriesLaunchRelease ): Promise { - this.launching.add(seriesKey); + this.count(seriesKey, 1); try { await launch(); } finally { + try { + await this.releaseChoice(seriesKey, release); + } finally { + this.count(seriesKey, -1); + } + } + } + + private async releaseChoice( + seriesKey: string, + release: StalkerSeriesLaunchRelease + ): Promise { + const choice = this.choices.get(seriesKey); + this.choices.delete(seriesKey); + if (!choice || !release.stillShown()) return; + if ((await release.replacePlayer()) && release.stillShown()) choice(); + } + + private count(seriesKey: string, delta: number): void { + const next = (this.launching.get(seriesKey) ?? 0) + delta; + if (next > 0) { + this.launching.set(seriesKey, next); + } else { 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 892a46fe2..28043d8ef 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 @@ -46,6 +46,7 @@ import { import { ExternalPlayerName, PlaybackPositionData, + PlayerContentInfo, ResolvedPortalPlayback, TmdbEnrichedCastMember, XtreamSerieEpisode, @@ -1033,12 +1034,21 @@ export class StalkerSeriesViewComponent implements OnDestroy { ) { const seriesKey = this.currentSeriesKey(); if (this.launchQueue.isLaunching(seriesKey)) { - // The launch cannot be cancelled: the choice plays once it settles. + // The launch cannot be cancelled: the choice replaces its player + // once it settled. this.launchQueue.hold(seriesKey, () => - this.onEpisodeClicked(episode, startTimeOverride, forcePlayer) + this.startEpisode(episode, startTimeOverride, forcePlayer) ); return; } + this.startEpisode(episode, startTimeOverride, forcePlayer); + } + + private startEpisode( + episode: XtreamSerieEpisode, + startTimeOverride?: number, + forcePlayer?: ExternalPlayerName + ): void { const item = this.displayItem(); const episodeState = resolveSelectedStalkerEpisodeState({ episodesBySeason: this.mappedSeasons(), @@ -1259,14 +1269,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { // `closeInlinePlayer()` just retired `request`'s generation; a fresh // one covers the close round trip, the identity check the series. const generation = this.seriesPlaybackRequestGeneration; - const own = playback.contentInfo; - const replaced = await replaceOwnedExternalSession( - this.externalPlayback, - (info) => - info.contentType === 'episode' && - info.playlistId === own?.playlistId && - info.seriesXtreamId === own?.seriesXtreamId, - (message, error) => this.logger.warn(message, error) + const replaced = await this.replaceOwnExternalSession( + playback.contentInfo ); if ( !replaced || @@ -1289,6 +1293,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } + /** Closes an episode of this series still running externally; false keeps it. */ + private replaceOwnExternalSession( + own: PlayerContentInfo | undefined + ): Promise { + return replaceOwnedExternalSession( + this.externalPlayback, + (info) => + info.contentType === 'episode' && + info.playlistId === own?.playlistId && + info.seriesXtreamId === own?.seriesXtreamId, + (message, error) => this.logger.warn(message, error) + ); + } + private async startPlayback( cmd: string | undefined, title: string | undefined, @@ -1356,7 +1374,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { forcePlayer, request ), - () => this.currentSeriesKey() === pendingSeriesId + { + stillShown: () => + this.currentSeriesKey() === pendingSeriesId, + replacePlayer: () => + this.replaceOwnExternalSession( + resolvedPlayback.contentInfo + ), + } ); } else { void this.portalPlayer.openResolvedPlayback( 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 0785b3e89..962d61153 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 @@ -245,8 +245,8 @@ describe('openEpisodeExternally', () => { // again: the reopened page's start runs, with its episode. const startOld = jest.fn(); const startNew = jest.fn(); - queueEpisodeChoice('xtream-1:103', 'episode-1', startOld); - queueEpisodeChoice('xtream-1:103', 'episode-2', startNew); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-1', startOld); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-2', startNew); await flush(); expect(startOld).not.toHaveBeenCalled(); @@ -258,6 +258,55 @@ describe('openEpisodeExternally', () => { expect(startNew).toHaveBeenCalledWith('episode-2'); }); + it('closes the player the launch opened before the queued choice starts', async () => { + const t = host(null); + const opened = session({ id: 'mpv-2', status: 'launching' }); + t.openExternalPlayback.mockImplementation(async () => { + t.host.externalPlayback.activeSession.set(opened); + return opened; + }); + let settleClose: () => void = () => undefined; + t.closeSession.mockImplementation( + () => new Promise((resolve) => (settleClose = resolve)) + ); + const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + const start = jest.fn(); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start); + + await launch; + await flush(); + // The choice must not play beside the player that just opened, and + // nothing else may start while that player closes. + expect(t.closeSession).toHaveBeenCalledWith(opened); + expect(start).not.toHaveBeenCalled(); + expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true); + + settleClose(); + await flush(); + expect(start).toHaveBeenCalledWith('episode-3'); + expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false); + }); + + it('drops the queued choice when the opened player cannot be closed', async () => { + const t = host(null); + const opened = session({ id: 'mpv-2' }); + t.openExternalPlayback.mockImplementation(async () => { + t.host.externalPlayback.activeSession.set(opened); + return opened; + }); + t.closeSession.mockRejectedValue(new Error('busy')); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv'); + const start = jest.fn(); + queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start); + + await launch; + await flush(); + expect(t.closeSession).toHaveBeenCalledWith(opened); + expect(start).not.toHaveBeenCalled(); + expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false); + }); + it('lets a reopened series launch the episode an earlier visit still settles', async () => { const first = host(session()); let settleClose: () => void = () => undefined; 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 5f65c9d25..b5612a6bd 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 @@ -92,7 +92,14 @@ export function whenEpisodeLaunchesSettle(owner: string | null): Promise { return tail ? tail.then(() => undefined) : Promise.resolve(); } +/** What replaying a queued choice reads from the page that made it. */ +type QueuedChoiceHost = Pick< + SeriesExternalLaunchHost, + 'externalPlayback' | 'launchOwner' +>; + interface QueuedEpisodeChoice { + readonly host: QueuedChoiceHost; readonly episode: unknown; readonly start: (episode: never) => void; } @@ -102,29 +109,76 @@ const queuedChoices = new Map(); /** * Keeps the latest choice made while the owner's forced launch settles and - * hands it to the `start` that came with it once that launch settled; the - * caller decides whether its page still shows the owner by then. One settle - * handler per owner: a page reopened meanwhile queues with its own `start`, - * and that one runs, not the handler of the page the viewer left. + * hands it to the `start` that came with it once that launch settled. One + * settle handler per owner: a page reopened meanwhile queues with its own + * `host` and `start`, and those run, not the ones of the page the viewer left. */ export function queueEpisodeChoice( + host: QueuedChoiceHost, owner: string, episode: TEpisode, start: (episode: TEpisode) => void ): void { const handlerRegistered = queuedChoices.has(owner); queuedChoices.set(owner, { + host, episode, start: start as (episode: never) => void, }); if (handlerRegistered) { return; } - void whenEpisodeLaunchesSettle(owner).then(() => { - const queued = queuedChoices.get(owner); - queuedChoices.delete(owner); - queued?.start(queued.episode as never); - }); + void whenEpisodeLaunchesSettle(owner).then(() => replayQueuedChoice(owner)); +} + +/** + * The settled launch opened a player for the series; the queued choice + * replaces it, never plays beside it. The owner stays pending while that + * player closes so no other start slips in between. The choice is dropped + * when the page moved on meanwhile or the player has to stay. + */ +async function replayQueuedChoice(owner: string): Promise { + const queued = queuedChoices.get(owner); + queuedChoices.delete(owner); + if (!queued || queued.host.launchOwner() !== owner) { + return; + } + countPending(owner, 1); + let replaced = false; + try { + replaced = await closeOwnedEpisodeSession(queued.host, owner); + } finally { + countPending(owner, -1); + } + if (replaced && queued.host.launchOwner() === owner) { + queued.start(queued.episode as never); + } +} + +/** + * Closes the external session when it plays an episode of `owner`; false + * when it has to stay (the close failed) and nothing may start beside it. + */ +function closeOwnedEpisodeSession( + host: Pick, + owner: string +): Promise { + const session = host.externalPlayback.activeSession(); + const info = session?.contentInfo; + const ownSession = + session && + info && + session.status !== 'closed' && + info.contentType === 'episode' && + `${info.playlistId}:${info.seriesXtreamId}` === owner + ? session + : null; + return closeRunningExternalSession( + ownSession, + (running) => host.externalPlayback.closeSession(running), + (message, error) => + console.warn(`[SerialDetailsPlayback] ${message}`, error) + ); } /** @@ -183,22 +237,7 @@ async function launchEpisode( if (host.launchOwner() !== owner) { return; } - const session = host.externalPlayback.activeSession(); - const info = session?.contentInfo; - const ownSession = - session && - info && - session.status !== 'closed' && - info.contentType === 'episode' && - `${info.playlistId}:${info.seriesXtreamId}` === owner - ? session - : null; - const replaced = await closeRunningExternalSession( - ownSession, - (running) => host.externalPlayback.closeSession(running), - (message, error) => - console.warn(`[SerialDetailsPlayback] ${message}`, error) - ); + const replaced = await closeOwnedEpisodeSession(host, owner); if (!replaced || host.launchOwner() !== owner) { return; } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index eb1fb206e..41311cdb7 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -207,12 +207,12 @@ export class SerialDetailsPlaybackService { player?: ExternalPlayerName ): Promise | void { // A forced launch still settling owns the next start: the latest - // choice made meanwhile starts once it settled, never beside it. + // choice made meanwhile replaces its player once it settled. const owner = this.launchOwner(); if (!player && owner && this.forcedLaunchPending()) { - queueEpisodeChoice(owner, episode, (queued) => { - if (this.launchOwner() === owner) this.playEpisode(queued); - }); + queueEpisodeChoice(this, owner, episode, (queued) => + this.playEpisode(queued) + ); return; } const playlist = this.xtreamStore.currentPlaylist();