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 b4c3ace44..b1efcbf34 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,6 +191,25 @@ describe('VodDetailsPlaybackService — external playback handoff', () => { expect(openResolvedPlayback).not.toHaveBeenCalled(); }); + it('refuses a start while a progress reset is still writing', async () => { + openResolvedPlayback.mockClear(); + service.resetPending.set(true); + + // The row is being cleared: a start now would resume from it and + // the clear would then report no progress for a resumed stream. + await expect( + service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Example Movie', + }) + ).resolves.toBe(false); + expect(openResolvedPlayback).not.toHaveBeenCalled(); + expect(service.startBlocked()).toBe(true); + + service.resetPending.set(false); + expect(service.startBlocked()).toBe(false); + }); + it('rejects a handoff when the external player launch fails', async () => { openResolvedPlayback.mockRejectedValueOnce( new Error('previous player is still shutting down') 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 14cc54988..ffb8c3e35 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 @@ -1,4 +1,4 @@ -import { signal } from '@angular/core'; +import { computed, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; @@ -35,6 +35,7 @@ describe('VodDetailsMenuService', () => { const loadAllPositions = jest.fn().mockResolvedValue(undefined); const routePlaybackPosition = signal(null); const vodPlaybackPosition = signal(null); + const resetPending = signal(false); const openExternal = jest.fn().mockResolvedValue(undefined); let service: VodDetailsMenuService; @@ -46,6 +47,7 @@ describe('VodDetailsMenuService', () => { primaryIsPinnedCopy.set(false); routePlaybackPosition.set(null); vodPlaybackPosition.set(null); + resetPending.set(false); jest.clearAllMocks(); TestBed.configureTestingModule({ providers: [ @@ -64,6 +66,10 @@ describe('VodDetailsMenuService', () => { vodPlaybackPosition, playbackStartPending, isExternalLaunchPending, + resetPending, + startBlocked: computed( + () => isExternalLaunchPending() || resetPending() + ), isExternalStopAction: signal(false), inlinePlayback: signal(null), discardPendingPositionLoads, @@ -161,6 +167,32 @@ describe('VodDetailsMenuService', () => { expect(vodPlaybackPosition()).toBeNull(); }); + it('holds every start while the reset is still writing', async () => { + primaryPosition.set({ + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', + positionSeconds: 2538, + durationSeconds: 7200, + }); + let finishClear: () => void = () => undefined; + clearPlaybackPositionOrThrow.mockImplementationOnce( + () => new Promise((resolve) => (finishClear = resolve)) + ); + + const reset = service.run(VOD_MENU_ACTION.ResetProgress); + // A start made now would resume from the row being cleared. + expect(resetPending()).toBe(true); + 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(row(VOD_MENU_ACTION.ExternalPlayer)?.disabled).toBe(false); + }); + it('hides the reset while the copy the button acts on has no row', () => { routePlaybackPosition.set({ playlistId: 'playlist-1', 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 97564aeaf..a74d14521 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 @@ -72,11 +72,12 @@ export class VodDetailsMenuService { const category = this.bindings()?.category() ?? 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. + // A start still resolving, a launch still inside the player IPC or + // a reset still writing: another start would be refused (or resume + // from the row being cleared), so the row would mislead. const startPending = this.playback.playbackStartPending() || - this.playback.isExternalLaunchPending(); + this.playback.startBlocked(); const sourceRows: VodMoreMenuSection['items'][number][] = []; if (this.multiSource.hasAlternatives()) { sourceRows.push({ @@ -125,6 +126,7 @@ export class VodDetailsMenuService { disabled: this.playback.isExternalStopAction() || this.playback.playbackStartPending() || + this.playback.resetPending() || this.playback.inlinePlayback() !== null, testId: 'vod-menu-reset-progress', }); @@ -187,6 +189,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); try { await this.playbackPositions.clearPlaybackPositionOrThrow( playlistId, @@ -196,6 +201,8 @@ export class VodDetailsMenuService { } catch (error) { this.logger.error('Resetting the playback position failed', error); return; + } finally { + this.playback.resetPending.set(false); } // 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-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 063f032ed..ad29c4227 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 @@ -407,13 +407,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); + /** No start may begin: a launch awaits the player or a reset is in flight. */ + readonly startBlocked = computed( + () => this.isExternalLaunchPending() || this.resetPending() + ); async startResolvedPlayback( playback: ResolvedPortalPlayback, isCurrent: () => boolean = () => true, player?: ExternalPlayerName ): Promise { - if (this.externalLaunchGeneration() !== null) { + if (this.externalLaunchGeneration() !== null || this.resetPending()) { return false; } const runningSession = runningExternalSession( diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 77b004f57..52bd3aa39 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -7,7 +7,7 @@ [meta]="primary.meta" [icon]="primary.icon" [state]="primary.state" - [disabled]="isExternalLaunchPending()" + [disabled]="startBlocked()" testId="vod-primary-action" (pressed)="onPrimaryAction(playableItem)" /> @@ -72,7 +72,7 @@ | translate " icon="play_arrow" - [disabled]="isExternalLaunchPending()" + [disabled]="startBlocked()" testId="vod-play-provider" (pressed)="playFromProviderSource(playableItem)" /> 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 4447647b6..58715e44f 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 @@ -343,6 +343,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly externalPrimaryLabel = this.playback.externalPrimaryLabel; readonly externalPrimaryIcon = this.playback.externalPrimaryIcon; readonly isExternalLaunchPending = this.playback.isExternalLaunchPending; + readonly startBlocked = this.playback.startBlocked; readonly isExternalStopAction = this.playback.isExternalStopAction; readonly externalPrimaryButtonState = this.playback.externalPrimaryButtonState; @@ -470,7 +471,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }, movie: this.multiSourceMovie, playbackLive: this.playbackLive, - playbackStartBlocked: this.playback.isExternalLaunchPending, + playbackStartBlocked: this.playback.startBlocked, }); // Initializes on first render and RE-initializes when the route @@ -639,7 +640,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { * user to the route's playlist. */ async restartVod(vodItem: XtreamVodDetails | null): Promise { - if (this.isExternalLaunchPending()) { + if (this.startBlocked()) { return; } @@ -712,7 +713,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { vodItem: XtreamVodDetails | null, player: ExternalPlayerName ): Promise { - if (this.isExternalLaunchPending()) { + if (this.startBlocked()) { return; } if (this.msUi.primaryIsPinnedCopy()) { @@ -737,7 +738,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { vodItem: XtreamVodDetails | null ): Promise { if ( - this.isExternalLaunchPending() || + this.startBlocked() || this.externalPrimaryButtonState() !== 'idle' ) { return;