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 dcc6168aa..14cc54988 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 @@ -8,6 +8,7 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, + type PlaybackPositionData, type XtreamVodDetails, } from '@iptvnator/shared/interfaces'; import { @@ -22,29 +23,61 @@ describe('VodDetailsMenuService', () => { const playbackStartPending = signal(false); const isExternalLaunchPending = signal(false); const hasPlaybackPosition = signal(true); + const primaryPosition = signal(null); + const primaryTarget = signal<{ + playlistId: string; + contentId: number; + } | null>({ playlistId: 'playlist-1', contentId: 7 }); + const primaryIsPinnedCopy = signal(false); + const forgetPinnedPosition = jest.fn(); + const clearPlaybackPositionOrThrow = jest.fn().mockResolvedValue(undefined); + const discardPendingPositionLoads = jest.fn(); + const loadAllPositions = jest.fn().mockResolvedValue(undefined); + const routePlaybackPosition = signal(null); + const vodPlaybackPosition = signal(null); const openExternal = jest.fn().mockResolvedValue(undefined); let service: VodDetailsMenuService; beforeEach(() => { playbackStartPending.set(false); isExternalLaunchPending.set(false); + primaryPosition.set(null); + primaryTarget.set({ playlistId: 'playlist-1', contentId: 7 }); + primaryIsPinnedCopy.set(false); + routePlaybackPosition.set(null); + vodPlaybackPosition.set(null); + jest.clearAllMocks(); TestBed.configureTestingModule({ providers: [ VodDetailsMenuService, - { provide: XtreamStore, useValue: {} }, + { + provide: XtreamStore, + useValue: { + currentPlaylist: signal({ id: 'playlist-1' }), + loadAllPositions, + }, + }, { provide: VodDetailsPlaybackService, useValue: { - routePlaybackPosition: signal(null), + routePlaybackPosition, + vodPlaybackPosition, playbackStartPending, isExternalLaunchPending, isExternalStopAction: signal(false), inlinePlayback: signal(null), + discardPendingPositionLoads, }, }, { provide: VodDetailsMultiSourceUiService, - useValue: { hasPlaybackPosition }, + useValue: { + hasPlaybackPosition, + primaryPosition, + primaryTarget, + primaryIsPinnedCopy, + forgetPinnedPosition, + }, }, { provide: VodMultiSourceHostService, @@ -53,7 +86,10 @@ describe('VodDetailsMenuService', () => { alternativeCount: signal(0), }, }, - { provide: PORTAL_PLAYBACK_POSITIONS, useValue: {} }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { clearPlaybackPositionOrThrow }, + }, { provide: RuntimeCapabilitiesService, useValue: { supportsManagedExternalPlayers: true }, @@ -99,6 +135,44 @@ describe('VodDetailsMenuService', () => { expect(openExternal).toHaveBeenCalledWith('mpv'); }); + it('offers and performs the reset for the pinned copy the button acts on', async () => { + // Only the pinned copy has progress: the hero shows it, so the menu + // must offer to reset it, and clear THAT row rather than the route's. + primaryIsPinnedCopy.set(true); + primaryTarget.set({ playlistId: 'playlist-2', contentId: 991 }); + primaryPosition.set({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7200, + }); + expect(row(VOD_MENU_ACTION.ResetProgress)).toBeDefined(); + + await service.run(VOD_MENU_ACTION.ResetProgress); + + expect(clearPlaybackPositionOrThrow).toHaveBeenCalledWith( + 'playlist-2', + 991, + 'vod' + ); + expect(forgetPinnedPosition).toHaveBeenCalledTimes(1); + expect(discardPendingPositionLoads).not.toHaveBeenCalled(); + expect(vodPlaybackPosition()).toBeNull(); + }); + + it('hides the reset while the copy the button acts on has no row', () => { + routePlaybackPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', + positionSeconds: 100, + durationSeconds: 7200, + }); + // The route copy's row says nothing about an unwatched pinned copy. + expect(row(VOD_MENU_ACTION.ResetProgress)).toBeUndefined(); + }); + it('holds both rows while a start resolves or a launch awaits the player', () => { // Another start would be refused while one is in flight: an enabled // row would close the menu and do nothing. 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 94ecd5098..97564aeaf 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 @@ -70,7 +70,8 @@ export class VodDetailsMenuService { readonly sections = computed(() => { const item = this.bindings()?.item() ?? null; const category = this.bindings()?.category() ?? null; - const started = this.playback.routePlaybackPosition() !== null; + // The copy the primary button acts on: a pinned copy's own row. + const started = this.msUi.primaryPosition() !== null; // A start still resolving or a launch still inside the player IPC: // another start would be refused, so the row would do nothing. const startPending = @@ -176,17 +177,20 @@ export class VodDetailsMenuService { } } - /** Clears the saved position: the next Play starts from the beginning. */ + /** + * Clears the saved position of the copy the primary button acts on (a + * pinned copy has its own row): the next Play starts from the beginning. + */ private async resetProgress(): Promise { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - const vodId = this.bindings()?.vodId() ?? NaN; - if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) { + const target = this.msUi.primaryTarget(); + if (!target) { return; } + const { playlistId, contentId } = target; try { await this.playbackPositions.clearPlaybackPositionOrThrow( playlistId, - vodId, + contentId, 'vod' ); } catch (error) { @@ -194,10 +198,12 @@ export class VodDetailsMenuService { return; } // The clear was async: the route may show another movie by now - // (the Similar rail reuses it), whose own state must stay. + // (the Similar rail reuses it), or the pin may have moved, and that + // state must stay. + const current = this.msUi.primaryTarget(); if ( - this.bindings()?.vodId() !== vodId || - this.xtreamStore.currentPlaylist()?.id !== playlistId + current?.playlistId !== playlistId || + current.contentId !== contentId ) { // Another movie of the same playlist still gets fresh store // badges; another playlist's store must not be replaced by the @@ -209,10 +215,16 @@ export class VodDetailsMenuService { } // A read still in flight started from the pre-write row; letting it // land would bring the position back. - this.playback.discardPendingPositionLoads(); - this.playback.routePlaybackPosition.set(null); + if (this.msUi.primaryIsPinnedCopy()) { + this.msUi.forgetPinnedPosition(); + } else { + this.playback.discardPendingPositionLoads(); + this.playback.routePlaybackPosition.set(null); + } this.playback.vodPlaybackPosition.set(null); - void this.xtreamStore.loadAllPositions(playlistId); + if (this.xtreamStore.currentPlaylist()?.id === playlistId) { + void this.xtreamStore.loadAllPositions(playlistId); + } this.notify('PORTALS.DETAIL.PROGRESS_RESET'); } 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 36a18b220..76c993a6e 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 @@ -131,6 +131,29 @@ export class VodDetailsMultiSourceUiService { * the live one while that copy plays, otherwise the route copy's. */ 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>(() => { + const pinned = this.primaryAction.foreignPin(); + if (pinned) { + return { + playlistId: pinned.playlistId, + contentId: pinned.contentId, + }; + } + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.routeContentId(); + return playlistId && Number.isFinite(contentId) && contentId > 0 + ? { playlistId, contentId } + : null; + }); + + /** After the pinned copy's row was cleared: Play, until it plays again. */ + forgetPinnedPosition(): void { + this.primaryAction.forgetPinnedPosition(); + } formatPosition(): string { return formatPlaybackPosition(this.primaryAction.position()); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts index 0a3befd53..ea2ad5601 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.spec.ts @@ -232,6 +232,33 @@ describe('createPrimaryActionPosition', () => { expect(api.hasPosition()).toBe(false); }); + it('forgets the pinned row after a reset and drops a lookup still in flight', async () => { + let resolveLookup: ( + value: PlaybackPositionData | null + ) => void = () => { + /* replaced below */ + }; + const load = jest.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveLookup = resolve; + }) + ); + const { api } = setup([source(), ALT], position(2538), load); + TestBed.tick(); + + // "Reset progress" cleared the pinned copy's row while its lookup + // was still out: the button must read Play, not the row that the + // lookup brings back afterwards. + api.forgetPinnedPosition(); + resolveLookup(position(4200)); + await Promise.resolve(); + TestBed.tick(); + + expect(api.position()).toBeNull(); + expect(api.hasPosition()).toBe(false); + }); + it('drops a lookup the pin outran', async () => { let resolveFirst: (value: PlaybackPositionData | null) => void = () => { /* replaced below */ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts index b9b8ce4a6..37db61443 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.ts @@ -41,6 +41,12 @@ export interface PrimaryActionPosition { hasPosition: Signal; /** The pinned copy the button acts on, when it is not the route's own. */ foreignPin: Signal; + /** + * The pinned copy's row was cleared: the button reads Play until that + * copy plays again, and a lookup still in flight must not bring the + * old row back. + */ + forgetPinnedPosition: () => void; } /** @@ -77,6 +83,8 @@ export function createPrimaryActionPosition( const pinnedPosition = signal(null); /** Distinguishes "not looked up yet" from "looked up, never watched". */ const pinnedLoadedFor = signal(null); + /** Bumped by a reset so a lookup that started before it lands as stale. */ + let loadGeneration = 0; effect(() => { const pinned = foreignPin(); @@ -90,10 +98,14 @@ export function createPrimaryActionPosition( return; } + const generation = ++loadGeneration; void deps.load(pinned).then((position) => { // The pin can change across the lookup — applying a stale answer // would describe a copy the button no longer plays. - if (foreignPin()?.id !== pinned.id) { + if ( + foreignPin()?.id !== pinned.id || + generation !== loadGeneration + ) { return; } @@ -132,7 +144,16 @@ export function createPrimaryActionPosition( const hasPosition = computed(() => isResumablePosition(position())); - return { position, hasPosition, foreignPin }; + const forgetPinnedPosition = (): void => { + loadGeneration += 1; + pinnedPosition.set(null); + const pinned = foreignPin(); + if (pinned) { + pinnedLoadedFor.set(pinned.id); + } + }; + + return { position, hasPosition, foreignPin, forgetPinnedPosition }; } /** `01:02:03`, or `02:03` for anything under an hour. */