From 2408eaa6c51c9fdf1db59250223bbe6fc05c9228 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 19:50:55 +0200 Subject: [PATCH] fix(portals): a reopened Stalker movie is not held by a start its earlier visit left pending Backing out of a movie while its portal request still resolved and reopening it found Play, Start over and the launches held: the pending start was keyed by the movie alone, so the new visit matched it until the request settled, if ever. The pending start of a movie the page left is retired on every owner change, in the catalog detail, the search detail and the collection detail. Co-Authored-By: Claude Fable 5.1 --- .../src/lib/pending-playback-start.spec.ts | 17 +++++++++ .../util/src/lib/pending-playback-start.ts | 11 ++++++ .../stalker-catalog-detail.component.spec.ts | 35 +++++++++++++++++++ .../stalker-catalog-detail.component.ts | 3 ++ .../stalker-collection-detail.component.ts | 5 +++ .../stalker-collection-playback.controller.ts | 5 +++ .../stalker-search.component.ts | 3 ++ .../stalker-vod-playback-controller.spec.ts | 21 ++++++++++- .../lib/stalker-vod-playback-controller.ts | 5 +++ 9 files changed, 104 insertions(+), 1 deletion(-) diff --git a/libs/portal/shared/util/src/lib/pending-playback-start.spec.ts b/libs/portal/shared/util/src/lib/pending-playback-start.spec.ts index 920fd3e6c..66c97c8b1 100644 --- a/libs/portal/shared/util/src/lib/pending-playback-start.spec.ts +++ b/libs/portal/shared/util/src/lib/pending-playback-start.spec.ts @@ -16,6 +16,23 @@ describe('createPendingPlaybackStart', () => { expect(pendingForA()).toBe(false); }); + it('forgets a start whose owner the page left, so a return is not held by it', () => { + const tracker = createPendingPlaybackStart(); + const first = tracker.begin('a'); + + // Back out of the movie while its request still resolves: whatever + // it settles with no longer applies, so reopening it starts afresh. + tracker.retire('a'); + expect(tracker.isPendingFor('a')).toBe(false); + + // The retired start settling late must not disturb a newer one. + const newer = tracker.begin('a'); + tracker.settle(first); + expect(tracker.isPendingFor('a')).toBe(true); + tracker.settle(newer); + expect(tracker.isPendingFor('a')).toBe(false); + }); + it('lets only the latest start clear the flag', () => { const tracker = createPendingPlaybackStart(); const older = tracker.begin('a'); diff --git a/libs/portal/shared/util/src/lib/pending-playback-start.ts b/libs/portal/shared/util/src/lib/pending-playback-start.ts index 280dc016c..388c63869 100644 --- a/libs/portal/shared/util/src/lib/pending-playback-start.ts +++ b/libs/portal/shared/util/src/lib/pending-playback-start.ts @@ -17,6 +17,12 @@ export interface PendingPlaybackStart { settle(startId: number): void; /** Reactive: whether the latest start belongs to this owner. */ isPendingFor(owner: TOwner): boolean; + /** + * Forgets the start of `owner` once the page left it: whatever it still + * settles with no longer applies, and a return to the same owner must + * not find its actions held by a request that may never settle. + */ + retire(owner: TOwner): void; } export function createPendingPlaybackStart< @@ -40,5 +46,10 @@ export function createPendingPlaybackStart< const current = pending(); return current !== null && current.owner === owner; }, + retire(owner) { + if (pending()?.owner === owner) { + pending.set(null); + } + }, }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts index 015b036ba..6c05e382a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -380,6 +380,41 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { expect(fixture.componentInstance.playbackStartPending()).toBe(false); }); + it('does not hold a reopened movie with a start its earlier visit left pending', async () => { + const original = selectedItem(); + let resolve: (value: unknown) => void = () => undefined; + resolveVodPlayback.mockReturnValueOnce( + new Promise((resolvePromise) => { + resolve = resolvePromise; + }) + ); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.onVodPlay({ + type: 'stalker', + cmd: '/media/42', + data: original, + } as never); + fixture.detectChanges(); + expect(fixture.componentInstance.playbackStartPending()).toBe(true); + + // Back out to another movie while the portal request hangs, then + // reopen the first one: its Play must not wait for that request. + selectedItem.set({ + id: '99', + cmd: '/media/99', + info: { name: 'Replacement movie' }, + }); + fixture.detectChanges(); + selectedItem.set(original); + fixture.detectChanges(); + expect(fixture.componentInstance.playbackStartPending()).toBe(false); + + resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await fixture.whenStable(); + expect(fixture.componentInstance.playbackStartPending()).toBe(false); + }); + it('does not mount a VOD resolution after the catalog owner changes', async () => { let resolve!: (value: { streamUrl: string }) => void; resolveVodPlayback.mockReturnValueOnce( diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index a648bef89..23ed55ada 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -216,6 +216,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy { effect(() => { const ownerKey = this.playbackOwnerKey(); if (ownerKey === this.currentPlaybackOwnerKey) return; + // A start the left movie still resolves no longer applies; a + // return to it must not find Play held by a hung request. + this.pendingStart.retire(this.currentPlaybackOwnerKey); this.currentPlaybackOwnerKey = ownerKey; this.closeInlinePlayer(); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 4df4824de..d53a44d8a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -213,6 +213,11 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff { const playbackOwnerKey = this.playbackSessionKey(); untracked(() => { if (playbackOwnerKey !== this.currentPlaybackOwnerKey) { + // A start the left item still resolves no longer + // applies; a return to it must not find Play held. + this.playback.retirePendingStart( + this.currentPlaybackOwnerKey + ); this.currentPlaybackOwnerKey = playbackOwnerKey; this.closeInlinePlayer(); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts index 544b28df1..24571283e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-playback.controller.ts @@ -175,6 +175,11 @@ export class StalkerCollectionPlaybackController { this.vodPlayback.handleInlineTimeUpdate(event); } + /** The page left `owner`: a start it still resolves no longer holds a return to it. */ + retirePendingStart(owner: string): void { + this.vodPlayback.retirePendingStart(owner); + } + closeInlinePlayer(): void { this.vodPlayback.closeInlinePlayer(); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index ff1842956..15fc9eaea 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -856,6 +856,9 @@ export class StalkerSearchComponent { private syncPlaybackOwner(ownerKey: string): void { if (ownerKey === this.currentPlaybackOwnerKey) return; + // A start the left movie still resolves no longer applies; a return + // to it must not find Play held by a hung request. + this.vodPlayback.retirePendingStart(this.currentPlaybackOwnerKey); this.currentPlaybackOwnerKey = ownerKey; this.closeInlinePlayer(); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts index d8d497917..38f42490e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -41,7 +41,7 @@ function createPosition( }; } -function createController() { +function createController(playbackOwnerKey?: () => string) { const inlinePlayback = signal(null); const selectedVodPosition = signal(null); const playbackPositions = { @@ -79,6 +79,7 @@ function createController() { translateService, logger, playbackErrorLogMessage: 'Playback failed', + playbackOwnerKey, }); return { @@ -120,6 +121,24 @@ describe('StalkerVodPlaybackController', () => { expect(selectedVodPosition()?.positionSeconds).toBe(20); }); + it('does not hold a reopened movie with a start its earlier visit left pending', () => { + let owner = 'movie-a'; + const { controller } = createController(() => owner); + const pending = controller.beginPendingStart(); + expect(controller.playbackStartPending()).toBe(true); + + // Back out of the movie while the portal request hangs, then + // reopen it: the new visit must be playable. + owner = ''; + controller.retirePendingStart('movie-a'); + owner = 'movie-a'; + expect(controller.playbackStartPending()).toBe(false); + expect(pending.isCurrent()).toBe(true); + + pending.settle(); + expect(controller.playbackStartPending()).toBe(false); + }); + it('does not mount playback that resolves after the detail closes', async () => { const { controller, inlinePlayback } = createController(); const pending = createDeferred(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index 179ebf2d8..d70dbfe29 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -38,6 +38,11 @@ export class StalkerVodPlaybackController { readonly playbackStartPending = computed(() => this.pendingStart.isPendingFor(this.config.playbackOwnerKey?.()) ); + + /** The page left `owner`: a start it still resolves no longer holds a return to it. */ + retirePendingStart(owner: string | undefined): void { + this.pendingStart.retire(owner); + } private lastInlineSaveTime = 0; private loadSelectedVodPositionRequestId = 0; private playbackRequestId = 0;