fix(portals): seventeenth review round on the cinematic details

- A superseded Stalker launch that fails stays silent; the newer start
  reports for the movie.
- Xtream episode launches are serialized per series until the launch
  settles, so a repeat before the first session is published cannot start
  a second player.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 13:53:26 +02:00
1 parent 95197baf98
commit 65d7804b21
4 files changed
+68 -8

No files matched your search

@@ -24,9 +24,16 @@ const POSITION = {
describe('createStalkerVodDetailActions openExternal', () => {
function setup(selectedVodId: () => number | null) {
let resolveLink: (playback: unknown) => void = () => undefined;
let rejectLink: (error: unknown) => void = () => undefined;
const resolvePlayback = jest.fn(
() => new Promise((resolve) => (resolveLink = resolve))
() =>
new Promise((resolve, reject) => {
resolveLink = resolve;
rejectLink = reject;
})
);
const open = jest.fn();
const logError = jest.fn();
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const beforeExternalLaunch = jest.fn();
const closeSession = jest.fn().mockResolvedValue(undefined);
@@ -47,15 +54,18 @@ describe('createStalkerVodDetailActions openExternal', () => {
selectedVodId,
selectedVodPosition: signal(null),
beforeExternalLaunch,
snackBar: { open: jest.fn() },
snackBar: { open },
translate: { instant: (key: string) => key },
logError: jest.fn(),
logError,
});
return {
actions,
openExternalPlayback,
beforeExternalLaunch,
closeSession,
open,
logError,
rejectLink: (error: unknown) => rejectLink(error),
beginPendingStart,
settlePendingStart,
supersede: () => (launchCurrent = false),
@@ -202,6 +212,21 @@ describe('createStalkerVodDetailActions openExternal', () => {
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
});
it('stays silent when a superseded launch fails', async () => {
const t = setup(() => 42);
const launch = t.actions.openExternal({
item: MOVIE,
player: 'mpv',
positionSeconds: null,
});
t.supersede();
t.rejectLink(new Error('create_link timed out'));
await launch;
expect(t.open).not.toHaveBeenCalled();
expect(t.logError).not.toHaveBeenCalled();
});
it('drops the stream once another movie was selected meanwhile', async () => {
let selected = 42;
const t = setup(() => selected);
@@ -168,10 +168,13 @@ export function createStalkerVodDetailActions(
event.player
);
} catch (error) {
deps.logError('External VOD playback failed', error);
if (stillSelected()) {
notify('PORTALS.PLAYBACK_ERROR');
// A launch a newer start superseded fails on its own; the
// newer one reports for the movie now.
if (!stillSelected() || superseded()) {
return;
}
deps.logError('External VOD playback failed', error);
notify('PORTALS.PLAYBACK_ERROR');
} finally {
externalLaunchesInFlight.delete(launchKey);
pending?.settle();
@@ -92,6 +92,20 @@ describe('openEpisodeExternally', () => {
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
it('ignores a repeat before the first launch settled', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
settleLaunch();
await first;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
@@ -20,12 +20,16 @@ export interface SeriesExternalLaunchHost {
launchOwner(): string | null;
}
/** Owners (`playlist:series`) whose launch has not settled yet. */
const launchesInFlight = new Set<string>();
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails, or
* the user moved on while it ran, the running player stays and nothing new
* launches.
* launches. A repeat before the first launch settled (Electron publishes
* the session only afterwards) is ignored.
*/
export async function openEpisodeExternally(
host: SeriesExternalLaunchHost,
@@ -33,9 +37,23 @@ export async function openEpisodeExternally(
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const owner = host.launchOwner();
if (!owner) {
if (!owner || launchesInFlight.has(owner)) {
return;
}
launchesInFlight.add(owner);
try {
return await launchEpisode(host, owner, playback, player);
} finally {
launchesInFlight.delete(owner);
}
}
async function launchEpisode(
host: SeriesExternalLaunchHost,
owner: string,
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const session = host.externalPlayback.activeSession();
const info = session?.contentInfo;
const ownSession =