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

@@ -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;
}