mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user