From 98eb267daa68ba13349abbbe8dcfa5820a1d9a2d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 4 Oct 2026 08:56:19 +0200 Subject: [PATCH] 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 --- docs/architecture/embedded-mpv-native.md | 10 +++- .../settings-search.service.spec.ts | 59 +++++++++++++++++++ .../settings-search.service.ts | 59 +++++++++++++------ 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 43f718162..f34f5d1f4 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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. 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 b945969e8..62850b7dc 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'; @@ -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); 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 aa256d8ea..eed3bb847 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 @@ -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 | 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 | 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 + ): Promise { + this.stopEmbeddedMpvSupportWatch?.(); + return new Promise((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