From b78b3a75e748336b7bf88f037464d334793be52d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 00:00:00 +0200 Subject: [PATCH] fix(settings): let search reveals win over pending input and gate embedded MPV rows - A reveal (result click, command palette, Enter) now cancels a search keystroke still waiting for its debounce, so its q navigation can no longer supersede the reveal and leave the results open. - Embedded MPV extra options and auto-reconnect require a lazily probed embedded MPV capability; frame copy also needs frameCopyAvailable, so search never offers a row the settings page cannot render. - Keyboard users keep a focus-visible ring on the revealed row after the highlight fades. Co-Authored-By: Claude Opus 5.5 --- .../app/settings/settings-search.facade.ts | 2 + .../src/app/settings/settings.component.scss | 12 ++- docs/architecture/workspace-shell.md | 13 ++- ...workspace-shell-command-palette.service.ts | 10 ++- ...orkspace-shell-search-sync.service.spec.ts | 16 ++++ .../workspace-shell-search-sync.service.ts | 16 ++-- .../workspace-shell-search.service.ts | 4 +- .../workspace-shell-search.settings.spec.ts | 3 - .../services/workspace-shell.facade.spec.ts | 2 +- .../settings-search-entries-playback.ts | 7 +- .../settings-search.service.spec.ts | 87 ++++++++++++++++++- .../settings-search.service.ts | 53 +++++++++++ .../settings-search/settings-search.types.ts | 5 +- 13 files changed, 200 insertions(+), 30 deletions(-) diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts index 78f1c595f..5d8c2ce55 100644 --- a/apps/web/src/app/settings/settings-search.facade.ts +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -66,6 +66,8 @@ export class SettingsSearchFacade { }); constructor() { + void this.settingsSearch.ensureEmbeddedMpvSupportLoaded(); + effect(() => { if (!this.isSearching()) { this.settingsCtx.setMatchCounts(null); diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index a7a3c3bbd..e02e8e917 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -115,8 +115,9 @@ } // A row opened from settings search or the command palette. It receives -// programmatic focus (tabindex -1); the highlight is its focus indicator, so -// the default outline is dropped. The wash fills the row's own padding box +// programmatic focus (tabindex -1). Keyboard users keep a focus ring after +// the temporary highlight fades; pointer users rely on the highlight alone, +// as with any mouse-driven focus. The wash fills the row's own padding box // and two offset shadows widen it past the flush-left row edges without // shifting layout or painting over the separators above and below. .setting-item[data-setting-id] { @@ -125,6 +126,13 @@ &:focus { outline: none; } + + &:focus-visible { + outline: 2px solid + color-mix(in srgb, var(--app-selection-color) 60%, transparent); + outline-offset: 6px; + border-radius: 4px; + } } .setting-item--revealed { diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 3acfc36ec..e013ea857 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -270,6 +270,10 @@ and from the command palette. Both use the same index and ranking. description of the current language plus the keywords; every query token must match (AND), and a label prefix outranks a word start, which outranks an inner match. Rows whose `requires` the runtime lacks are never returned. + Embedded MPV rows depend on a lazy support probe + (`ensureEmbeddedMpvSupportLoaded()`), run when the settings page or the + command palette opens, never from shell bootstrap; frame copy also needs + `frameCopyAvailable`, matching the settings page gate. 5. Settings routes use `local-filter` search mode, so the term lives in `q`. While `q` is set, the settings page shows ranked results in place of the section page and the settings context panel shows per-section match @@ -280,9 +284,12 @@ and from the command palette. Both use the same index and ranking. to the section page without `q` (which clears the box) and the page scrolls to, focuses, and briefly highlights the row once the form is hydrated. A row hidden by the current form state falls back to its - `fallbackId`, the control that makes it appear. Enter deliberately does - not apply the term first, because the `q` sync navigation would supersede - the reveal navigation. + `fallbackId`, the control that makes it appear. A reveal must win over the + typed term: `WorkspaceShellSearchSyncService` drops a keystroke still + waiting for its debounce through `onReveal()`, and Enter does not apply + the term first, because either `q` sync navigation would supersede the + reveal navigation. Keyboard users keep a `:focus-visible` ring on the row + after the highlight fades. 7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening global search. diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts index 66e439ac6..205bf8e87 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts @@ -52,11 +52,13 @@ export class WorkspaceShellCommandPaletteService { return; } - const embeddedMpvSupportLoad = - this.playerCommands.ensureEmbeddedMpvSupportLoaded(); - if (embeddedMpvSupportLoad) { + const embeddedMpvSupportLoads = [ + this.playerCommands.ensureEmbeddedMpvSupportLoaded(), + this.settingsSearch.ensureEmbeddedMpvSupportLoaded(), + ].filter((load): load is Promise => load !== undefined); + if (embeddedMpvSupportLoads.length > 0) { this.commandPaletteOpening = true; - void embeddedMpvSupportLoad.finally(() => { + void Promise.allSettled(embeddedMpvSupportLoads).finally(() => { this.commandPaletteOpening = false; this.openResolvedCommandPalette(ctx, initialQuery); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts index f3f5471b0..8a783418e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts @@ -9,6 +9,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; +import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search'; import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants'; import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service'; import { WorkspaceShellSearchService } from './workspace-shell-search.service'; @@ -133,6 +134,21 @@ describe('WorkspaceShellSearchSyncService', () => { jest.useRealTimers(); }); + it('drops a still-debouncing keystroke when a settings result is opened', () => { + // Typing one more letter and clicking a visible settings result + // within the debounce window: applying that keystroke afterwards + // would start a `q` navigation that supersedes the reveal. + service.onSearchInput('them'); + TestBed.inject(SettingsSearchService).reveal({ + id: 'theme', + section: 'general', + labelKey: 'SETTINGS.THEME', + }); + jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS); + + expect(service.appliedSearchQuery()).toBe(''); + }); + it('keeps in-flight typing when the page writes an unrelated query param', () => { // The downloads filter chips write `?filter=…` with replaceUrl. Under // load that navigation can land after the first keystroke — it must diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts index e4b1bf450..6ff4bd8bb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts @@ -5,6 +5,7 @@ import { filter } from 'rxjs'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util'; +import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search'; import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants'; import { getRoutePath, @@ -30,6 +31,13 @@ export class WorkspaceShellSearchSyncService { constructor() { this.destroyRef.onDestroy(() => this.cancelPendingSearchApply()); + // Opening a settings search result navigates away from the typed + // term; a keystroke still debouncing must not apply afterwards. + this.destroyRef.onDestroy( + inject(SettingsSearchService).onReveal(() => + this.cancelPendingSearchApply() + ) + ); this.router.events .pipe( @@ -139,14 +147,6 @@ export class WorkspaceShellSearchSyncService { this.appliedSearchQuery.set(value.trim()); } - /** - * Drops a keystroke still waiting for the debounce without applying it, - * for callers that are about to navigate away from the typed term. - */ - discardPendingInput(): void { - this.cancelPendingSearchApply(); - } - private syncSearchFromUrl(url: string): void { const previousUrl = this.lastSyncedUrl; this.lastSyncedUrl = url; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts index f6e7ff7ba..26cb3af0f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts @@ -189,11 +189,11 @@ export class WorkspaceShellSearchService { // Enter on settings jumps to the best match, like picking the first // result. The reveal navigation drops `q`, which clears the box. The // term is deliberately not applied: applying would make the `q` sync - // start its own navigation, superseding the reveal navigation. + // start its own navigation, superseding the reveal navigation (the + // reveal also cancels a keystroke still waiting for its debounce). if (this.routeState.currentRoute().kind === 'settings') { const [bestMatch] = this.settingsSearch.search(trimmedValue, 1); if (bestMatch) { - this.searchSync.discardPendingInput(); this.settingsSearch.reveal(bestMatch.entry); } else { this.searchSync.applySearchQuery(trimmedValue); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts index b52b7707a..d7150c568 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts @@ -31,7 +31,6 @@ describe('WorkspaceShellSearchService on settings routes', () => { searchQuery: ReturnType>; appliedSearchQuery: ReturnType>; applySearchQuery: jest.Mock; - discardPendingInput: jest.Mock; }; let settingsSearch: { search: jest.Mock; reveal: jest.Mock }; @@ -43,7 +42,6 @@ describe('WorkspaceShellSearchService on settings routes', () => { searchQuery: signal(''), appliedSearchQuery: signal(''), applySearchQuery: jest.fn(), - discardPendingInput: jest.fn(), }; settingsSearch = { search: jest.fn((query: string) => @@ -112,7 +110,6 @@ describe('WorkspaceShellSearchService on settings routes', () => { expect(settingsSearch.search).toHaveBeenCalledWith('theme', 1); expect(settingsSearch.reveal).toHaveBeenCalledWith(THEME); - expect(searchSync.discardPendingInput).toHaveBeenCalled(); // Applying would start the `q` sync navigation and supersede the // reveal navigation. expect(searchSync.applySearchQuery).not.toHaveBeenCalled(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index e0b056a7a..68d514628 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -1152,7 +1152,7 @@ describe('WorkspaceShellFacade', () => { expect(dialog.open).not.toHaveBeenCalled(); resolveSupport(); - await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve)); expect(dialog.open).toHaveBeenCalledTimes(1); }); diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts index 0ef917e55..a63709c7e 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts @@ -93,8 +93,7 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = labelKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY', descriptionKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION', keywords: ['mpv', 'gpu', 'rendering', 'compatibility'], - requires: ['desktop'], - fallbackId: 'video-player', + requires: ['embedded-mpv-frame-copy'], }, { id: 'embedded-mpv-extra-options', @@ -102,7 +101,7 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = labelKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS', descriptionKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION', keywords: ['mpv', 'options', 'config', 'hwdec', 'arguments'], - requires: ['desktop'], + requires: ['embedded-mpv'], fallbackId: 'video-player', }, { @@ -111,7 +110,7 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = labelKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT', descriptionKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION', keywords: ['mpv', 'reconnect', 'retry', 'buffering'], - requires: ['desktop'], + requires: ['embedded-mpv'], fallbackId: 'video-player', }, { 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 b03f63128..355d5c911 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 @@ -27,6 +27,7 @@ interface RuntimeStub { supportsPortalConnectivityGuard: boolean; supportsManagedExternalPlayers: boolean; supportsExternalPlayerPathSettings: boolean; + supportsEmbeddedMpv: boolean; } function setup(runtime: Partial = {}) { @@ -44,6 +45,7 @@ function setup(runtime: Partial = {}) { supportsPortalConnectivityGuard: false, supportsManagedExternalPlayers: false, supportsExternalPlayerPathSettings: false, + supportsEmbeddedMpv: false, ...runtime, }, }, @@ -116,7 +118,7 @@ describe('SettingsSearchService', () => { expect(service.search('guide')).toEqual([]); }); - it('offers every row on a fully capable desktop runtime except gated VOD failover', () => { + it('offers the desktop rows that need no probe on a capable runtime', () => { const { service } = setup(DESKTOP); const entries = service.visibleEntries().map(({ id }) => id); @@ -125,7 +127,72 @@ describe('SettingsSearchService', () => { expect(entries).toContain('remote-control-port'); expect(entries).toContain('mpv-player-path'); expect(entries).not.toContain('vod-auto-failover'); - expect(entries.length).toBe(SETTINGS_SEARCH_ENTRIES.length - 1); + // Embedded MPV rows wait for the support probe. + expect(entries).not.toContain('embedded-mpv-extra-options'); + expect(entries).not.toContain('embedded-mpv-frame-copy'); + expect(entries.length).toBe(SETTINGS_SEARCH_ENTRIES.length - 4); + }); + + describe('embedded MPV probe', () => { + const originalElectron = window.electron; + + afterEach(() => { + window.electron = originalElectron; + }); + + function probeWith(support: { + supported: boolean; + frameCopyAvailable?: boolean; + }) { + const getEmbeddedMpvSupport = jest + .fn() + .mockResolvedValue({ platform: 'darwin', ...support }); + window.electron = { + getEmbeddedMpvSupport, + } as unknown as typeof window.electron; + return getEmbeddedMpvSupport; + } + + it('reveals embedded MPV rows once support resolves, frame copy only when available', async () => { + const getEmbeddedMpvSupport = probeWith({ supported: true }); + const { service } = setup({ + ...DESKTOP, + supportsEmbeddedMpv: true, + }); + + await service.ensureEmbeddedMpvSupportLoaded(); + const entries = service.visibleEntries().map(({ id }) => id); + + expect(entries).toContain('embedded-mpv-extra-options'); + expect(entries).toContain('embedded-mpv-auto-reconnect'); + // Frame copy is not available on this machine, so the row the + // settings page never renders must not be offered either. + expect(entries).not.toContain('embedded-mpv-frame-copy'); + expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined(); + expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1); + }); + + it('offers frame copy where the machine can run it', async () => { + probeWith({ supported: true, frameCopyAvailable: true }); + const { service } = setup({ + ...DESKTOP, + supportsEmbeddedMpv: true, + }); + + await service.ensureEmbeddedMpvSupportLoaded(); + + expect(service.visibleEntries().map(({ id }) => id)).toContain( + 'embedded-mpv-frame-copy' + ); + }); + + it('does not probe where the runtime has no embedded MPV bridge', () => { + const getEmbeddedMpvSupport = probeWith({ supported: true }); + const { service } = setup(DESKTOP); + + expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined(); + expect(getEmbeddedMpvSupport).not.toHaveBeenCalled(); + }); }); it('returns translated, ranked results with their section', () => { @@ -187,6 +254,22 @@ describe('SettingsSearchService', () => { ); }); + it('notifies reveal listeners before navigating, until unsubscribed', () => { + const { service, router } = setup(); + const calls: string[] = []; + router.navigate.mockImplementation(() => { + calls.push('navigate'); + return Promise.resolve(true); + }); + const stop = service.onReveal(() => calls.push('listener')); + + service.reveal(SETTINGS_SEARCH_ENTRIES[0]); + stop(); + service.reveal(SETTINGS_SEARCH_ENTRIES[1]); + + expect(calls).toEqual(['listener', 'navigate', 'navigate']); + }); + it('completes only the reveal request that is still pending', () => { const { service } = setup(); const [first, second] = SETTINGS_SEARCH_ENTRIES; 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 076aac88b..0dddc9e8d 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 @@ -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 { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries'; import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank'; import { @@ -32,6 +33,12 @@ export class SettingsSearchService { private readonly revealRequest = signal(null); private revealNonce = 0; + private readonly revealListeners = new Set<() => void>(); + private readonly embeddedMpvSupport = signal( + null + ); + private embeddedMpvSupportChecked = false; + private embeddedMpvSupportLoad: Promise | undefined; /** Row the settings page should scroll to and highlight next. */ readonly pendingReveal = this.revealRequest.asReadonly(); @@ -48,9 +55,54 @@ export class SettingsSearchService { 'managed-external-players': runtime.supportsManagedExternalPlayers, 'external-player-paths': runtime.supportsExternalPlayerPathSettings, 'vod-multi-source': this.vodSourceDiscovery.isAvailable, + 'embedded-mpv': !!this.embeddedMpvSupport()?.supported, + 'embedded-mpv-frame-copy': + !!this.embeddedMpvSupport()?.frameCopyAvailable, }; } + /** + * 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. + */ + ensureEmbeddedMpvSupportLoaded(): Promise | undefined { + if (this.embeddedMpvSupportChecked) { + return undefined; + } + + const electron = + typeof window === 'undefined' ? undefined : window.electron; + if ( + !this.runtime.supportsEmbeddedMpv || + typeof electron?.getEmbeddedMpvSupport !== 'function' + ) { + this.embeddedMpvSupportChecked = true; + return undefined; + } + + this.embeddedMpvSupportLoad ??= electron + .getEmbeddedMpvSupport() + .then((support) => this.embeddedMpvSupport.set(support)) + .catch(() => this.embeddedMpvSupport.set(null)) + .finally(() => { + this.embeddedMpvSupportChecked = true; + }); + return this.embeddedMpvSupportLoad; + } + + /** + * Runs `listener` synchronously before every reveal navigation, so the + * shell can drop a search keystroke still waiting for its debounce: if + * it applied later, its `q` navigation would supersede the reveal. + */ + onReveal(listener: () => void): () => void { + this.revealListeners.add(listener); + return () => this.revealListeners.delete(listener); + } + visibleSections(): SettingsSectionDefinition[] { const capabilities = this.capabilities(); return SETTINGS_SECTION_DEFINITIONS.filter((section) => @@ -121,6 +173,7 @@ export class SettingsSearchService { /** Opens the row's section page and asks it to highlight the row. */ reveal(entry: SettingsSearchEntry): void { + this.revealListeners.forEach((listener) => listener()); this.revealNonce += 1; this.revealRequest.set({ id: entry.id, diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts index 089305a2a..c4e76e6f0 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts @@ -11,7 +11,10 @@ export type SettingsSearchRequirement = | 'portal-connectivity-guard' | 'managed-external-players' | 'external-player-paths' - | 'vod-multi-source'; + | 'vod-multi-source' + /** Probed lazily; false until `ensureEmbeddedMpvSupportLoaded` resolves. */ + | 'embedded-mpv' + | 'embedded-mpv-frame-copy'; export type SettingsSearchCapabilities = Readonly< Record