mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive (#1803)
* fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive On Linux native-view the support check runs `mpv --version` by bare name and waits for the login shell PATH first. Since #1784 that lookup is asynchronous with a 10 s budget; when it ran out, the check ran on the inherited PATH and answered a plain `supported: false`. The settings store took that as a verdict and persisted the default player over a saved Embedded MPV selection. The main process probed again once the shell answered, but nothing restored the setting. `EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it on a missing mpv while its probe has only seen the inherited PATH; the IPC handler declares that state before probing and registers the re-probe before the check, so a throwing check cannot leave it stuck. Every other answer stays final. Consumers no longer settle on an inconclusive answer: the settings store keeps the saved player, and the command palette and the settings search probe again on their next use instead of caching it for the session. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * 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> * fix(settings): follow an inconclusive Embedded MPV answer to a final decision The settings store checked a saved Embedded MPV selection once. After an inconclusive answer it kept the selection and never looked again, so when mpv turned out to be really missing the player stayed on Embedded MPV instead of falling back to the default one. The store now follows the answer with `watchEmbeddedMpvSupport()` until it is final and only then decides. It acts on an answer only while Embedded MPV is still the saved player, so a player picked meanwhile, also while the first answer was pending, is never overwritten. The watch backs off from 3 s to 30 s between rechecks, so a login shell that never answers does not keep the app polling at the first rate, and it no longer schedules a recheck after its answer handler stopped it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep the settings search following an inconclusive Embedded MPV answer The settings page asks the search service for Embedded MPV support once, when its search facade is created. After an inconclusive answer the service only probed again on its next call, so with the page left open the Embedded MPV rows stayed unsearchable after the login shell answered, while the player option on the same page already updated. The service now follows the answer with `watchEmbeddedMpvSupport()` until it is final, which updates the open page and the command palette alike. A call made while the answer is still inconclusive restarts the watch, so it asks at once as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): follow Embedded MPV support for search only while the settings page is open The settings search service is provided in the root injector, so the watch it started on its first use had no owner: with a login shell that never answers it kept asking every 30 s until the app quit, long after the settings page or the command palette that needed the answer was closed. A failed recheck also ended the watch as if it were a final answer, hiding the Embedded MPV rows for the rest of the session. The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()` is a single request again, for the command palette. The settings page calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed. Only a final answer is kept: after an inconclusive one or a failed request the next use asks again, for the palette's player commands too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
040acbd976
commit
bc5a7fcbf9
24 files changed
+1361
-85
No files matched your search
@@ -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';
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface EmbeddedMpvSupport {
|
||||
supported: boolean;
|
||||
platform: string;
|
||||
reason?: string;
|
||||
/**
|
||||
* True when `supported: false` is not a verdict on this machine yet: the
|
||||
* Linux native-view `mpv` executable was looked up before the login shell
|
||||
* PATH arrived, and is looked up again once the shell answers. Never
|
||||
* persist a decision made from such an answer; ask again later.
|
||||
*/
|
||||
inconclusive?: boolean;
|
||||
capabilities?: EmbeddedMpvCapabilities;
|
||||
/**
|
||||
* Rendering engine the main process will use for new sessions.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS,
|
||||
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 * 2
|
||||
);
|
||||
expect(getSupport).toHaveBeenCalledTimes(3);
|
||||
expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
|
||||
);
|
||||
expect(getSupport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('backs off to the slowest rate while the answer stays inconclusive', async () => {
|
||||
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
|
||||
|
||||
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
|
||||
// Rechecks after 3, 6, 12 and 24 s, then every 30 s.
|
||||
await jest.advanceTimersByTimeAsync(3000 + 6000 + 12_000 + 24_000);
|
||||
expect(getSupport).toHaveBeenCalledTimes(5);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS - 1
|
||||
);
|
||||
expect(getSupport).toHaveBeenCalledTimes(5);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(getSupport).toHaveBeenCalledTimes(6);
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS
|
||||
);
|
||||
expect(getSupport).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('asks no more when the answer handler stops the watch', async () => {
|
||||
const getSupport = jest.fn().mockResolvedValue(INCONCLUSIVE);
|
||||
const stop: () => void = watchEmbeddedMpvSupport(
|
||||
getSupport,
|
||||
() => stop(),
|
||||
onError
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3
|
||||
);
|
||||
|
||||
expect(getSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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_MAX_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_MAX_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,64 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Each further recheck waits twice as long, up to this: a login shell that
|
||||
* never answers must not keep the app polling at the first rate for good.
|
||||
*/
|
||||
export const EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS = 30_000;
|
||||
|
||||
/**
|
||||
* 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 whatever holds on to one answer: the surfaces that stay mounted on it
|
||||
* (the player, the settings page) and the settings store, which has to reach
|
||||
* a decision. 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;
|
||||
let recheckMs = EMBEDDED_MPV_SUPPORT_RECHECK_MS;
|
||||
|
||||
const ask = async (): Promise<void> => {
|
||||
let support: EmbeddedMpvSupport;
|
||||
try {
|
||||
support = await getSupport();
|
||||
} catch (error) {
|
||||
if (!stopped) {
|
||||
onError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
onAnswer(support);
|
||||
// `onAnswer` may have stopped the watch.
|
||||
if (support.inconclusive && !stopped) {
|
||||
recheck = setTimeout(() => void ask(), recheckMs);
|
||||
recheckMs = Math.min(
|
||||
recheckMs * 2,
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS
|
||||
);
|
||||
}
|
||||
};
|
||||
void ask();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimeout(recheck);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user