fix(portals): a pending progress reset holds only the copy it clears

The reset-in-flight flag was page-wide, so following Similar to another
movie before the write landed held that movie's Play and launches too.
The pending reset now names the copy it clears and blocks a start only
while the page's actions act on that same copy, so the reused page can
start another movie, and the original is held again if the viewer
comes back before the write landed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 18:58:45 +02:00
1 parent ea99118be9
commit c17e199106
7 files changed
+91 -37

No files matched your search

@@ -191,9 +191,23 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
expect(openResolvedPlayback).not.toHaveBeenCalled();
});
it('refuses a start while a progress reset is still writing', async () => {
it('refuses a start of the copy whose progress reset is still writing', async () => {
openResolvedPlayback.mockClear();
service.resetPending.set(true);
const resetTarget = signal<{
playlistId: string;
contentId: number;
} | null>({ playlistId: ROUTE_PLAYLIST, contentId: ROUTE_VOD_ID });
service.bind({
vodId: routeVodId,
vodInfo: signal(null),
activeSource,
supersedePendingSwitch: jest.fn(),
resetTarget,
});
service.pendingReset.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.
@@ -206,8 +220,18 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
expect(openResolvedPlayback).not.toHaveBeenCalled();
expect(service.startBlocked()).toBe(true);
service.resetPending.set(false);
// The reused page shows another movie before the write landed: its
// progress is not being cleared, so it starts.
resetTarget.set({ playlistId: ROUTE_PLAYLIST, contentId: 991 });
expect(service.startBlocked()).toBe(false);
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/other.mkv',
title: 'Other Movie',
})
).resolves.toBe(true);
service.pendingReset.set(null);
});
it('rejects a handoff when the external player launch fails', async () => {
@@ -35,7 +35,11 @@ describe('VodDetailsMenuService', () => {
const loadAllPositions = jest.fn().mockResolvedValue(undefined);
const routePlaybackPosition = signal<PlaybackPositionData | null>(null);
const vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
const resetPending = signal(false);
const pendingReset = signal<{
playlistId: string;
contentId: number;
} | null>(null);
const resetPending = computed(() => pendingReset() !== null);
const openExternal = jest.fn().mockResolvedValue(undefined);
let service: VodDetailsMenuService;
@@ -47,7 +51,7 @@ describe('VodDetailsMenuService', () => {
primaryIsPinnedCopy.set(false);
routePlaybackPosition.set(null);
vodPlaybackPosition.set(null);
resetPending.set(false);
pendingReset.set(null);
jest.clearAllMocks();
TestBed.configureTestingModule({
providers: [
@@ -66,6 +70,7 @@ describe('VodDetailsMenuService', () => {
vodPlaybackPosition,
playbackStartPending,
isExternalLaunchPending,
pendingReset,
resetPending,
startBlocked: computed(
() => isExternalLaunchPending() || resetPending()
@@ -182,14 +187,17 @@ describe('VodDetailsMenuService', () => {
const reset = service.run(VOD_MENU_ACTION.ResetProgress);
// A start made now would resume from the row being cleared.
expect(resetPending()).toBe(true);
expect(pendingReset()).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(resetPending()).toBe(false);
expect(pendingReset()).toBeNull();
expect(row(VOD_MENU_ACTION.ExternalPlayer)?.disabled).toBe(false);
});
@@ -17,6 +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 { VodMultiSourceHostService } from './vod-multi-source-host.service';
export const VOD_MENU_ACTION = {
@@ -189,9 +190,9 @@ export class VodDetailsMenuService {
return;
}
const { playlistId, contentId } = target;
// Every start is refused until the write landed: one made meanwhile
// would resume from the very row being cleared.
this.playback.resetPending.set(true);
// 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);
try {
await this.playbackPositions.clearPlaybackPositionOrThrow(
playlistId,
@@ -202,7 +203,9 @@ export class VodDetailsMenuService {
this.logger.error('Resetting the playback position failed', error);
return;
} finally {
this.playback.resetPending.set(false);
if (sameVodResetTarget(this.playback.pendingReset(), target)) {
this.playback.pendingReset.set(null);
}
}
// 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
@@ -13,6 +13,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import type { VodResetTarget } from './vod-details-reset-target';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import {
createPrimaryActionPosition,
@@ -132,10 +133,7 @@ export class VodDetailsMultiSourceUiService {
*/
readonly primaryPosition = this.primaryAction.position;
/** The copy whose row "Reset progress" clears: the one the button acts on. */
readonly primaryTarget = computed<{
playlistId: string;
contentId: number;
} | null>(() => {
readonly primaryTarget = computed<VodResetTarget | null>(() => {
const pinned = this.primaryAction.foreignPin();
if (pinned) {
return {
@@ -42,6 +42,10 @@ import {
import { injectXtreamRecentHistory } from '../xtream-recent-history';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import {
sameVodResetTarget,
type VodResetTarget,
} from './vod-details-reset-target';
import { isResumablePosition } from './vod-primary-action-position';
export interface VodDetailsPlaybackBindings {
@@ -59,6 +63,8 @@ export interface VodDetailsPlaybackBindings {
supersedePendingSwitch: () => void;
/** An MPV/VLC launch failed while nothing superseded it; the page tells the user. */
reportExternalLaunchFailure?: (error: unknown) => void;
/** The copy the page's actions act on, for telling a pending reset's copy apart. */
resetTarget?: Signal<VodResetTarget | null>;
}
/**
@@ -170,15 +176,7 @@ export class VodDetailsPlaybackService {
});
}
private ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined
): boolean {
private ownsContent(info: Parameters<typeof ownsContent>[0]): boolean {
return ownsContent(info, {
routePlaylistId: this.xtreamStore.currentPlaylist()?.id,
routeContentId: this.bindings()?.vodId(),
@@ -259,21 +257,14 @@ export class VodDetailsPlaybackService {
}
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
if (!vodItem) return;
if (this.isExternalStopAction()) {
void this.stopExternalPlayback().catch(() => undefined);
return;
}
if (this.hasPlaybackPosition()) {
} else if (this.hasPlaybackPosition()) {
void this.resumeVod(vodItem);
return;
} else {
void this.playVod(vodItem);
}
void this.playVod(vodItem);
}
stopExternalPlayback(): Promise<void> {
@@ -407,8 +398,19 @@ export class VodDetailsPlaybackService {
readonly playbackStartPending = computed(() =>
this.pendingStart.isPendingFor(this.bindings()?.vodId())
);
/** A progress reset still writing: a start meanwhile would resume from the row being cleared. */
readonly resetPending = signal(false);
/** The copy whose row a reset is still clearing, if any. */
readonly pendingReset = signal<VodResetTarget | null>(null);
/**
* 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(),
this.bindings()?.resetTarget?.()
)
);
/** No start may begin: a launch awaits the player or a reset is in flight. */
readonly startBlocked = computed(
() => this.isExternalLaunchPending() || this.resetPending()
@@ -0,0 +1,18 @@
/** The copy whose stored position a reset clears: a pinned copy has its own row. */
export interface VodResetTarget {
readonly playlistId: string;
readonly contentId: number;
}
/** Whether two targets name the same copy; nothing matches a missing one. */
export function sameVodResetTarget(
a: VodResetTarget | null | undefined,
b: VodResetTarget | null | undefined
): boolean {
return (
!!a &&
!!b &&
a.playlistId === b.playlistId &&
a.contentId === b.contentId
);
}
@@ -422,6 +422,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
activeSource: this.msUi.activeAlternativeSource,
supersedePendingSwitch: () =>
this.multiSource.supersedePendingSwitch(),
resetTarget: this.msUi.primaryTarget,
reportExternalLaunchFailure: (error) => {
this.logger.error('External launch failed', error);
this.snackBar.open(