fix(playback): keep asking for Embedded MPV support while the answer is inconclusive

Keeping the saved player on an inconclusive answer left a mounted player
stuck on it: the session controller asked for support once, in its
constructor, and the session effect never starts while unsupported, so the
player did not recover after the login shell answered. The settings page
held its one answer the same way.

`watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final
or the surface is destroyed. The player controller and the settings page
facade load support through it, so playback starts by itself and the
Embedded MPV option appears without reopening the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:52:28 +02:00
1 parent 417eaf5005
commit d143fb85f5
8 files changed
+407 -33

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ export * from './lib/content-metadata.interface';
export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
export * from './lib/embedded-mpv-support-watch.util';
export * from './lib/subtitle-style.util';
export * from './lib/zoom-level.util';
export * from './lib/electron-api.interface';
@@ -0,0 +1,131 @@
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
watchEmbeddedMpvSupport,
} from './embedded-mpv-support-watch.util';
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
const UNSUPPORTED: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'no mpv',
};
const INCONCLUSIVE: EmbeddedMpvSupport = { ...UNSUPPORTED, inconclusive: true };
describe('watchEmbeddedMpvSupport', () => {
let onAnswer: jest.Mock;
let onError: jest.Mock;
beforeEach(() => {
jest.useFakeTimers();
onAnswer = jest.fn();
onError = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([SUPPORTED, UNSUPPORTED])(
'delivers a final answer once and asks no more: %j',
async (answer) => {
const getSupport = jest.fn().mockResolvedValue(answer);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer.mock.calls).toEqual([[answer]]);
expect(onError).not.toHaveBeenCalled();
}
);
it('asks again while the answer is inconclusive, until a final one', async () => {
const getSupport = jest
.fn()
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(0);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(getSupport).toHaveBeenCalledTimes(2);
expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(getSupport).toHaveBeenCalledTimes(3);
expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(3);
});
it('asks no more once stopped', async () => {
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(0);
stop();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer).toHaveBeenCalledTimes(1);
});
it('drops an answer that arrives after it was stopped', async () => {
let answer: (support: EmbeddedMpvSupport) => void = () => undefined;
const getSupport = jest.fn(
() =>
new Promise<EmbeddedMpvSupport>((resolve) => {
answer = resolve;
})
);
const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
stop();
answer(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
});
it.each([
['rejects', () => Promise.reject(new Error('bridge failed'))],
[
'throws',
() => {
throw new Error('bridge failed');
},
],
])(
'ends the watch with the error when the request %s',
async (_how, request) => {
const getSupport = jest.fn(request);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toEqual(
new Error('bridge failed')
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
}
);
});
@@ -0,0 +1,54 @@
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
/**
* How soon an inconclusive support answer is asked for again. The main
* process answers from its cached probe meanwhile, so a recheck costs one
* IPC round trip and spawns nothing.
*/
export const EMBEDDED_MPV_SUPPORT_RECHECK_MS = 3000;
/**
* Asks for embedded MPV support and hands every answer to `onAnswer`. An
* `inconclusive` answer is not final, so it is asked for again until a final
* one arrives or the returned function is called. A failed request ends the
* watch through `onError`, as a final answer would.
*
* For surfaces that stay mounted on one answer (the player, the settings
* page). A surface that asks on demand simply asks again the next time.
*/
export function watchEmbeddedMpvSupport(
getSupport: () => Promise<EmbeddedMpvSupport>,
onAnswer: (support: EmbeddedMpvSupport) => void,
onError: (error: unknown) => void
): () => void {
let stopped = false;
let recheck: ReturnType<typeof setTimeout> | undefined;
const ask = async (): Promise<void> => {
let support: EmbeddedMpvSupport;
try {
support = await getSupport();
} catch (error) {
if (!stopped) {
onError(error);
}
return;
}
if (stopped) {
return;
}
onAnswer(support);
if (support.inconclusive) {
recheck = setTimeout(
() => void ask(),
EMBEDDED_MPV_SUPPORT_RECHECK_MS
);
}
};
void ask();
return () => {
stopped = true;
clearTimeout(recheck);
};
}