fix(portals): no movie start while a progress reset is still writing

Between the reset's click and its write landing, the effective position
stayed populated and the primary, provider, Start over and external
actions stayed enabled, so a start made then resumed from the row being
cleared. The playback service tracks the reset in flight and refuses a
start meanwhile; the hero's actions and the menu rows are held for the
same window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 18:50:21 +02:00
1 parent 1248c2cca0
commit ea99118be9
6 files changed
+76 -11

No files matched your search

@@ -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')
@@ -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<PlaybackPositionData | null>(null);
const vodPlaybackPosition = signal<PlaybackPositionData | null>(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<void>((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',
@@ -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
@@ -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<boolean> {
if (this.externalLaunchGeneration() !== null) {
if (this.externalLaunchGeneration() !== null || this.resetPending()) {
return false;
}
const runningSession = runningExternalSession(
@@ -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)"
/>
@@ -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<void> {
if (this.isExternalLaunchPending()) {
if (this.startBlocked()) {
return;
}
@@ -712,7 +713,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodItem: XtreamVodDetails | null,
player: ExternalPlayerName
): Promise<void> {
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<void> {
if (
this.isExternalLaunchPending() ||
this.startBlocked() ||
this.externalPrimaryButtonState() !== 'idle'
) {
return;