mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(portals): the movie menu resets the progress of the copy the button acts on
Reset progress was offered on the route copy's row and cleared only that row, so with a pinned alternative the visible pinned progress stayed and the next Play still resumed it. The row now follows the effective position the primary button describes, and the reset clears the pinned copy's own row, forgetting its loaded position so a lookup in flight cannot bring it back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
b8240b75ab
commit
2166bbce2c
5 files changed
+175
-18
No files matched your search
@@ -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<PlaybackPositionData | null>(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<PlaybackPositionData | null>(null);
|
||||
const vodPlaybackPosition = signal<PlaybackPositionData | null>(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.
|
||||
|
||||
@@ -70,7 +70,8 @@ export class VodDetailsMenuService {
|
||||
readonly sections = computed<VodMoreMenuSection[]>(() => {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<PlaybackPositionData | null>((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 */
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface PrimaryActionPosition {
|
||||
hasPosition: Signal<boolean>;
|
||||
/** The pinned copy the button acts on, when it is not the route's own. */
|
||||
foreignPin: Signal<VodSourceDescriptor | null>;
|
||||
/**
|
||||
* 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<PlaybackPositionData | null>(null);
|
||||
/** Distinguishes "not looked up yet" from "looked up, never watched". */
|
||||
const pinnedLoadedFor = signal<string | null>(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. */
|
||||
|
||||
Reference in new issue
Block a user