mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
c17e199106
commit
ccac905ba0
5 files changed
+83
-27
No files matched your search
+4
-5
@@ -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)];
|
||||
}
|
||||
Reference in new issue
Block a user