From c17e199106e22be2d15b6d3b146d7c6da7fdd2fe Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 18:58:45 +0200 Subject: [PATCH] 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 --- .../vod-details-external-playback.spec.ts | 30 ++++++++++-- .../vod-details-menu.service.spec.ts | 16 +++++-- .../vod-details/vod-details-menu.service.ts | 11 +++-- .../vod-details-multi-source-ui.service.ts | 6 +-- .../vod-details-playback.service.ts | 46 ++++++++++--------- .../vod-details/vod-details-reset-target.ts | 18 ++++++++ .../vod-details-route.component.ts | 1 + 7 files changed, 91 insertions(+), 37 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts 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 b1efcbf34..86a1247c1 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 @@ -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 () => { 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 ffb8c3e35..08684391a 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,7 +35,11 @@ describe('VodDetailsMenuService', () => { const loadAllPositions = jest.fn().mockResolvedValue(undefined); const routePlaybackPosition = signal(null); const vodPlaybackPosition = signal(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); }); 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 a74d14521..15934958e 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,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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts index 76c993a6e..e92b17371 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts @@ -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(() => { const pinned = this.primaryAction.foreignPin(); if (pinned) { return { 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 ad29c4227..ec2ab5f57 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 @@ -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; } /** @@ -170,15 +176,7 @@ export class VodDetailsPlaybackService { }); } - private ownsContent( - info: - | { - playlistId?: string; - contentXtreamId?: number; - contentType?: string; - } - | undefined - ): boolean { + private ownsContent(info: Parameters[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 { @@ -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(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() 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 new file mode 100644 index 000000000..39ce56062 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-reset-target.ts @@ -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 + ); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 58715e44f..ffa450009 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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(