fix(settings): keep the settings search following an inconclusive Embedded MPV answer

The settings page asks the search service for Embedded MPV support once,
when its search facade is created. After an inconclusive answer the service
only probed again on its next call, so with the page left open the
Embedded MPV rows stayed unsearchable after the login shell answered,
while the player option on the same page already updated.

The service now follows the answer with `watchEmbeddedMpvSupport()` until
it is final, which updates the open page and the command palette alike. A
call made while the answer is still inconclusive restarts the watch, so it
asks at once as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-04 08:56:19 +02:00
1 parent dad9217363
commit 98eb267daa
3 files changed
+108 -20

No files matched your search

+7 -3
View File
@@ -206,10 +206,14 @@ Whatever holds on to one answer follows it through `watchEmbeddedMpvSupport()`
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 search (`SettingsSearchService`) serves both the open settings
page and the command palette, so it follows the answer as well: the Embedded
MPV rows become searchable while the page stays open, and a call made while
the answer is still inconclusive asks again at once.
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.
The command palette's player commands ask on demand, on every palette open;
they keep a final answer for the session, but probe again on the next open
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.
@@ -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';
@@ -213,6 +214,64 @@ describe('SettingsSearchService', () => {
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
describe('while the settings page stays open', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('follows an inconclusive answer without being asked again', async () => {
const getEmbeddedMpvSupport = probeWith({
supported: false,
inconclusive: true,
});
const { service } = setup({
...DESKTOP,
supportsEmbeddedMpv: true,
});
const entries = () =>
service.visibleEntries().map(({ id }) => id);
// The page asks once, when it is created.
await service.ensureEmbeddedMpvSupportLoaded();
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 following once the service is destroyed', async () => {
const getEmbeddedMpvSupport = probeWith({
supported: false,
inconclusive: true,
});
const { service } = setup({
...DESKTOP,
supportsEmbeddedMpv: true,
});
await service.ensureEmbeddedMpvSupportLoaded();
TestBed.resetTestingModule();
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);
@@ -1,9 +1,12 @@
import { inject, Injectable, signal } from '@angular/core';
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
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 {
@@ -39,6 +42,13 @@ export class SettingsSearchService {
);
private embeddedMpvSupportChecked = false;
private embeddedMpvSupportLoad: Promise<void> | undefined;
private stopEmbeddedMpvSupportWatch: (() => void) | undefined;
constructor() {
inject(DestroyRef).onDestroy(() =>
this.stopEmbeddedMpvSupportWatch?.()
);
}
/** Row the settings page should scroll to and highlight next. */
readonly pendingReveal = this.revealRequest.asReadonly();
@@ -62,12 +72,13 @@ 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. An inconclusive answer is used until
* the next call, which probes again.
* 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, settings page), never from
* shell bootstrap: supported desktop builds may load the native addon
* while answering. A final answer is kept. An inconclusive one keeps
* being followed, so an open settings page updates by itself, and the
* next call asks again at once.
*/
ensureEmbeddedMpvSupportLoaded(): Promise<void> | undefined {
if (this.embeddedMpvSupportChecked) {
@@ -84,18 +95,32 @@ export class SettingsSearchService {
return undefined;
}
this.embeddedMpvSupportLoad ??= electron
.getEmbeddedMpvSupport()
.then((support) => this.embeddedMpvSupport.set(support))
.catch(() => this.embeddedMpvSupport.set(null))
.finally(() => {
this.embeddedMpvSupportChecked =
!this.embeddedMpvSupport()?.inconclusive;
this.embeddedMpvSupportLoad = undefined;
});
this.embeddedMpvSupportLoad ??= this.followEmbeddedMpvSupport(() =>
electron.getEmbeddedMpvSupport()
);
return this.embeddedMpvSupportLoad;
}
/** Follows the answer afresh; resolves with its first one. */
private followEmbeddedMpvSupport(
getSupport: () => Promise<EmbeddedMpvSupport>
): Promise<void> {
this.stopEmbeddedMpvSupportWatch?.();
return new Promise<void>((answered) => {
const take = (support: EmbeddedMpvSupport | null) => {
this.embeddedMpvSupport.set(support);
this.embeddedMpvSupportChecked = !support?.inconclusive;
this.embeddedMpvSupportLoad = undefined;
answered();
};
this.stopEmbeddedMpvSupportWatch = watchEmbeddedMpvSupport(
getSupport,
take,
() => take(null)
);
});
}
/**
* Runs `listener` synchronously before every reveal navigation, so the
* shell can drop a search keystroke still waiting for its debounce: if