mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(portals): a Stalker series start cannot double a launch still in flight
The forced MPV/VLC launch IPC cannot be cancelled by a later generation, so an episode chosen while it runs now waits for it and plays once it settles, instead of opening a second player next to the first. The hero button and the external-player menu row are held while a start is pending, in both portals. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
5787a6643a
commit
36079b89c1
6 files changed
+131
-15
No files matched your search
@@ -65,6 +65,7 @@ export class StalkerSeriesMenuService {
|
||||
(watch?.seriesActionDisabled() ?? true),
|
||||
hasProgress: this.bindings()?.hasProgress() ?? false,
|
||||
playbackActive: this.bindings()?.playbackActive() ?? false,
|
||||
startPending: this.bindings()?.startPending() ?? false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint:
|
||||
quickStart?.action &&
|
||||
|
||||
+66
@@ -39,6 +39,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
const resolveVodPlayback = jest.fn();
|
||||
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const openExternalPlayback = jest.fn();
|
||||
const isEmbeddedPlayer = jest.fn();
|
||||
const tmdbGetSeason = jest.fn();
|
||||
const fetchLinkToPlay = jest.fn();
|
||||
@@ -54,6 +55,13 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
/** Lets a start's awaited continuations run, then renders their state. */
|
||||
async function settleStart(): Promise<void> {
|
||||
await stabilize();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
selectedContentType.set('series');
|
||||
selectedItem.set({
|
||||
@@ -104,6 +112,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
getSeriesPlaybackPositions.mockClear();
|
||||
getSeriesPlaybackPositions.mockResolvedValue([]);
|
||||
openResolvedPlayback.mockClear();
|
||||
openExternalPlayback.mockReset();
|
||||
isEmbeddedPlayer.mockReset();
|
||||
isEmbeddedPlayer.mockReturnValue(false);
|
||||
tmdbGetSeason.mockReset();
|
||||
@@ -159,6 +168,7 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
useValue: {
|
||||
isEmbeddedPlayer,
|
||||
openResolvedPlayback,
|
||||
openExternalPlayback,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -269,6 +279,62 @@ describe('StalkerSeriesViewComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('holds the quick-start button until the start settles', async () => {
|
||||
let finishResolve!: (playback: unknown) => void;
|
||||
resolveVodPlayback.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finishResolve = resolve))
|
||||
);
|
||||
await stabilize();
|
||||
fixture.detectChanges();
|
||||
const button = (): HTMLButtonElement | null =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="series-quick-start"]'
|
||||
);
|
||||
|
||||
button()?.click();
|
||||
fixture.detectChanges();
|
||||
// A second press could not cancel the first start.
|
||||
expect(button()?.disabled).toBe(true);
|
||||
|
||||
finishResolve({ streamUrl: 'http://stalker.example/episode.mpg' });
|
||||
await settleStart();
|
||||
expect(button()?.disabled).toBe(false);
|
||||
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('plays an episode chosen during a forced launch once that launch settles', async () => {
|
||||
let finishLaunch!: () => void;
|
||||
openExternalPlayback.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (finishLaunch = resolve))
|
||||
);
|
||||
await stabilize();
|
||||
const [first, second] = fixture.componentInstance.mappedSeasons()['1'];
|
||||
|
||||
fixture.componentInstance.onEpisodeClicked(first, undefined, 'mpv');
|
||||
await settleStart();
|
||||
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The launch IPC cannot be cancelled: the choice waits for it instead
|
||||
// of opening a second player next to the first.
|
||||
fixture.componentInstance.onEpisodeClicked(second);
|
||||
await settleStart();
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
|
||||
expect(openResolvedPlayback).not.toHaveBeenCalled();
|
||||
|
||||
finishLaunch();
|
||||
await settleStart();
|
||||
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
|
||||
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
|
||||
'/media/file_30001.mpg',
|
||||
'Regular Series',
|
||||
'poster.jpg',
|
||||
2,
|
||||
expect.any(Number),
|
||||
undefined
|
||||
);
|
||||
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps provider episodes playable while hiding download presentation', async () => {
|
||||
fixture.componentRef.setInput('providerOnly', true);
|
||||
|
||||
|
||||
+45
-12
@@ -289,11 +289,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
* starting. Provider series ids are playlist-scoped.
|
||||
*/
|
||||
private readonly pendingStartSeriesIds = signal<readonly string[]>([]);
|
||||
/**
|
||||
* `playlist:series` keys whose forced MPV/VLC launch is mid-flight. The
|
||||
* launch IPC cannot be cancelled, so an episode chosen meanwhile waits
|
||||
* in `queuedEpisodeChoice` instead of opening a second player.
|
||||
*/
|
||||
private readonly launchingSeriesIds = new Set<string>();
|
||||
private queuedEpisodeChoice: (() => void) | null = null;
|
||||
/** `playlist:series` of the series on screen; provider ids collide across playlists. */
|
||||
readonly currentSeriesKey = computed(
|
||||
() =>
|
||||
`${this.stalkerStore.currentPlaylist()?._id ?? ''}:${this.displayItem()?.id ?? ''}`
|
||||
);
|
||||
/** A start of the series on screen that has not settled. */
|
||||
readonly startPending = computed(() =>
|
||||
this.pendingStartSeriesIds().includes(this.currentSeriesKey())
|
||||
);
|
||||
readonly seasonWatchBatchRunning = signal(false);
|
||||
|
||||
/**
|
||||
@@ -397,7 +408,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
constructor() {
|
||||
this.heroPresenter.bind({
|
||||
displayItem: this.displayItem,
|
||||
quickStart: this.quickStartAction,
|
||||
quickStart: this.quickStartButton,
|
||||
yearLabel: (releaseDate) => this.discover.yearLabel(releaseDate),
|
||||
similarInPortals: this.similarInPortals,
|
||||
openSimilarInPortals: (item) => this.openSimilarInPortals(item),
|
||||
@@ -410,16 +421,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
),
|
||||
playbackActive: computed(
|
||||
() =>
|
||||
this.pendingStartSeriesIds().includes(
|
||||
this.currentSeriesKey()
|
||||
) ||
|
||||
this.startPending() ||
|
||||
this.inlinePlayback() !== null ||
|
||||
this.openingEpisodeId() !== null ||
|
||||
this.activeEpisodeId() !== null
|
||||
),
|
||||
startPending: computed(() =>
|
||||
this.pendingStartSeriesIds().includes(this.currentSeriesKey())
|
||||
),
|
||||
startPending: this.startPending,
|
||||
resetProgress: () => this.resetProgress(),
|
||||
openExternal: (player) => this.openQuickStartExternally(player),
|
||||
});
|
||||
@@ -765,6 +772,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
vodSeriesSeasons: this.vodSeriesSeasons(),
|
||||
});
|
||||
});
|
||||
/** The hero's button: held while a start is pending, a second press would double it. */
|
||||
readonly quickStartButton = computed<StalkerQuickStartButton | null>(() => {
|
||||
const button = this.quickStartAction();
|
||||
return button && this.startPending()
|
||||
? { ...button, disabled: true }
|
||||
: button;
|
||||
});
|
||||
readonly inlineEpisodeState = computed(() => {
|
||||
const identity = this.inlinePlaybackEpisodeIdentity();
|
||||
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
|
||||
@@ -1021,6 +1035,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
startTimeOverride?: number,
|
||||
forcePlayer?: ExternalPlayerName
|
||||
) {
|
||||
if (this.launchingSeriesIds.has(this.currentSeriesKey())) {
|
||||
// The last choice made during the launch plays once it settles.
|
||||
this.queuedEpisodeChoice = () =>
|
||||
this.onEpisodeClicked(episode, startTimeOverride, forcePlayer);
|
||||
return;
|
||||
}
|
||||
const item = this.displayItem();
|
||||
const episodeState = resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
@@ -1330,11 +1350,17 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
if (forcePlayer) {
|
||||
// Awaited so the start stays pending through the close of the
|
||||
// previous player and the launch itself.
|
||||
await this.openEpisodeExternally(
|
||||
resolvedPlayback,
|
||||
forcePlayer,
|
||||
request
|
||||
);
|
||||
this.launchingSeriesIds.add(pendingSeriesId);
|
||||
try {
|
||||
await this.openEpisodeExternally(
|
||||
resolvedPlayback,
|
||||
forcePlayer,
|
||||
request
|
||||
);
|
||||
} finally {
|
||||
this.launchingSeriesIds.delete(pendingSeriesId);
|
||||
this.playQueuedEpisodeChoice(pendingSeriesId);
|
||||
}
|
||||
} else {
|
||||
void this.portalPlayer.openResolvedPlayback(
|
||||
resolvedPlayback,
|
||||
@@ -1363,6 +1389,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Plays the choice held during a launch, unless the page moved on meanwhile. */
|
||||
private playQueuedEpisodeChoice(seriesKey: string): void {
|
||||
const queued = this.queuedEpisodeChoice;
|
||||
this.queuedEpisodeChoice = null;
|
||||
if (queued && this.currentSeriesKey() === seriesKey) queued();
|
||||
}
|
||||
|
||||
private isPlaybackRequestCurrent(
|
||||
request: StalkerSeriesPlaybackRequestContext
|
||||
): boolean {
|
||||
|
||||
@@ -162,6 +162,7 @@ export class SerialDetailsMenuService {
|
||||
(watch?.seriesActionDisabled() ?? true),
|
||||
hasProgress: this.hasProgress(),
|
||||
playbackActive: this.bindings()?.playbackActive() ?? false,
|
||||
startPending: this.bindings()?.startPending() ?? false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint:
|
||||
quickStart && this.runtime.supportsManagedExternalPlayers
|
||||
|
||||
@@ -15,8 +15,9 @@ const BASE = {
|
||||
seriesActionDisabled: false,
|
||||
hasProgress: true,
|
||||
playbackActive: false,
|
||||
startPending: false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint: null,
|
||||
externalPlayerHint: 'MPV' as const,
|
||||
copyUrlEpisodeCode: null,
|
||||
downloadVisible: false,
|
||||
downloadCount: 0,
|
||||
@@ -26,10 +27,14 @@ const BASE = {
|
||||
inContinueWatching: false,
|
||||
};
|
||||
|
||||
function resetRow(input: Partial<typeof BASE>) {
|
||||
function row(input: Partial<typeof BASE>, id: string) {
|
||||
return buildSeriesMenuSections({ ...BASE, ...input })
|
||||
.flatMap((section) => section.items)
|
||||
.find((row) => row.id === SERIES_MENU_ACTION.ResetProgress);
|
||||
.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
function resetRow(input: Partial<typeof BASE>) {
|
||||
return row(input, SERIES_MENU_ACTION.ResetProgress);
|
||||
}
|
||||
|
||||
describe('buildSeriesMenuSections', () => {
|
||||
@@ -47,4 +52,11 @@ describe('buildSeriesMenuSections', () => {
|
||||
it('hides the reset once every episode is watched', () => {
|
||||
expect(resetRow({ seriesFullyWatched: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds the external-player row while a start has not settled', () => {
|
||||
const external = SERIES_MENU_ACTION.ExternalPlayer;
|
||||
expect(row({}, external)?.disabled).toBeFalsy();
|
||||
// A second launch could not cancel the first: both players would open.
|
||||
expect(row({ startPending: true }, external)?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,8 @@ export interface SeriesMenuInput {
|
||||
readonly hasProgress: boolean;
|
||||
/** An episode plays or launches: its next position tick would undo a reset. */
|
||||
readonly playbackActive: boolean;
|
||||
/** A start has not settled: another launch would double it. */
|
||||
readonly startPending: boolean;
|
||||
readonly sourcesCount: number;
|
||||
readonly externalPlayerHint: 'MPV' | 'VLC' | null;
|
||||
readonly copyUrlEpisodeCode: string | null;
|
||||
@@ -261,6 +263,7 @@ export function buildSeriesMenuSections(
|
||||
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
|
||||
icon: 'open_in_new',
|
||||
hint: input.externalPlayerHint,
|
||||
disabled: input.startPending,
|
||||
testId: 'vod-menu-external',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user