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

@@ -9,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="version">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
@@ -31,7 +31,10 @@
</div>
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
<div class="setting-item setting-item--full-control app-update-panel">
<div
class="setting-item setting-item--full-control app-update-panel"
data-setting-id="app-update"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}</p>
@@ -263,7 +266,10 @@
</div>
}
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="tmdb-credits"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
@@ -281,7 +287,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="community"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
<p>{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}</p>
@@ -364,7 +373,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="support"
>
<div class="support-card" data-test-id="support-card">
<div class="support-card__intro">
<div class="support-card__emblem">
@@ -1,7 +1,4 @@
<section
class="settings-group"
id="backup"
>
<section class="settings-group" id="backup">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>backup</mat-icon>
@@ -12,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="import-export-data">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
<p>
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="dashboard"
[formGroup]="form()"
>
<section class="settings-group" id="dashboard" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>dashboard</mat-icon>
@@ -13,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="show-dashboard">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
<p>
@@ -29,7 +25,7 @@
</div>
<div formGroupName="dashboardRails">
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-hero">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
<p>
@@ -44,7 +40,10 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-continue-watching"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
<p>
@@ -62,11 +61,14 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-live-favorites"
>
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
@@ -82,11 +84,9 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate
}}</h4>
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
@@ -102,11 +102,11 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
@@ -122,7 +122,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-sources">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
<p>
@@ -140,11 +140,11 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
<div class="setting-item__meta">
<h4>{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate
}}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
@@ -160,9 +160,14 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-trending"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}</h4>
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
@@ -178,7 +183,10 @@
</div>
</div>
<div class="setting-item">
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-recommendations"
>
<div class="setting-item__meta">
<h4>
{{
@@ -9,7 +9,10 @@
</div>
</header>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-view-mode"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
@@ -43,7 +46,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-offset"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
@@ -69,7 +75,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="epg-sources"
>
<div class="setting-item__meta" data-test-id="epg-source-formats">
<h4>{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}</p>
@@ -178,7 +187,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
@@ -9,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="language">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
@@ -40,7 +40,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="theme"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
@@ -74,7 +77,10 @@
</div>
</div>
<div class="setting-item setting-item--full-control">
<div
class="setting-item setting-item--full-control"
data-setting-id="cover-size"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
@@ -108,7 +114,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="cover-titles">
<div class="setting-item__meta">
<h4 id="cover-titles-label">
{{ 'SETTINGS.COVER_TITLES' | translate }}
@@ -127,7 +133,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="startup-behavior">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
<p>
@@ -160,7 +166,11 @@
</div>
@if (supportsStartupWindowMode()) {
<div class="setting-item" data-test-id="startup-window-mode-setting">
<div
class="setting-item"
data-setting-id="startup-window-mode"
data-test-id="startup-window-mode-setting"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
<p>
@@ -195,7 +205,7 @@
</div>
}
<div class="setting-item">
<div class="setting-item" data-setting-id="show-captions">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
<p>
@@ -207,7 +217,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="strip-country-prefix">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
<p>
@@ -233,7 +243,7 @@
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="portal-connectivity-guard">
<div class="setting-item__meta">
<h4 id="portal-connectivity-label">
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
+14 -61
View File
@@ -5,6 +5,10 @@ import {
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
meetsSettingsRequirements,
SETTINGS_SECTION_DEFINITIONS,
} from '@iptvnator/workspace/shell/util/settings-search';
import {
CoverSizeOption,
EpgViewModeOption,
@@ -164,65 +168,14 @@ export function buildSettingsSectionNavItems({
supportsEpg,
supportsRemoteControl,
}: SettingsSectionVisibility): SettingsSection[] {
return [
{
id: 'general',
label: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
visible: true,
},
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
visible: true,
},
{
id: 'epg',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
visible: supportsEpg,
},
{
id: 'dashboard',
label: 'SETTINGS.NAV_DASHBOARD',
icon: 'dashboard',
visible: true,
},
{
// Must match the section's HTML id (`remote-control`) so the
// settings-section-scroll directive can resolve the anchor.
// Was previously '@iptvnator/ui/remote-control' (the NX lib
// name), which meant clicking the nav item silently no-op'd
// because document.getElementById of that string returned null.
id: 'remote-control',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
visible: supportsRemoteControl,
},
{
id: 'tmdb',
label: 'SETTINGS.NAV_TMDB',
icon: 'movie',
visible: true,
},
{
id: 'backup',
label: 'SETTINGS.NAV_BACKUP',
icon: 'backup',
visible: true,
},
{
id: 'reset',
label: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
visible: true,
},
{
id: 'about',
label: 'SETTINGS.NAV_ABOUT',
icon: 'info',
visible: true,
},
];
const capabilities = {
epg: supportsEpg,
'remote-control': supportsRemoteControl,
};
return SETTINGS_SECTION_DEFINITIONS.map((section) => ({
id: section.id,
label: section.navLabelKey,
icon: section.icon,
visible: meetsSettingsRequirements(section.requires, capabilities),
}));
}
@@ -9,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="video-player">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
@@ -35,7 +35,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="stream-format">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
@@ -67,6 +67,7 @@
@if (isWebPlayerSelected()) {
<div
class="setting-item"
data-setting-id="web-player-shared-controls"
data-test-id="web-player-shared-controls-setting"
>
<div class="setting-item__meta">
@@ -91,7 +92,11 @@
</div>
</div>
<div class="setting-item" data-test-id="player-ambient-mode-setting">
<div
class="setting-item"
data-setting-id="player-ambient-mode"
data-test-id="player-ambient-mode-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }}
@@ -109,7 +114,11 @@
</div>
</div>
<div class="setting-item" data-test-id="player-up-next-rail-setting">
<div
class="setting-item"
data-setting-id="player-up-next-rail"
data-test-id="player-up-next-rail-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }}
@@ -131,6 +140,7 @@
@if (supportsFullscreenChannelPanel()) {
<div
class="setting-item"
data-setting-id="fullscreen-channel-panel"
data-test-id="fullscreen-channel-panel-setting"
>
<div class="setting-item__meta">
@@ -159,7 +169,11 @@
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
or Embedded MPV nothing would ever trigger a failover. -->
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
<div class="setting-item" data-test-id="vod-auto-failover-setting">
<div
class="setting-item"
data-setting-id="vod-auto-failover"
data-test-id="vod-auto-failover-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }}
@@ -181,6 +195,7 @@
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
<div
class="setting-item"
data-setting-id="external-player-double-click"
data-test-id="external-player-double-click-setting"
>
<div class="setting-item__meta">
@@ -206,7 +221,7 @@
}
@if (isDesktop()) {
<div class="setting-item">
<div class="setting-item" data-setting-id="show-external-playback-bar">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
<p>
@@ -226,6 +241,7 @@
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
<div
class="setting-item"
data-setting-id="embedded-mpv-frame-copy"
data-test-id="embedded-mpv-frame-copy-setting"
>
<div class="setting-item__meta">
@@ -262,6 +278,7 @@
@if (form().value.player === 'embedded-mpv') {
<div
class="setting-item"
data-setting-id="embedded-mpv-extra-options"
data-test-id="embedded-mpv-extra-options-setting"
>
<div class="setting-item__meta">
@@ -332,6 +349,7 @@
<div
class="setting-item"
data-setting-id="embedded-mpv-auto-reconnect"
data-test-id="embedded-mpv-auto-reconnect-setting"
>
<div class="setting-item__meta">
@@ -354,7 +372,11 @@
</div>
}
<div class="setting-item" data-test-id="recording-folder-setting">
<div
class="setting-item"
data-setting-id="recording-folder"
data-test-id="recording-folder-setting"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
<p>
@@ -391,7 +413,7 @@
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
) {
<div class="setting-item">
<div class="setting-item" data-setting-id="mpv-player-path">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
<p>
@@ -418,6 +440,7 @@
<div
class="setting-item setting-item--external-player-arguments"
data-setting-id="mpv-player-arguments"
data-test-id="mpv-player-arguments-setting"
>
<div class="setting-item__meta">
@@ -449,7 +472,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="mpv-reuse-instance">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
<p>
@@ -465,7 +488,7 @@
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
) {
<div class="setting-item">
<div class="setting-item" data-setting-id="vlc-player-path">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
<p>
@@ -489,6 +512,7 @@
<div
class="setting-item setting-item--external-player-arguments"
data-setting-id="vlc-player-arguments"
data-test-id="vlc-player-arguments-setting"
>
<div class="setting-item__meta">
@@ -520,7 +544,7 @@
</div>
</div>
<div class="setting-item">
<div class="setting-item" data-setting-id="vlc-reuse-instance">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}</h4>
<p>
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="remote-control"
[formGroup]="form()"
>
<section class="settings-group" id="remote-control" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings_remote</mat-icon>
@@ -13,7 +9,7 @@
</div>
</header>
<div class="setting-item">
<div class="setting-item" data-setting-id="remote-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h4>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
@@ -27,7 +23,7 @@
</div>
@if (form().value.remoteControl === true) {
<div class="setting-item">
<div class="setting-item" data-setting-id="remote-control-port">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
<p>
@@ -76,7 +72,7 @@
</div>
@if (localIpAddresses().length > 0) {
<div class="setting-item">
<div class="setting-item" data-setting-id="remote-control-url">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
<p>
@@ -1,7 +1,4 @@
<section
class="settings-group"
id="reset"
>
<section class="settings-group" id="reset">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>delete_sweep</mat-icon>
@@ -12,7 +9,10 @@
</div>
</header>
<div class="setting-item danger-zone">
<div
class="setting-item danger-zone"
data-setting-id="remove-all-playlists"
>
<div class="danger-zone__header">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
@@ -0,0 +1,129 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { SETTINGS_SEARCH_ENTRIES } from '@iptvnator/workspace/shell/util/settings-search';
/**
* The settings search index is hand-maintained next to templates whose
* labels are hard-coded translate keys. This spec keeps the two in step: a
* new settings row without an index entry (or a renamed key) fails here
* instead of silently becoming unsearchable.
*/
interface TemplateRow {
file: string;
id: string | null;
labelKey: string | null;
descriptionKey: string | null;
}
// The web jest config runs as ESM, so there is no `__dirname` here.
const SETTINGS_DIR = resolve(process.cwd(), 'apps/web/src/app/settings');
const EN = JSON.parse(
readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8')
);
function readTemplateRows(): TemplateRow[] {
const rows: TemplateRow[] = [];
const files = readdirSync(SETTINGS_DIR).filter((file) =>
/^settings-.+-section\.component\.html$/.test(file)
);
for (const file of files) {
const html = readFileSync(join(SETTINGS_DIR, file), 'utf8');
// Row containers only: `setting-item` itself, not `__meta` etc.
const starts = [
...html.matchAll(/<div\b[^>]*class="setting-item(?:\s[^"]*)?"/g),
];
starts.forEach((match, index) => {
const end = starts[index + 1]?.index ?? html.length;
const chunk = html.slice(match.index, end);
const tag = chunk.slice(0, chunk.indexOf('>') + 1);
const meta = chunk.split('setting-item__control')[0];
rows.push({
file,
id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null,
labelKey:
chunk.match(
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
)?.[1] ?? null,
descriptionKey:
meta.match(
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
)?.[1] ?? null,
});
});
}
return rows;
}
function translationExists(key: string): boolean {
return (
typeof key
.split('.')
.reduce<unknown>(
(node, part) =>
node && typeof node === 'object'
? (node as Record<string, unknown>)[part]
: undefined,
EN
) === 'string'
);
}
describe('settings search registry', () => {
const rows = readTemplateRows();
const titledRows = rows.filter((row) => row.labelKey !== null);
const byId = new Map(SETTINGS_SEARCH_ENTRIES.map((e) => [e.id, e]));
it('finds the settings rows in the section templates', () => {
expect(titledRows.length).toBeGreaterThan(40);
});
it('anchors every titled settings row with a registered id', () => {
const missing = titledRows
.filter((row) => !row.id || !byId.has(row.id))
.map((row) => `${row.file}: ${row.labelKey}`);
expect(missing).toEqual([]);
});
it('registers every anchored row exactly once', () => {
const templateIds = rows.flatMap((row) => (row.id ? [row.id] : []));
expect(new Set(templateIds).size).toBe(templateIds.length);
expect(templateIds.slice().sort()).toEqual(
SETTINGS_SEARCH_ENTRIES.map((entry) => entry.id).sort()
);
});
it('indexes the same title and description the row renders', () => {
const mismatches = titledRows.flatMap((row) => {
const entry = row.id ? byId.get(row.id) : undefined;
if (!entry) {
return [];
}
const problems: string[] = [];
if (entry.labelKey !== row.labelKey) {
problems.push(`${row.id}: label ${row.labelKey}`);
}
if (entry.descriptionKey !== (row.descriptionKey ?? undefined)) {
problems.push(`${row.id}: description ${row.descriptionKey}`);
}
if (!row.file.includes(`settings-${entry.section}-section`)) {
problems.push(`${row.id}: rendered in ${row.file}`);
}
return problems;
});
expect(mismatches).toEqual([]);
});
it('uses translation keys that exist in English', () => {
const missing = SETTINGS_SEARCH_ENTRIES.flatMap((entry) =>
[entry.labelKey, entry.descriptionKey].filter(
(key): key is string => !!key && !translationExists(key)
)
);
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,62 @@
<section
class="settings-group settings-search-results"
data-test-id="settings-search-results"
aria-labelledby="settings-search-results-title"
>
<header class="settings-group__header">
<div class="settings-group__header-text">
<h3 id="settings-search-results-title">
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
</h3>
<p aria-live="polite" data-test-id="settings-search-summary">
{{
'SETTINGS.SEARCH_RESULTS_COUNT'
| translate: { count: results().length, query: query() }
}}
</p>
</div>
</header>
@if (results().length > 0) {
<ul class="settings-search-results__list">
@for (result of results(); track result.entry.id) {
<li>
<button
type="button"
class="settings-search-result"
[attr.data-test-id]="
'settings-search-result-' + result.entry.id
"
(click)="selected.emit(result.entry)"
>
<mat-icon class="settings-search-result__icon">{{
result.section.icon
}}</mat-icon>
<span class="settings-search-result__text">
<span class="settings-search-result__label">{{
result.label
}}</span>
@if (result.description) {
<span
class="settings-search-result__description"
>{{ result.description }}</span
>
}
</span>
<span class="settings-search-result__section">{{
result.sectionLabel
}}</span>
<mat-icon class="settings-search-result__chevron"
>chevron_right</mat-icon
>
</button>
</li>
}
</ul>
} @else {
<div class="settings-search-empty" data-test-id="settings-search-empty">
<mat-icon>search_off</mat-icon>
<p>{{ 'SETTINGS.SEARCH_EMPTY_HINT' | translate }}</p>
</div>
}
</section>
@@ -0,0 +1,121 @@
:host {
display: block;
}
.settings-search-results__list {
list-style: none;
margin: 0;
padding: 0;
}
.settings-search-result {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: 56px;
padding: 12px 10px;
border: 0;
border-top: 1px solid var(--settings-item-border);
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: var(--app-hover-overlay);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
border-radius: 8px;
}
}
li:first-child .settings-search-result {
border-top: 0;
}
.settings-search-result__icon,
.settings-search-result__chevron {
flex-shrink: 0;
color: var(--settings-muted);
}
.settings-search-result__text {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.settings-search-result__label {
font-size: 0.95rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--app-heading-color);
}
.settings-search-result__description {
overflow: hidden;
color: var(--settings-muted);
font-size: 0.84rem;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-search-result__section {
flex-shrink: 0;
padding: 2px 10px;
border: 1px solid var(--settings-item-border);
border-radius: 999px;
color: var(--settings-muted);
font-size: 0.78rem;
white-space: nowrap;
}
.settings-search-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 40px 16px;
color: var(--settings-muted);
text-align: center;
mat-icon {
width: 32px;
height: 32px;
font-size: 32px;
}
p {
margin: 0;
max-width: 420px;
line-height: 1.45;
}
}
@media (max-width: 640px) {
.settings-search-result {
flex-wrap: wrap;
padding: 12px 14px;
}
.settings-search-result__description {
white-space: normal;
}
.settings-search-result__section {
margin-left: 38px;
}
.settings-search-result__chevron {
display: none;
}
}
@@ -0,0 +1,30 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
SettingsSearchEntry,
SettingsSearchResult,
} from '@iptvnator/workspace/shell/util/settings-search';
/**
* Ranked settings matches for the header search term, shown in place of the
* section page while a term is active. Best match first — the same row the
* Enter key in the header search opens.
*/
@Component({
selector: 'app-settings-search-results',
imports: [MatIconModule, TranslatePipe],
templateUrl: './settings-search-results.component.html',
styleUrl: './settings-search-results.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SettingsSearchResultsComponent {
readonly query = input.required<string>();
readonly results = input.required<readonly SettingsSearchResult[]>();
readonly selected = output<SettingsSearchEntry>();
}
@@ -0,0 +1,178 @@
import {
afterNextRender,
computed,
DestroyRef,
effect,
ElementRef,
inject,
Injectable,
Injector,
Signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import {
SettingsRevealRequest,
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { map, startWith } from 'rxjs';
/** How long a revealed row keeps its highlight. */
export const SETTINGS_REVEAL_HIGHLIGHT_MS = 2400;
export const SETTINGS_REVEALED_CLASS = 'setting-item--revealed';
export interface SettingsRevealBinding {
/** Section page currently rendered. */
readonly activeSection: Signal<string>;
/** True once the form is hydrated, so form-dependent rows exist. */
readonly ready: Signal<boolean>;
}
/**
* Settings page side of settings search. The header search writes the term
* to the `q` query param (shell-owned); this facade turns it into ranked
* results, publishes per-section match counts to the settings navigation,
* and scrolls to and highlights a row that search or the command palette
* asked `SettingsSearchService` to reveal.
*/
@Injectable()
export class SettingsSearchFacade {
private readonly route = inject(ActivatedRoute);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly settingsCtx = inject(SettingsContextService);
private readonly translate = inject(TranslateService);
private readonly host = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
private highlightTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly query = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim())
),
{ initialValue: '' }
);
readonly isSearching = computed(() => this.query().length > 0);
readonly results = computed(() => {
this.languageTick();
return this.settingsSearch.search(this.query());
});
constructor() {
void this.settingsSearch.ensureEmbeddedMpvSupportLoaded();
effect(() => {
if (!this.isSearching()) {
this.settingsCtx.setMatchCounts(null);
return;
}
const counts: Record<string, number> = {};
for (const { entry } of this.results()) {
counts[entry.section] = (counts[entry.section] ?? 0) + 1;
}
this.settingsCtx.setMatchCounts(counts);
});
inject(DestroyRef).onDestroy(() => {
this.settingsCtx.setMatchCounts(null);
this.clearHighlightTimer();
});
}
select(entry: SettingsSearchEntry): void {
this.settingsSearch.reveal(entry);
}
/** Starts handling reveal requests once the page can render them. */
bindReveal({ activeSection, ready }: SettingsRevealBinding): void {
effect(
() => {
const request = this.settingsSearch.pendingReveal();
if (
!request ||
!ready() ||
this.isSearching() ||
activeSection() !== request.section
) {
return;
}
afterNextRender(() => this.revealRow(request), {
injector: this.injector,
});
},
{ injector: this.injector }
);
}
private revealRow(request: SettingsRevealRequest): void {
// A request superseded before this render is handled by its successor.
if (this.settingsSearch.pendingReveal()?.nonce !== request.nonce) {
return;
}
this.settingsSearch.completeReveal(request);
const row =
this.findRow(request.id) ??
(request.fallbackId ? this.findRow(request.fallbackId) : null);
if (!row) {
return;
}
this.clearHighlight();
row.scrollIntoView({
block: 'center',
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
});
// Moving focus to the row lets keyboard and screen-reader users
// continue from the setting they searched for; Tab reaches its
// control next.
if (!row.hasAttribute('tabindex')) {
row.setAttribute('tabindex', '-1');
}
row.focus({ preventScroll: true });
row.classList.add(SETTINGS_REVEALED_CLASS);
this.highlightTimeoutId = setTimeout(
() => this.clearHighlight(),
SETTINGS_REVEAL_HIGHLIGHT_MS
);
}
/** `id` comes from the static registry (`[a-z0-9-]`), never from input. */
private findRow(id: string): HTMLElement | null {
return (this.host.nativeElement as HTMLElement).querySelector(
`[data-setting-id="${id}"]`
);
}
private clearHighlight(): void {
this.clearHighlightTimer();
(this.host.nativeElement as HTMLElement)
.querySelectorAll(`.${SETTINGS_REVEALED_CLASS}`)
.forEach((element) =>
element.classList.remove(SETTINGS_REVEALED_CLASS)
);
}
private clearHighlightTimer(): void {
if (this.highlightTimeoutId !== null) {
clearTimeout(this.highlightTimeoutId);
this.highlightTimeoutId = null;
}
}
}
function prefersReducedMotion(): boolean {
return (
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
@@ -10,7 +10,7 @@
</header>
<div formGroupName="tmdb">
<div class="setting-item">
<div class="setting-item" data-setting-id="tmdb-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_ENABLE' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
@@ -24,7 +24,7 @@
</div>
@if (form().value.tmdb?.enabled === true) {
<div class="setting-item">
<div class="setting-item" data-setting-id="tmdb-api-key">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
@@ -96,7 +96,10 @@
@if (form().value.tmdb?.enabled === true) {
@if (m3uVodDetailsControl; as m3uVodDetails) {
<div class="setting-item">
<div
class="setting-item"
data-setting-id="tmdb-m3u-vod-details"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
@@ -118,7 +121,7 @@
}
}
<div class="setting-item">
<div class="setting-item" data-setting-id="tmdb-cache">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
<p>
+131 -107
View File
@@ -16,113 +16,137 @@
<!-- Only the routed section renders. The parent keeps the shared
form alive across section switches, so edits staged on one
page survive a visit to another. -->
@switch (activeSection()) {
@case ('playback') {
<app-settings-playback-section
[form]="settingsForm"
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="
supportsManagedExternalPlayers
"
[supportsExternalPlayerPathSettings]="
supportsExternalPlayerPathSettings
"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
}
@case ('epg') {
<app-settings-epg-section
[form]="settingsForm"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[canBrowseFiles]="epg.supportsFilePicker"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="epg.refresh($event)"
(browseEpgSource)="epg.browse($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
(clearEpgData)="epg.clear()"
(selectEpgViewMode)="form.selectEpgViewMode($event)"
/>
}
@case ('dashboard') {
<app-settings-dashboard-section [form]="settingsForm" />
}
@case ('remote-control') {
<app-settings-remote-control-section
[form]="settingsForm"
[localIpAddresses]="remoteControl.localIpAddresses()"
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
(toggleQrCode)="remoteControl.toggleQrCode($event)"
/>
}
@case ('tmdb') {
<app-settings-tmdb-section [form]="settingsForm" />
}
@case ('backup') {
<app-settings-backup-section
[isPwa]="isPwa"
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[isExportingData]="backup.isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
}
@case ('reset') {
<app-settings-reset-section
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
[playlistDeleteSummary]="playlistReset.deleteSummary()"
[removeAllProgressLabel]="
playlistReset.removeAllProgressLabel()
"
(removeAll)="removeAll()"
/>
}
@case ('about') {
<app-settings-about-section
[isDesktop]="isDesktop"
[form]="settingsForm"
[updateChannelOptions]="updateChannelOptions"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="appUpdate.updateMessage()"
[appUpdateStatus]="appUpdate.status()"
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
(saveAndCheckForAppUpdate)="onSubmit()"
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
(installAppUpdate)="appUpdate.installAppUpdate()"
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
(openAppUpdateReleaseNotes)="
appUpdate.openReleaseNotes()
"
/>
}
@default {
<app-settings-general-section
[form]="settingsForm"
[languageEnum]="languageEnum"
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
[startupWindowModeOptions]="startupWindowModeOptions"
[supportsPortalConnectivityGuard]="
supportsPortalConnectivityGuard
"
[supportsStartupWindowMode]="supportsStartupWindowMode"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
<!-- An active header search term (`q`) replaces the section page
with ranked results across every section. -->
@if (search.isSearching()) {
<app-settings-search-results
[query]="search.query()"
[results]="search.results()"
(selected)="search.select($event)"
/>
} @else {
@switch (activeSection()) {
@case ('playback') {
<app-settings-playback-section
[form]="settingsForm"
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="
supportsManagedExternalPlayers
"
[supportsExternalPlayerPathSettings]="
supportsExternalPlayerPathSettings
"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="
embeddedMpv.frameCopyAvailable()
"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
}
@case ('epg') {
<app-settings-epg-section
[form]="settingsForm"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[canBrowseFiles]="epg.supportsFilePicker"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="epg.refresh($event)"
(browseEpgSource)="epg.browse($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
(clearEpgData)="epg.clear()"
(selectEpgViewMode)="form.selectEpgViewMode($event)"
/>
}
@case ('dashboard') {
<app-settings-dashboard-section [form]="settingsForm" />
}
@case ('remote-control') {
<app-settings-remote-control-section
[form]="settingsForm"
[localIpAddresses]="
remoteControl.localIpAddresses()
"
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
(toggleQrCode)="remoteControl.toggleQrCode($event)"
/>
}
@case ('tmdb') {
<app-settings-tmdb-section [form]="settingsForm" />
}
@case ('backup') {
<app-settings-backup-section
[isPwa]="isPwa"
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[isExportingData]="backup.isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
}
@case ('reset') {
<app-settings-reset-section
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[canRemoveAllPlaylists]="
playlistReset.canRemoveAll()
"
[playlistDeleteSummary]="
playlistReset.deleteSummary()
"
[removeAllProgressLabel]="
playlistReset.removeAllProgressLabel()
"
(removeAll)="removeAll()"
/>
}
@case ('about') {
<app-settings-about-section
[isDesktop]="isDesktop"
[form]="settingsForm"
[updateChannelOptions]="updateChannelOptions"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="appUpdate.updateMessage()"
[appUpdateStatus]="appUpdate.status()"
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
(saveAndCheckForAppUpdate)="onSubmit()"
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
(installAppUpdate)="appUpdate.installAppUpdate()"
(openManualAppUpdate)="
appUpdate.openManualAppUpdate()
"
(openAppUpdateReleaseNotes)="
appUpdate.openReleaseNotes()
"
/>
}
@default {
<app-settings-general-section
[form]="settingsForm"
[languageEnum]="languageEnum"
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
[startupWindowModeOptions]="
startupWindowModeOptions
"
[supportsPortalConnectivityGuard]="
supportsPortalConnectivityGuard
"
[supportsStartupWindowMode]="
supportsStartupWindowMode
"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
}
}
}
</div>
@@ -114,6 +114,64 @@
margin-bottom: 0;
}
// A row opened from settings search or the command palette. It receives
// programmatic focus (tabindex -1). Keyboard users keep a focus ring after
// the temporary highlight fades; pointer users rely on the highlight alone,
// as with any mouse-driven focus. The wash fills the row's own padding box
// and two offset shadows widen it past the flush-left row edges without
// shifting layout or painting over the separators above and below.
.setting-item[data-setting-id] {
scroll-margin: 96px 0 calc(96px + var(--settings-action-bar-space));
&:focus {
outline: none;
}
&:focus-visible {
outline: 2px solid
color-mix(in srgb, var(--app-selection-color) 60%, transparent);
outline-offset: 6px;
border-radius: 4px;
}
}
.setting-item--revealed {
--settings-reveal-wash: color-mix(
in srgb,
var(--app-selection-color) 12%,
transparent
);
background: var(--settings-reveal-wash);
box-shadow:
-12px 0 0 var(--settings-reveal-wash),
12px 0 0 var(--settings-reveal-wash);
animation: settings-reveal-fade 2.4s ease-out forwards;
}
@keyframes settings-reveal-fade {
0%,
60% {
background: var(--settings-reveal-wash);
box-shadow:
-12px 0 0 var(--settings-reveal-wash),
12px 0 0 var(--settings-reveal-wash);
}
100% {
background: transparent;
box-shadow:
-12px 0 0 transparent,
12px 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.setting-item--revealed {
animation: none;
}
}
.setting-item__meta {
h4 {
margin: 0;
@@ -0,0 +1,181 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import {
SETTINGS_SEARCH_ENTRIES,
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { SettingsComponent } from './settings.component';
import { SETTINGS_REVEALED_CLASS } from './settings-search.facade';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
MockRouter,
setSettingsSearchQuery,
setSettingsSection,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
function entry(id: string): SettingsSearchEntry {
const found = SETTINGS_SEARCH_ENTRIES.find((item) => item.id === id);
if (!found) {
throw new Error(`unknown settings entry ${id}`);
}
return found;
}
/**
* Settings search on the page: the shell writes the header term to `q`,
* the page swaps the section for ranked results, and a chosen result (or a
* command palette pick) scrolls to and highlights the row. The labels are
* translation keys here (no translations are loaded), which the index
* searches like any other text.
*/
describe('SettingsComponent search', () => {
let fixture: ComponentFixture<SettingsComponent>;
let component: SettingsComponent;
let settingsSearch: SettingsSearchService;
let settingsCtx: SettingsContextService;
let scrollIntoView: jest.Mock;
const originalElectron = window.electron;
const originalScrollIntoView = Element.prototype.scrollIntoView;
const element = (): HTMLElement => fixture.nativeElement as HTMLElement;
const query = (selector: string) => element().querySelector(selector);
beforeEach(waitForAsync(() => {
configureSettingsComponentTestBed(createEpgBridgeStub());
}));
beforeEach(async () => {
window.electron = createElectronStub();
scrollIntoView = jest.fn();
Element.prototype.scrollIntoView = scrollIntoView;
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
settingsSearch = TestBed.inject(SettingsSearchService);
settingsCtx = TestBed.inject(SettingsContextService);
stubSettingsSideEffects(component);
const loadSettings = jest.spyOn(component.form, 'loadSettings');
fixture.detectChanges();
await loadSettings.mock.results[0].value;
await fixture.whenStable();
fixture.detectChanges();
});
afterEach(() => {
jest.useRealTimers();
window.electron = originalElectron;
Element.prototype.scrollIntoView = originalScrollIntoView;
});
it('replaces the section page with ranked results while searching', () => {
expect(query('app-settings-general-section')).not.toBeNull();
setSettingsSearchQuery('theme');
fixture.detectChanges();
expect(query('app-settings-general-section')).toBeNull();
expect(
query('[data-test-id="settings-search-result-theme"]')
).not.toBeNull();
expect(
query('[data-test-id="settings-search-summary"]')?.getAttribute(
'aria-live'
)
).toBe('polite');
setSettingsSearchQuery('');
fixture.detectChanges();
expect(query('[data-test-id="settings-search-results"]')).toBeNull();
expect(query('app-settings-general-section')).not.toBeNull();
});
it('shows an empty state when nothing matches', () => {
setSettingsSearchQuery('zzzz-no-such-setting');
fixture.detectChanges();
expect(query('[data-test-id="settings-search-empty"]')).not.toBeNull();
expect(query('.settings-search-result')).toBeNull();
});
it('publishes match counts per section for the navigation', () => {
setSettingsSearchQuery('dark');
fixture.detectChanges();
expect(settingsCtx.matchCounts()).toEqual(
expect.objectContaining({ general: 1 })
);
setSettingsSearchQuery('');
fixture.detectChanges();
expect(settingsCtx.matchCounts()).toBeNull();
});
it('reveals the chosen result through the settings search service', () => {
const reveal = jest.spyOn(settingsSearch, 'reveal');
setSettingsSearchQuery('theme');
fixture.detectChanges();
(
query(
'[data-test-id="settings-search-result-theme"]'
) as HTMLButtonElement
).click();
expect(reveal).toHaveBeenCalledWith(entry('theme'));
expect(
(TestBed.inject(Router) as unknown as MockRouter).navigate
).toHaveBeenCalledWith(['/workspace/settings', 'general']);
});
it('scrolls to, focuses and briefly highlights a revealed row', () => {
jest.useFakeTimers();
settingsSearch.reveal(entry('show-captions'));
fixture.detectChanges();
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
expect(scrollIntoView).toHaveBeenCalled();
expect(scrollIntoView.mock.contexts[0]).toBe(row);
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
expect(document.activeElement).toBe(row);
expect(settingsSearch.pendingReveal()).toBeNull();
jest.runOnlyPendingTimers();
expect(row.classList).not.toContain(SETTINGS_REVEALED_CLASS);
});
it('waits for the section page before revealing a row on it', () => {
settingsSearch.reveal(entry('epg-offset'));
fixture.detectChanges();
expect(scrollIntoView).not.toHaveBeenCalled();
expect(settingsSearch.pendingReveal()?.id).toBe('epg-offset');
setSettingsSection('epg');
fixture.detectChanges();
expect(query('[data-setting-id="epg-offset"]')?.classList).toContain(
SETTINGS_REVEALED_CLASS
);
});
it('falls back to the controlling row when the searched row is hidden', () => {
// The API key row only renders while TMDB is enabled.
setSettingsSection('tmdb');
fixture.detectChanges();
expect(query('[data-setting-id="tmdb-api-key"]')).toBeNull();
settingsSearch.reveal(entry('tmdb-api-key'));
fixture.detectChanges();
expect(query('[data-setting-id="tmdb-enable"]')?.classList).toContain(
SETTINGS_REVEALED_CLASS
);
});
});
@@ -6,6 +6,7 @@ import {
inject,
OnDestroy,
OnInit,
signal,
ViewEncapsulation,
ChangeDetectionStrategy,
} from '@angular/core';
@@ -49,6 +50,8 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSearchFacade } from './settings-search.facade';
import { SettingsSearchResultsComponent } from './settings-search-results.component';
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
import {
SettingsUnsavedChangesChoice,
@@ -93,6 +96,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
SettingsSearchResultsComponent,
SettingsTmdbSectionComponent,
],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
@@ -105,6 +109,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsFormFacade,
SettingsPlaylistResetFacade,
SettingsRemoteControlFacade,
SettingsSearchFacade,
SettingsSnackbarService,
SettingsUnloadGuardService,
],
@@ -119,6 +124,7 @@ export class SettingsComponent
readonly form = inject(SettingsFormFacade);
readonly playlistReset = inject(SettingsPlaylistResetFacade);
readonly remoteControl = inject(SettingsRemoteControlFacade);
readonly search = inject(SettingsSearchFacade);
private readonly settingsCtx = inject(SettingsContextService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
@@ -155,6 +161,9 @@ export class SettingsComponent
/** Settings form object */
readonly settingsForm = this.form.form;
/** Set once the form is hydrated, so form-dependent rows can render. */
private readonly formReady = signal(false);
/** Player options */
readonly players = computed(() =>
buildSettingsPlayerOptions({
@@ -219,10 +228,16 @@ export class SettingsComponent
// on purpose — this is navigation, not an animated transition.
effect(() => {
this.activeSection();
this.search.isSearching();
this.hostElement.nativeElement
.closest('main.workspace-content')
?.scrollTo({ top: 0 });
});
this.search.bindReveal({
activeSection: this.activeSection,
ready: this.formReady.asReadonly(),
});
}
/**
@@ -241,6 +256,7 @@ export class SettingsComponent
await this.form.loadSettings();
this.form.hydrateFromStore();
this.form.bindDashboardControlsEnabledState();
this.formReady.set(true);
void this.embeddedMpv.load();
this.appUpdate.checkAppVersion();
this.appUpdate.init();
@@ -146,19 +146,28 @@ export class MockRouter {
}
/**
* Stands in for the `:section` route param the settings page renders from.
* Specs switch section pages with `setSettingsSection` below.
* Stands in for the `:section` route param the settings page renders from
* and the shell-owned `q` search param. Specs switch section pages with
* `setSettingsSection` and search with `setSettingsSearchQuery` below.
*/
export class MockActivatedRoute {
private readonly params = new BehaviorSubject<ParamMap>(
convertToParamMap({ section: 'general' })
);
private readonly queryParams = new BehaviorSubject<ParamMap>(
convertToParamMap({})
);
readonly paramMap = this.params.asObservable();
readonly queryParamMap = this.queryParams.asObservable();
setSection(section: string): void {
this.params.next(convertToParamMap({ section }));
}
setQuery(query: string): void {
this.queryParams.next(convertToParamMap(query ? { q: query } : {}));
}
}
/** Routes the rendered settings page to the given section page. */
@@ -168,6 +177,13 @@ export function setSettingsSection(section: string): void {
).setSection(section);
}
/** Sets the header search term (`q`) the settings page searches for. */
export function setSettingsSearchQuery(query: string): void {
(
TestBed.inject(ActivatedRoute) as unknown as MockActivatedRoute
).setQuery(query);
}
export class MockSettingsStore {
private _settings = signal(DEFAULT_SETTINGS);