diff --git a/.changes/settings-search.md b/.changes/settings-search.md new file mode 100644 index 000000000..137d7eff1 --- /dev/null +++ b/.changes/settings-search.md @@ -0,0 +1,10 @@ +--- +type: feature +area: settings +highlight: Search your settings +--- + +Settings are now searchable: type in the search box on the Settings page to +find any option by name, description or a common word like "dark" or "mpv", +then jump straight to it. The command palette (Ctrl/Cmd+K) finds settings +too, from anywhere in the app. diff --git a/README.md b/README.md index fbec01873..25bcbb9d8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ The application is a cross-platform, open-source project built with Electron and - Per-playlist and global favorites, aggregated across all playlists ⭐ - Recently viewed / watch history -- Command palette (`Ctrl/Cmd+K`) +- Command palette (`Ctrl/Cmd+K`) that also finds and opens individual settings +- Settings search from the header search box on the Settings page **Platform** @@ -76,9 +77,9 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list. | Area | Shortcut | Action | | ----------------- | --------------------------- | ---------------------------------------------------------- | | Global | `Ctrl/Cmd+K` | Open command palette | -| Global | `Ctrl/Cmd+F` | Open global search in the desktop app | +| Global | `Ctrl/Cmd+F` | Open global search (desktop); on Settings, search settings | | Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app | -| Global | `Enter` in workspace search | Submit the current search | +| Global | `Enter` in workspace search | Submit the search; on Settings, open the best match | | Global | `F11` | Toggle app window fullscreen in the desktop app | | Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | | Navigation | `0-9` | Select an M3U channel by number | diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1ed31f3f8..c4b131b6b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -254,6 +254,84 @@ test.describe('Settings', () => { ); }); + test('@settings @search @web Search settings from the header and open a result', async ({ + page, + }) => { + await openSettings(page); + // The fresh browser context has no playlists; settings search must + // still be offered. + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + await expect(search).toBeEnabled(); + + // "subtitles" is a keyword of the "Show captions" row. + await search.fill('subtitles'); + await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/); + await expect( + page.locator('[data-test-id="settings-search-results"]') + ).toBeVisible(); + await expect(page.locator('app-settings-general-section')).toHaveCount(0); + await expect( + page.locator('[data-test-id="settings-section-matches-general"]') + ).toHaveText('1'); + + await page + .locator('[data-test-id="settings-search-result-show-captions"]') + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/general$/); + await expect(search).toHaveValue(''); + const row = page.locator('[data-setting-id="show-captions"]'); + await expect(row).toBeFocused(); + await expect(row).toHaveClass(/setting-item--revealed/); + await expect(row).not.toHaveClass(/setting-item--revealed/, { + timeout: 5000, + }); + }); + + test('@settings @search @web Enter opens the best settings match', async ({ + page, + }) => { + await openSettings(page); + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + + await search.fill('stream format'); + await search.press('Enter'); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="stream-format"]') + ).toBeFocused(); + }); + + test('@settings @search @web Command palette opens a setting from anywhere', async ({ + page, + }) => { + // The shell registers Ctrl/Cmd+K once its lazy chunk has rendered. + await expect( + page.locator('a[href$="/workspace/settings"]') + ).toBeVisible(); + await expect(page).not.toHaveURL(/\/workspace\/settings/); + await page.keyboard.press('Control+k'); + + const palette = page.locator('.workspace-command-palette-overlay'); + await expect(palette).toBeVisible(); + await palette.locator('input[type="search"]').fill('ambient'); + await expect(palette).toContainText('Settings'); + await palette + .locator('.palette-command', { hasText: /ambient/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="player-ambient-mode"]') + ).toBeFocused(); + }); + test.afterEach(async ({ page }, testInfo) => { await page.screenshot({ path: join( diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index 7542ce093..0d6f87c9f 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -9,7 +9,7 @@ -
{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}
@@ -31,7 +31,10 @@{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}
@@ -263,7 +266,10 @@{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}
@@ -281,7 +287,10 @@{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}
@@ -364,7 +373,10 @@
diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html
index 5c5657c4f..cd5ee2e0e 100644
--- a/apps/web/src/app/settings/settings-dashboard-section.component.html
+++ b/apps/web/src/app/settings/settings-dashboard-section.component.html
@@ -1,8 +1,4 @@
-
@@ -29,7 +25,7 @@
@@ -44,7 +40,10 @@
@@ -62,11 +61,14 @@
{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
@@ -82,11 +84,9 @@
{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
@@ -102,11 +102,11 @@
{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
@@ -122,7 +122,7 @@
@@ -140,11 +140,11 @@
{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
@@ -160,9 +160,14 @@
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
@@ -178,7 +183,10 @@
{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }} {{ 'SETTINGS.EPG_OFFSET_HINT' | translate }} {{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }} {{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }} {{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }} {{ 'SETTINGS.THEME_DESCRIPTION' | translate }} {{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}
@@ -160,7 +166,11 @@
@@ -195,7 +205,7 @@
@@ -207,7 +217,7 @@
@@ -233,7 +243,7 @@
{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }} {{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}
@@ -226,6 +241,7 @@
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
@@ -391,7 +413,7 @@
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
) {
-
@@ -418,6 +440,7 @@
@@ -465,7 +488,7 @@
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
) {
-
@@ -489,6 +512,7 @@
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html
index 510c81ea4..bdead5a0d 100644
--- a/apps/web/src/app/settings/settings-remote-control-section.component.html
+++ b/apps/web/src/app/settings/settings-remote-control-section.component.html
@@ -1,8 +1,4 @@
- {{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}
@@ -76,7 +72,7 @@
diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html
index 18b4f441e..cb15ca67b 100644
--- a/apps/web/src/app/settings/settings-reset-section.component.html
+++ b/apps/web/src/app/settings/settings-reset-section.component.html
@@ -1,7 +1,4 @@
- ]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
+ )?.[1] ?? null,
+ });
+ });
+ }
+ return rows;
+}
+
+function translationExists(key: string): boolean {
+ return (
+ typeof key
+ .split('.')
+ .reduce
+ {{
+ 'SETTINGS.SEARCH_RESULTS_COUNT'
+ | translate: { count: results().length, query: query() }
+ }}
+ {{ 'SETTINGS.SEARCH_EMPTY_HINT' | translate }} {{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }} {{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}
diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html
index c3ee2e281..11649ffa6 100644
--- a/apps/web/src/app/settings/settings.component.html
+++ b/apps/web/src/app/settings/settings.component.html
@@ -16,113 +16,137 @@
- @switch (activeSection()) {
- @case ('playback') {
- {{ 'SETTINGS.SHOW_DASHBOARD' | translate }}
{{ 'SETTINGS.DASHBOARD_HERO' | translate }}
{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}
{{
- 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate
- }}
+
+ {{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
+
{{
- 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate
- }}
+ {{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}
{{
- 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate
- }}
+
+ {{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
+
{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}
{{
- 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate
- }}
+
+ {{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
+
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
+
+ {{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
+
{{
diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html
index c8a96c1c1..b82aa1643 100644
--- a/apps/web/src/app/settings/settings-epg-section.component.html
+++ b/apps/web/src/app/settings/settings-epg-section.component.html
@@ -9,7 +9,10 @@
{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}
{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}
{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}
{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}
{{ 'SETTINGS.LANGUAGE' | translate }}
{{ 'SETTINGS.THEME' | translate }}
{{ 'SETTINGS.COVER_SIZE' | translate }}
{{ 'SETTINGS.COVER_TITLES' | translate }}
@@ -127,7 +133,7 @@
{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}
{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}
{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}
{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts
index d17660ec8..3b03e57aa 100644
--- a/apps/web/src/app/settings/settings-options.ts
+++ b/apps/web/src/app/settings/settings-options.ts
@@ -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),
+ }));
}
diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html
index 121bcafb2..7c93d5840 100644
--- a/apps/web/src/app/settings/settings-playback-section.component.html
+++ b/apps/web/src/app/settings/settings-playback-section.component.html
@@ -9,7 +9,7 @@
{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}
{{ 'SETTINGS.STREAM_FORMAT' | translate }}
{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }}
@@ -109,7 +114,11 @@
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }}
@@ -131,6 +140,7 @@
@if (supportsFullscreenChannelPanel()) {
{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }}
@@ -181,6 +195,7 @@
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}
{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}
{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}
{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}
{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}
{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}
{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}
{{ 'SETTINGS.REMOVE_ALL' | translate }}
diff --git a/apps/web/src/app/settings/settings-search-registry.spec.ts b/apps/web/src/app/settings/settings-search-registry.spec.ts
new file mode 100644
index 000000000..f6afbf8fa
--- /dev/null
+++ b/apps/web/src/app/settings/settings-search-registry.spec.ts
@@ -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(/]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
+ )?.[1] ?? null,
+ descriptionKey:
+ meta.match(
+ /
+ {{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
+
+
+ @for (result of results(); track result.entry.id) {
+
+ } @else {
+ {{ 'SETTINGS.TMDB_ENABLE' | translate }}
{{ 'SETTINGS.TMDB_API_KEY' | translate }}
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
@@ -118,7 +121,7 @@
}
}
-
{{ 'SETTINGS.TMDB_CACHE' | translate }}