From ccac905ba024ef88c6cd0f2f014e7e8b878d84e2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 19:06:47 +0200 Subject: [PATCH] fix(portals): overlapping progress resets each hold their own copy A single pending-reset slot held only the latest reset, so resetting A, moving on to B and resetting it, then coming back to A before A's write landed freed A's starts. Pending resets are a list now; a start is held while any of them names the copy the page acts on, and each reset removes only its own entry when it settles. Co-Authored-By: Claude Fable 5.1 --- .../vod-details-external-playback.spec.ts | 9 ++- .../vod-details-menu.service.spec.ts | 58 +++++++++++++++---- .../vod-details/vod-details-menu.service.ts | 12 ++-- .../vod-details-playback.service.ts | 10 ++-- .../vod-details/vod-details-reset-target.ts | 21 +++++++ 5 files changed, 83 insertions(+), 27 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts index 86a1247c1..dbed9bed1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-external-playback.spec.ts @@ -204,10 +204,9 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { supersedePendingSwitch: jest.fn(), resetTarget, }); - service.pendingReset.set({ - playlistId: ROUTE_PLAYLIST, - contentId: ROUTE_VOD_ID, - }); + service.pendingResets.set([ + { playlistId: ROUTE_PLAYLIST, contentId: ROUTE_VOD_ID }, + ]); // The row is being cleared: a start now would resume from it and // the clear would then report no progress for a resumed stream. @@ -231,7 +230,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { }) ).resolves.toBe(true); - service.pendingReset.set(null); + service.pendingResets.set([]); }); it('rejects a handoff when the external player launch fails', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.spec.ts index 08684391a..86a645fb5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.spec.ts @@ -35,11 +35,10 @@ describe('VodDetailsMenuService', () => { const loadAllPositions = jest.fn().mockResolvedValue(undefined); const routePlaybackPosition = signal(null); const vodPlaybackPosition = signal(null); - const pendingReset = signal<{ - playlistId: string; - contentId: number; - } | null>(null); - const resetPending = computed(() => pendingReset() !== null); + const pendingResets = signal< + readonly { playlistId: string; contentId: number }[] + >([]); + const resetPending = computed(() => pendingResets().length > 0); const openExternal = jest.fn().mockResolvedValue(undefined); let service: VodDetailsMenuService; @@ -51,7 +50,7 @@ describe('VodDetailsMenuService', () => { primaryIsPinnedCopy.set(false); routePlaybackPosition.set(null); vodPlaybackPosition.set(null); - pendingReset.set(null); + pendingResets.set([]); jest.clearAllMocks(); TestBed.configureTestingModule({ providers: [ @@ -70,7 +69,7 @@ describe('VodDetailsMenuService', () => { vodPlaybackPosition, playbackStartPending, isExternalLaunchPending, - pendingReset, + pendingResets, resetPending, startBlocked: computed( () => isExternalLaunchPending() || resetPending() @@ -187,20 +186,55 @@ describe('VodDetailsMenuService', () => { const reset = service.run(VOD_MENU_ACTION.ResetProgress); // A start made now would resume from the row being cleared. - expect(pendingReset()).toEqual({ - playlistId: 'playlist-1', - contentId: 7, - }); + expect(pendingResets()).toEqual([ + { playlistId: 'playlist-1', contentId: 7 }, + ]); expect(row(VOD_MENU_ACTION.ExternalPlayer)?.disabled).toBe(true); expect(row(VOD_MENU_ACTION.StartOver)?.disabled).toBe(true); expect(row(VOD_MENU_ACTION.ResetProgress)?.disabled).toBe(true); finishClear(); await reset; - expect(pendingReset()).toBeNull(); + expect(pendingResets()).toEqual([]); expect(row(VOD_MENU_ACTION.ExternalPlayer)?.disabled).toBe(false); }); + it('keeps an earlier reset pending while a later one finishes first', async () => { + const clears: Array<() => void> = []; + clearPlaybackPositionOrThrow.mockImplementation( + () => new Promise((resolve) => clears.push(resolve)) + ); + primaryPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7200, + }); + + // Movie A's reset, then the page moves on to B and resets it too. + const resetA = service.run(VOD_MENU_ACTION.ResetProgress); + primaryTarget.set({ playlistId: 'playlist-1', contentId: 8 }); + const resetB = service.run(VOD_MENU_ACTION.ResetProgress); + expect(pendingResets()).toEqual([ + { playlistId: 'playlist-1', contentId: 7 }, + { playlistId: 'playlist-1', contentId: 8 }, + ]); + + // B's write lands first: A is still being cleared, so coming back + // to A must still hold its starts. + clears[1](); + await resetB; + expect(pendingResets()).toEqual([ + { playlistId: 'playlist-1', contentId: 7 }, + ]); + + clears[0](); + await resetA; + expect(pendingResets()).toEqual([]); + clearPlaybackPositionOrThrow.mockResolvedValue(undefined); + }); + it('hides the reset while the copy the button acts on has no row', () => { routePlaybackPosition.set({ playlistId: 'playlist-1', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index 15934958e..8a6f7a4de 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -17,7 +17,7 @@ import { import type { VodMoreMenuSection } from '@iptvnator/ui/components'; import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service'; import { VodDetailsPlaybackService } from './vod-details-playback.service'; -import { sameVodResetTarget } from './vod-details-reset-target'; +import { withoutVodResetTarget } from './vod-details-reset-target'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; export const VOD_MENU_ACTION = { @@ -192,7 +192,7 @@ export class VodDetailsMenuService { const { playlistId, contentId } = target; // Every start of this copy is refused until the write landed: one // made meanwhile would resume from the very row being cleared. - this.playback.pendingReset.set(target); + this.playback.pendingResets.update((pending) => [...pending, target]); try { await this.playbackPositions.clearPlaybackPositionOrThrow( playlistId, @@ -203,9 +203,11 @@ export class VodDetailsMenuService { this.logger.error('Resetting the playback position failed', error); return; } finally { - if (sameVodResetTarget(this.playback.pendingReset(), target)) { - this.playback.pendingReset.set(null); - } + // Only this reset's entry: another copy's, or an overlapping + // reset of the same copy, keeps holding its starts. + this.playback.pendingResets.update((pending) => + withoutVodResetTarget(pending, target) + ); } // The clear was async: the route may show another movie by now // (the Similar rail reuses it), or the pin may have moved, and that diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index ec2ab5f57..b43a64d66 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -43,7 +43,7 @@ import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { settleOwnedExternalLaunch } from './vod-details-external-launch'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; import { - sameVodResetTarget, + hasVodResetTarget, type VodResetTarget, } from './vod-details-reset-target'; import { isResumablePosition } from './vod-primary-action-position'; @@ -398,16 +398,16 @@ export class VodDetailsPlaybackService { readonly playbackStartPending = computed(() => this.pendingStart.isPendingFor(this.bindings()?.vodId()) ); - /** The copy whose row a reset is still clearing, if any. */ - readonly pendingReset = signal(null); + /** The copies whose rows resets are still clearing: the page can move on and come back. */ + readonly pendingResets = signal([]); /** * A reset still writing for the copy the page acts on: a start meanwhile * would resume from the row being cleared. Another movie shown on the * reused page meanwhile is not held up by it. */ readonly resetPending = computed(() => - sameVodResetTarget( - this.pendingReset(), + hasVodResetTarget( + this.pendingResets(), this.bindings()?.resetTarget?.() ) ); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts index 39ce56062..11a16b27c 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts @@ -16,3 +16,24 @@ export function sameVodResetTarget( a.contentId === b.contentId ); } + +/** Whether a reset of `target` is among the pending ones. */ +export function hasVodResetTarget( + pending: readonly VodResetTarget[], + target: VodResetTarget | null | undefined +): boolean { + return pending.some((entry) => sameVodResetTarget(entry, target)); +} + +/** The pending resets without one occurrence of `target`: resets of one copy can overlap. */ +export function withoutVodResetTarget( + pending: readonly VodResetTarget[], + target: VodResetTarget +): readonly VodResetTarget[] { + const index = pending.findIndex((entry) => + sameVodResetTarget(entry, target) + ); + return index < 0 + ? pending + : [...pending.slice(0, index), ...pending.slice(index + 1)]; +}