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(