mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -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
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
On Linux, the app no longer switches a saved Embedded MPV player back to the
|
||||
default player when your login shell is slow to start. It now keeps your
|
||||
choice until it knows for certain whether mpv is installed.
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The Linux native-view support check end to end in the main process: the
|
||||
* real IPC handlers, native service and login shell PATH lookup. Only the
|
||||
* process boundary is faked: the shell (`readPath`), `mpv --version`
|
||||
* (`spawnSync`) and Electron. `process.platform` is forced here, so the
|
||||
* Linux branch runs on every host.
|
||||
*/
|
||||
import {
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
type EmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
const mockSpawnSync = jest.fn();
|
||||
const mockIpcHandle = jest.fn();
|
||||
|
||||
jest.mock('child_process', () => ({ spawnSync: mockSpawnSync }));
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
commandLine: { getSwitchValue: () => '' },
|
||||
},
|
||||
ipcMain: { handle: mockIpcHandle },
|
||||
powerSaveBlocker: {
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
isStarted: jest.fn(),
|
||||
},
|
||||
screen: { getDisplayMatching: jest.fn() },
|
||||
}));
|
||||
jest.mock('../app', () => ({
|
||||
__esModule: true,
|
||||
default: { mainWindow: null },
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => ({
|
||||
extraOptions: [],
|
||||
autoReconnect: true,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../services/embedded-mpv-frame-copy-platform.util', () => ({
|
||||
...jest.requireActual('../services/embedded-mpv-frame-copy-platform.util'),
|
||||
getFrameCopyRuntimeAvailability: () => ({
|
||||
usable: false,
|
||||
reason: 'helper-probe-failed',
|
||||
}),
|
||||
isFrameCopyRuntimeUsable: () => false,
|
||||
}));
|
||||
|
||||
const INHERITED_PATH = '/usr/bin:/bin';
|
||||
const LOGIN_SHELL_ONLY_DIR = '/home/user/.local/bin';
|
||||
const LOGIN_SHELL_PATH = `${LOGIN_SHELL_ONLY_DIR}:${INHERITED_PATH}`;
|
||||
/** Budget of the lookup; the shell in these tests never answers within it. */
|
||||
const LOOKUP_BUDGET_MS = 5;
|
||||
|
||||
type SupportHandler = (event: unknown) => Promise<EmbeddedMpvSupport>;
|
||||
|
||||
async function flushLookup(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('Embedded MPV support and a slow login shell (Linux native-view)', () => {
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = {
|
||||
PATH: process.env.PATH,
|
||||
DISPLAY: process.env.DISPLAY,
|
||||
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
|
||||
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
|
||||
};
|
||||
let answerShell: (path: string) => void;
|
||||
|
||||
function handlerFor(channel: string): SupportHandler {
|
||||
const registration = mockIpcHandle.mock.calls.find(
|
||||
([registered]) => registered === channel
|
||||
);
|
||||
if (!registration) {
|
||||
throw new Error(`Missing ipcMain handler for ${channel}`);
|
||||
}
|
||||
return registration[1] as SupportHandler;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockIpcHandle.mockReset();
|
||||
mockSpawnSync.mockReset();
|
||||
// mpv is installed where only the login shell PATH reaches it.
|
||||
mockSpawnSync.mockImplementation(() => ({
|
||||
status: (process.env.PATH ?? '')
|
||||
.split(':')
|
||||
.includes(LOGIN_SHELL_ONLY_DIR)
|
||||
? 0
|
||||
: 1,
|
||||
}));
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.PATH = INHERITED_PATH;
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
||||
|
||||
const { scheduleDeferredFixPath } =
|
||||
await import('../startup/login-shell-path');
|
||||
const { embeddedMpvNativeService } =
|
||||
await import('../services/embedded-mpv-native.service');
|
||||
await import('./embedded-mpv.events');
|
||||
// The addon is normally a vendored .node file; with it in place,
|
||||
// mpv on PATH is the only thing support depends on.
|
||||
(
|
||||
embeddedMpvNativeService as unknown as {
|
||||
addon: { isSupported(): boolean };
|
||||
}
|
||||
).addon = { isSupported: () => true };
|
||||
|
||||
scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
answerShell = resolve;
|
||||
}),
|
||||
LOOKUP_BUDGET_MS
|
||||
);
|
||||
// The lookup starts on the next tick; only then can it be answered.
|
||||
await flushLookup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Let the lookup finish, so no test leaves a pending shell behind.
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
|
||||
'%s reports a missing mpv as inconclusive until the shell answers',
|
||||
async (channel) => {
|
||||
const check = handlerFor(channel);
|
||||
|
||||
// The lookup runs out of budget: this probe sees the inherited
|
||||
// PATH, where mpv is missing.
|
||||
await expect(check({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
// Asked again meanwhile: the cached answer is still not final.
|
||||
await expect(check({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
answerShell(LOGIN_SHELL_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const settled = await check({});
|
||||
expect(settled.supported).toBe(true);
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
);
|
||||
|
||||
it('reports a missing mpv as final once the shell answered without it', async () => {
|
||||
const support = handlerFor(EMBEDDED_MPV_SUPPORT);
|
||||
await expect(support({})).resolves.toMatchObject({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const settled = await support({});
|
||||
expect(settled.supported).toBe(false);
|
||||
expect(settled.reason).toContain('mpv executable');
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a missing mpv as final when the shell answered in time', async () => {
|
||||
answerShell(INHERITED_PATH);
|
||||
await flushLookup();
|
||||
|
||||
const answer = await handlerFor(EMBEDDED_MPV_SUPPORT)({});
|
||||
expect(answer.supported).toBe(false);
|
||||
expect(answer.inconclusive).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const mockEmbeddedMpvService = {
|
||||
getSupport: jest.fn(),
|
||||
willProbeLinuxMpvExecutable: jest.fn(() => false),
|
||||
forgetLinuxMpvExecutableProbe: jest.fn(),
|
||||
markLinuxMpvExecutableProbeProvisional: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
@@ -26,9 +27,7 @@ jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
}));
|
||||
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
|
||||
let settleLookup: () => void = () => undefined;
|
||||
const mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
let mockLookupSettled = Promise.resolve();
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
|
||||
whenLoginShellPathSettled: () => mockLookupSettled,
|
||||
@@ -69,6 +68,16 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
});
|
||||
|
||||
describe('support checks and the login shell PATH', () => {
|
||||
beforeEach(() => {
|
||||
// A lookup of its own per test: the pending re-probe of one test
|
||||
// must not answer for the next.
|
||||
mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe.mockClear();
|
||||
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockWaitForLoginShellPath.mockClear();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
|
||||
@@ -110,6 +119,10 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
// The probe saw the login shell PATH: its answer is final.
|
||||
expect(
|
||||
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -125,6 +138,17 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).resolves.toEqual({ supported: false });
|
||||
// The service is told before it probes, so the answer of this
|
||||
// very check is already marked as not final.
|
||||
const { markLinuxMpvExecutableProbeProvisional, getSupport } =
|
||||
mockEmbeddedMpvService;
|
||||
expect(
|
||||
markLinuxMpvExecutableProbeProvisional
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
markLinuxMpvExecutableProbeProvisional.mock
|
||||
.invocationCallOrder[0]
|
||||
).toBeLessThan(getSupport.mock.invocationCallOrder[0]);
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
@@ -136,6 +160,33 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still re-probes when the check on the inherited PATH throws', async () => {
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
|
||||
mockEmbeddedMpvService.getSupport.mockImplementation(() => {
|
||||
throw new Error('probe failed');
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).rejects.toThrow('probe failed');
|
||||
// Otherwise the provisional state would outlive the lookup.
|
||||
settleLookup();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wait when no probe runs, nor for session calls', async () => {
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
|
||||
@@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
|
||||
) {
|
||||
return check();
|
||||
}
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH.
|
||||
// Once the shell does answer, the result is probed again.
|
||||
const result = check();
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH: a
|
||||
// missing mpv is answered as inconclusive, never as a verdict the
|
||||
// renderer may persist. Once the shell does answer, it is probed again.
|
||||
getService().markLinuxMpvExecutableProbeProvisional();
|
||||
void whenLoginShellPathSettled().then(() =>
|
||||
getService().forgetLinuxMpvExecutableProbe()
|
||||
);
|
||||
return result;
|
||||
return check();
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
|
||||
|
||||
@@ -414,6 +414,56 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a missing mpv as inconclusive only while its probe is provisional', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
// The login shell has not answered: mpv is looked up on the
|
||||
// inherited PATH.
|
||||
service.markLinuxMpvExecutableProbeProvisional();
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
})
|
||||
);
|
||||
expect(service.prepareAddon()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
})
|
||||
);
|
||||
|
||||
// It answered: the next probe is a verdict again.
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
const settled = service.getSupport();
|
||||
expect(settled.supported).toBe(false);
|
||||
expect(settled.reason).toContain('mpv executable');
|
||||
expect(settled.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps every other answer final while the mpv probe is provisional', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
mockRuntimeUsable();
|
||||
service.markLinuxMpvExecutableProbeProvisional();
|
||||
|
||||
const found = service.getSupport();
|
||||
expect(found.supported).toBe(true);
|
||||
expect(found.inconclusive).toBeUndefined();
|
||||
|
||||
// mpv is there, the addon is not: the PATH cannot change that.
|
||||
addon.isSupported.mockReturnValue(false);
|
||||
const unsupported = service.getSupport();
|
||||
expect(unsupported.supported).toBe(false);
|
||||
expect(unsupported.inconclusive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('predicts no probe for the frame-copy engine or native Wayland', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
|
||||
@@ -174,6 +174,12 @@ export class EmbeddedMpvNativeService {
|
||||
private powerBlockerId: number | null = null;
|
||||
private readonly loadAddonModule = createRequire(__filename);
|
||||
private cachedLinuxMpvExecutableReason: string | null | undefined;
|
||||
/**
|
||||
* True while `mpv --version` runs, or was cached, on the inherited PATH
|
||||
* because the login shell had not answered: a missing mpv is then no
|
||||
* verdict yet.
|
||||
*/
|
||||
private linuxMpvExecutableProbeIsProvisional = false;
|
||||
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
|
||||
private sessionOptionsDirectory: string | null = null;
|
||||
/**
|
||||
@@ -401,6 +407,16 @@ export class EmbeddedMpvNativeService {
|
||||
*/
|
||||
forgetLinuxMpvExecutableProbe(): void {
|
||||
this.cachedLinuxMpvExecutableReason = undefined;
|
||||
this.linuxMpvExecutableProbeIsProvisional = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares that the probe sees the inherited PATH, the login shell one
|
||||
* not having arrived. Until `forgetLinuxMpvExecutableProbe()`, a missing
|
||||
* mpv is reported as `inconclusive`, so no caller settles on it.
|
||||
*/
|
||||
markLinuxMpvExecutableProbeProvisional(): void {
|
||||
this.linuxMpvExecutableProbeIsProvisional = true;
|
||||
}
|
||||
|
||||
getSupport(): EmbeddedMpvSupport {
|
||||
@@ -458,6 +474,9 @@ export class EmbeddedMpvNativeService {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingLinuxMpvExecutableReason,
|
||||
...(this.linuxMpvExecutableProbeIsProvisional
|
||||
? { inconclusive: true }
|
||||
: {}),
|
||||
...this.getFrameCopySupportDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
|
||||
EmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
|
||||
|
||||
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
|
||||
/** A slow login shell: mpv was looked up before its PATH arrived. */
|
||||
const INCONCLUSIVE: EmbeddedMpvSupport = {
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'mpv executable missing',
|
||||
inconclusive: true,
|
||||
};
|
||||
|
||||
describe('SettingsEmbeddedMpvFacade', () => {
|
||||
const originalElectron = window.electron;
|
||||
let getEmbeddedMpvSupport: jest.Mock;
|
||||
let facade: SettingsEmbeddedMpvFacade;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
getEmbeddedMpvSupport = jest.fn();
|
||||
window.electron = {
|
||||
platform: 'linux',
|
||||
getEmbeddedMpvSupport,
|
||||
} as unknown as typeof window.electron;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SettingsEmbeddedMpvFacade,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
facade = TestBed.inject(SettingsEmbeddedMpvFacade);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
window.electron = originalElectron;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('offers Embedded MPV once an inconclusive answer turns into supported', async () => {
|
||||
getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(INCONCLUSIVE)
|
||||
.mockResolvedValue(SUPPORTED);
|
||||
|
||||
await facade.load();
|
||||
expect(facade.supported()).toBe(false);
|
||||
|
||||
// The page stays open; nobody calls load() again.
|
||||
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
|
||||
expect(facade.supported()).toBe(true);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops asking once the settings page is closed', async () => {
|
||||
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
|
||||
await facade.load();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports a failed probe as unsupported', async () => {
|
||||
getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed'));
|
||||
|
||||
await facade.load();
|
||||
|
||||
expect(facade.support()).toEqual({
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'addon load failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Probes the desktop backend for embedded MPV support so the playback
|
||||
@@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
|
||||
@Injectable()
|
||||
export class SettingsEmbeddedMpvFacade {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private stopSupportWatch: (() => void) | undefined;
|
||||
|
||||
readonly support = signal<EmbeddedMpvSupport | null>(null);
|
||||
|
||||
@@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade {
|
||||
() => this.support()?.engine === 'frame-copy'
|
||||
);
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves with the first answer. An inconclusive one keeps being asked
|
||||
* for while the page is open, so the option appears without reopening it.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (!this.runtime.isElectron) {
|
||||
this.support.set({
|
||||
@@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.support.set(await window.electron.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron.platform,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
this.stopSupportWatch?.();
|
||||
await new Promise<void>((answered) => {
|
||||
this.stopSupportWatch = watchEmbeddedMpvSupport(
|
||||
() => window.electron.getEmbeddedMpvSupport(),
|
||||
(support) => {
|
||||
this.support.set(support);
|
||||
answered();
|
||||
},
|
||||
(error) => {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron.platform,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
});
|
||||
answered();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,7 +66,9 @@ export class SettingsSearchFacade {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.settingsSearch.ensureEmbeddedMpvSupportLoaded();
|
||||
inject(DestroyRef).onDestroy(
|
||||
this.settingsSearch.followEmbeddedMpvSupport()
|
||||
);
|
||||
|
||||
effect(() => {
|
||||
if (!this.isSearching()) {
|
||||
|
||||
@@ -95,6 +95,21 @@ describe('SettingsComponent search', () => {
|
||||
expect(query('app-settings-general-section')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('follows Embedded MPV support only while the page is open', () => {
|
||||
const stopFollowing = jest.fn();
|
||||
const follow = jest
|
||||
.spyOn(settingsSearch, 'followEmbeddedMpvSupport')
|
||||
.mockReturnValue(stopFollowing);
|
||||
|
||||
const page = TestBed.createComponent(SettingsComponent);
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
expect(stopFollowing).not.toHaveBeenCalled();
|
||||
|
||||
// Closing the page ends it: nothing shows these rows any more.
|
||||
page.destroy();
|
||||
expect(stopFollowing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows an empty state when nothing matches', () => {
|
||||
setSettingsSearchQuery('zzzz-no-such-setting');
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -185,6 +185,39 @@ support API from global workspace startup paths; use an explicit user action
|
||||
or idle preparation path when a renderer surface only needs to reveal optional
|
||||
Embedded MPV UI.
|
||||
|
||||
An unsupported answer can be `inconclusive`. The Linux native-view `mpv`
|
||||
executable check runs `mpv --version` by bare name, so the support and prepare
|
||||
handlers wait for the login shell PATH lookup (`startup/login-shell-path.ts`)
|
||||
first. When that lookup runs out of its budget, the check runs on the
|
||||
inherited PATH: `EmbeddedMpvNativeService` then reports a missing `mpv` as
|
||||
`supported: false` with `inconclusive: true`, keeps doing so while the cached
|
||||
result stands, and probes again once the shell answers. Every other answer,
|
||||
including a missing `mpv` after the shell answered, is final. An inconclusive
|
||||
answer is not a verdict on the machine: never persist a decision made from it.
|
||||
Whatever holds on to one answer follows it through `watchEmbeddedMpvSupport()`
|
||||
(`@iptvnator/shared/interfaces`), which asks again after
|
||||
`EMBEDDED_MPV_SUPPORT_RECHECK_MS`, backing off to
|
||||
`EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS`, until the answer is final:
|
||||
|
||||
- The settings store keeps a saved Embedded MPV selection while the answer is
|
||||
inconclusive, falls back to the default player only on a final unsupported
|
||||
answer, and never overwrites a player the user picked meanwhile.
|
||||
- The player (`EmbeddedMpvSessionController`) and the settings page stay
|
||||
mounted on one answer: a player mounted in that window starts playback by
|
||||
itself once `mpv` is found, and the option appears without reopening the
|
||||
page.
|
||||
- The settings page also follows the answer for its search
|
||||
(`SettingsSearchService.followEmbeddedMpvSupport()`) and ends that when it
|
||||
closes: the Embedded MPV rows become searchable while the page stays open,
|
||||
and nothing keeps asking once no surface shows them.
|
||||
|
||||
The command palette asks on demand, on every open, for its player commands and
|
||||
its settings rows (`ensureEmbeddedMpvSupportLoaded()`). Only a final answer is
|
||||
kept for the session; after an inconclusive one, or a failed request, the next
|
||||
open asks again. An open palette is a snapshot of that moment: it does not wait
|
||||
for a final answer, because a login shell that never answers would then keep
|
||||
it from opening.
|
||||
|
||||
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
|
||||
|
||||
For the native-view engine, the MPV video surface is a platform view/window,
|
||||
@@ -967,7 +1000,7 @@ Defensive practice for this component:
|
||||
|
||||
Concrete bugs from the audit, recorded so they don't get reintroduced:
|
||||
|
||||
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities.
|
||||
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `watchSupport()` already populates it including capabilities.
|
||||
- **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph.
|
||||
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
|
||||
- **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
StreamFormat,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Defaults and boot-time helpers of `SettingsStore`, split out for size. */
|
||||
@@ -115,3 +116,52 @@ export function scheduleEmbeddedMpvPrepare(): void {
|
||||
window.setTimeout(prepare, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a saved Embedded MPV selection against this machine: schedules the
|
||||
* idle prepare when it is supported, and calls `fallBack` when it is not or
|
||||
* when the check itself fails. An inconclusive answer is no verdict: the
|
||||
* selection stays and the answer is followed until it is final. Nothing is
|
||||
* done once `isSaved` turns false, because the user picked another player
|
||||
* meanwhile. Resolves when the first answer has been handled.
|
||||
*/
|
||||
export function verifySavedEmbeddedMpvPlayer(
|
||||
isSaved: () => boolean,
|
||||
fallBack: () => Promise<void>
|
||||
): Promise<void> {
|
||||
const electron =
|
||||
typeof window === 'undefined' ? undefined : window.electron;
|
||||
if (!electron?.getEmbeddedMpvSupport) {
|
||||
return fallBack();
|
||||
}
|
||||
|
||||
return new Promise<void>((handled, failed) => {
|
||||
const stop = watchEmbeddedMpvSupport(
|
||||
() => electron.getEmbeddedMpvSupport(),
|
||||
(support) => {
|
||||
if (!isSaved()) {
|
||||
stop();
|
||||
handled();
|
||||
} else if (support.supported) {
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
handled();
|
||||
} else if (support.inconclusive) {
|
||||
handled();
|
||||
} else {
|
||||
fallBack().then(handled, failed);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support; reverting to the default inline player.',
|
||||
error
|
||||
);
|
||||
if (isSaved()) {
|
||||
fallBack().then(handled, failed);
|
||||
} else {
|
||||
handled();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
|
||||
EmbeddedMpvSupport,
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceSettingsService } from './epg-source-settings.service';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
|
||||
/** What the main process answers when the Linux `mpv` probe finds nothing. */
|
||||
const MPV_MISSING: EmbeddedMpvSupport = {
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'Embedded MPV on Linux requires the mpv executable on PATH.',
|
||||
frameCopyAvailable: false,
|
||||
frameCopyUnavailableReason: 'helper-probe-failed',
|
||||
};
|
||||
/** A slow login shell: mpv was looked up before its PATH arrived. */
|
||||
const INCONCLUSIVE: EmbeddedMpvSupport = { ...MPV_MISSING, inconclusive: true };
|
||||
const SUPPORTED: EmbeddedMpvSupport = {
|
||||
supported: true,
|
||||
platform: 'linux',
|
||||
engine: 'native',
|
||||
};
|
||||
|
||||
describe('SettingsStore saved Embedded MPV selection', () => {
|
||||
const testWindow = window as unknown as {
|
||||
electron?: { getEmbeddedMpvSupport: jest.Mock };
|
||||
};
|
||||
const originalElectron = testWindow.electron;
|
||||
let getEmbeddedMpvSupport: jest.Mock;
|
||||
let storage: { get: jest.Mock; set: jest.Mock };
|
||||
let injector: Injector;
|
||||
|
||||
/** Loads settings as on startup and lets the first answer be handled. */
|
||||
async function start(): Promise<InstanceType<typeof SettingsStore>> {
|
||||
const store = injector.get(SettingsStore);
|
||||
await store.loadSettings();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalled();
|
||||
return store;
|
||||
}
|
||||
|
||||
const persistedPlayers = () =>
|
||||
storage.set.mock.calls.map(([, settings]) => settings.player);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
const saved: Partial<Settings> = { player: VideoPlayer.EmbeddedMpv };
|
||||
storage = {
|
||||
get: jest.fn(() => of(saved)),
|
||||
set: jest.fn(() => of(undefined)),
|
||||
};
|
||||
getEmbeddedMpvSupport = jest.fn();
|
||||
testWindow.electron = { getEmbeddedMpvSupport };
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
SettingsStore,
|
||||
EpgSourceSettingsService,
|
||||
{ provide: StorageMap, useValue: storage },
|
||||
],
|
||||
});
|
||||
jest.spyOn(
|
||||
injector.get(EpgSourceSettingsService),
|
||||
'synchronize'
|
||||
).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
testWindow.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('keeps the saved player while the support check is inconclusive', async () => {
|
||||
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
|
||||
|
||||
const store = await start();
|
||||
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
|
||||
expect(storage.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the default player on a final unsupported answer', async () => {
|
||||
getEmbeddedMpvSupport.mockResolvedValue(MPV_MISSING);
|
||||
|
||||
const store = await start();
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.VideoJs);
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
STORE_KEY.Settings,
|
||||
expect.objectContaining({ player: VideoPlayer.VideoJs })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the saved player when Embedded MPV is supported', async () => {
|
||||
getEmbeddedMpvSupport.mockResolvedValue(SUPPORTED);
|
||||
|
||||
const store = await start();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
|
||||
expect(storage.set).not.toHaveBeenCalled();
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('follows an inconclusive answer and falls back once mpv is finally missing', async () => {
|
||||
getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(INCONCLUSIVE)
|
||||
.mockResolvedValue(MPV_MISSING);
|
||||
|
||||
const store = await start();
|
||||
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
|
||||
|
||||
// The login shell answered: mpv really is not installed.
|
||||
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.VideoJs);
|
||||
expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]);
|
||||
});
|
||||
|
||||
it('follows an inconclusive answer and keeps the player once mpv is found', async () => {
|
||||
getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(INCONCLUSIVE)
|
||||
.mockResolvedValue(SUPPORTED);
|
||||
|
||||
const store = await start();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.EmbeddedMpv);
|
||||
expect(storage.set).not.toHaveBeenCalled();
|
||||
// The final answer ended the checks.
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('leaves a player the user picked meanwhile alone and stops checking', async () => {
|
||||
getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(INCONCLUSIVE)
|
||||
.mockResolvedValue(MPV_MISSING);
|
||||
const store = await start();
|
||||
|
||||
await store.updateSettings({ player: VideoPlayer.MPV });
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.MPV);
|
||||
expect(persistedPlayers()).toEqual([VideoPlayer.MPV]);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('leaves a player picked while the first answer was pending alone', async () => {
|
||||
let answer: (support: EmbeddedMpvSupport) => void = () => undefined;
|
||||
getEmbeddedMpvSupport.mockReturnValue(
|
||||
new Promise<EmbeddedMpvSupport>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
);
|
||||
const store = await start();
|
||||
|
||||
await store.updateSettings({ player: VideoPlayer.VLC });
|
||||
answer(MPV_MISSING);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.VLC);
|
||||
expect(persistedPlayers()).toEqual([VideoPlayer.VLC]);
|
||||
});
|
||||
|
||||
it('falls back to the default player when the support check fails', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation();
|
||||
getEmbeddedMpvSupport.mockRejectedValue(new Error('bridge failed'));
|
||||
|
||||
try {
|
||||
const store = await start();
|
||||
|
||||
expect(store.player()).toBe(VideoPlayer.VideoJs);
|
||||
expect(persistedPlayers()).toEqual([VideoPlayer.VideoJs]);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
scheduleEmbeddedMpvPrepare,
|
||||
SettingsStorageState,
|
||||
verifySavedEmbeddedMpvPlayer,
|
||||
} from './settings-store.defaults';
|
||||
|
||||
export type { SettingsStorageFailure } from './settings-store.defaults';
|
||||
@@ -324,39 +325,14 @@ export const SettingsStore = signalStore(
|
||||
},
|
||||
|
||||
async sanitizeEmbeddedMpvSelection() {
|
||||
if (store.player() !== VideoPlayer.EmbeddedMpv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.electron?.getEmbeddedMpvSupport
|
||||
) {
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const support =
|
||||
await window.electron.getEmbeddedMpvSupport();
|
||||
if (!support.supported) {
|
||||
await this.updateSettings({
|
||||
const isSaved = () =>
|
||||
store.player() === VideoPlayer.EmbeddedMpv;
|
||||
if (isSaved()) {
|
||||
await verifySavedEmbeddedMpvPlayer(isSaved, () =>
|
||||
this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleEmbeddedMpvPrepare();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support; reverting to the default inline player.',
|
||||
error
|
||||
})
|
||||
);
|
||||
await this.updateSettings({
|
||||
player: DEFAULT_SETTINGS.player,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
+53
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
|
||||
EmbeddedMpvEngine,
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -106,6 +107,58 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an inconclusive support answer', () => {
|
||||
// A slow login shell: mpv was looked up before its PATH arrived.
|
||||
const inconclusive = {
|
||||
supported: false,
|
||||
platform: 'linux',
|
||||
reason: 'mpv executable missing',
|
||||
inconclusive: true,
|
||||
};
|
||||
|
||||
it('is asked for again, so a mounted player recovers by itself', async () => {
|
||||
const settled = createSupport('native');
|
||||
electron.getEmbeddedMpvSupport
|
||||
.mockResolvedValueOnce(inconclusive)
|
||||
.mockResolvedValue(settled);
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
|
||||
await waitFor(
|
||||
() => controller.support() !== null,
|
||||
'the first support answer'
|
||||
);
|
||||
expect(controller.support()).toBe(inconclusive);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS
|
||||
);
|
||||
expect(controller.support()).toBe(settled);
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A final answer is kept.
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is no longer asked for once the player is gone', async () => {
|
||||
electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive);
|
||||
const controller = TestBed.inject(EmbeddedMpvSessionController);
|
||||
await waitFor(
|
||||
() => controller.support() !== null,
|
||||
'the first support answer'
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
|
||||
);
|
||||
|
||||
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the constructor support probe after preparing a session', async () => {
|
||||
const probedSupport = createSupport('native');
|
||||
const preparedSupport = createSupport('frame-copy');
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
EmbeddedMpvSupport,
|
||||
RecordingStartMetadata,
|
||||
ResolvedPortalPlayback,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
|
||||
import { measureBounds } from './embedded-mpv-format.utils';
|
||||
@@ -95,8 +96,9 @@ export class EmbeddedMpvSessionController {
|
||||
this.session.set(session);
|
||||
});
|
||||
|
||||
let stopSupportWatch: (() => void) | undefined;
|
||||
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
|
||||
void this.loadSupport();
|
||||
stopSupportWatch = this.watchSupport();
|
||||
} else {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
@@ -114,6 +116,7 @@ export class EmbeddedMpvSessionController {
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.unsubscribeSessionUpdate?.();
|
||||
stopSupportWatch?.();
|
||||
this.stalledTracker.cancel();
|
||||
if (this.boundsAnimationFrame !== null) {
|
||||
cancelAnimationFrame(this.boundsAnimationFrame);
|
||||
@@ -381,22 +384,31 @@ export class EmbeddedMpvSessionController {
|
||||
EmbeddedMpvSession['recording'] | null
|
||||
> => this.commands.stopRecording();
|
||||
|
||||
private async loadSupport(): Promise<void> {
|
||||
try {
|
||||
const electron = this.getElectronBridge();
|
||||
if (!electron?.getEmbeddedMpvSupport) {
|
||||
throw new Error(
|
||||
'Embedded MPV requires the Electron desktop build.'
|
||||
);
|
||||
}
|
||||
this.support.set(await electron.getEmbeddedMpvSupport());
|
||||
} catch (error) {
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Loads support and keeps asking while the answer is inconclusive: the
|
||||
* session effect starts playback as soon as `support` turns supported,
|
||||
* so a player mounted during that window recovers by itself.
|
||||
*/
|
||||
private watchSupport(): () => void {
|
||||
return watchEmbeddedMpvSupport(
|
||||
() => {
|
||||
const electron = this.getElectronBridge();
|
||||
if (!electron?.getEmbeddedMpvSupport) {
|
||||
throw new Error(
|
||||
'Embedded MPV requires the Electron desktop build.'
|
||||
);
|
||||
}
|
||||
return electron.getEmbeddedMpvSupport();
|
||||
},
|
||||
(support) => this.support.set(support),
|
||||
(error) =>
|
||||
this.support.set({
|
||||
supported: false,
|
||||
platform: window.electron?.platform ?? 'unknown',
|
||||
reason:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getElectronBridge(): ElectronBridge | undefined {
|
||||
|
||||
+76
-2
@@ -53,7 +53,7 @@ describe('WorkspacePlayerCommandsContributor', () => {
|
||||
let electronStub:
|
||||
| {
|
||||
getEmbeddedMpvSupport: jest.Mock<
|
||||
Promise<{ supported: boolean }>,
|
||||
Promise<{ supported: boolean; inconclusive?: boolean }>,
|
||||
[]
|
||||
>;
|
||||
}
|
||||
@@ -63,7 +63,10 @@ describe('WorkspacePlayerCommandsContributor', () => {
|
||||
function bootstrap(options: {
|
||||
supportsManagedExternalPlayers: boolean;
|
||||
supportsEmbeddedMpv?: boolean;
|
||||
embeddedMpvSupportResult?: { supported: boolean } | null;
|
||||
embeddedMpvSupportResult?: {
|
||||
supported: boolean;
|
||||
inconclusive?: boolean;
|
||||
} | null;
|
||||
}) {
|
||||
viewCommands = {
|
||||
registerCommand: jest.fn().mockReturnValue(() => undefined),
|
||||
@@ -200,6 +203,77 @@ describe('WorkspacePlayerCommandsContributor', () => {
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a final unsupported answer, but asks again after an inconclusive one', async () => {
|
||||
const contributor = bootstrap({
|
||||
supportsManagedExternalPlayers: true,
|
||||
supportsEmbeddedMpv: true,
|
||||
// A slow login shell: mpv was looked up before its PATH arrived.
|
||||
embeddedMpvSupportResult: { supported: false, inconclusive: true },
|
||||
});
|
||||
const embedded = getRegistered(viewCommands).find(
|
||||
(c) => c.id === 'switch-player-embedded-mpv'
|
||||
);
|
||||
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(false);
|
||||
|
||||
// The shell answered without mpv: that answer is final.
|
||||
electronStub?.getEmbeddedMpvSupport.mockResolvedValue({
|
||||
supported: false,
|
||||
});
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(false);
|
||||
expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
|
||||
expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows embedded MPV once an inconclusive answer turns into supported', async () => {
|
||||
const contributor = bootstrap({
|
||||
supportsManagedExternalPlayers: true,
|
||||
supportsEmbeddedMpv: true,
|
||||
embeddedMpvSupportResult: { supported: false, inconclusive: true },
|
||||
});
|
||||
const embedded = getRegistered(viewCommands).find(
|
||||
(c) => c.id === 'switch-player-embedded-mpv'
|
||||
);
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
|
||||
electronStub?.getEmbeddedMpvSupport.mockResolvedValue({
|
||||
supported: true,
|
||||
});
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(true);
|
||||
expect(contributor.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('asks again after a failed request instead of giving up for the session', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const contributor = bootstrap({
|
||||
supportsManagedExternalPlayers: true,
|
||||
supportsEmbeddedMpv: true,
|
||||
});
|
||||
const embedded = getRegistered(viewCommands).find(
|
||||
(c) => c.id === 'switch-player-embedded-mpv'
|
||||
);
|
||||
electronStub?.getEmbeddedMpvSupport.mockRejectedValueOnce(
|
||||
new Error('bridge failed')
|
||||
);
|
||||
|
||||
try {
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(false);
|
||||
|
||||
await contributor.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(resolveBoolean(embedded?.visible)).toBe(true);
|
||||
expect(electronStub?.getEmbeddedMpvSupport).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('switches to embedded MPV on run', () => {
|
||||
bootstrap({
|
||||
supportsManagedExternalPlayers: true,
|
||||
|
||||
+5
-1
@@ -120,16 +120,20 @@ export class WorkspacePlayerCommandsContributor {
|
||||
}
|
||||
|
||||
private async loadEmbeddedMpvSupport(): Promise<void> {
|
||||
// Only a final answer is kept. An inconclusive one, or a failed
|
||||
// request, hides the command for now: the next palette open asks again.
|
||||
let final = false;
|
||||
try {
|
||||
const support = await window.electron?.getEmbeddedMpvSupport?.();
|
||||
this.embeddedMpvSupported.set(!!support?.supported);
|
||||
final = !support?.inconclusive;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to verify embedded MPV support for the command palette.',
|
||||
error
|
||||
);
|
||||
} finally {
|
||||
this.embeddedMpvSupportChecked = true;
|
||||
this.embeddedMpvSupportChecked = final;
|
||||
this.embeddedMpvSupportLoad = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EMBEDDED_MPV_SUPPORT_RECHECK_MS } from '@iptvnator/shared/interfaces';
|
||||
import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
|
||||
import { SETTINGS_SECTION_DEFINITIONS } from './settings-search-sections';
|
||||
import { SettingsSearchService } from './settings-search.service';
|
||||
@@ -142,6 +143,7 @@ describe('SettingsSearchService', () => {
|
||||
|
||||
function probeWith(support: {
|
||||
supported: boolean;
|
||||
inconclusive?: boolean;
|
||||
frameCopyAvailable?: boolean;
|
||||
}) {
|
||||
const getEmbeddedMpvSupport = jest
|
||||
@@ -186,6 +188,144 @@ describe('SettingsSearchService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('asks again after an inconclusive answer and keeps the final one', async () => {
|
||||
// A slow login shell: mpv was looked up before its PATH arrived.
|
||||
const getEmbeddedMpvSupport = probeWith({
|
||||
supported: false,
|
||||
inconclusive: true,
|
||||
});
|
||||
const { service } = setup({
|
||||
...DESKTOP,
|
||||
supportsEmbeddedMpv: true,
|
||||
});
|
||||
const entries = () => service.visibleEntries().map(({ id }) => id);
|
||||
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(entries()).not.toContain('embedded-mpv-extra-options');
|
||||
|
||||
// The shell answered meanwhile, and mpv is there.
|
||||
getEmbeddedMpvSupport.mockResolvedValue({
|
||||
platform: 'linux',
|
||||
supported: true,
|
||||
});
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(entries()).toContain('embedded-mpv-extra-options');
|
||||
expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('asks again after a failed request instead of giving up for the session', async () => {
|
||||
const getEmbeddedMpvSupport = probeWith({ supported: true });
|
||||
getEmbeddedMpvSupport.mockRejectedValueOnce(
|
||||
new Error('bridge failed')
|
||||
);
|
||||
const { service } = setup({
|
||||
...DESKTOP,
|
||||
supportsEmbeddedMpv: true,
|
||||
});
|
||||
const entries = () => service.visibleEntries().map(({ id }) => id);
|
||||
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(entries()).not.toContain('embedded-mpv-extra-options');
|
||||
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(entries()).toContain('embedded-mpv-extra-options');
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('while the settings page stays open', () => {
|
||||
const inconclusive = { supported: false, inconclusive: true };
|
||||
const mountedSetup = () =>
|
||||
setup({ ...DESKTOP, supportsEmbeddedMpv: true });
|
||||
|
||||
beforeEach(() => jest.useFakeTimers());
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('follows an inconclusive answer without being asked again', async () => {
|
||||
const getEmbeddedMpvSupport = probeWith(inconclusive);
|
||||
const { service } = mountedSetup();
|
||||
const entries = () =>
|
||||
service.visibleEntries().map(({ id }) => id);
|
||||
|
||||
// The page starts following once, when it is created.
|
||||
service.followEmbeddedMpvSupport();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(entries()).not.toContain('embedded-mpv-extra-options');
|
||||
|
||||
getEmbeddedMpvSupport.mockResolvedValue({
|
||||
platform: 'linux',
|
||||
supported: true,
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS
|
||||
);
|
||||
|
||||
expect(entries()).toContain('embedded-mpv-extra-options');
|
||||
expect(
|
||||
service.ensureEmbeddedMpvSupportLoaded()
|
||||
).toBeUndefined();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops asking once the page is closed, and asks again on the next use', async () => {
|
||||
const getEmbeddedMpvSupport = probeWith(inconclusive);
|
||||
const { service } = mountedSetup();
|
||||
const stopFollowing = service.followEmbeddedMpvSupport();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Nothing shows these rows any more: no polling is left.
|
||||
stopFollowing();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ends on a failed recheck, and the next use asks again', async () => {
|
||||
const getEmbeddedMpvSupport = probeWith(inconclusive);
|
||||
const { service } = mountedSetup();
|
||||
service.followEmbeddedMpvSupport();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
getEmbeddedMpvSupport.mockRejectedValueOnce(
|
||||
new Error('bridge failed')
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
|
||||
|
||||
getEmbeddedMpvSupport.mockResolvedValue({
|
||||
platform: 'linux',
|
||||
supported: true,
|
||||
});
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
expect(service.visibleEntries().map(({ id }) => id)).toContain(
|
||||
'embedded-mpv-extra-options'
|
||||
);
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('has nothing to follow once a final answer is in hand', async () => {
|
||||
const getEmbeddedMpvSupport = probeWith({ supported: true });
|
||||
const { service } = mountedSetup();
|
||||
await service.ensureEmbeddedMpvSupportLoaded();
|
||||
|
||||
service.followEmbeddedMpvSupport();
|
||||
await jest.advanceTimersByTimeAsync(
|
||||
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 20
|
||||
);
|
||||
|
||||
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not probe where the runtime has no embedded MPV bridge', () => {
|
||||
const getEmbeddedMpvSupport = probeWith({ supported: true });
|
||||
const { service } = setup(DESKTOP);
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EmbeddedMpvSupport,
|
||||
watchEmbeddedMpvSupport,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
|
||||
import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank';
|
||||
import {
|
||||
@@ -62,13 +65,51 @@ export class SettingsSearchService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes embedded MPV support once so rows that need it become
|
||||
* searchable. Returns the pending probe, or `undefined` when there is
|
||||
* nothing to wait for. Call it lazily (palette open, settings page),
|
||||
* never from shell bootstrap: supported desktop builds may load the
|
||||
* native addon while answering.
|
||||
* Probes embedded MPV support so rows that need it become searchable.
|
||||
* Returns the pending probe, or `undefined` when there is nothing to
|
||||
* wait for. Call it lazily (palette open), never from shell bootstrap:
|
||||
* supported desktop builds may load the native addon while answering.
|
||||
* Only a final answer is kept; after an inconclusive one, or a failed
|
||||
* request, the next call asks again.
|
||||
*/
|
||||
ensureEmbeddedMpvSupportLoaded(): Promise<void> | undefined {
|
||||
const getSupport = this.embeddedMpvSupportProbe();
|
||||
if (!getSupport) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.embeddedMpvSupportLoad ??= getSupport()
|
||||
.then((support) => this.takeEmbeddedMpvSupport(support))
|
||||
.catch(() => this.takeEmbeddedMpvSupport(null))
|
||||
.finally(() => {
|
||||
this.embeddedMpvSupportLoad = undefined;
|
||||
});
|
||||
return this.embeddedMpvSupportLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a surface that keeps showing these rows (the settings page): probes
|
||||
* like `ensureEmbeddedMpvSupportLoaded()` and follows an inconclusive
|
||||
* answer until it is final, so the rows appear by themselves. Returns
|
||||
* the function that ends it; call it when the surface goes away, so
|
||||
* nothing keeps asking for an answer no one shows.
|
||||
*/
|
||||
followEmbeddedMpvSupport(): () => void {
|
||||
const getSupport = this.embeddedMpvSupportProbe();
|
||||
if (!getSupport) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
return watchEmbeddedMpvSupport(
|
||||
getSupport,
|
||||
(support) => this.takeEmbeddedMpvSupport(support),
|
||||
() => this.takeEmbeddedMpvSupport(null)
|
||||
);
|
||||
}
|
||||
|
||||
/** The support request, or `undefined` when there is nothing to ask. */
|
||||
private embeddedMpvSupportProbe():
|
||||
(() => Promise<EmbeddedMpvSupport>) | undefined {
|
||||
if (this.embeddedMpvSupportChecked) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -83,14 +124,13 @@ export class SettingsSearchService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.embeddedMpvSupportLoad ??= electron
|
||||
.getEmbeddedMpvSupport()
|
||||
.then((support) => this.embeddedMpvSupport.set(support))
|
||||
.catch(() => this.embeddedMpvSupport.set(null))
|
||||
.finally(() => {
|
||||
this.embeddedMpvSupportChecked = true;
|
||||
});
|
||||
return this.embeddedMpvSupportLoad;
|
||||
return () => electron.getEmbeddedMpvSupport();
|
||||
}
|
||||
|
||||
private takeEmbeddedMpvSupport(support: EmbeddedMpvSupport | null): void {
|
||||
this.embeddedMpvSupport.set(support);
|
||||
this.embeddedMpvSupportChecked =
|
||||
support !== null && !support.inconclusive;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user