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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:06:47 +02:00
1 parent c17e199106
commit ccac905ba0
5 files changed
+83 -27

No files matched your search

@@ -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 () => {
@@ -35,11 +35,10 @@ describe('VodDetailsMenuService', () => {
const loadAllPositions = jest.fn().mockResolvedValue(undefined);
const routePlaybackPosition = signal<PlaybackPositionData | null>(null);
const vodPlaybackPosition = signal<PlaybackPositionData | null>(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<void>((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',
@@ -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
@@ -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<VodResetTarget | null>(null);
/** The copies whose rows resets are still clearing: the page can move on and come back. */
readonly pendingResets = signal<readonly VodResetTarget[]>([]);
/**
* 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?.()
)
);
@@ -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)];
}