fix(portals): one explicit Stalker launch at a time

A second "Open in external player" while the first create_link is still
resolving is ignored, and the menu row is disabled for that window, so
two resolutions cannot each start a player.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 12:38:25 +02:00
1 parent 175967e06b
commit b47aeda543
5 files changed
+42 -1

No files matched your search

@@ -112,6 +112,25 @@ describe('createStalkerVodDetailActions openExternal', () => {
);
});
it('ignores a second launch while the first is still resolving', async () => {
const t = setup(() => 42);
const first = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
await t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.resolveLink();
await first;
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('drops the stream once another movie was selected meanwhile', async () => {
let selected = 42;
const t = setup(() => selected);
@@ -71,6 +71,9 @@ export function createStalkerVodDetailActions(
deps.snackBar.open(deps.translate.instant(key), undefined, {
duration: 3000,
});
// A second "Open in external player" during a slow `create_link` would
// resolve beside the first and start a second player.
let externalLaunchInFlight = false;
return {
/** The inline player copied the stream URL. */
@@ -93,9 +96,10 @@ export function createStalkerVodDetailActions(
},
async openExternal(event: StalkerVodExternalPlayEvent): Promise<void> {
if (event.item.type !== 'stalker') {
if (event.item.type !== 'stalker' || externalLaunchInFlight) {
return;
}
externalLaunchInFlight = true;
// The `create_link` round trip may outlive the selection: a
// stream resolved for a movie the user left is dropped, and its
// failure is not reported over the new one.
@@ -137,6 +141,7 @@ export function createStalkerVodDetailActions(
notify('PORTALS.PLAYBACK_ERROR');
}
} finally {
externalLaunchInFlight = false;
settlePendingStart?.();
}
},
@@ -125,6 +125,7 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
playbackActive:
deps.externalState() !== 'idle' ||
deps.playbackStartPending(),
startPending: deps.playbackStartPending(),
})
),
};
@@ -20,6 +20,7 @@ const BASE = {
hasPlaybackPosition: true,
hasStoredProgress: true,
playbackActive: false,
startPending: false,
};
function rows(input: Partial<typeof BASE>) {
@@ -38,6 +39,18 @@ describe('buildVodMenuSections', () => {
expect(reset?.disabled).toBeFalsy();
});
it('disables the external launch while a start still resolves', () => {
const external = rows({ startPending: true }).find(
(row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer
);
expect(external?.disabled).toBe(true);
expect(
rows({}).find(
(row) => row.id === VOD_DETAILS_MENU_ACTION.ExternalPlayer
)?.disabled
).toBeFalsy();
});
it('disables the progress reset while playback owns the position', () => {
// A running player, or a start still resolving its stream, would
// write the position right back.
@@ -180,6 +180,8 @@ export function buildVodMenuSections(input: {
hasStoredProgress: boolean;
/** An external player owns the row: its next tick would undo a reset. */
playbackActive: boolean;
/** A start still resolving its stream: a second launch would double it. */
startPending: boolean;
}): VodMoreMenuSection[] {
const sourceRows: VodMoreMenuSection['items'][number][] = [];
if (input.externalPlayerAvailable) {
@@ -188,6 +190,7 @@ export function buildVodMenuSections(input: {
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
icon: 'open_in_new',
hint: input.externalPlayerHint,
disabled: input.startPending,
testId: 'vod-menu-external',
});
}