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:
4grayandClaude Fable 5.1 committed 2026-10-03 15:32:10 +02:00
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 &&
@@ -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);
@@ -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',
});
}