fix(playback): keep asking for Embedded MPV support while the answer is inconclusive

Keeping the saved player on an inconclusive answer left a mounted player
stuck on it: the session controller asked for support once, in its
constructor, and the session effect never starts while unsupported, so the
player did not recover after the login shell answered. The settings page
held its one answer the same way.

`watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final
or the surface is destroyed. The player controller and the settings page
facade load support through it, so playback starts by itself and the
Embedded MPV option appears without reopening the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 19:52:28 +02:00
1 parent 417eaf5005
commit d143fb85f5
8 files changed
+407 -33

No files matched your search

@@ -0,0 +1,89 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
/** A slow login shell: mpv was looked up before its PATH arrived. */
const INCONCLUSIVE: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
describe('SettingsEmbeddedMpvFacade', () => {
const originalElectron = window.electron;
let getEmbeddedMpvSupport: jest.Mock;
let facade: SettingsEmbeddedMpvFacade;
beforeEach(() => {
jest.useFakeTimers();
getEmbeddedMpvSupport = jest.fn();
window.electron = {
platform: 'linux',
getEmbeddedMpvSupport,
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
providers: [
SettingsEmbeddedMpvFacade,
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
});
facade = TestBed.inject(SettingsEmbeddedMpvFacade);
});
afterEach(() => {
TestBed.resetTestingModule();
window.electron = originalElectron;
jest.useRealTimers();
});
it('offers Embedded MPV once an inconclusive answer turns into supported', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
await facade.load();
expect(facade.supported()).toBe(false);
// The page stays open; nobody calls load() again.
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(facade.supported()).toBe(true);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('stops asking once the settings page is closed', async () => {
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
await facade.load();
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
it('reports a failed probe as unsupported', async () => {
getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed'));
await facade.load();
expect(facade.support()).toEqual({
supported: false,
platform: 'linux',
reason: 'addon load failed',
});
});
});
@@ -1,6 +1,15 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import {
computed,
DestroyRef,
inject,
Injectable,
signal,
} from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSupport,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
/**
* Probes the desktop backend for embedded MPV support so the playback
@@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
@Injectable()
export class SettingsEmbeddedMpvFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private stopSupportWatch: (() => void) | undefined;
readonly support = signal<EmbeddedMpvSupport | null>(null);
@@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade {
() => this.support()?.engine === 'frame-copy'
);
constructor() {
inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.());
}
/**
* Resolves with the first answer. An inconclusive one keeps being asked
* for while the page is open, so the option appears without reopening it.
*/
async load(): Promise<void> {
if (!this.runtime.isElectron) {
this.support.set({
@@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade {
return;
}
try {
this.support.set(await window.electron.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron.platform,
reason: error instanceof Error ? error.message : String(error),
});
}
this.stopSupportWatch?.();
await new Promise<void>((answered) => {
this.stopSupportWatch = watchEmbeddedMpvSupport(
() => window.electron.getEmbeddedMpvSupport(),
(support) => {
this.support.set(support);
answered();
},
(error) => {
this.support.set({
supported: false,
platform: window.electron.platform,
reason:
error instanceof Error
? error.message
: String(error),
});
answered();
}
);
});
}
/**
+9 -5
View File
@@ -195,10 +195,14 @@ 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, and the player
asks again when playback starts. The command palette and the settings search
keep a final answer for the session, but probe again on their next use after
an inconclusive one.
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.
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.
@@ -982,7 +986,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.
+1
View File
@@ -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';
@@ -0,0 +1,131 @@
import type { EmbeddedMpvSupport } from './embedded-mpv-session.interface';
import {
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);
expect(getSupport).toHaveBeenCalledTimes(3);
expect(onAnswer).toHaveBeenLastCalledWith(SUPPORTED);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(3);
});
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_MS * 3
);
expect(getSupport).toHaveBeenCalledTimes(1);
expect(onAnswer).toHaveBeenCalledTimes(1);
});
it('drops an answer that arrives after it was stopped', async () => {
let answer: (support: EmbeddedMpvSupport) => void = () => undefined;
const getSupport = jest.fn(
() =>
new Promise<EmbeddedMpvSupport>((resolve) => {
answer = resolve;
})
);
const stop = watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
stop();
answer(INCONCLUSIVE);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
});
it.each([
['rejects', () => Promise.reject(new Error('bridge failed'))],
[
'throws',
() => {
throw new Error('bridge failed');
},
],
])(
'ends the watch with the error when the request %s',
async (_how, request) => {
const getSupport = jest.fn(request);
watchEmbeddedMpvSupport(getSupport, onAnswer, onError);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0]).toEqual(
new Error('bridge failed')
);
expect(onAnswer).not.toHaveBeenCalled();
expect(getSupport).toHaveBeenCalledTimes(1);
}
);
});
@@ -0,0 +1,54 @@
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;
/**
* 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.
*/
export function watchEmbeddedMpvSupport(
getSupport: () => Promise<EmbeddedMpvSupport>,
onAnswer: (support: EmbeddedMpvSupport) => void,
onError: (error: unknown) => void
): () => void {
let stopped = false;
let recheck: ReturnType<typeof setTimeout> | undefined;
const ask = async (): Promise<void> => {
let support: EmbeddedMpvSupport;
try {
support = await getSupport();
} catch (error) {
if (!stopped) {
onError(error);
}
return;
}
if (stopped) {
return;
}
onAnswer(support);
if (support.inconclusive) {
recheck = setTimeout(
() => void ask(),
EMBEDDED_MPV_SUPPORT_RECHECK_MS
);
}
};
void ask();
return () => {
stopped = true;
clearTimeout(recheck);
};
}
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvEngine,
EmbeddedMpvSession,
ResolvedPortalPlayback,
@@ -106,6 +107,58 @@ describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
});
});
describe('an inconclusive support answer', () => {
// A slow login shell: mpv was looked up before its PATH arrived.
const inconclusive = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
it('is asked for again, so a mounted player recovers by itself', async () => {
const settled = createSupport('native');
electron.getEmbeddedMpvSupport
.mockResolvedValueOnce(inconclusive)
.mockResolvedValue(settled);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
expect(controller.support()).toBe(inconclusive);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS
);
expect(controller.support()).toBe(settled);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
// A final answer is kept.
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('is no longer asked for once the player is gone', async () => {
electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
});
it('preserves the constructor support probe after preparing a session', async () => {
const probedSupport = createSupport('native');
const preparedSupport = createSupport('frame-copy');
@@ -15,6 +15,7 @@ import {
EmbeddedMpvSupport,
RecordingStartMetadata,
ResolvedPortalPlayback,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvCommandRunner } from './embedded-mpv-command-runner';
import { measureBounds } from './embedded-mpv-format.utils';
@@ -95,8 +96,9 @@ export class EmbeddedMpvSessionController {
this.session.set(session);
});
let stopSupportWatch: (() => void) | undefined;
if (typeof window.electron?.getEmbeddedMpvSupport === 'function') {
void this.loadSupport();
stopSupportWatch = this.watchSupport();
} else {
this.support.set({
supported: false,
@@ -114,6 +116,7 @@ export class EmbeddedMpvSessionController {
this.destroyRef.onDestroy(() => {
this.unsubscribeSessionUpdate?.();
stopSupportWatch?.();
this.stalledTracker.cancel();
if (this.boundsAnimationFrame !== null) {
cancelAnimationFrame(this.boundsAnimationFrame);
@@ -381,22 +384,31 @@ export class EmbeddedMpvSessionController {
EmbeddedMpvSession['recording'] | null
> => this.commands.stopRecording();
private async loadSupport(): Promise<void> {
try {
const electron = this.getElectronBridge();
if (!electron?.getEmbeddedMpvSupport) {
throw new Error(
'Embedded MPV requires the Electron desktop build.'
);
}
this.support.set(await electron.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron?.platform ?? 'unknown',
reason: error instanceof Error ? error.message : String(error),
});
}
/**
* Loads support and keeps asking while the answer is inconclusive: the
* session effect starts playback as soon as `support` turns supported,
* so a player mounted during that window recovers by itself.
*/
private watchSupport(): () => void {
return watchEmbeddedMpvSupport(
() => {
const electron = this.getElectronBridge();
if (!electron?.getEmbeddedMpvSupport) {
throw new Error(
'Embedded MPV requires the Electron desktop build.'
);
}
return electron.getEmbeddedMpvSupport();
},
(support) => this.support.set(support),
(error) =>
this.support.set({
supported: false,
platform: window.electron?.platform ?? 'unknown',
reason:
error instanceof Error ? error.message : String(error),
})
);
}
private getElectronBridge(): ElectronBridge | undefined {