feat(settings): search settings from the header and the command palette (#1714)

* feat(settings): search settings from the header and the command palette

The header search on the Settings page was shown but disabled. It now
searches a shared index of all 56 settings rows by translated title,
description and English synonyms, replaces the section page with ranked
results, and opens a result by scrolling to, focusing and briefly
highlighting its row. Enter opens the best match, and the section
navigation shows per-section match counts.

The command palette gains a "Settings" group that lists the best six
matches for a non-empty query, so any setting is one Ctrl/Cmd+K away.
Rows hidden by the current form state fall back to the control that
reveals them; rows the runtime cannot render are never returned.

The index ships through a new @iptvnator/workspace/shell/util/settings-search
sub-entrypoint so it stays out of the eager bundle, and a registry spec
keeps it in step with the section templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(workspace): wait for palette probes without Promise.allSettled

The web tsconfig lib predates Promise.allSettled; use Promise.all over
rejection-safe probes instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-09-27 07:42:53 +02:00
1 parent b87291e388
commit ea51cae3db
77 files changed
+3304 -316

No files matched your search

@@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => {
).toBe(false);
});
});
describe('WorkspaceCommandPaletteComponent - settings group', () => {
function settingsCommand(
id: string,
label: string
): WorkspaceResolvedCommandItem {
return {
id: `settings:${id}`,
label,
description: 'Playback',
group: 'settings',
icon: 'play_circle',
keywords: [],
priority: 100,
visible: true,
enabled: true,
run: () => undefined,
};
}
const commands: WorkspaceResolvedCommandItem[] = [
{
id: 'open-settings',
label: 'Open settings',
description: '',
group: 'global',
icon: 'settings',
keywords: ['settings'],
priority: 50,
visible: true,
enabled: true,
run: () => undefined,
},
settingsCommand('video-player', 'Video player'),
settingsCommand('theme', 'Theme'),
];
function setup(options: {
query: string;
recentIds?: readonly string[];
searchSettings?: jest.Mock;
}) {
const searchSettings =
options.searchSettings ??
jest.fn((query: string) =>
query.includes('player') ? ['settings:video-player'] : []
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{
provide: MAT_DIALOG_DATA,
useValue: {
query: options.query,
commands,
recentIds: options.recentIds ?? [],
searchSettings,
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const fixture = TestBed.createComponent(
WorkspaceCommandPaletteComponent
);
fixture.detectChanges();
return {
component: fixture.componentInstance,
fixture,
searchSettings,
};
}
it('keeps settings out of the list while the query is empty', () => {
const { component, searchSettings } = setup({ query: '' });
expect(component.flatCommands().map((command) => command.id)).toEqual([
'open-settings',
]);
expect(searchSettings).not.toHaveBeenCalled();
});
it('lists settings matches after the command groups, in search order', () => {
const searchSettings = jest.fn(() => [
'settings:theme',
'settings:video-player',
]);
const { component, fixture } = setup({
query: 'settings',
searchSettings,
});
const groups = component.commandGroups();
expect(groups.map((group) => group.group)).toEqual([
'global',
'settings',
]);
expect(groups[1].items.map((item) => item.id)).toEqual([
'settings:theme',
'settings:video-player',
]);
expect(searchSettings).toHaveBeenLastCalledWith('settings');
expect(fixture.nativeElement.textContent).toContain(
'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'
);
});
it('ranks settings only through the search callback, never by substring', () => {
const { component } = setup({ query: 'theme' });
// "Theme" contains the query, but the search callback decides.
expect(component.flatCommands()).toEqual([]);
component.query.set('player');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'settings:video-player',
]);
});
it('shows a recently opened setting in the recent section', () => {
const { component } = setup({
query: '',
recentIds: ['settings:theme'],
});
const [recent] = component.commandGroups();
expect(recent.group).toBe('recent');
expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']);
});
});
@@ -23,10 +23,15 @@ import {
} from '@iptvnator/portal/shared/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
interface WorkspaceCommandPaletteData {
export interface WorkspaceCommandPaletteData {
commands: WorkspaceResolvedCommandItem[];
query?: string;
recentIds?: readonly string[];
/**
* Ranked ids of the `settings` group commands matching a query. Settings
* commands are listed only through this, never for an empty query.
*/
searchSettings?: (query: string) => readonly string[];
}
type PaletteSectionGroup = WorkspaceCommandGroup | 'recent';
@@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
readonly filteredCommands = computed(() => {
const term = foldSearchText(this.query().trim());
const commands = this.visibleCommands();
const commands = this.visibleCommands().filter(
(command) => command.group !== 'settings'
);
if (!term) {
return commands;
@@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
);
readonly settingsSection = computed<WorkspaceCommandGroupSection | null>(
() => {
const query = this.query().trim();
const searchSettings = this.data?.searchSettings;
if (!query || !searchSettings) {
return null;
}
const byId = new Map(
this.visibleCommands().map((command) => [command.id, command])
);
const items = searchSettings(query)
.map((id) => byId.get(id))
.filter(
(command): command is WorkspaceResolvedCommandItem =>
command?.group === 'settings'
);
return items.length === 0 ? null : { group: 'settings', items };
}
);
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const recent = this.recentSection();
@@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
);
sections.push(...groups);
const settings = this.settingsSection();
if (settings) {
sections.push(settings);
}
return sections;
});
@@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
if (group === 'settings') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
@@ -45,6 +45,11 @@
border-top: 1px solid var(--app-separator);
}
// Sections without settings-search matches stay reachable but recede.
.settings-section-item.has-no-matches:not(.active) {
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
describe('WorkspaceSettingsContextPanelComponent', () => {
function setup() {
TestBed.configureTestingModule({
imports: [WorkspaceSettingsContextPanelComponent],
providers: [
provideRouter([]),
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const ctx = TestBed.inject(SettingsContextService);
ctx.setSections([
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
},
]);
const fixture = TestBed.createComponent(
WorkspaceSettingsContextPanelComponent
);
fixture.detectChanges();
const link = (id: string) =>
(fixture.nativeElement as HTMLElement).querySelector(
`[data-test-id="settings-section-${id}"]`
) as HTMLElement;
return { ctx, fixture, link };
}
it('shows no counts while settings search is idle', () => {
const { link } = setup();
expect(link('general').querySelector('.nav-item-meta')).toBeNull();
expect(link('general').classList).not.toContain('has-no-matches');
});
it('shows per-section match counts and mutes sections without matches', () => {
const { ctx, fixture, link } = setup();
ctx.setMatchCounts({ general: 2 });
fixture.detectChanges();
expect(
link('general').querySelector('.nav-item-meta')?.textContent
).toBe('2');
expect(
link('playback').querySelector('.nav-item-meta')?.textContent
).toBe('0');
expect(link('general').classList).not.toContain('has-no-matches');
expect(link('playback').classList).toContain('has-no-matches');
ctx.reset();
fixture.detectChanges();
expect(ctx.matchCounts()).toBeNull();
});
});
@@ -28,10 +28,27 @@ import {
[routerLink]="['/workspace/settings', section.id]"
[replaceUrl]="true"
[attr.data-test-id]="'settings-section-' + section.id"
[class.has-no-matches]="
ctx.matchCounts() !== null &&
!ctx.matchCounts()?.[section.id]
"
(click)="onSectionClicked()"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
<span class="nav-item-label">{{
section.label | translate
}}</span>
<!-- While a settings search is active, each section
shows how many of its settings match. -->
@if (ctx.matchCounts(); as counts) {
<span
class="nav-item-meta"
[attr.data-test-id]="
'settings-section-matches-' + section.id
"
>{{ counts[section.id] ?? 0 }}</span
>
}
</a>
}
</div>
@@ -42,7 +42,9 @@
/>
}
@if (!hasNoPlaylists()) {
<!-- Settings search works without any playlist, so a fresh user can
find settings before importing a first source. -->
@if (!hasNoPlaylists() || isSettingsRoute()) {
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
@@ -229,6 +229,21 @@ describe('WorkspaceShellHeaderComponent', () => {
).toBeNull();
});
it('hides search without playlists except on the settings route', () => {
const searchInput = () =>
fixture.nativeElement.querySelector('input[type="search"]');
fixture.componentRef.setInput('hasNoPlaylists', true);
fixture.detectChanges();
expect(searchInput()).toBeNull();
// Settings search needs no playlist: a fresh user can still search.
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(searchInput()).not.toBeNull();
expect(searchInput().disabled).toBe(false);
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -0,0 +1,96 @@
import {
SettingsSearchEntry,
SettingsSearchResult,
SettingsSearchService,
SETTINGS_SECTION_DEFINITIONS,
} from '@iptvnator/workspace/shell/util/settings-search';
import {
buildSettingsPaletteCommands,
SETTINGS_PALETTE_RESULT_LIMIT,
} from './workspace-settings-commands';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
keywords: ['dark'],
};
const PLAYER: SettingsSearchEntry = {
id: 'video-player',
section: 'playback',
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
};
function createSettingsSearch() {
return {
visibleEntries: jest.fn(() => [THEME, PLAYER]),
search: jest.fn((): SettingsSearchResult[] => [
{
entry: PLAYER,
section: SETTINGS_SECTION_DEFINITIONS[1],
label: 'Video player',
description: '',
sectionLabel: 'Playback',
score: 10,
},
]),
reveal: jest.fn(),
};
}
describe('buildSettingsPaletteCommands', () => {
const translate = (key: string) => `t(${key})`;
it('resolves one settings-group command per visible row', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(commands).toEqual([
expect.objectContaining({
id: 'settings:theme',
group: 'settings',
icon: 'tune',
label: 't(SETTINGS.THEME)',
description: 't(SETTINGS.NAV_GENERAL)',
keywords: ['dark'],
visible: true,
enabled: true,
}),
expect.objectContaining({
id: 'settings:video-player',
icon: 'play_circle',
description: 't(SETTINGS.NAV_PLAYBACK)',
}),
]);
});
it('reveals the row when a command runs', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
commands[1].run({ query: 'player' });
expect(settingsSearch.reveal).toHaveBeenCalledWith(PLAYER);
});
it('delegates ranking to the settings search with the palette cap', () => {
const settingsSearch = createSettingsSearch();
const { search } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(search('player')).toEqual(['settings:video-player']);
expect(settingsSearch.search).toHaveBeenCalledWith(
'player',
SETTINGS_PALETTE_RESULT_LIMIT
);
});
});
@@ -0,0 +1,58 @@
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import {
SETTINGS_SECTION_DEFINITIONS,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { TranslateFn } from './workspace-shell-search-labels';
/** Palette rows reserved for settings matches, so commands stay visible. */
export const SETTINGS_PALETTE_RESULT_LIMIT = 6;
const SETTINGS_COMMAND_PREFIX = 'settings:';
export interface SettingsPaletteCommands {
/** One command per visible settings row, for recents and selection. */
readonly commands: WorkspaceResolvedCommandItem[];
/** Ids of the best settings commands for `query`, best first. */
readonly search: (query: string) => readonly string[];
}
/**
* Settings rows as command palette entries. Ranking is delegated to
* `SettingsSearchService` so the palette and the settings page search agree
* on which settings match and in which order.
*/
export function buildSettingsPaletteCommands(
settingsSearch: SettingsSearchService,
translate: TranslateFn
): SettingsPaletteCommands {
const sections = new Map(
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
);
const commands = settingsSearch
.visibleEntries()
.map((entry): WorkspaceResolvedCommandItem => {
const section = sections.get(entry.section);
return {
id: `${SETTINGS_COMMAND_PREFIX}${entry.id}`,
group: 'settings',
icon: section?.icon ?? 'settings',
label: translate(entry.labelKey),
description: section ? translate(section.navLabelKey) : '',
keywords: entry.keywords ?? [],
priority: 100,
visible: true,
enabled: true,
run: () => settingsSearch.reveal(entry),
};
});
return {
commands,
search: (query) =>
settingsSearch
.search(query, SETTINGS_PALETTE_RESULT_LIMIT)
.map(({ entry }) => `${SETTINGS_COMMAND_PREFIX}${entry.id}`),
};
}
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
return 0;
case 'playlist':
return 1;
case 'settings':
return 3;
default:
return 2;
}
@@ -10,6 +10,8 @@ export const FILTER_SECTION_PLACEHOLDER =
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
export const SEARCH_SOURCES_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
export const SEARCH_SETTINGS_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SETTINGS_PLACEHOLDER';
export const SEARCH_LOADED_ONLY_STATUS =
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
@@ -6,9 +6,14 @@ import {
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
import {
WorkspaceCommandPaletteComponent,
WorkspaceCommandPaletteData,
} from '../../workspace-command-palette/workspace-command-palette.component';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { buildSettingsPaletteCommands } from './helpers/workspace-settings-commands';
import {
buildCommandPaletteItems,
CommandBuilderContext,
@@ -23,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
private readonly playerCommands = inject(
WorkspacePlayerCommandsContributor
);
private readonly settingsSearch = inject(SettingsSearchService);
private commandPaletteRef: MatDialogRef<
WorkspaceCommandPaletteComponent,
@@ -46,11 +52,18 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const embeddedMpvSupportLoad =
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
if (embeddedMpvSupportLoad) {
const embeddedMpvSupportLoads = [
this.playerCommands.ensureEmbeddedMpvSupportLoaded(),
this.settingsSearch.ensureEmbeddedMpvSupportLoaded(),
].filter((load): load is Promise<void> => load !== undefined);
if (embeddedMpvSupportLoads.length > 0) {
this.commandPaletteOpening = true;
void embeddedMpvSupportLoad.finally(() => {
// A failed probe only hides its commands; it must not block opening.
void Promise.all(
embeddedMpvSupportLoads.map((load) =>
load.catch(() => undefined)
)
).finally(() => {
this.commandPaletteOpening = false;
this.openResolvedCommandPalette(ctx, initialQuery);
});
@@ -68,17 +81,20 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const commands = this.buildPaletteCommands(ctx);
const settings = buildSettingsPaletteCommands(
this.settingsSearch,
ctx.translate
);
const commands = [
...this.buildPaletteCommands(ctx),
...settings.commands,
];
const recentIds = this.recentCommands
.entries()
.map((entry) => entry.id);
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{
commands: WorkspaceResolvedCommandItem[];
query: string;
recentIds: readonly string[];
},
WorkspaceCommandPaletteData,
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
@@ -89,6 +105,7 @@ export class WorkspaceShellCommandPaletteService {
commands,
query: initialQuery,
recentIds,
searchSettings: settings.search,
},
});
this.commandPaletteRef = dialogRef;
@@ -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
@@ -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(
@@ -7,9 +7,11 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import {
SEARCH_LOADED_ONLY_STATUS,
SEARCH_PLAYLIST_PLACEHOLDER,
SEARCH_SETTINGS_PLACEHOLDER,
} from './helpers/workspace-shell-constants';
import {
resolveSearchPlaceholderKey,
@@ -28,6 +30,7 @@ export class WorkspaceShellSearchService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -44,17 +47,19 @@ export class WorkspaceShellSearchService {
const section = route.section;
const appliedQuery = this.appliedSearchQuery().trim();
// Settings search filters an in-memory index of setting rows (see
// SettingsSearchService); the settings page reads the term from `q`.
if (route.kind === 'settings') {
return {
enabled: false,
behavior: 'disabled',
enabled: true,
behavior: 'local-filter',
context: null,
section: null,
searchMode: 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
minLength: 1,
advancedRouteTarget: null,
};
}
@@ -181,6 +186,21 @@ export class WorkspaceShellSearchService {
const trimmedValue = value.trim();
this.searchQuery.set(trimmedValue);
// 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 (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.settingsSearch.reveal(bestMatch.entry);
} else {
this.searchSync.applySearchQuery(trimmedValue);
}
return;
}
if (this.searchCapability().behavior === 'advanced-only') {
const advancedRouteTarget =
this.searchCapability().advancedRouteTarget;
@@ -0,0 +1,133 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import {
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_SETTINGS_PLACEHOLDER } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
};
describe('WorkspaceShellSearchService on settings routes', () => {
let service: WorkspaceShellSearchService;
let currentRoute: ReturnType<
typeof signal<ReturnType<typeof parseWorkspaceShellRoute>>
>;
let searchSync: {
searchQuery: ReturnType<typeof signal<string>>;
appliedSearchQuery: ReturnType<typeof signal<string>>;
applySearchQuery: jest.Mock;
};
let settingsSearch: { search: jest.Mock; reveal: jest.Mock };
beforeEach(() => {
currentRoute = signal(
parseWorkspaceShellRoute('/workspace/settings/general')
);
searchSync = {
searchQuery: signal(''),
appliedSearchQuery: signal(''),
applySearchQuery: jest.fn(),
};
settingsSearch = {
search: jest.fn((query: string) =>
query === 'theme' ? [{ entry: THEME, score: 1 }] : []
),
reveal: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
WorkspaceShellSearchService,
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: XtreamStore,
useValue: { getSelectedCategory: () => null },
},
{
provide: StalkerStore,
useValue: { getSelectedCategoryName: () => '' },
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute,
playlists: signal([]),
dashboardXtreamContext: signal(null),
currentContext: signal(null),
},
},
{
provide: WorkspaceShellSearchSyncService,
useValue: searchSync,
},
{ provide: SettingsSearchService, useValue: settingsSearch },
],
});
service = TestBed.inject(WorkspaceShellSearchService);
});
it('enables a local settings filter with the settings placeholder', () => {
expect(service.searchCapability()).toEqual(
expect.objectContaining({
enabled: true,
behavior: 'local-filter',
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
})
);
expect(service.canUseSearch()).toBe(true);
});
it('opens the best match on Enter without applying the term', () => {
service.onSearchEnter(' theme ');
expect(settingsSearch.search).toHaveBeenCalledWith('theme', 1);
expect(settingsSearch.reveal).toHaveBeenCalledWith(THEME);
// Applying would start the `q` sync navigation and supersede the
// reveal navigation.
expect(searchSync.applySearchQuery).not.toHaveBeenCalled();
});
it('applies the term on Enter when nothing matches', () => {
service.onSearchEnter('zzz ');
expect(settingsSearch.reveal).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('zzz');
});
it('leaves Enter on other routes to their own search', () => {
currentRoute.set(parseWorkspaceShellRoute('/workspace/sources'));
service.onSearchEnter('theme');
expect(settingsSearch.search).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('theme');
});
});
@@ -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);
});
@@ -546,6 +546,25 @@ describe('WorkspaceShellComponent', () => {
expect(event.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
// On settings the header search is the settings search, so find
// focuses it in place instead of leaving for global search.
facade.openGlobalSearch.mockClear();
header.focusSearchInput.mockClear();
facade.isSettingsRoute.set(true);
const settingsEvent = new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(settingsEvent);
jest.runOnlyPendingTimers();
expect(settingsEvent.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).not.toHaveBeenCalled();
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
jest.useRealTimers();
});
@@ -163,7 +163,11 @@ export class WorkspaceShellComponent {
}
event.preventDefault();
this.facade.openGlobalSearch(this.facade.searchQuery());
// On settings the header search already searches the settings, so
// find stays on the page instead of leaving for global search.
if (!this.facade.isSettingsRoute()) {
this.facade.openGlobalSearch(this.facade.searchQuery());
}
setTimeout(() => header?.focusSearchInput({ select: true }));
}
}
@@ -28,6 +28,9 @@ describe('workspace-shell-route.utils', () => {
kind: 'settings',
context: null,
contextPanel: 'settings',
// Settings search filters rows and keeps its term in `q`.
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/global-favorites')).toEqual(
@@ -182,6 +182,7 @@ function resolveRouteSearchMode(
if (
kind === 'sources' ||
kind === 'settings' ||
kind === 'downloads' ||
kind === 'global-favorites' ||
kind === 'global-recent'
@@ -16,12 +16,25 @@ export interface SettingsNavItem {
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
/**
* Settings search matches per section id while a search term is active,
* `null` otherwise. The panel shows the counts and mutes sections
* without matches.
*/
readonly matchCounts = signal<Readonly<Record<string, number>> | null>(
null
);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setMatchCounts(counts: Readonly<Record<string, number>> | null): void {
this.matchCounts.set(counts);
}
reset(): void {
this.sections.set([]);
this.matchCounts.set(null);
}
}
@@ -0,0 +1,5 @@
export * from './settings-search.types';
export * from './settings-search-sections';
export * from './settings-search-entries';
export * from './settings-search-rank';
export * from './settings-search.service';
@@ -0,0 +1,178 @@
import { SettingsSearchEntry } from './settings-search.types';
const MPV = ['mpv', 'external player'];
const VLC = ['vlc', 'external player'];
/** Playback section rows, in page order. */
export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
[
{
id: 'video-player',
section: 'playback',
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
descriptionKey: 'SETTINGS.VIDEO_PLAYER_DESCRIPTION',
keywords: [
'player',
'engine',
'mpv',
'vlc',
'videojs',
'html5',
'artplayer',
],
},
{
id: 'stream-format',
section: 'playback',
labelKey: 'SETTINGS.STREAM_FORMAT',
descriptionKey: 'SETTINGS.STREAM_FORMAT_DESCRIPTION',
keywords: ['hls', 'm3u8', 'mpeg-ts', 'ts', 'format'],
},
{
id: 'web-player-shared-controls',
section: 'playback',
labelKey: 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS',
descriptionKey: 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION',
keywords: ['controls', 'overlay', 'skin'],
fallbackId: 'video-player',
},
{
id: 'player-ambient-mode',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_AMBIENT_MODE',
descriptionKey: 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION',
keywords: ['ambient', 'glow', 'backlight'],
fallbackId: 'video-player',
},
{
id: 'player-up-next-rail',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_UP_NEXT_RAIL',
descriptionKey: 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION',
keywords: ['up next', 'next episode', 'autoplay'],
fallbackId: 'video-player',
},
{
id: 'fullscreen-channel-panel',
section: 'playback',
labelKey: 'SETTINGS.FULLSCREEN_CHANNEL_PANEL',
descriptionKey: 'SETTINGS.FULLSCREEN_CHANNEL_PANEL_DESCRIPTION',
keywords: ['fullscreen', 'channel list', 'zapping'],
fallbackId: 'video-player',
},
{
id: 'vod-auto-failover',
section: 'playback',
labelKey: 'SETTINGS.VOD_AUTO_FAILOVER',
descriptionKey: 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION',
keywords: ['failover', 'fallback', 'mirror', 'retry', 'movie'],
requires: ['vod-multi-source'],
fallbackId: 'video-player',
},
{
id: 'external-player-double-click',
section: 'playback',
labelKey: 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK',
descriptionKey:
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION',
keywords: ['double click', ...MPV, ...VLC],
requires: ['managed-external-players'],
fallbackId: 'video-player',
},
{
id: 'show-external-playback-bar',
section: 'playback',
labelKey: 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR',
descriptionKey: 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION',
keywords: ['playback bar', 'footer', ...MPV, ...VLC],
requires: ['desktop'],
},
{
id: 'embedded-mpv-frame-copy',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION',
keywords: ['mpv', 'gpu', 'rendering', 'compatibility'],
requires: ['embedded-mpv-frame-copy'],
},
{
id: 'embedded-mpv-extra-options',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION',
keywords: ['mpv', 'options', 'config', 'hwdec', 'arguments'],
requires: ['embedded-mpv'],
fallbackId: 'video-player',
},
{
id: 'embedded-mpv-auto-reconnect',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION',
keywords: ['mpv', 'reconnect', 'retry', 'buffering'],
requires: ['embedded-mpv'],
fallbackId: 'video-player',
},
{
id: 'recording-folder',
section: 'playback',
labelKey: 'SETTINGS.RECORDING_FOLDER_LABEL',
descriptionKey: 'SETTINGS.RECORDING_FOLDER_DESCRIPTION',
keywords: ['recording', 'record', 'dvr', 'folder', 'directory'],
requires: ['desktop'],
},
{
id: 'mpv-player-path',
section: 'playback',
labelKey: 'SETTINGS.MPV_PLAYER_PATH_LABEL',
descriptionKey: 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION',
keywords: [...MPV, 'path', 'executable', 'binary'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'mpv-player-arguments',
section: 'playback',
labelKey: 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL',
descriptionKey: 'SETTINGS.MPV_PLAYER_ARGUMENTS_DESCRIPTION',
keywords: [...MPV, 'arguments', 'flags', 'command line'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'mpv-reuse-instance',
section: 'playback',
labelKey: 'SETTINGS.MPV_REUSE_INSTANCE_LABEL',
descriptionKey: 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION',
keywords: [...MPV, 'single window', 'instance'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-player-path',
section: 'playback',
labelKey: 'SETTINGS.VLC_PLAYER_PATH_LABEL',
descriptionKey: 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION',
keywords: [...VLC, 'path', 'executable', 'binary'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-player-arguments',
section: 'playback',
labelKey: 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL',
descriptionKey: 'SETTINGS.VLC_PLAYER_ARGUMENTS_DESCRIPTION',
keywords: [...VLC, 'arguments', 'flags', 'command line'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-reuse-instance',
section: 'playback',
labelKey: 'SETTINGS.VLC_REUSE_INSTANCE_LABEL',
descriptionKey: 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION',
keywords: [...VLC, 'single window', 'instance'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
];
@@ -0,0 +1,259 @@
import { PLAYBACK_SETTINGS_SEARCH_ENTRIES } from './settings-search-entries-playback';
import { SettingsSearchEntry } from './settings-search.types';
const DASHBOARD_KEYWORDS = ['dashboard', 'home', 'rail', 'row'];
function dashboardRail(
id: string,
key: string,
keywords: readonly string[]
): SettingsSearchEntry {
return {
id: `dashboard-rail-${id}`,
section: 'dashboard',
labelKey: `SETTINGS.DASHBOARD_RAIL_${key}`,
descriptionKey: `SETTINGS.DASHBOARD_RAIL_${key}_DESCRIPTION`,
keywords: [...DASHBOARD_KEYWORDS, ...keywords],
fallbackId: 'show-dashboard',
};
}
/**
* Every searchable settings row, in page order (section order, then row
* order). Ties in search ranking keep this order, so results read like the
* settings page itself. A unit test in the web app checks this list against
* the `data-setting-id` anchors in the section templates.
*/
export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
{
id: 'language',
section: 'general',
labelKey: 'SETTINGS.LANGUAGE',
descriptionKey: 'SETTINGS.LANGUAGE_DESCRIPTION',
keywords: ['language', 'locale', 'translation'],
},
{
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
descriptionKey: 'SETTINGS.THEME_DESCRIPTION',
keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
},
{
id: 'cover-size',
section: 'general',
labelKey: 'SETTINGS.COVER_SIZE',
descriptionKey: 'SETTINGS.COVER_SIZE_DESCRIPTION',
keywords: ['poster', 'cover', 'grid', 'card', 'size'],
},
{
id: 'cover-titles',
section: 'general',
labelKey: 'SETTINGS.COVER_TITLES',
descriptionKey: 'SETTINGS.COVER_TITLES_DESCRIPTION',
keywords: ['poster', 'cover', 'title', 'caption'],
},
{
id: 'startup-behavior',
section: 'general',
labelKey: 'SETTINGS.STARTUP_BEHAVIOR',
descriptionKey: 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION',
keywords: ['startup', 'launch', 'restore', 'last view'],
},
{
id: 'startup-window-mode',
section: 'general',
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE',
descriptionKey: 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION',
keywords: ['startup', 'window', 'fullscreen', 'maximized'],
requires: ['startup-window-mode'],
},
{
id: 'show-captions',
section: 'general',
labelKey: 'SETTINGS.SHOW_CAPTIONS',
descriptionKey: 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION',
keywords: ['subtitles', 'captions', 'cc'],
},
{
id: 'strip-country-prefix',
section: 'general',
labelKey: 'SETTINGS.STRIP_COUNTRY_PREFIX',
descriptionKey: 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION',
keywords: ['channel name', 'country', 'prefix', 'tag'],
},
{
id: 'portal-connectivity-guard',
section: 'general',
labelKey: 'SETTINGS.PORTAL_CONNECTIVITY_GUARD',
descriptionKey: 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION',
keywords: ['network', 'offline', 'connection', 'internet', 'vpn'],
requires: ['portal-connectivity-guard'],
},
...PLAYBACK_SETTINGS_SEARCH_ENTRIES,
{
id: 'epg-view-mode',
section: 'epg',
labelKey: 'SETTINGS.EPG_VIEW_MODE',
descriptionKey: 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION',
keywords: ['epg', 'tv guide', 'timeline', 'grid', 'programme'],
requires: ['epg'],
},
{
id: 'epg-offset',
section: 'epg',
labelKey: 'SETTINGS.EPG_OFFSET_TITLE',
descriptionKey: 'SETTINGS.EPG_OFFSET_HINT',
keywords: ['epg', 'timezone', 'time shift', 'offset', 'hours'],
requires: ['epg'],
},
{
id: 'epg-sources',
section: 'epg',
labelKey: 'SETTINGS.EPG_SOURCE_LIST_TITLE',
descriptionKey: 'SETTINGS.EPG_SOURCE_FORMATS_HINT',
keywords: ['epg', 'xmltv', 'tv guide', 'url', 'refresh', 'clear'],
requires: ['epg'],
},
{
id: 'prefer-uploaded-epg',
section: 'epg',
labelKey: 'SETTINGS.PREFER_UPLOADED_EPG_TITLE',
descriptionKey: 'SETTINGS.PREFER_UPLOADED_EPG_HINT',
keywords: ['epg', 'xmltv', 'priority', 'provider'],
requires: ['epg'],
},
{
id: 'show-dashboard',
section: 'dashboard',
labelKey: 'SETTINGS.SHOW_DASHBOARD',
descriptionKey: 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION',
keywords: ['dashboard', 'home', 'start page'],
},
{
id: 'dashboard-hero',
section: 'dashboard',
labelKey: 'SETTINGS.DASHBOARD_HERO',
descriptionKey: 'SETTINGS.DASHBOARD_HERO_DESCRIPTION',
keywords: [...DASHBOARD_KEYWORDS, 'banner', 'featured'],
fallbackId: 'show-dashboard',
},
dashboardRail('continue-watching', 'CONTINUE', ['resume', 'progress']),
dashboardRail('live-favorites', 'LIVE_FAVORITES', ['favorites', 'live']),
dashboardRail('recent-live', 'RECENT_LIVE', ['recent', 'live']),
dashboardRail('favorite-vod', 'FAVORITE_VOD', ['favorites', 'movies']),
dashboardRail('sources', 'SOURCES', ['playlists', 'sources']),
dashboardRail('xtream-added', 'XTREAM_ADDED', ['xtream', 'new']),
dashboardRail('tmdb-trending', 'TMDB_TRENDING', ['tmdb', 'trending']),
dashboardRail('tmdb-recommendations', 'TMDB_RECOMMENDATIONS', [
'tmdb',
'recommendations',
]),
{
id: 'remote-control',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_DESCRIPTION',
keywords: ['remote', 'phone', 'mobile', 'companion'],
requires: ['remote-control'],
},
{
id: 'remote-control-port',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL_PORT',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION',
keywords: ['remote', 'port', 'network'],
requires: ['remote-control'],
fallbackId: 'remote-control',
},
{
id: 'remote-control-url',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL_URL',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION',
keywords: ['remote', 'qr code', 'address', 'ip'],
requires: ['remote-control'],
fallbackId: 'remote-control',
},
{
id: 'tmdb-enable',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_ENABLE',
descriptionKey: 'SETTINGS.TMDB_ENABLE_DESCRIPTION',
keywords: ['tmdb', 'themoviedb', 'metadata', 'posters', 'ratings'],
},
{
id: 'tmdb-api-key',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_API_KEY',
descriptionKey: 'SETTINGS.TMDB_API_KEY_DESCRIPTION',
keywords: ['tmdb', 'api key', 'token'],
fallbackId: 'tmdb-enable',
},
{
id: 'tmdb-m3u-vod-details',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_M3U_VOD_DETAILS',
descriptionKey: 'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION',
keywords: ['tmdb', 'm3u', 'movies', 'details'],
fallbackId: 'tmdb-enable',
},
{
id: 'tmdb-cache',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_CACHE',
descriptionKey: 'SETTINGS.TMDB_CACHE_DESCRIPTION',
keywords: ['tmdb', 'cache', 'clear', 'storage'],
},
{
id: 'import-export-data',
section: 'backup',
labelKey: 'SETTINGS.IMPORT_EXPORT_DATA',
descriptionKey: 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION',
keywords: ['backup', 'restore', 'import', 'export', 'migrate'],
},
{
id: 'remove-all-playlists',
section: 'reset',
labelKey: 'SETTINGS.REMOVE_ALL',
descriptionKey: 'SETTINGS.REMOVE_ALL_DESCRIPTION',
keywords: ['reset', 'delete', 'remove', 'wipe', 'playlists'],
},
{
id: 'version',
section: 'about',
labelKey: 'SETTINGS.VERSION',
descriptionKey: 'SETTINGS.VERSION_DESCRIPTION',
keywords: ['version', 'build', 'commit'],
},
{
id: 'app-update',
section: 'about',
labelKey: 'SETTINGS.APP_UPDATE_TITLE',
descriptionKey: 'SETTINGS.APP_UPDATE_DESCRIPTION',
keywords: ['update', 'upgrade', 'nightly', 'channel', 'release'],
requires: ['desktop'],
fallbackId: 'version',
},
{
id: 'tmdb-credits',
section: 'about',
labelKey: 'SETTINGS.TMDB_CREDITS',
descriptionKey: 'SETTINGS.TMDB_ATTRIBUTION',
keywords: ['tmdb', 'credits', 'attribution'],
},
{
id: 'community',
section: 'about',
labelKey: 'SETTINGS.COMMUNITY',
descriptionKey: 'SETTINGS.COMMUNITY_DESCRIPTION',
keywords: ['github', 'telegram', 'help', 'feedback', 'bug'],
},
{
id: 'support',
section: 'about',
labelKey: 'SETTINGS.SUPPORT_TITLE',
descriptionKey: 'SETTINGS.SUPPORT_DESCRIPTION',
keywords: ['donate', 'sponsor', 'support'],
},
];
@@ -0,0 +1,86 @@
import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank';
describe('tokenizeSearchQuery', () => {
it('folds case and splits on whitespace', () => {
expect(tokenizeSearchQuery(' Video PLAYER ')).toEqual([
'video',
'player',
]);
});
it('returns no tokens for a blank query', () => {
expect(tokenizeSearchQuery(' ')).toEqual([]);
});
it('folds the Turkish dotted capital I like the list filters do', () => {
expect(tokenizeSearchQuery('İnşaat')).toEqual(['inşaat']);
});
});
describe('rankSearchMatch', () => {
const rank = (
query: string,
fields: Parameters<typeof rankSearchMatch>[1]
) => rankSearchMatch(tokenizeSearchQuery(query), fields);
it('returns 0 without tokens or without a match', () => {
expect(rank('', { label: 'Theme' })).toBe(0);
expect(rank('volume', { label: 'Theme', keywords: ['dark'] })).toBe(0);
});
it('requires every token to match somewhere', () => {
const fields = {
label: 'Video player',
keywords: ['mpv'],
context: ['Playback'],
};
expect(rank('player mpv', fields)).toBeGreaterThan(0);
expect(rank('player vlc', fields)).toBe(0);
});
it('ranks label prefix over word start over inner substring', () => {
const prefix = rank('play', { label: 'Playback bar' });
const wordStart = rank('play', { label: 'External playback bar' });
const inner = rank('play', { label: 'Autoplay' });
expect(prefix).toBeGreaterThan(wordStart);
expect(wordStart).toBeGreaterThan(inner);
expect(inner).toBeGreaterThan(0);
});
it('prefers a word-start occurrence that follows an inner one', () => {
const laterWordStart = rank('art', { label: 'Smart artplayer' });
expect(laterWordStart).toBe(rank('art', { label: 'x artplayer' }));
expect(laterWordStart).toBeGreaterThan(rank('art', { label: 'Smart' }));
});
it('weights label hits over keywords over context', () => {
const label = rank('dark', { label: 'Dark mode' });
const keyword = rank('dark', { label: 'Theme', keywords: ['dark'] });
const context = rank('dark', {
label: 'Theme',
context: ['dark or light'],
});
expect(label).toBeGreaterThan(keyword);
expect(keyword).toBeGreaterThan(context);
expect(context).toBeGreaterThan(0);
});
it('rewards the whole query as a label phrase', () => {
const phrase = rank('video player', { label: 'Video player' });
const scattered = rank('video player', {
label: 'Player',
keywords: ['video'],
});
expect(phrase).toBeGreaterThan(scattered);
});
it('matches case-insensitively, accented letters included', () => {
expect(rank('epg', { label: 'EPG sources' })).toBeGreaterThan(0);
expect(rank('ÉPG', { label: 'épg' })).toBeGreaterThan(0);
});
});
@@ -0,0 +1,92 @@
import { foldSearchText } from '@iptvnator/shared/interfaces';
/**
* Texts of one searchable item, by how strongly a hit counts: the visible
* label first, then curated keywords, then supporting context (description,
* section name).
*/
export interface SearchRankFields {
readonly label: string;
readonly keywords?: readonly string[];
readonly context?: readonly string[];
}
const LABEL_WEIGHT = 12;
const KEYWORD_WEIGHT = 6;
const CONTEXT_WEIGHT = 2;
const LABEL_PHRASE_PREFIX_BONUS = 20;
const LABEL_PHRASE_BONUS = 8;
const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
/** Folded, whitespace-separated query tokens; empty for a blank query. */
export function tokenizeSearchQuery(query: string): string[] {
return foldSearchText(query).split(/\s+/).filter(Boolean);
}
/**
* Relevance of `fields` for the query tokens; `0` means no match. Every
* token must match somewhere (AND semantics), so adding a word narrows the
* results. A hit at the start of a text beats a hit at a word start, which
* beats a hit inside a word; the whole query as a label prefix wins
* outright. Pure and locale-invariant (see `foldSearchText`).
*/
export function rankSearchMatch(
tokens: readonly string[],
fields: SearchRankFields
): number {
if (tokens.length === 0) {
return 0;
}
const label = foldSearchText(fields.label);
const keywords = (fields.keywords ?? []).map(foldSearchText);
const context = (fields.context ?? []).map(foldSearchText);
let score = 0;
for (const token of tokens) {
const tokenScore = Math.max(
scoreText(label, token, LABEL_WEIGHT),
...keywords.map((text) => scoreText(text, token, KEYWORD_WEIGHT)),
...context.map((text) => scoreText(text, token, CONTEXT_WEIGHT))
);
if (tokenScore === 0) {
return 0;
}
score += tokenScore;
}
const phrase = tokens.join(' ');
if (label.startsWith(phrase)) {
score += LABEL_PHRASE_PREFIX_BONUS;
} else if (label.includes(phrase)) {
score += LABEL_PHRASE_BONUS;
}
return score;
}
function scoreText(text: string, token: string, weight: number): number {
const index = text.indexOf(token);
if (index < 0) {
return 0;
}
if (index === 0) {
return weight * 3;
}
if (isWordStart(text, index)) {
return weight * 2;
}
// A later occurrence may still start a word ("art" in "smart artplayer").
let next = text.indexOf(token, index + 1);
while (next >= 0) {
if (isWordStart(text, next)) {
return weight * 2;
}
next = text.indexOf(token, next + 1);
}
return weight;
}
function isWordStart(text: string, index: number): boolean {
return !LETTER_OR_DIGIT.test(text.charAt(index - 1));
}
@@ -0,0 +1,47 @@
import { SettingsSectionDefinition } from './settings-search.types';
/**
* Settings section pages in navigation order. Single source for the settings
* page navigation, the settings search index and the command palette.
*/
export const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] =
[
{ id: 'general', navLabelKey: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'playback',
navLabelKey: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
},
{
id: 'epg',
navLabelKey: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
requires: ['epg'],
},
{
id: 'dashboard',
navLabelKey: 'SETTINGS.NAV_DASHBOARD',
icon: 'dashboard',
},
{
id: 'remote-control',
navLabelKey: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
requires: ['remote-control'],
},
{ id: 'tmdb', navLabelKey: 'SETTINGS.NAV_TMDB', icon: 'movie' },
{ id: 'backup', navLabelKey: 'SETTINGS.NAV_BACKUP', icon: 'backup' },
{
id: 'reset',
navLabelKey: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
},
{ id: 'about', navLabelKey: 'SETTINGS.NAV_ABOUT', icon: 'info' },
];
export function meetsSettingsRequirements(
requires: SettingsSectionDefinition['requires'],
capabilities: Readonly<Record<string, boolean>>
): boolean {
return (requires ?? []).every((requirement) => capabilities[requirement]);
}
@@ -0,0 +1,294 @@
import { TestBed } from '@angular/core/testing';
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 { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
import { SETTINGS_SECTION_DEFINITIONS } from './settings-search-sections';
import { SettingsSearchService } from './settings-search.service';
const TRANSLATIONS: Record<string, string> = {
'SETTINGS.THEME': 'Theme',
'SETTINGS.THEME_DESCRIPTION': 'Light, dark or system',
'SETTINGS.VIDEO_PLAYER_LABEL': 'Video player',
'SETTINGS.VIDEO_PLAYER_DESCRIPTION': 'Engine used for playback',
'SETTINGS.EPG_VIEW_MODE': 'Programme guide layout',
'SETTINGS.REMOTE_CONTROL': 'Remote control',
'SETTINGS.NAV_GENERAL': 'General',
'SETTINGS.NAV_PLAYBACK': 'Playback',
};
interface RuntimeStub {
isElectron: boolean;
supportsEpgImport: boolean;
supportsEpgDataManagement: boolean;
supportsRemoteControl: boolean;
supportsStartupWindowMode: boolean;
supportsPortalConnectivityGuard: boolean;
supportsManagedExternalPlayers: boolean;
supportsExternalPlayerPathSettings: boolean;
supportsEmbeddedMpv: boolean;
}
function setup(runtime: Partial<RuntimeStub> = {}) {
const router = { navigate: jest.fn().mockResolvedValue(true) };
TestBed.configureTestingModule({
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: {
isElectron: false,
supportsEpgImport: false,
supportsEpgDataManagement: false,
supportsRemoteControl: false,
supportsStartupWindowMode: false,
supportsPortalConnectivityGuard: false,
supportsManagedExternalPlayers: false,
supportsExternalPlayerPathSettings: false,
supportsEmbeddedMpv: false,
...runtime,
},
},
{
provide: VodSourceDiscoveryService,
useValue: { isAvailable: false },
},
{ provide: Router, useValue: router },
{
provide: TranslateService,
useValue: {
instant: (key: string) => TRANSLATIONS[key] ?? key,
},
},
],
});
return { service: TestBed.inject(SettingsSearchService), router };
}
const DESKTOP: Partial<RuntimeStub> = {
isElectron: true,
supportsEpgImport: true,
supportsEpgDataManagement: true,
supportsRemoteControl: true,
supportsStartupWindowMode: true,
supportsPortalConnectivityGuard: true,
supportsManagedExternalPlayers: true,
supportsExternalPlayerPathSettings: true,
};
describe('SettingsSearchService', () => {
it('gives every entry a unique id and a known section', () => {
const ids = SETTINGS_SEARCH_ENTRIES.map((entry) => entry.id);
const sectionIds = new Set(
SETTINGS_SECTION_DEFINITIONS.map((section) => section.id)
);
expect(new Set(ids).size).toBe(ids.length);
for (const entry of SETTINGS_SEARCH_ENTRIES) {
expect(sectionIds.has(entry.section)).toBe(true);
}
});
it('points every fallback at a row of the same section', () => {
const byId = new Map(
SETTINGS_SEARCH_ENTRIES.map((entry) => [entry.id, entry])
);
for (const entry of SETTINGS_SEARCH_ENTRIES) {
if (entry.fallbackId) {
expect(byId.get(entry.fallbackId)?.section).toBe(entry.section);
// A fallback must itself always render, or it cannot help.
expect(byId.get(entry.fallbackId)?.fallbackId).toBeUndefined();
}
}
});
it('hides sections and rows the runtime cannot render', () => {
const { service } = setup();
const sections = service.visibleSections().map(({ id }) => id);
const entries = service.visibleEntries().map(({ id }) => id);
expect(sections).not.toContain('epg');
expect(sections).not.toContain('remote-control');
expect(entries).not.toContain('epg-view-mode');
expect(entries).not.toContain('recording-folder');
expect(entries).not.toContain('mpv-player-path');
expect(entries).toContain('theme');
expect(service.search('guide')).toEqual([]);
});
it('offers the desktop rows that need no probe on a capable runtime', () => {
const { service } = setup(DESKTOP);
const entries = service.visibleEntries().map(({ id }) => id);
expect(entries).toContain('epg-view-mode');
expect(entries).toContain('remote-control-port');
expect(entries).toContain('mpv-player-path');
expect(entries).not.toContain('vod-auto-failover');
// 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', () => {
const { service } = setup();
const [first] = service.search('theme');
expect(first.entry.id).toBe('theme');
expect(first.label).toBe('Theme');
expect(first.description).toBe('Light, dark or system');
expect(first.sectionLabel).toBe('General');
expect(first.section.icon).toBe('tune');
});
it('matches untranslated keywords and caps results', () => {
const { service } = setup(DESKTOP);
expect(service.search('dark')[0].entry.id).toBe('theme');
expect(service.search('mpv').length).toBeGreaterThan(3);
expect(service.search('mpv', 2)).toHaveLength(2);
expect(service.search(' ')).toEqual([]);
});
it('keeps settings page order for equal scores', () => {
const { service } = setup(DESKTOP);
const ids = service.search('vlc').map(({ entry }) => entry.id);
const pageOrder = SETTINGS_SEARCH_ENTRIES.map(({ id }) => id).filter(
(id) => ids.includes(id)
);
expect(ids.slice().sort()).toEqual(pageOrder.slice().sort());
expect(ids.indexOf('vlc-player-path')).toBeLessThan(
ids.indexOf('vlc-reuse-instance')
);
});
it('navigates to the section and records a reveal request', () => {
const { service, router } = setup();
const entry = SETTINGS_SEARCH_ENTRIES.find(
({ id }) => id === 'tmdb-api-key'
);
if (!entry) {
throw new Error('missing tmdb-api-key entry');
}
service.reveal(entry);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace/settings',
'tmdb',
]);
expect(service.pendingReveal()).toEqual(
expect.objectContaining({
id: 'tmdb-api-key',
section: 'tmdb',
fallbackId: 'tmdb-enable',
})
);
});
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;
service.reveal(first);
const stale = service.pendingReveal();
service.reveal(second);
if (!stale) {
throw new Error('expected a pending reveal');
}
service.completeReveal(stale);
expect(service.pendingReveal()?.id).toBe(second.id);
const current = service.pendingReveal();
if (!current) {
throw new Error('expected a pending reveal');
}
service.completeReveal(current);
expect(service.pendingReveal()).toBeNull();
});
});
@@ -0,0 +1,193 @@
import { 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 { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank';
import {
meetsSettingsRequirements,
SETTINGS_SECTION_DEFINITIONS,
} from './settings-search-sections';
import {
SettingsRevealRequest,
SettingsSearchCapabilities,
SettingsSearchEntry,
SettingsSearchResult,
SettingsSectionDefinition,
} from './settings-search.types';
/**
* Searchable index of the settings page, shared by the settings page search
* and the command palette so both return the same results in the same
* order. Also owns "reveal": navigating to a row's section page and asking
* the page to scroll to and highlight it.
*/
@Injectable({ providedIn: 'root' })
export class SettingsSearchService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly revealRequest = signal<SettingsRevealRequest | null>(null);
private revealNonce = 0;
private readonly revealListeners = new Set<() => void>();
private readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(
null
);
private embeddedMpvSupportChecked = false;
private embeddedMpvSupportLoad: Promise<void> | undefined;
/** Row the settings page should scroll to and highlight next. */
readonly pendingReveal = this.revealRequest.asReadonly();
capabilities(): SettingsSearchCapabilities {
const runtime = this.runtime;
return {
desktop: runtime.isElectron,
epg: runtime.supportsEpgImport && runtime.supportsEpgDataManagement,
'remote-control': runtime.supportsRemoteControl,
'startup-window-mode': runtime.supportsStartupWindowMode,
'portal-connectivity-guard':
runtime.supportsPortalConnectivityGuard,
'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<void> | 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) =>
meetsSettingsRequirements(section.requires, capabilities)
);
}
/** Entries whose section and row render on this runtime. */
visibleEntries(): SettingsSearchEntry[] {
const capabilities = this.capabilities();
const sectionIds = new Set(
this.visibleSections().map((section) => section.id)
);
return SETTINGS_SEARCH_ENTRIES.filter(
(entry) =>
sectionIds.has(entry.section) &&
meetsSettingsRequirements(entry.requires, capabilities)
);
}
/**
* Ranked matches for `query` in the current UI language; best first,
* ties in settings page order. Empty for a blank query.
*/
search(query: string, limit = Infinity): SettingsSearchResult[] {
const tokens = tokenizeSearchQuery(query);
if (tokens.length === 0) {
return [];
}
const sections = new Map(
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
);
const results: SettingsSearchResult[] = [];
for (const entry of this.visibleEntries()) {
const section = sections.get(entry.section);
if (!section) {
continue;
}
const label = this.translate.instant(entry.labelKey);
const description = entry.descriptionKey
? this.translate.instant(entry.descriptionKey)
: '';
const sectionLabel = this.translate.instant(section.navLabelKey);
const score = rankSearchMatch(tokens, {
label,
keywords: entry.keywords,
context: [description, sectionLabel],
});
if (score > 0) {
results.push({
entry,
section,
label,
description,
sectionLabel,
score,
});
}
}
// Array#sort is stable, so equal scores keep settings page order.
return results.sort((a, b) => b.score - a.score).slice(0, limit);
}
/** 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,
section: entry.section,
fallbackId: entry.fallbackId,
nonce: this.revealNonce,
});
void this.router.navigate(['/workspace/settings', entry.section]);
}
/** Called by the settings page once it has handled `request`. */
completeReveal(request: SettingsRevealRequest): void {
if (this.revealRequest()?.nonce === request.nonce) {
this.revealRequest.set(null);
}
}
}
@@ -0,0 +1,74 @@
/**
* Runtime capabilities a settings row can depend on. The settings search
* index must hide rows the settings page itself never renders on this
* runtime, otherwise search would "find" settings the user cannot reach.
*/
export type SettingsSearchRequirement =
| 'desktop'
| 'epg'
| 'remote-control'
| 'startup-window-mode'
| 'portal-connectivity-guard'
| 'managed-external-players'
| 'external-player-paths'
| 'vod-multi-source'
/** Probed lazily; false until `ensureEmbeddedMpvSupportLoaded` resolves. */
| 'embedded-mpv'
| 'embedded-mpv-frame-copy';
export type SettingsSearchCapabilities = Readonly<
Record<SettingsSearchRequirement, boolean>
>;
/** One routed settings section page (`/workspace/settings/:id`). */
export interface SettingsSectionDefinition {
readonly id: string;
/** Short label used by the section navigation. */
readonly navLabelKey: string;
readonly icon: string;
readonly requires?: readonly SettingsSearchRequirement[];
}
/**
* One searchable settings row. `id` is also the row's `data-setting-id`
* anchor on the settings page, which is how a search result scrolls to and
* highlights the row.
*/
export interface SettingsSearchEntry {
readonly id: string;
readonly section: string;
readonly labelKey: string;
readonly descriptionKey?: string;
/**
* Untranslated synonyms. They are matched in addition to the translated
* label and description, so English terms ("dark", "mpv", "subtitles")
* keep working under every UI language.
*/
readonly keywords?: readonly string[];
readonly requires?: readonly SettingsSearchRequirement[];
/**
* Row to reveal when this one is not rendered for the current form
* state, e.g. the VLC path row while another player is selected: the
* user lands on the control that makes the searched row appear.
*/
readonly fallbackId?: string;
}
/** A matched entry with its translated texts, ready to render. */
export interface SettingsSearchResult {
readonly entry: SettingsSearchEntry;
readonly section: SettingsSectionDefinition;
readonly label: string;
readonly description: string;
readonly sectionLabel: string;
readonly score: number;
}
/** Pending "scroll to and highlight this row" request for the settings page. */
export interface SettingsRevealRequest {
readonly id: string;
readonly section: string;
readonly fallbackId?: string;
/** Distinguishes repeated requests for the same row. */
readonly nonce: number;
}