fix(portals): a failed MPV/VLC movie launch is reported to the viewer

The launch settlement swallowed a rejected launch together with a
superseded one, so the menu's launch had nothing to report. It now hands
a failure that nothing superseded to the page, which logs it and shows
the playback error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 16:15:54 +02:00
1 parent cf191eb1a0
commit 93523a1d3c
4 files changed
+57 -1

No files matched your search

@@ -5,6 +5,8 @@ interface OwnedExternalLaunchOptions {
owns: () => boolean;
close: (session: ExternalPlayerSession) => Promise<void>;
warnCloseFailure: (error: unknown) => void;
/** The launch itself failed while nothing superseded it: the user's to hear about. */
reportLaunchFailure?: (error: unknown) => void;
clearPending: () => void;
clearOwnership: () => void;
}
@@ -38,7 +40,10 @@ export async function settleOwnedExternalLaunch(
options.clearPending();
options.clearOwnership();
return false;
} catch {
} catch (error) {
if (options.owns()) {
options.reportLaunchFailure?.(error);
}
// A partial reuse can reject while Electron retains a matching
// closable error session. Keep its credential-free identity so the
// next source start can still find and close that exact process.
@@ -211,6 +211,45 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('reports a failed launch to the page unless another start superseded it', async () => {
const reportExternalLaunchFailure = jest.fn();
service.bind({
vodId: routeVodId,
vodInfo: signal(null),
activeSource,
supersedePendingSwitch: jest.fn(),
reportExternalLaunchFailure,
});
const failure = new Error('mpv is not installed');
const playback = {
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 1,
contentType: 'vod' as const,
},
};
openResolvedPlayback.mockRejectedValueOnce(failure);
await service.startResolvedPlayback(playback);
expect(reportExternalLaunchFailure).toHaveBeenCalledWith(failure);
// The route moved on before the failure arrived: the error belongs
// to a launch nothing shows any more.
let shown = true;
let rejectLaunch: (error: Error) => void = () => undefined;
openResolvedPlayback.mockImplementationOnce(
() => new Promise((_, reject) => (rejectLaunch = reject))
);
const stale = service.startResolvedPlayback(playback, () => shown);
await new Promise((resolve) => setTimeout(resolve));
shown = false;
rejectLaunch(failure);
await expect(stale).resolves.toBe(false);
expect(reportExternalLaunchFailure).toHaveBeenCalledTimes(1);
});
it('closes a closable error before starting a replacement source', async () => {
activeSession.set({
...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID),
@@ -57,6 +57,8 @@ export interface VodDetailsPlaybackBindings {
activeSource?: Signal<PlayerContentInfo | null>;
/** Retires a source resolution that the accepted fallback now supersedes. */
supersedePendingSwitch: () => void;
/** An MPV/VLC launch failed while nothing superseded it; the page tells the user. */
reportExternalLaunchFailure?: (error: unknown) => void;
}
/**
@@ -532,6 +534,8 @@ export class VodDetailsPlaybackService {
'Closing a superseded external player failed.',
error
),
reportLaunchFailure: (error) =>
this.bindings()?.reportExternalLaunchFailure?.(error),
clearPending: () => this.clearExternalLaunchPending(generation),
clearOwnership: () => this.clearExternalLaunchOwnership(generation),
});
@@ -420,6 +420,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
activeSource: this.msUi.activeAlternativeSource,
supersedePendingSwitch: () =>
this.multiSource.supersedePendingSwitch(),
reportExternalLaunchFailure: (error) => {
this.logger.error('External launch failed', error);
this.snackBar.open(
this.translateService.instant('PORTALS.PLAYBACK_ERROR'),
undefined,
{ duration: 3000 }
);
},
});
effect(() => {