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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:50:55 +02:00
1 parent d8a34570a3
commit 2408eaa6c5
9 files changed
+104 -1

No files matched your search

@@ -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<string>();
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<string>();
const older = tracker.begin('a');
@@ -17,6 +17,12 @@ export interface PendingPlaybackStart<TOwner> {
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);
}
},
};
}
@@ -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(
@@ -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();
});
@@ -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();
}
@@ -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();
}
@@ -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();
}
@@ -41,7 +41,7 @@ function createPosition(
};
}
function createController() {
function createController(playbackOwnerKey?: () => string) {
const inlinePlayback = signal(null);
const selectedVodPosition = signal<PlaybackPositionData | null>(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<ResolvedPortalPlayback>();
@@ -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;