diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 11ffa7e0a..43f718162 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -194,15 +194,22 @@ inherited PATH: `EmbeddedMpvNativeService` then reports a missing `mpv` as 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. -The settings store resets a saved Embedded MPV selection only on a final -unsupported answer; on an inconclusive one the selection stays. Surfaces that -stay mounted on one answer, the player (`EmbeddedMpvSessionController`) and the -settings page, load support through `watchEmbeddedMpvSupport()` -(`@iptvnator/shared/interfaces`), which asks again every -`EMBEDDED_MPV_SUPPORT_RECHECK_MS` until the answer is final: a player mounted -in that window starts playback by itself once `mpv` is found. The command -palette and the settings search ask on demand; they keep a final answer for -the session, but probe again on their next use after an inconclusive one. +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 command palette and the settings search ask on demand; they keep a final +answer for the session, but probe again on their next use after an +inconclusive one. 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. diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts index 794437f24..6e000c7c9 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. */ @@ -114,3 +115,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 index 6724f1064..243477609 100644 --- a/libs/services/src/lib/settings-store.embedded-mpv.spec.ts +++ b/libs/services/src/lib/settings-store.embedded-mpv.spec.ts @@ -2,6 +2,7 @@ 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, @@ -18,35 +19,44 @@ const MPV_MISSING: EmbeddedMpvSupport = { 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 support check finish. */ - async function startWithSavedEmbeddedMpv( - support: EmbeddedMpvSupport - ): Promise> { - testWindow.electron = { - getEmbeddedMpvSupport: jest.fn().mockResolvedValue(support), - }; + /** 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 new Promise((resolve) => setTimeout(resolve)); - expect(testWindow.electron.getEmbeddedMpvSupport).toHaveBeenCalled(); + 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, @@ -61,22 +71,24 @@ describe('SettingsStore saved Embedded MPV selection', () => { }); afterEach(() => { + jest.useRealTimers(); testWindow.electron = originalElectron; }); - it('keeps the saved player when the support check is inconclusive', async () => { - // A slow login shell: mpv was looked up before its PATH arrived. - const store = await startWithSavedEmbeddedMpv({ - ...MPV_MISSING, - inconclusive: true, - }); + 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 () => { - const store = await startWithSavedEmbeddedMpv(MPV_MISSING); + getEmbeddedMpvSupport.mockResolvedValue(MPV_MISSING); + + const store = await start(); expect(store.player()).toBe(VideoPlayer.VideoJs); expect(storage.set).toHaveBeenCalledWith( @@ -86,13 +98,93 @@ describe('SettingsStore saved Embedded MPV selection', () => { }); it('keeps the saved player when Embedded MPV is supported', async () => { - const store = await startWithSavedEmbeddedMpv({ - supported: true, - platform: 'linux', - engine: 'native', - }); + 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 63f863bb4..f68a35784 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'; @@ -321,40 +322,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) { - scheduleEmbeddedMpvPrepare(); - } else if (!support.inconclusive) { - // An inconclusive answer is no verdict on this - // machine: the saved player stays as it is. - await this.updateSettings({ + const isSaved = () => + store.player() === VideoPlayer.EmbeddedMpv; + if (isSaved()) { + await verifySavedEmbeddedMpvPlayer(isSaved, () => + this.updateSettings({ player: DEFAULT_SETTINGS.player, - }); - } - } 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/lib/embedded-mpv-support-watch.util.spec.ts b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.spec.ts index d089e0a87..8cccf13d7 100644 --- 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 @@ -1,5 +1,6 @@ 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'; @@ -58,16 +59,53 @@ describe('watchEmbeddedMpvSupport', () => { expect(getSupport).toHaveBeenCalledTimes(2); expect(onAnswer).toHaveBeenLastCalledWith(INCONCLUSIVE); - await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS); + await jest.advanceTimersByTimeAsync( + EMBEDDED_MPV_SUPPORT_RECHECK_MS * 2 + ); expect(getSupport).toHaveBeenCalledTimes(3); expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED); await jest.advanceTimersByTimeAsync( - EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + 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); @@ -75,7 +113,7 @@ describe('watchEmbeddedMpvSupport', () => { await jest.advanceTimersByTimeAsync(0); stop(); await jest.advanceTimersByTimeAsync( - EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 ); expect(getSupport).toHaveBeenCalledTimes(1); @@ -95,7 +133,7 @@ describe('watchEmbeddedMpvSupport', () => { stop(); answer(INCONCLUSIVE); await jest.advanceTimersByTimeAsync( - EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3 + EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS * 3 ); expect(onAnswer).not.toHaveBeenCalled(); 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 index f9ae9d79b..67317411f 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-support-watch.util.ts @@ -7,14 +7,21 @@ import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface'; */ 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 surfaces that stay mounted on one answer (the player, the settings - * page). A surface that asks on demand simply asks again the next time. + * 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, @@ -23,6 +30,7 @@ export function watchEmbeddedMpvSupport( ): () => void { let stopped = false; let recheck: ReturnType | undefined; + let recheckMs = EMBEDDED_MPV_SUPPORT_RECHECK_MS; const ask = async (): Promise => { let support: EmbeddedMpvSupport; @@ -38,10 +46,12 @@ export function watchEmbeddedMpvSupport( return; } onAnswer(support); - if (support.inconclusive) { - recheck = setTimeout( - () => void ask(), - EMBEDDED_MPV_SUPPORT_RECHECK_MS + // `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 ); } };