mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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>
This commit is contained in:
1 parent
d143fb85f5
commit
dad9217363
6 files changed
+244
-72
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<InstanceType<typeof SettingsStore>> {
|
||||
testWindow.electron = {
|
||||
getEmbeddedMpvSupport: jest.fn().mockResolvedValue(support),
|
||||
};
|
||||
/** 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 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<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,
|
||||
@@ -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<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';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<EmbeddedMpvSupport>,
|
||||
@@ -23,6 +30,7 @@ export function watchEmbeddedMpvSupport(
|
||||
): () => 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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user