diff --git a/.changes/playback-embedded-mpv-slow-shell-selection.md b/.changes/playback-embedded-mpv-slow-shell-selection.md new file mode 100644 index 000000000..61d0b1f1f --- /dev/null +++ b/.changes/playback-embedded-mpv-slow-shell-selection.md @@ -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. diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts new file mode 100644 index 000000000..8fab35178 --- /dev/null +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.login-shell.spec.ts @@ -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; + +async function flushLookup(): Promise { + 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(); + }); +}); diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts index b496fc0c2..1c34a63c1 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.spec.ts @@ -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((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((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((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, diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index 904bd9bb2..cd49c6c58 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing(check: () => T): Promise { ) { 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, () => diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 728af5580..9991436e8 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -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'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 10f30a806..e929d240f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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(), }; } diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts new file mode 100644 index 000000000..069b409c6 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.spec.ts @@ -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', + }); + }); +}); diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts index cfe738c72..59c2cb632 100644 --- a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -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(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 { 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((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(); + } + ); + }); } /** diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts index 036c80b73..5893c85e0 100644 --- a/apps/web/src/app/settings/settings-search.facade.ts +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -66,7 +66,9 @@ export class SettingsSearchFacade { }); constructor() { - void this.settingsSearch.ensureEmbeddedMpvSupportLoaded(); + inject(DestroyRef).onDestroy( + this.settingsSearch.followEmbeddedMpvSupport() + ); effect(() => { if (!this.isSearching()) { diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts index 075357c25..540761a1d 100644 --- a/apps/web/src/app/settings/settings.component.search.spec.ts +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -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(); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 69f46a5d3..d3ac24548 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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. diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts index 75d4da6c2..aec852a0b 100644 --- a/libs/services/src/lib/settings-store.defaults.ts +++ b/libs/services/src/lib/settings-store.defaults.ts @@ -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 +): Promise { + const electron = + typeof window === 'undefined' ? undefined : window.electron; + if (!electron?.getEmbeddedMpvSupport) { + return fallBack(); + } + + return new Promise((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(); + } + } + ); + }); +} diff --git a/libs/services/src/lib/settings-store.embedded-mpv.spec.ts b/libs/services/src/lib/settings-store.embedded-mpv.spec.ts new file mode 100644 index 000000000..243477609 --- /dev/null +++ b/libs/services/src/lib/settings-store.embedded-mpv.spec.ts @@ -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> { + 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 = { 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((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(); + } + }); +}); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index a517f1fcb..e6ae6aa9f 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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, - }); } }, }; diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 6e8ee1abf..95a3a08f2 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index f452966c5..0f90c67c0 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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. diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts new file mode 100644 index 000000000..8cccf13d7 --- /dev/null +++ b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts @@ -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((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); + } + ); +}); diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts new file mode 100644 index 000000000..67317411f --- /dev/null +++ b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts @@ -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, + onAnswer: (support: EmbeddedMpvSupport) => void, + onError: (error: unknown) => void +): () => void { + let stopped = false; + let recheck: ReturnType | undefined; + let recheckMs = EMBEDDED_MPV_SUPPORT_RECHECK_MS; + + const ask = async (): Promise => { + 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); + }; +} diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts index 2a3ee9e6a..c9cb22bf6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts @@ -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'); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index 61064b980..f7ea0c166 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -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 { - 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 { diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts index 6fd6a4a32..f3c93b8a0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.spec.ts @@ -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, diff --git a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts index 4d0893e39..fc9f58dd3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-player-commands/workspace-player-commands.contributor.ts @@ -120,16 +120,20 @@ export class WorkspacePlayerCommandsContributor { } private async loadEmbeddedMpvSupport(): Promise { + // 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; } } diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts index 355d5c911..260e83686 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts @@ -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); diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts index 0dddc9e8d..1a17827c1 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts @@ -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 | 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) | 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; } /**