mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
77 files changed
+3304
-316
No files matched your search
+146
@@ -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']);
|
||||
});
|
||||
});
|
||||
+40
-2
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+75
@@ -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();
|
||||
});
|
||||
});
|
||||
+18
-1
@@ -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>
|
||||
|
||||
+3
-1
@@ -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()) {
|
||||
|
||||
+15
@@ -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(
|
||||
|
||||
+96
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
+58
@@ -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}`),
|
||||
};
|
||||
}
|
||||
+2
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
|
||||
return 0;
|
||||
case 'playlist':
|
||||
return 1;
|
||||
case 'settings':
|
||||
return 3;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
|
||||
+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';
|
||||
|
||||
|
||||
+28
-11
@@ -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;
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
+8
@@ -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(
|
||||
|
||||
+25
-5
@@ -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;
|
||||
|
||||
+133
@@ -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');
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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);
|
||||
});
|
||||
|
||||
+19
@@ -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';
|
||||
+178
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user