From bfa04001c95197eb338d6bd8f6acc3d6fe4e64cd Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 19:20:20 +0200 Subject: [PATCH] fix(portals): a Stalker movie launch superseded inside the IPC closes its player, and a reset holds the starts After the launch IPC began, moving on to another movie or a newer start could no longer stop it, and the session it returned was dropped, so its player opened beside the new one; the session is kept and closed when the launch is no longer current. A progress reset now counts as a pending start of its movie, so Play, Start over and launches are held until the write landed instead of resuming from the row being cleared. Co-Authored-By: Claude Fable 5.1 --- .../lib/stalker-vod-detail-actions.spec.ts | 53 +++++++++++++++++++ .../src/lib/stalker-vod-detail-actions.ts | 14 ++++- 2 files changed, 66 insertions(+), 1 deletion(-) 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 3396bb3a3..424228f69 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 @@ -196,6 +196,32 @@ describe('createStalkerVodDetailActions openExternal', () => { expect(t.openExternalPlayback).not.toHaveBeenCalled(); }); + it('closes the player a launch opened after the page moved on', async () => { + let selected = 42; + const t = setup(() => selected); + const opened = { + id: 'mpv-7', + status: 'launching', + } as ExternalPlayerSession; + t.openExternalPlayback.mockResolvedValue(opened); + const launch = t.actions.openExternal({ + item: MOVIE, + player: 'mpv', + positionSeconds: null, + }); + t.resolveLink(); + await Promise.resolve(); + await Promise.resolve(); + // The launch is inside the player IPC when the viewer opens another + // movie: its player must not open beside that one. + selected = 43; + await launch; + + expect(t.openExternalPlayback).toHaveBeenCalledTimes(1); + expect(t.closeSession).toHaveBeenCalledWith(opened); + expect(t.open).not.toHaveBeenCalled(); + }); + it('drops the launch once a newer start superseded it', async () => { const t = setup(() => 42); const launch = t.actions.openExternal({ @@ -301,6 +327,12 @@ describe('createStalkerVodDetailActions resetProgress', () => { const discardPendingPositionLoad = jest.fn(); const afterProgressReset = jest.fn(); const open = jest.fn(); + const settlePendingStart = jest.fn(); + const beginPendingStart = jest.fn().mockReturnValue({ + settle: settlePendingStart, + isCurrent: () => true, + rebase: jest.fn(), + }); const actions = createStalkerVodDetailActions({ resolvePlayback: jest.fn(), portalPlayer: { openExternalPlayback: jest.fn() }, @@ -313,6 +345,7 @@ describe('createStalkerVodDetailActions resetProgress', () => { selectedVodId, selectedVodPosition, discardPendingPositionLoad, + beginPendingStart, afterProgressReset, snackBar: { open }, translate: { instant: (key: string) => key }, @@ -325,9 +358,29 @@ describe('createStalkerVodDetailActions resetProgress', () => { discardPendingPositionLoad, afterProgressReset, open, + beginPendingStart, + settlePendingStart, }; } + it("holds the movie's starts until the clear landed", async () => { + const t = setup(() => 42); + let finishClear: () => void = () => undefined; + t.clearPlaybackPositionOrThrow.mockImplementationOnce( + () => new Promise((resolve) => (finishClear = resolve)) + ); + + const reset = t.actions.resetProgress(MOVIE); + // A start made now would resume from the row being cleared. + expect(t.beginPendingStart).toHaveBeenCalledTimes(1); + expect(t.settlePendingStart).not.toHaveBeenCalled(); + + finishClear(); + await reset; + expect(t.settlePendingStart).toHaveBeenCalledTimes(1); + expect(t.selectedVodPosition()).toBeNull(); + }); + it('clears the shown position of the movie that is still selected', async () => { const t = setup(() => 42); await t.actions.resetProgress(MOVIE); 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 acb551741..170673bdb 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 @@ -171,10 +171,16 @@ export function createStalkerVodDetailActions( // The host's inline teardown retired the request id too; // only a start made after this point supersedes the launch. pending?.rebase(); - await deps.portalPlayer.openExternalPlayback( + const session = await deps.portalPlayer.openExternalPlayback( playback, event.player ); + // The page moved on, or a newer start took over, while the + // launch sat inside the player IPC: the player it opened + // must not stay beside what the viewer chose since. + if (session && (!stillSelected() || superseded())) { + await deps.externalPlayback.closeSession(session); + } } catch (error) { // A launch a newer start superseded fails on its own; the // newer one reports for the movie now. @@ -195,6 +201,10 @@ export function createStalkerVodDetailActions( if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) { return; } + // Counted as a pending start of this movie: its Play, Start over + // and launches are held until the write landed, or one made + // meanwhile would resume from the very row being cleared. + const pending = deps.beginPendingStart?.(); try { await deps.playbackPositions.clearPlaybackPositionOrThrow( playlistId, @@ -204,6 +214,8 @@ export function createStalkerVodDetailActions( } catch (error) { deps.logError('Resetting the VOD position failed', error); return; + } finally { + pending?.settle(); } // The clear was async: only the movie still on screen loses its // shown progress, and no older read may put it back.