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

+10
View File
@@ -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.
+4 -3
View File
@@ -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 |
+78
View File
@@ -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(
@@ -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);
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "بعد تكرار فشل الاتصال، تُوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تعطيل هذا الخيار إذا كانت البوابة تصبح غير متاحة بشكل متقطع. يُطبّق بعد الحفظ دون إعادة التشغيل.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "سيُنفّذ كل طلب بشكل مستقل. قد يستغرق ظهور الخطأ وقتًا أطول عند عدم توفر الخادم.",
"TITLE": "الإعدادات",
"SEARCH_RESULTS_TITLE": "نتائج البحث",
"SEARCH_RESULTS_COUNT": "الإعدادات المطابقة لـ «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "لا توجد إعدادات مطابقة. جرّب كلمة أخرى، مثل المشغل أو السمة أو EPG.",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "تغيير تكوين التطبيق",
"PLAYBACK": "التشغيل",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "إجراءات السياق",
"SEARCH_LABEL": "بحث",
"SEARCH_SOURCES_PLACEHOLDER": "البحث في المصادر (جميع قوائم التشغيل)...",
"SEARCH_SETTINGS_PLACEHOLDER": "البحث في الإعدادات...",
"SEARCH_GLOBAL_PLACEHOLDER": "البحث في كل قوائم التشغيل...",
"SEARCH_PLAYLIST_PLACEHOLDER": "البحث في قائمة التشغيل هذه...",
"SEARCH_SECTION_PLACEHOLDER": "البحث في هذا القسم...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
"SUBTITLE": "إجراءات عامة وسياقية",
"SEARCH_PLACEHOLDER": "اكتب لتصفية الأوامر",
"SEARCH_PLACEHOLDER": "البحث في الأوامر والإعدادات",
"SEARCH_LABEL": "تصفية الأوامر",
"EMPTY": "لم يتم العثور على أوامر",
"GROUP_GLOBAL": "الإجراءات العامة",
"GROUP_PLAYLIST": "قائمة التشغيل هذه",
"GROUP_VIEW": "العرض الحالي",
"GROUP_SECTION": "هذا القسم",
"GROUP_RECENT": "المستخدمة مؤخرًا"
"GROUP_RECENT": "المستخدمة مؤخرًا",
"GROUP_SETTINGS": "الإعدادات"
},
"SHORTCUTS": {
"TITLE": "اختصارات لوحة المفاتيح",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "من بعد ما يفشل الاتصال عدة مرات، كيوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تطفي هاد الخيار إلا كانت البوابة كتقطع مرة مرة. كيتطبق من بعد الحفظ بلا إعادة التشغيل.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "كل طلب غادي يتنفذ بوحدو. إلا كان السيرفر ما متاحش، رسالة الخطأ تقدر تتأخر.",
"TITLE": "الإعدادات",
"SEARCH_RESULTS_TITLE": "نتائج البحث",
"SEARCH_RESULTS_COUNT": "الإعدادات اللي كتطابق «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "ما كاين حتى إعداد مطابق. جرب كلمة أخرى، بحال المشغل ولا المظهر ولا EPG.",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "بدل تكوين التطبيق",
"PLAYBACK": "التشغيل",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
"SEARCH_LABEL": "بحث",
"SEARCH_SOURCES_PLACEHOLDER": "قلب على مصادر (جميع قوائم التشغيل)...",
"SEARCH_SETTINGS_PLACEHOLDER": "قلب فالإعدادات...",
"SEARCH_GLOBAL_PLACEHOLDER": "قلب فكل لوائح التشغيل...",
"SEARCH_PLAYLIST_PLACEHOLDER": "قلب في هاد قائمة التشغيل...",
"SEARCH_SECTION_PLACEHOLDER": "قلب فهاد القسم...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "لوحة الأوامر",
"SUBTITLE": "إجراءات عامة وسياقية",
"SEARCH_PLACEHOLDER": "كتب باش تفلتر الأوامر",
"SEARCH_PLACEHOLDER": "قلب على الأوامر والإعدادات",
"SEARCH_LABEL": "فلتر الأوامر",
"EMPTY": "ما تلقاتش حتى أمر",
"GROUP_GLOBAL": "إجراءات عامة",
"GROUP_PLAYLIST": "هاد قائمة التشغيل",
"GROUP_VIEW": "العرض الحالي",
"GROUP_SECTION": "هاد القسم",
"GROUP_RECENT": "مستعملة مؤخراً"
"GROUP_RECENT": "مستعملة مؤخراً",
"GROUP_SETTINGS": "الإعدادات"
},
"SHORTCUTS": {
"TITLE": "اختصارات الكلافيي",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Пасля паўторных памылак падключэння прыпыняць новыя запыты Xtream і Stalker на 30 секунд. Паспрабуйце выключыць, калі партал перыядычна становіцца недаступным. Ужываецца пасля захавання без перазапуску.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Кожны запыт будзе выконвацца незалежна. Калі сервер недаступны, чаканне памылкі можа павялічыцца.",
"TITLE": "Налады",
"SEARCH_RESULTS_TITLE": "Вынікі пошуку",
"SEARCH_RESULTS_COUNT": "Налады па запыце «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Нічога не знойдзена. Паспрабуйце іншае слова, напрыклад «прайгравальнік», «тэма» ці «EPG».",
"GENERAL": "Агульныя",
"GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы",
"PLAYBACK": "Прайграванне",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Кантэкстныя дзеянні",
"SEARCH_LABEL": "Пошук",
"SEARCH_SOURCES_PLACEHOLDER": "Пошук крыніц (усе плэйлісты)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Шукаць у наладах...",
"SEARCH_GLOBAL_PLACEHOLDER": "Шукаць ва ўсіх плэйлістах...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Пошук у гэтым плэйлісце...",
"SEARCH_SECTION_PLACEHOLDER": "Шукаць у гэтым раздзеле...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Палітра каманд",
"SUBTITLE": "Глабальныя і кантэкстныя дзеянні",
"SEARCH_PLACEHOLDER": "Увядзіце для фільтрацыі каманд",
"SEARCH_PLACEHOLDER": "Шукаць каманды і налады",
"SEARCH_LABEL": "Фільтр каманд",
"EMPTY": "Каманд не знойдзена",
"GROUP_GLOBAL": "Глабальныя дзеянні",
"GROUP_PLAYLIST": "Гэты плэйліст",
"GROUP_VIEW": "Бягучы выгляд",
"GROUP_SECTION": "Гэты раздзел",
"GROUP_RECENT": "Нядаўна выкарыстаныя"
"GROUP_RECENT": "Нядаўна выкарыстаныя",
"GROUP_SETTINGS": "Налады"
},
"SHORTCUTS": {
"TITLE": "Спалучэнні клавіш",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Nach wiederholten Verbindungsfehlern neue Xtream- und Stalker-Anfragen 30 Sekunden pausieren. Bei zeitweise nicht erreichbaren Portalen versuchsweise deaktivieren. Gilt nach dem Speichern ohne Neustart.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Jede Anfrage wird unabhängig ausgeführt. Bei nicht erreichbaren Servern kann die Fehlermeldung länger dauern.",
"TITLE": "Einstellungen",
"SEARCH_RESULTS_TITLE": "Suchergebnisse",
"SEARCH_RESULTS_COUNT": "Einstellungen zu „{{query}}“: {{count}}",
"SEARCH_EMPTY_HINT": "Keine passenden Einstellungen. Ein anderes Wort versuchen, etwa Player, Design oder EPG.",
"GENERAL": "Allgemein",
"GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern",
"PLAYBACK": "Wiedergabe",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Abschnittsaktionen",
"SEARCH_LABEL": "Suche",
"SEARCH_SOURCES_PLACEHOLDER": "Quellen durchsuchen (alle Playlists)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Einstellungen durchsuchen...",
"SEARCH_GLOBAL_PLACEHOLDER": "Alle Wiedergabelisten durchsuchen...",
"SEARCH_PLAYLIST_PLACEHOLDER": "In dieser Playlist suchen...",
"SEARCH_SECTION_PLACEHOLDER": "In diesem Abschnitt suchen...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Befehlspalette",
"SUBTITLE": "Globale und kontextbezogene Aktionen",
"SEARCH_PLACEHOLDER": "Zum Filtern der Befehle tippen",
"SEARCH_PLACEHOLDER": "Befehle und Einstellungen durchsuchen",
"SEARCH_LABEL": "Befehle filtern",
"EMPTY": "Keine Befehle gefunden",
"GROUP_GLOBAL": "Globale Aktionen",
"GROUP_PLAYLIST": "Diese Playlist",
"GROUP_VIEW": "Aktuelle Ansicht",
"GROUP_SECTION": "Dieser Abschnitt",
"GROUP_RECENT": "Kürzlich verwendet"
"GROUP_RECENT": "Kürzlich verwendet",
"GROUP_SETTINGS": "Einstellungen"
},
"SHORTCUTS": {
"TITLE": "Tastenkürzel",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Μετά από επανειλημμένα σφάλματα σύνδεσης, παύση νέων αιτημάτων Xtream και Stalker για 30 δευτερόλεπτα. Δοκιμάστε να το απενεργοποιήσετε αν μια πύλη είναι κατά διαστήματα μη διαθέσιμη. Ισχύει μετά την αποθήκευση, χωρίς επανεκκίνηση.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Κάθε αίτημα εκτελείται ανεξάρτητα. Οι μη διαθέσιμοι διακομιστές μπορεί να αργούν περισσότερο να αναφέρουν σφάλμα.",
"TITLE": "Ρυθμίσεις",
"SEARCH_RESULTS_TITLE": "Αποτελέσματα αναζήτησης",
"SEARCH_RESULTS_COUNT": "Ρυθμίσεις για «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Δεν βρέθηκαν ρυθμίσεις. Δοκιμάστε άλλη λέξη, π.χ. αναπαραγωγή, θέμα ή EPG.",
"GENERAL": "Γενικά",
"GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
"PLAYBACK": "Αναπαραγωγή",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας",
"SEARCH_LABEL": "Αναζήτηση",
"SEARCH_SOURCES_PLACEHOLDER": "Αναζήτηση πηγών (όλες οι λίστες αναπαραγωγής)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Αναζήτηση ρυθμίσεων...",
"SEARCH_GLOBAL_PLACEHOLDER": "Αναζήτηση σε όλες τις λίστες...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Αναζήτηση σε αυτή τη λίστα αναπαραγωγής...",
"SEARCH_SECTION_PLACEHOLDER": "Αναζήτηση σε αυτή την ενότητα...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Παλέτα εντολών",
"SUBTITLE": "Καθολικές και συμφραζόμενες ενέργειες",
"SEARCH_PLACEHOLDER": "Πληκτρολογήστε για φιλτράρισμα εντολών",
"SEARCH_PLACEHOLDER": "Αναζήτηση εντολών και ρυθμίσεων",
"SEARCH_LABEL": "Φιλτράρισμα εντολών",
"EMPTY": "Δεν βρέθηκαν εντολές",
"GROUP_GLOBAL": "Καθολικές ενέργειες",
"GROUP_PLAYLIST": "Αυτή η λίστα αναπαραγωγής",
"GROUP_VIEW": "Τρέχουσα προβολή",
"GROUP_SECTION": "Αυτή η ενότητα",
"GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες"
"GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες",
"GROUP_SETTINGS": "Ρυθμίσεις"
},
"SHORTCUTS": {
"TITLE": "Συντομεύσεις πληκτρολογίου",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "After repeated connection failures, pause new Xtream and Stalker requests for 30 seconds. Try disabling this if a portal becomes unavailable intermittently. Applies after saving, without restarting.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Each request will run independently. Unavailable servers may take longer to report an error.",
"TITLE": "Settings",
"SEARCH_RESULTS_TITLE": "Search results",
"SEARCH_RESULTS_COUNT": "Settings matching “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "No settings match. Try another word, such as player, theme or EPG.",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Change the configuration of the application",
"PLAYBACK": "Playback",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Section actions",
"SEARCH_LABEL": "Search",
"SEARCH_SOURCES_PLACEHOLDER": "Search sources (all playlists)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Search settings...",
"SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Search in this playlist...",
"SEARCH_SECTION_PLACEHOLDER": "Search in this section...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Command palette",
"SUBTITLE": "Global and contextual actions",
"SEARCH_PLACEHOLDER": "Type to filter commands",
"SEARCH_PLACEHOLDER": "Search commands and settings",
"SEARCH_LABEL": "Filter commands",
"EMPTY": "No commands found",
"GROUP_GLOBAL": "Global actions",
"GROUP_PLAYLIST": "This playlist",
"GROUP_VIEW": "Current view",
"GROUP_SECTION": "This section",
"GROUP_RECENT": "Recently used"
"GROUP_RECENT": "Recently used",
"GROUP_SETTINGS": "Settings"
},
"SHORTCUTS": {
"TITLE": "Keyboard shortcuts",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tras varios fallos de conexión, pausar las nuevas solicitudes de Xtream y Stalker durante 30 segundos. Prueba a desactivarlo si un portal deja de estar disponible de forma intermitente. Se aplica al guardar, sin reiniciar.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada solicitud se ejecutará de forma independiente. Los servidores no disponibles pueden tardar más en mostrar un error.",
"TITLE": "Ajustes",
"SEARCH_RESULTS_TITLE": "Resultados de búsqueda",
"SEARCH_RESULTS_COUNT": "Ajustes que coinciden con «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Ningún ajuste coincide. Prueba con otra palabra, como reproductor, tema o EPG.",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion",
"PLAYBACK": "Reproduccion",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Acciones de la sección",
"SEARCH_LABEL": "Buscar",
"SEARCH_SOURCES_PLACEHOLDER": "Buscar fuentes (todas las listas)…",
"SEARCH_SETTINGS_PLACEHOLDER": "Buscar en ajustes…",
"SEARCH_GLOBAL_PLACEHOLDER": "Buscar en todas las listas...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Buscar en esta lista de reproducción…",
"SEARCH_SECTION_PLACEHOLDER": "Buscar en esta sección...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Paleta de comandos",
"SUBTITLE": "Acciones globales y contextuales",
"SEARCH_PLACEHOLDER": "Escribe para filtrar comandos",
"SEARCH_PLACEHOLDER": "Buscar comandos y ajustes",
"SEARCH_LABEL": "Filtrar comandos",
"EMPTY": "No se encontraron comandos",
"GROUP_GLOBAL": "Acciones globales",
"GROUP_PLAYLIST": "Esta lista de reproducción",
"GROUP_VIEW": "Vista actual",
"GROUP_SECTION": "Esta sección",
"GROUP_RECENT": "Usados recientemente"
"GROUP_RECENT": "Usados recientemente",
"GROUP_SETTINGS": "Ajustes"
},
"SHORTCUTS": {
"TITLE": "Atajos de teclado",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Après plusieurs échecs de connexion, suspendre les nouvelles requêtes Xtream et Stalker pendant 30 secondes. Essayez de désactiver cette option si un portail devient indisponible par intermittence. Prend effet après enregistrement, sans redémarrage.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Chaque requête sera exécutée indépendamment. Les serveurs indisponibles peuvent mettre plus de temps à signaler une erreur.",
"TITLE": "Paramètres",
"SEARCH_RESULTS_TITLE": "Résultats de la recherche",
"SEARCH_RESULTS_COUNT": "Paramètres correspondant à « {{query}} » : {{count}}",
"SEARCH_EMPTY_HINT": "Aucun paramètre ne correspond. Essayez un autre mot, par exemple lecteur, thème ou EPG.",
"GENERAL": "Général",
"GENERAL_SUBTITLE": "Modifier la configuration de l'application",
"PLAYBACK": "Lecture",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Actions de la section",
"SEARCH_LABEL": "Rechercher",
"SEARCH_SOURCES_PLACEHOLDER": "Rechercher des sources (toutes les listes)…",
"SEARCH_SETTINGS_PLACEHOLDER": "Rechercher dans les paramètres…",
"SEARCH_GLOBAL_PLACEHOLDER": "Rechercher dans toutes les playlists...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Rechercher dans cette liste de lecture…",
"SEARCH_SECTION_PLACEHOLDER": "Rechercher dans cette section...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Palette de commandes",
"SUBTITLE": "Actions globales et contextuelles",
"SEARCH_PLACEHOLDER": "Saisir pour filtrer les commandes",
"SEARCH_PLACEHOLDER": "Rechercher des commandes et des paramètres",
"SEARCH_LABEL": "Filtrer les commandes",
"EMPTY": "Aucune commande trouvée",
"GROUP_GLOBAL": "Actions globales",
"GROUP_PLAYLIST": "Cette liste de lecture",
"GROUP_VIEW": "Vue actuelle",
"GROUP_SECTION": "Cette section",
"GROUP_RECENT": "Récemment utilisées"
"GROUP_RECENT": "Récemment utilisées",
"GROUP_SETTINGS": "Paramètres"
},
"SHORTCUTS": {
"TITLE": "Raccourcis clavier",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Ismételt kapcsolódási hibák után az új Xtream- és Stalker-kérések szüneteltetése 30 másodpercre. Ha egy portál időnként elérhetetlenné válik, próbálja kikapcsolni. Mentés után, újraindítás nélkül érvényesül.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Minden kérés önállóan fut. Elérhetetlen szervereknél a hiba megjelenése tovább tarthat.",
"TITLE": "Beállítások",
"SEARCH_RESULTS_TITLE": "Keresési találatok",
"SEARCH_RESULTS_COUNT": "„{{query}}” kifejezésre illeszkedő beállítások: {{count}}",
"SEARCH_EMPTY_HINT": "Nincs illeszkedő beállítás. Próbáljon másik szót, például lejátszó, téma vagy EPG.",
"GENERAL": "Általános",
"GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása",
"PLAYBACK": "Lejátszás",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Szakaszműveletek",
"SEARCH_LABEL": "Keresés",
"SEARCH_SOURCES_PLACEHOLDER": "Keresés a források között (összes lejátszási lista)…",
"SEARCH_SETTINGS_PLACEHOLDER": "Keresés a beállításokban…",
"SEARCH_GLOBAL_PLACEHOLDER": "Keresés az összes lejátszási listában…",
"SEARCH_PLAYLIST_PLACEHOLDER": "Keresés ebben a lejátszási listában…",
"SEARCH_SECTION_PLACEHOLDER": "Keresés ebben a szakaszban…",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Parancspaletta",
"SUBTITLE": "Globális és környezetfüggő műveletek",
"SEARCH_PLACEHOLDER": "A parancsok szűréséhez kezdjen gépelni",
"SEARCH_PLACEHOLDER": "Parancsok és beállítások keresése",
"SEARCH_LABEL": "Parancsok szűrése",
"EMPTY": "Nincs találat a parancsok között",
"GROUP_GLOBAL": "Globális műveletek",
"GROUP_PLAYLIST": "Ez a lejátszási lista",
"GROUP_VIEW": "Aktuális nézet",
"GROUP_SECTION": "Aktuális szakasz",
"GROUP_RECENT": "Legutóbb használt"
"GROUP_RECENT": "Legutóbb használt",
"GROUP_SETTINGS": "Beállítások"
},
"SHORTCUTS": {
"TITLE": "Billentyűparancsok",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Dopo ripetuti errori di connessione, sospendi le nuove richieste Xtream e Stalker per 30 secondi. Prova a disattivare se un portale diventa intermittentemente non disponibile. Si applica al salvataggio, senza riavvio.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Ogni richiesta verrà eseguita indipendentemente. I server non disponibili potrebbero impiegare più tempo a segnalare un errore.",
"TITLE": "Impostazioni IPTVnator",
"SEARCH_RESULTS_TITLE": "Risultati della ricerca",
"SEARCH_RESULTS_COUNT": "Impostazioni corrispondenti a “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Nessuna impostazione corrisponde. Prova un'altra parola, ad esempio riproduttore, tema o EPG.",
"GENERAL": "Generale",
"GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione",
"PLAYBACK": "Riproduzione",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Azioni sezione",
"SEARCH_LABEL": "Cerca",
"SEARCH_SOURCES_PLACEHOLDER": "Cerca sorgenti (tutte le playlist)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Cerca nelle impostazioni...",
"SEARCH_GLOBAL_PLACEHOLDER": "Cerca in tutte le playlist...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Cerca in questa playlist...",
"SEARCH_SECTION_PLACEHOLDER": "Cerca in questa sezione...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Tavolozza comandi",
"SUBTITLE": "Azioni globali e contestuali",
"SEARCH_PLACEHOLDER": "Digita per filtrare i comandi",
"SEARCH_PLACEHOLDER": "Cerca comandi e impostazioni",
"SEARCH_LABEL": "Filtra comandi",
"EMPTY": "Nessun comando trovato",
"GROUP_GLOBAL": "Azioni globali",
"GROUP_PLAYLIST": "Questa playlist",
"GROUP_VIEW": "Vista corrente",
"GROUP_SECTION": "Questa sezione",
"GROUP_RECENT": "Usati di recente"
"GROUP_RECENT": "Usati di recente",
"GROUP_SETTINGS": "Impostazioni"
},
"SHORTCUTS": {
"TITLE": "Scorciatoie da tastiera",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "接続エラーが繰り返された場合、新しい Xtream と Stalker のリクエストを30秒間停止します。ポータルが断続的に利用できなくなる場合は、無効にしてみてください。保存後、再起動せずに適用されます。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "各リクエストは個別に実行されます。サーバーが利用できない場合、エラーの表示に時間がかかることがあります。",
"TITLE": "設定",
"SEARCH_RESULTS_TITLE": "検索結果",
"SEARCH_RESULTS_COUNT": "「{{query}}」に一致する設定: {{count}}",
"SEARCH_EMPTY_HINT": "一致する設定がありません。「プレーヤー」「テーマ」「EPG」など別の言葉を試してください。",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "アプリケーションの設定を変更",
"PLAYBACK": "再生",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "コンテキスト操作",
"SEARCH_LABEL": "検索",
"SEARCH_SOURCES_PLACEHOLDER": "ソースを検索(すべてのプレイリスト)...",
"SEARCH_SETTINGS_PLACEHOLDER": "設定を検索...",
"SEARCH_GLOBAL_PLACEHOLDER": "すべてのプレイリストを検索...",
"SEARCH_PLAYLIST_PLACEHOLDER": "このプレイリスト内を検索...",
"SEARCH_SECTION_PLACEHOLDER": "このセクション内を検索...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "コマンドパレット",
"SUBTITLE": "グローバルおよびコンテキストアクション",
"SEARCH_PLACEHOLDER": "コマンドを絞り込むには入力",
"SEARCH_PLACEHOLDER": "コマンドと設定を検索",
"SEARCH_LABEL": "コマンドを絞り込む",
"EMPTY": "コマンドが見つかりません",
"GROUP_GLOBAL": "グローバルアクション",
"GROUP_PLAYLIST": "このプレイリスト",
"GROUP_VIEW": "現在のビュー",
"GROUP_SECTION": "現在のセクション",
"GROUP_RECENT": "最近使用"
"GROUP_RECENT": "最近使用",
"GROUP_SETTINGS": "設定"
},
"SHORTCUTS": {
"TITLE": "キーボードショートカット",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "연결 오류가 반복되면 새 Xtream 및 Stalker 요청을 30초 동안 중지합니다. 포털을 간헐적으로 사용할 수 없다면 이 옵션을 꺼 보세요. 저장 후 재시작 없이 적용됩니다.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "각 요청이 독립적으로 실행됩니다. 서버를 사용할 수 없으면 오류가 표시되기까지 더 오래 걸릴 수 있습니다.",
"TITLE": "설정",
"SEARCH_RESULTS_TITLE": "검색 결과",
"SEARCH_RESULTS_COUNT": "“{{query}}”와 일치하는 설정: {{count}}",
"SEARCH_EMPTY_HINT": "일치하는 설정이 없습니다. 플레이어, 테마, EPG 같은 다른 단어로 검색해 보세요.",
"GENERAL": "일반",
"GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다",
"PLAYBACK": "재생",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "컨텍스트 작업",
"SEARCH_LABEL": "검색",
"SEARCH_SOURCES_PLACEHOLDER": "소스 검색 (모든 재생목록)...",
"SEARCH_SETTINGS_PLACEHOLDER": "설정 검색...",
"SEARCH_GLOBAL_PLACEHOLDER": "모든 플레이리스트 검색...",
"SEARCH_PLAYLIST_PLACEHOLDER": "이 재생목록에서 검색...",
"SEARCH_SECTION_PLACEHOLDER": "이 섹션에서 검색...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "명령어 팔레트",
"SUBTITLE": "전역 및 상황별 작업",
"SEARCH_PLACEHOLDER": "명령어를 필터링하려면 입력하세요",
"SEARCH_PLACEHOLDER": "명령어와 설정 검색",
"SEARCH_LABEL": "명령어 필터",
"EMPTY": "명령어를 찾을 수 없습니다",
"GROUP_GLOBAL": "전역 작업",
"GROUP_PLAYLIST": "이 재생목록",
"GROUP_VIEW": "현재 화면",
"GROUP_SECTION": "현재 섹션",
"GROUP_RECENT": "최근 사용"
"GROUP_RECENT": "최근 사용",
"GROUP_SETTINGS": "설정"
},
"SHORTCUTS": {
"TITLE": "키보드 단축키",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Pauzeer nieuwe Xtream- en Stalker-verzoeken 30 seconden na herhaalde verbindingsfouten. Probeer dit uit te schakelen als een portal af en toe onbereikbaar is. Geldt na opslaan, zonder herstart.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Elk verzoek wordt onafhankelijk uitgevoerd. Bij onbereikbare servers kan een foutmelding langer duren.",
"TITLE": "Instellingen",
"SEARCH_RESULTS_TITLE": "Zoekresultaten",
"SEARCH_RESULTS_COUNT": "Instellingen voor “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Geen instellingen gevonden. Probeer een ander woord, zoals speler, thema of EPG.",
"GENERAL": "Algemeen",
"GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie",
"PLAYBACK": "Afspelen",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Sectieacties",
"SEARCH_LABEL": "Zoeken",
"SEARCH_SOURCES_PLACEHOLDER": "Zoek bronnen (alle afspeellijsten)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Zoek in instellingen...",
"SEARCH_GLOBAL_PLACEHOLDER": "Alle afspeellijsten doorzoeken...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Zoek in deze afspeellijst...",
"SEARCH_SECTION_PLACEHOLDER": "Zoeken in deze sectie...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Opdrachtenpalet",
"SUBTITLE": "Algemene en contextuele acties",
"SEARCH_PLACEHOLDER": "Typ om opdrachten te filteren",
"SEARCH_PLACEHOLDER": "Zoek opdrachten en instellingen",
"SEARCH_LABEL": "Filter opdrachten",
"EMPTY": "Geen opdrachten gevonden",
"GROUP_GLOBAL": "Algemene acties",
"GROUP_PLAYLIST": "Deze afspeellijst",
"GROUP_VIEW": "Huidige weergave",
"GROUP_SECTION": "Deze sectie",
"GROUP_RECENT": "Recent gebruikt"
"GROUP_RECENT": "Recent gebruikt",
"GROUP_SETTINGS": "Instellingen"
},
"SHORTCUTS": {
"TITLE": "Sneltoetsen",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Po powtarzających się błędach połączenia wstrzymuj nowe żądania Xtream i Stalker na 30 sekund. Spróbuj wyłączyć, jeśli portal bywa niedostępny. Zmiana działa po zapisaniu, bez ponownego uruchomienia.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Każde żądanie będzie wykonywane niezależnie. Niedostępne serwery mogą zgłaszać błąd z większym opóźnieniem.",
"TITLE": "Ustawienia",
"SEARCH_RESULTS_TITLE": "Wyniki wyszukiwania",
"SEARCH_RESULTS_COUNT": "Ustawienia pasujące do „{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Brak pasujących ustawień. Spróbuj innego słowa, np. odtwarzacz, motyw lub EPG.",
"GENERAL": "Ogólne",
"GENERAL_SUBTITLE": "Zmień konfigurację aplikacji",
"PLAYBACK": "Odtwarzanie",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Akcje sekcji",
"SEARCH_LABEL": "Szukaj",
"SEARCH_SOURCES_PLACEHOLDER": "Szukaj źródeł (wszystkie listy odtwarzania)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Szukaj w ustawieniach...",
"SEARCH_GLOBAL_PLACEHOLDER": "Przeszukaj wszystkie listy odtwarzania...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Szukaj na tej liście odtwarzania...",
"SEARCH_SECTION_PLACEHOLDER": "Szukaj w tej sekcji...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Paleta poleceń",
"SUBTITLE": "Akcje globalne i kontekstowe",
"SEARCH_PLACEHOLDER": "Wpisz, aby filtrować polecenia",
"SEARCH_PLACEHOLDER": "Szukaj poleceń i ustawień",
"SEARCH_LABEL": "Filtruj polecenia",
"EMPTY": "Nie znaleziono poleceń",
"GROUP_GLOBAL": "Akcje globalne",
"GROUP_PLAYLIST": "Ta lista odtwarzania",
"GROUP_VIEW": "Bieżący widok",
"GROUP_SECTION": "Ta sekcja",
"GROUP_RECENT": "Ostatnio używane"
"GROUP_RECENT": "Ostatnio używane",
"GROUP_SETTINGS": "Ustawienia"
},
"SHORTCUTS": {
"TITLE": "Skróty klawiaturowe",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Após falhas repetidas de ligação, pausar novos pedidos Xtream e Stalker durante 30 segundos. Experimente desativar se um portal ficar indisponível de forma intermitente. Aplica-se ao guardar, sem reiniciar.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada pedido será executado de forma independente. Os servidores indisponíveis podem demorar mais a indicar um erro.",
"TITLE": "Configurações",
"SEARCH_RESULTS_TITLE": "Resultados da pesquisa",
"SEARCH_RESULTS_COUNT": "Configurações correspondentes a “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Nenhuma configuração corresponde. Tente outra palavra, como reprodutor, tema ou EPG.",
"GENERAL": "Geral",
"GENERAL_SUBTITLE": "Altere a configuração do aplicativo",
"PLAYBACK": "Reprodução",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Ações de contexto",
"SEARCH_LABEL": "Pesquisar",
"SEARCH_SOURCES_PLACEHOLDER": "Pesquisar fontes (todas as playlists)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Pesquisar nas configurações...",
"SEARCH_GLOBAL_PLACEHOLDER": "Pesquisar todas as playlists...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Pesquisar nesta playlist...",
"SEARCH_SECTION_PLACEHOLDER": "Pesquisar nesta seção...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Paleta de comandos",
"SUBTITLE": "Ações globais e contextuais",
"SEARCH_PLACEHOLDER": "Digite para filtrar comandos",
"SEARCH_PLACEHOLDER": "Pesquisar comandos e configurações",
"SEARCH_LABEL": "Filtrar comandos",
"EMPTY": "Nenhum comando encontrado",
"GROUP_GLOBAL": "Ações globais",
"GROUP_PLAYLIST": "Esta playlist",
"GROUP_VIEW": "Visualização atual",
"GROUP_SECTION": "Esta seção",
"GROUP_RECENT": "Usados recentemente"
"GROUP_RECENT": "Usados recentemente",
"GROUP_SETTINGS": "Configurações"
},
"SHORTCUTS": {
"TITLE": "Atalhos de teclado",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "После повторных ошибок подключения приостанавливать новые запросы Xtream и Stalker на 30 секунд. Попробуйте отключить, если портал периодически становится недоступен. Применяется после сохранения без перезапуска.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Каждый запрос будет выполняться независимо. Если сервер недоступен, ожидание ошибки может увеличиться.",
"TITLE": "Настройки",
"SEARCH_RESULTS_TITLE": "Результаты поиска",
"SEARCH_RESULTS_COUNT": "Настройки по запросу «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Ничего не найдено. Попробуйте другое слово, например «плеер», «тема» или «EPG».",
"GENERAL": "Общие",
"GENERAL_SUBTITLE": "Изменение основных параметров приложения",
"PLAYBACK": "Воспроизведение",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Действия раздела",
"SEARCH_LABEL": "Поиск",
"SEARCH_SOURCES_PLACEHOLDER": "Искать источники (все плейлисты)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Искать в настройках...",
"SEARCH_GLOBAL_PLACEHOLDER": "Искать во всех плейлистах...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Искать в этом плейлисте...",
"SEARCH_SECTION_PLACEHOLDER": "Искать в этом разделе...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Палитра команд",
"SUBTITLE": "Глобальные и контекстные действия",
"SEARCH_PLACEHOLDER": "Введите текст для фильтрации команд",
"SEARCH_PLACEHOLDER": "Искать команды и настройки",
"SEARCH_LABEL": "Фильтровать команды",
"EMPTY": "Команды не найдены",
"GROUP_GLOBAL": "Глобальные действия",
"GROUP_PLAYLIST": "Этот плейлист",
"GROUP_VIEW": "Текущее представление",
"GROUP_SECTION": "Этот раздел",
"GROUP_RECENT": "Недавно использованные"
"GROUP_RECENT": "Недавно использованные",
"GROUP_SETTINGS": "Настройки"
},
"SHORTCUTS": {
"TITLE": "Сочетания клавиш",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra yeni Xtream ve Stalker isteklerini 30 saniye duraklatır. Portal zaman zaman kullanılamıyorsa kapatmayı deneyin. Kaydettikten sonra yeniden başlatmadan uygulanır.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Her istek bağımsız yürütülür. Kullanılamayan sunucularda hata bildirimi daha uzun sürebilir.",
"TITLE": "Ayarlar",
"SEARCH_RESULTS_TITLE": "Arama sonuçları",
"SEARCH_RESULTS_COUNT": "“{{query}}” ile eşleşen ayarlar: {{count}}",
"SEARCH_EMPTY_HINT": "Eşleşen ayar yok. Oynatıcı, tema veya EPG gibi başka bir kelime deneyin.",
"GENERAL": "Genel",
"GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin",
"PLAYBACK": "Oynatma",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "Bölüm eylemleri",
"SEARCH_LABEL": "Ara",
"SEARCH_SOURCES_PLACEHOLDER": "Kaynaklarda ara (tüm oynatma listeleri)...",
"SEARCH_SETTINGS_PLACEHOLDER": "Ayarlarda ara...",
"SEARCH_GLOBAL_PLACEHOLDER": "Tüm oynatma listelerinde ara...",
"SEARCH_PLAYLIST_PLACEHOLDER": "Bu oynatma listesinde ara...",
"SEARCH_SECTION_PLACEHOLDER": "Bu bölümde ara...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "Komut paleti",
"SUBTITLE": "Genel ve bağlamsal işlemler",
"SEARCH_PLACEHOLDER": "Komutları filtrelemek için yazın",
"SEARCH_PLACEHOLDER": "Komutlarda ve ayarlarda ara",
"SEARCH_LABEL": "Komutları filtrele",
"EMPTY": "Komut bulunamadı",
"GROUP_GLOBAL": "Genel işlemler",
"GROUP_PLAYLIST": "Bu oynatma listesi",
"GROUP_VIEW": "Geçerli görünüm",
"GROUP_SECTION": "Bu bölüm",
"GROUP_RECENT": "Son kullanılanlar"
"GROUP_RECENT": "Son kullanılanlar",
"GROUP_SETTINGS": "Ayarlar"
},
"SHORTCUTS": {
"TITLE": "Klavye kısayolları",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "连接连续失败后,暂停新的 Xtream 和 Stalker 请求 30 秒。如果门户间歇性不可用,请尝试关闭此选项。保存后生效,无需重启。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "每个请求将独立执行。服务器不可用时,可能需要更长时间才能显示错误。",
"TITLE": "设置",
"SEARCH_RESULTS_TITLE": "搜索结果",
"SEARCH_RESULTS_COUNT": "与“{{query}}”匹配的设置:{{count}}",
"SEARCH_EMPTY_HINT": "没有匹配的设置。试试其他词,例如“播放器”“主题”或“EPG”。",
"GENERAL": "通用",
"GENERAL_SUBTITLE": "更改应用程序的配置",
"PLAYBACK": "播放",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "上下文操作",
"SEARCH_LABEL": "搜索",
"SEARCH_SOURCES_PLACEHOLDER": "搜索源(所有播放列表)…",
"SEARCH_SETTINGS_PLACEHOLDER": "搜索设置…",
"SEARCH_GLOBAL_PLACEHOLDER": "搜索所有播放列表…",
"SEARCH_PLAYLIST_PLACEHOLDER": "在此播放列表中搜索…",
"SEARCH_SECTION_PLACEHOLDER": "在此区域中搜索…",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "命令面板",
"SUBTITLE": "全局和上下文操作",
"SEARCH_PLACEHOLDER": "输入以筛选命令",
"SEARCH_PLACEHOLDER": "搜索命令和设置",
"SEARCH_LABEL": "筛选命令",
"EMPTY": "未找到命令",
"GROUP_GLOBAL": "全局操作",
"GROUP_PLAYLIST": "本播放列表",
"GROUP_VIEW": "当前视图",
"GROUP_SECTION": "当前区域",
"GROUP_RECENT": "最近使用"
"GROUP_RECENT": "最近使用",
"GROUP_SETTINGS": "设置"
},
"SHORTCUTS": {
"TITLE": "键盘快捷键",
+7 -2
View File
@@ -264,6 +264,9 @@
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "連線連續失敗後,暫停新的 Xtream 和 Stalker 請求 30 秒。如果入口網站間歇性無法使用,請嘗試關閉此選項。儲存後生效,無需重新啟動。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "每個請求將獨立執行。伺服器無法使用時,可能需要更長時間才能顯示錯誤。",
"TITLE": "設定",
"SEARCH_RESULTS_TITLE": "搜尋結果",
"SEARCH_RESULTS_COUNT": "符合「{{query}}」的設定:{{count}}",
"SEARCH_EMPTY_HINT": "沒有符合的設定。試試其他詞,例如「播放器」「主題」或「EPG」。",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "更改應用程式的配置",
"PLAYBACK": "播放",
@@ -1569,6 +1572,7 @@
"RAIL_CONTEXT_ACTIONS": "區段操作",
"SEARCH_LABEL": "搜尋",
"SEARCH_SOURCES_PLACEHOLDER": "搜尋來源(所有播放清單)...",
"SEARCH_SETTINGS_PLACEHOLDER": "搜尋設定...",
"SEARCH_GLOBAL_PLACEHOLDER": "搜尋所有播放清單…",
"SEARCH_PLAYLIST_PLACEHOLDER": "在此播放清單中搜尋...",
"SEARCH_SECTION_PLACEHOLDER": "在此區段中搜尋...",
@@ -1722,14 +1726,15 @@
"COMMAND_PALETTE": {
"TITLE": "命令選擇區",
"SUBTITLE": "全域與情境化操作",
"SEARCH_PLACEHOLDER": "輸入以篩選命令",
"SEARCH_PLACEHOLDER": "搜尋命令和設定",
"SEARCH_LABEL": "篩選命令",
"EMPTY": "找不到命令",
"GROUP_GLOBAL": "全域操作",
"GROUP_PLAYLIST": "此播放清單",
"GROUP_VIEW": "目前畫面",
"GROUP_SECTION": "此區段",
"GROUP_RECENT": "最近使用"
"GROUP_RECENT": "最近使用",
"GROUP_SETTINGS": "設定"
},
"SHORTCUTS": {
"TITLE": "鍵盤快捷鍵",
@@ -232,6 +232,9 @@ sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and
`@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the
preload bundle must not carry, so the preload only type-imports the barrel
and value-imports those two dependency-free modules directly.
`@iptvnator/workspace/shell/util/settings-search` exists for the opposite
reason: the main `workspace-shell-util` barrel is imported eagerly, and the
settings search index must stay in the lazy settings and shell chunks.
For a buildable library that has a local `package.json`, its `name` must match
the scoped alias. Nx uses that package name when rewriting buildable dependency
+51 -3
View File
@@ -169,7 +169,8 @@ restarting playback; remote commands retain captured playback order. See the
Search is shell-owned and route-aware:
1. Disabled on settings routes.
1. On settings routes, searches the settings themselves (see
[Settings search](#settings-search)).
2. Enabled on sources routes.
3. Enabled for `/workspace/search`, which is the Electron-only routed
global-search view. `Ctrl/Cmd+F` in Electron opens this route and
@@ -214,8 +215,10 @@ Rail navigation is also shell-owned:
Command palette behavior is shell-owned but view-extensible:
1. The shell resolves commands into three groups in fixed order: current view,
this playlist, then global.
1. The shell resolves commands into groups in fixed order: current view,
this playlist, global, then settings. The settings group appears only for a
non-empty query and holds at most six settings matches (see
[Settings search](#settings-search)).
2. Shell-owned commands are derived from route context and current playlist
state; empty groups are omitted instead of rendering disabled placeholders.
3. Workspace features contribute current-view commands through
@@ -245,6 +248,51 @@ Command palette behavior is shell-owned but view-extensible:
is disabled. The new player setting applies to the next playback session; an
existing stream is not re-mounted.
### Settings search
Settings rows are searchable from the header search on `/workspace/settings`
and from the command palette. Both use the same index and ranking.
1. The index is `SETTINGS_SEARCH_ENTRIES` in
`libs/workspace/shell/util/src/lib/settings-search/`, published through the
`@iptvnator/workspace/shell/util/settings-search` sub-entrypoint. Eager
code imports the main shell util barrel, so the index stays out of it and
ships only in lazy chunks (the initial-bytes ratchet enforces this).
2. Each entry names its section, title and description translation keys,
untranslated synonyms (`keywords`), runtime `requires`, and an optional
`fallbackId`. Section definitions (`SETTINGS_SECTION_DEFINITIONS`) are the
single source for the settings navigation too.
3. Every titled `.setting-item` in the section templates carries
`data-setting-id`. `settings-search-registry.spec.ts` fails when a row, id,
title key or description key drifts from the index, so a new settings row
must be added to the index in the same change.
4. `SettingsSearchService.search()` matches the translated title and
description of the current language plus the keywords; every query token
must match (AND), and a label prefix outranks a word start, which outranks
an inner match. Rows whose `requires` the runtime lacks are never returned.
Embedded MPV rows depend on a lazy support probe
(`ensureEmbeddedMpvSupportLoaded()`), run when the settings page or the
command palette opens, never from shell bootstrap; frame copy also needs
`frameCopyAvailable`, matching the settings page gate.
5. Settings routes use `local-filter` search mode, so the term lives in `q`.
While `q` is set, the settings page shows ranked results in place of the
section page and the settings context panel shows per-section match
counts, muting sections without matches. The search box is shown on
settings even when no playlist exists.
6. Choosing a result, pressing `Enter` in the header search (best match), or
picking a settings command in the palette calls `reveal()`: it navigates
to the section page without `q` (which clears the box) and the page
scrolls to, focuses, and briefly highlights the row once the form is
hydrated. A row hidden by the current form state falls back to its
`fallbackId`, the control that makes it appear. A reveal must win over the
typed term: `WorkspaceShellSearchSyncService` drops a keystroke still
waiting for its debounce through `onReveal()`, and Enter does not apply
the term first, because either `q` sync navigation would supersede the
reveal navigation. Keyboard users keep a `:focus-visible` ring on the row
after the highlight fades.
7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening
global search.
Keyboard shortcut help is shell-owned:
1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`.
@@ -1,4 +1,9 @@
export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global';
/**
* `settings` holds one command per searchable settings row. The palette
* lists them only for a non-empty query, ranked and capped, so they never
* flood the default command list.
*/
export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global' | 'settings';
export interface WorkspaceCommandActionContext {
query: string;
@@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => {
).toBe(false);
});
});
describe('WorkspaceCommandPaletteComponent - settings group', () => {
function settingsCommand(
id: string,
label: string
): WorkspaceResolvedCommandItem {
return {
id: `settings:${id}`,
label,
description: 'Playback',
group: 'settings',
icon: 'play_circle',
keywords: [],
priority: 100,
visible: true,
enabled: true,
run: () => undefined,
};
}
const commands: WorkspaceResolvedCommandItem[] = [
{
id: 'open-settings',
label: 'Open settings',
description: '',
group: 'global',
icon: 'settings',
keywords: ['settings'],
priority: 50,
visible: true,
enabled: true,
run: () => undefined,
},
settingsCommand('video-player', 'Video player'),
settingsCommand('theme', 'Theme'),
];
function setup(options: {
query: string;
recentIds?: readonly string[];
searchSettings?: jest.Mock;
}) {
const searchSettings =
options.searchSettings ??
jest.fn((query: string) =>
query.includes('player') ? ['settings:video-player'] : []
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
{
provide: MAT_DIALOG_DATA,
useValue: {
query: options.query,
commands,
recentIds: options.recentIds ?? [],
searchSettings,
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const fixture = TestBed.createComponent(
WorkspaceCommandPaletteComponent
);
fixture.detectChanges();
return {
component: fixture.componentInstance,
fixture,
searchSettings,
};
}
it('keeps settings out of the list while the query is empty', () => {
const { component, searchSettings } = setup({ query: '' });
expect(component.flatCommands().map((command) => command.id)).toEqual([
'open-settings',
]);
expect(searchSettings).not.toHaveBeenCalled();
});
it('lists settings matches after the command groups, in search order', () => {
const searchSettings = jest.fn(() => [
'settings:theme',
'settings:video-player',
]);
const { component, fixture } = setup({
query: 'settings',
searchSettings,
});
const groups = component.commandGroups();
expect(groups.map((group) => group.group)).toEqual([
'global',
'settings',
]);
expect(groups[1].items.map((item) => item.id)).toEqual([
'settings:theme',
'settings:video-player',
]);
expect(searchSettings).toHaveBeenLastCalledWith('settings');
expect(fixture.nativeElement.textContent).toContain(
'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'
);
});
it('ranks settings only through the search callback, never by substring', () => {
const { component } = setup({ query: 'theme' });
// "Theme" contains the query, but the search callback decides.
expect(component.flatCommands()).toEqual([]);
component.query.set('player');
expect(component.flatCommands().map((command) => command.id)).toEqual([
'settings:video-player',
]);
});
it('shows a recently opened setting in the recent section', () => {
const { component } = setup({
query: '',
recentIds: ['settings:theme'],
});
const [recent] = component.commandGroups();
expect(recent.group).toBe('recent');
expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']);
});
});
@@ -23,10 +23,15 @@ import {
} from '@iptvnator/portal/shared/util';
import { foldSearchText } from '@iptvnator/shared/interfaces';
interface WorkspaceCommandPaletteData {
export interface WorkspaceCommandPaletteData {
commands: WorkspaceResolvedCommandItem[];
query?: string;
recentIds?: readonly string[];
/**
* Ranked ids of the `settings` group commands matching a query. Settings
* commands are listed only through this, never for an empty query.
*/
searchSettings?: (query: string) => readonly string[];
}
type PaletteSectionGroup = WorkspaceCommandGroup | 'recent';
@@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
readonly filteredCommands = computed(() => {
const term = foldSearchText(this.query().trim());
const commands = this.visibleCommands();
const commands = this.visibleCommands().filter(
(command) => command.group !== 'settings'
);
if (!term) {
return commands;
@@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
}
);
readonly settingsSection = computed<WorkspaceCommandGroupSection | null>(
() => {
const query = this.query().trim();
const searchSettings = this.data?.searchSettings;
if (!query || !searchSettings) {
return null;
}
const byId = new Map(
this.visibleCommands().map((command) => [command.id, command])
);
const items = searchSettings(query)
.map((id) => byId.get(id))
.filter(
(command): command is WorkspaceResolvedCommandItem =>
command?.group === 'settings'
);
return items.length === 0 ? null : { group: 'settings', items };
}
);
readonly commandGroups = computed<WorkspaceCommandGroupSection[]>(() => {
const commands = this.filteredCommands();
const recent = this.recentSection();
@@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
);
sections.push(...groups);
const settings = this.settingsSection();
if (settings) {
sections.push(settings);
}
return sections;
});
@@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit {
if (group === 'playlist') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST';
}
if (group === 'settings') {
return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS';
}
return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL';
}
@@ -45,6 +45,11 @@
border-top: 1px solid var(--app-separator);
}
// Sections without settings-search matches stay reachable but recede.
.settings-section-item.has-no-matches:not(.active) {
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
describe('WorkspaceSettingsContextPanelComponent', () => {
function setup() {
TestBed.configureTestingModule({
imports: [WorkspaceSettingsContextPanelComponent],
providers: [
provideRouter([]),
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
});
const ctx = TestBed.inject(SettingsContextService);
ctx.setSections([
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
},
]);
const fixture = TestBed.createComponent(
WorkspaceSettingsContextPanelComponent
);
fixture.detectChanges();
const link = (id: string) =>
(fixture.nativeElement as HTMLElement).querySelector(
`[data-test-id="settings-section-${id}"]`
) as HTMLElement;
return { ctx, fixture, link };
}
it('shows no counts while settings search is idle', () => {
const { link } = setup();
expect(link('general').querySelector('.nav-item-meta')).toBeNull();
expect(link('general').classList).not.toContain('has-no-matches');
});
it('shows per-section match counts and mutes sections without matches', () => {
const { ctx, fixture, link } = setup();
ctx.setMatchCounts({ general: 2 });
fixture.detectChanges();
expect(
link('general').querySelector('.nav-item-meta')?.textContent
).toBe('2');
expect(
link('playback').querySelector('.nav-item-meta')?.textContent
).toBe('0');
expect(link('general').classList).not.toContain('has-no-matches');
expect(link('playback').classList).toContain('has-no-matches');
ctx.reset();
fixture.detectChanges();
expect(ctx.matchCounts()).toBeNull();
});
});
@@ -28,10 +28,27 @@ import {
[routerLink]="['/workspace/settings', section.id]"
[replaceUrl]="true"
[attr.data-test-id]="'settings-section-' + section.id"
[class.has-no-matches]="
ctx.matchCounts() !== null &&
!ctx.matchCounts()?.[section.id]
"
(click)="onSectionClicked()"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
<span class="nav-item-label">{{
section.label | translate
}}</span>
<!-- While a settings search is active, each section
shows how many of its settings match. -->
@if (ctx.matchCounts(); as counts) {
<span
class="nav-item-meta"
[attr.data-test-id]="
'settings-section-matches-' + section.id
"
>{{ counts[section.id] ?? 0 }}</span
>
}
</a>
}
</div>
@@ -42,7 +42,9 @@
/>
}
@if (!hasNoPlaylists()) {
<!-- Settings search works without any playlist, so a fresh user can
find settings before importing a first source. -->
@if (!hasNoPlaylists() || isSettingsRoute()) {
<label class="search-field">
<mat-icon>search</mat-icon>
@if (searchScopeLabel()) {
@@ -229,6 +229,21 @@ describe('WorkspaceShellHeaderComponent', () => {
).toBeNull();
});
it('hides search without playlists except on the settings route', () => {
const searchInput = () =>
fixture.nativeElement.querySelector('input[type="search"]');
fixture.componentRef.setInput('hasNoPlaylists', true);
fixture.detectChanges();
expect(searchInput()).toBeNull();
// Settings search needs no playlist: a fresh user can still search.
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(searchInput()).not.toBeNull();
expect(searchInput().disabled).toBe(false);
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -0,0 +1,96 @@
import {
SettingsSearchEntry,
SettingsSearchResult,
SettingsSearchService,
SETTINGS_SECTION_DEFINITIONS,
} from '@iptvnator/workspace/shell/util/settings-search';
import {
buildSettingsPaletteCommands,
SETTINGS_PALETTE_RESULT_LIMIT,
} from './workspace-settings-commands';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
keywords: ['dark'],
};
const PLAYER: SettingsSearchEntry = {
id: 'video-player',
section: 'playback',
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
};
function createSettingsSearch() {
return {
visibleEntries: jest.fn(() => [THEME, PLAYER]),
search: jest.fn((): SettingsSearchResult[] => [
{
entry: PLAYER,
section: SETTINGS_SECTION_DEFINITIONS[1],
label: 'Video player',
description: '',
sectionLabel: 'Playback',
score: 10,
},
]),
reveal: jest.fn(),
};
}
describe('buildSettingsPaletteCommands', () => {
const translate = (key: string) => `t(${key})`;
it('resolves one settings-group command per visible row', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(commands).toEqual([
expect.objectContaining({
id: 'settings:theme',
group: 'settings',
icon: 'tune',
label: 't(SETTINGS.THEME)',
description: 't(SETTINGS.NAV_GENERAL)',
keywords: ['dark'],
visible: true,
enabled: true,
}),
expect.objectContaining({
id: 'settings:video-player',
icon: 'play_circle',
description: 't(SETTINGS.NAV_PLAYBACK)',
}),
]);
});
it('reveals the row when a command runs', () => {
const settingsSearch = createSettingsSearch();
const { commands } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
commands[1].run({ query: 'player' });
expect(settingsSearch.reveal).toHaveBeenCalledWith(PLAYER);
});
it('delegates ranking to the settings search with the palette cap', () => {
const settingsSearch = createSettingsSearch();
const { search } = buildSettingsPaletteCommands(
settingsSearch as unknown as SettingsSearchService,
translate
);
expect(search('player')).toEqual(['settings:video-player']);
expect(settingsSearch.search).toHaveBeenCalledWith(
'player',
SETTINGS_PALETTE_RESULT_LIMIT
);
});
});
@@ -0,0 +1,58 @@
import { WorkspaceResolvedCommandItem } from '@iptvnator/portal/shared/util';
import {
SETTINGS_SECTION_DEFINITIONS,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { TranslateFn } from './workspace-shell-search-labels';
/** Palette rows reserved for settings matches, so commands stay visible. */
export const SETTINGS_PALETTE_RESULT_LIMIT = 6;
const SETTINGS_COMMAND_PREFIX = 'settings:';
export interface SettingsPaletteCommands {
/** One command per visible settings row, for recents and selection. */
readonly commands: WorkspaceResolvedCommandItem[];
/** Ids of the best settings commands for `query`, best first. */
readonly search: (query: string) => readonly string[];
}
/**
* Settings rows as command palette entries. Ranking is delegated to
* `SettingsSearchService` so the palette and the settings page search agree
* on which settings match and in which order.
*/
export function buildSettingsPaletteCommands(
settingsSearch: SettingsSearchService,
translate: TranslateFn
): SettingsPaletteCommands {
const sections = new Map(
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
);
const commands = settingsSearch
.visibleEntries()
.map((entry): WorkspaceResolvedCommandItem => {
const section = sections.get(entry.section);
return {
id: `${SETTINGS_COMMAND_PREFIX}${entry.id}`,
group: 'settings',
icon: section?.icon ?? 'settings',
label: translate(entry.labelKey),
description: section ? translate(section.navLabelKey) : '',
keywords: entry.keywords ?? [],
priority: 100,
visible: true,
enabled: true,
run: () => settingsSearch.reveal(entry),
};
});
return {
commands,
search: (query) =>
settingsSearch
.search(query, SETTINGS_PALETTE_RESULT_LIMIT)
.map(({ entry }) => `${SETTINGS_COMMAND_PREFIX}${entry.id}`),
};
}
@@ -468,6 +468,8 @@ export function getCommandGroupOrder(
return 0;
case 'playlist':
return 1;
case 'settings':
return 3;
default:
return 2;
}
@@ -10,6 +10,8 @@ export const FILTER_SECTION_PLACEHOLDER =
'WORKSPACE.SHELL.FILTER_SECTION_PLACEHOLDER';
export const SEARCH_SOURCES_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SOURCES_PLACEHOLDER';
export const SEARCH_SETTINGS_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_SETTINGS_PLACEHOLDER';
export const SEARCH_LOADED_ONLY_STATUS =
'WORKSPACE.SHELL.SEARCH_STATUS_LOADED_ONLY';
@@ -6,9 +6,14 @@ import {
WorkspaceResolvedCommandItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceCommandPaletteComponent } from '../../workspace-command-palette/workspace-command-palette.component';
import {
WorkspaceCommandPaletteComponent,
WorkspaceCommandPaletteData,
} from '../../workspace-command-palette/workspace-command-palette.component';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { buildSettingsPaletteCommands } from './helpers/workspace-settings-commands';
import {
buildCommandPaletteItems,
CommandBuilderContext,
@@ -23,6 +28,7 @@ export class WorkspaceShellCommandPaletteService {
private readonly playerCommands = inject(
WorkspacePlayerCommandsContributor
);
private readonly settingsSearch = inject(SettingsSearchService);
private commandPaletteRef: MatDialogRef<
WorkspaceCommandPaletteComponent,
@@ -46,11 +52,18 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const embeddedMpvSupportLoad =
this.playerCommands.ensureEmbeddedMpvSupportLoaded();
if (embeddedMpvSupportLoad) {
const embeddedMpvSupportLoads = [
this.playerCommands.ensureEmbeddedMpvSupportLoaded(),
this.settingsSearch.ensureEmbeddedMpvSupportLoaded(),
].filter((load): load is Promise<void> => load !== undefined);
if (embeddedMpvSupportLoads.length > 0) {
this.commandPaletteOpening = true;
void embeddedMpvSupportLoad.finally(() => {
// A failed probe only hides its commands; it must not block opening.
void Promise.all(
embeddedMpvSupportLoads.map((load) =>
load.catch(() => undefined)
)
).finally(() => {
this.commandPaletteOpening = false;
this.openResolvedCommandPalette(ctx, initialQuery);
});
@@ -68,17 +81,20 @@ export class WorkspaceShellCommandPaletteService {
return;
}
const commands = this.buildPaletteCommands(ctx);
const settings = buildSettingsPaletteCommands(
this.settingsSearch,
ctx.translate
);
const commands = [
...this.buildPaletteCommands(ctx),
...settings.commands,
];
const recentIds = this.recentCommands
.entries()
.map((entry) => entry.id);
const dialogRef = this.dialog.open<
WorkspaceCommandPaletteComponent,
{
commands: WorkspaceResolvedCommandItem[];
query: string;
recentIds: readonly string[];
},
WorkspaceCommandPaletteData,
WorkspaceCommandSelection | undefined
>(WorkspaceCommandPaletteComponent, {
width: 'min(760px, 92vw)',
@@ -89,6 +105,7 @@ export class WorkspaceShellCommandPaletteService {
commands,
query: initialQuery,
recentIds,
searchSettings: settings.search,
},
});
this.commandPaletteRef = dialogRef;
@@ -9,6 +9,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
@@ -133,6 +134,21 @@ describe('WorkspaceShellSearchSyncService', () => {
jest.useRealTimers();
});
it('drops a still-debouncing keystroke when a settings result is opened', () => {
// Typing one more letter and clicking a visible settings result
// within the debounce window: applying that keystroke afterwards
// would start a `q` navigation that supersedes the reveal.
service.onSearchInput('them');
TestBed.inject(SettingsSearchService).reveal({
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
});
jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS);
expect(service.appliedSearchQuery()).toBe('');
});
it('keeps in-flight typing when the page writes an unrelated query param', () => {
// The downloads filter chips write `?filter=…` with replaceUrl. Under
// load that navigation can land after the first keystroke — it must
@@ -5,6 +5,7 @@ import { filter } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_INPUT_DEBOUNCE_MS } from './helpers/workspace-shell-constants';
import {
getRoutePath,
@@ -30,6 +31,13 @@ export class WorkspaceShellSearchSyncService {
constructor() {
this.destroyRef.onDestroy(() => this.cancelPendingSearchApply());
// Opening a settings search result navigates away from the typed
// term; a keystroke still debouncing must not apply afterwards.
this.destroyRef.onDestroy(
inject(SettingsSearchService).onReveal(() =>
this.cancelPendingSearchApply()
)
);
this.router.events
.pipe(
@@ -7,9 +7,11 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import { SettingsSearchService } from '@iptvnator/workspace/shell/util/settings-search';
import {
SEARCH_LOADED_ONLY_STATUS,
SEARCH_PLAYLIST_PLACEHOLDER,
SEARCH_SETTINGS_PLACEHOLDER,
} from './helpers/workspace-shell-constants';
import {
resolveSearchPlaceholderKey,
@@ -28,6 +30,7 @@ export class WorkspaceShellSearchService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
private readonly settingsSearch = inject(SettingsSearchService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -44,17 +47,19 @@ export class WorkspaceShellSearchService {
const section = route.section;
const appliedQuery = this.appliedSearchQuery().trim();
// Settings search filters an in-memory index of setting rows (see
// SettingsSearchService); the settings page reads the term from `q`.
if (route.kind === 'settings') {
return {
enabled: false,
behavior: 'disabled',
enabled: true,
behavior: 'local-filter',
context: null,
section: null,
searchMode: 'none',
placeholderKey: SEARCH_PLAYLIST_PLACEHOLDER,
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
statusLabel: '',
minLength: 0,
minLength: 1,
advancedRouteTarget: null,
};
}
@@ -181,6 +186,21 @@ export class WorkspaceShellSearchService {
const trimmedValue = value.trim();
this.searchQuery.set(trimmedValue);
// Enter on settings jumps to the best match, like picking the first
// result. The reveal navigation drops `q`, which clears the box. The
// term is deliberately not applied: applying would make the `q` sync
// start its own navigation, superseding the reveal navigation (the
// reveal also cancels a keystroke still waiting for its debounce).
if (this.routeState.currentRoute().kind === 'settings') {
const [bestMatch] = this.settingsSearch.search(trimmedValue, 1);
if (bestMatch) {
this.settingsSearch.reveal(bestMatch.entry);
} else {
this.searchSync.applySearchQuery(trimmedValue);
}
return;
}
if (this.searchCapability().behavior === 'advanced-only') {
const advancedRouteTarget =
this.searchCapability().advancedRouteTarget;
@@ -0,0 +1,133 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { parseWorkspaceShellRoute } from '@iptvnator/workspace/shell/util';
import {
SettingsSearchEntry,
SettingsSearchService,
} from '@iptvnator/workspace/shell/util/settings-search';
import { SEARCH_SETTINGS_PLACEHOLDER } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './workspace-shell-search.service';
const THEME: SettingsSearchEntry = {
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
};
describe('WorkspaceShellSearchService on settings routes', () => {
let service: WorkspaceShellSearchService;
let currentRoute: ReturnType<
typeof signal<ReturnType<typeof parseWorkspaceShellRoute>>
>;
let searchSync: {
searchQuery: ReturnType<typeof signal<string>>;
appliedSearchQuery: ReturnType<typeof signal<string>>;
applySearchQuery: jest.Mock;
};
let settingsSearch: { search: jest.Mock; reveal: jest.Mock };
beforeEach(() => {
currentRoute = signal(
parseWorkspaceShellRoute('/workspace/settings/general')
);
searchSync = {
searchQuery: signal(''),
appliedSearchQuery: signal(''),
applySearchQuery: jest.fn(),
};
settingsSearch = {
search: jest.fn((query: string) =>
query === 'theme' ? [{ entry: THEME, score: 1 }] : []
),
reveal: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
WorkspaceShellSearchService,
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: XtreamStore,
useValue: { getSelectedCategory: () => null },
},
{
provide: StalkerStore,
useValue: { getSelectedCategoryName: () => '' },
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
onLangChange: of(null),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute,
playlists: signal([]),
dashboardXtreamContext: signal(null),
currentContext: signal(null),
},
},
{
provide: WorkspaceShellSearchSyncService,
useValue: searchSync,
},
{ provide: SettingsSearchService, useValue: settingsSearch },
],
});
service = TestBed.inject(WorkspaceShellSearchService);
});
it('enables a local settings filter with the settings placeholder', () => {
expect(service.searchCapability()).toEqual(
expect.objectContaining({
enabled: true,
behavior: 'local-filter',
searchMode: 'local-filter',
placeholderKey: SEARCH_SETTINGS_PLACEHOLDER,
scopeLabel: '',
})
);
expect(service.canUseSearch()).toBe(true);
});
it('opens the best match on Enter without applying the term', () => {
service.onSearchEnter(' theme ');
expect(settingsSearch.search).toHaveBeenCalledWith('theme', 1);
expect(settingsSearch.reveal).toHaveBeenCalledWith(THEME);
// Applying would start the `q` sync navigation and supersede the
// reveal navigation.
expect(searchSync.applySearchQuery).not.toHaveBeenCalled();
});
it('applies the term on Enter when nothing matches', () => {
service.onSearchEnter('zzz ');
expect(settingsSearch.reveal).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('zzz');
});
it('leaves Enter on other routes to their own search', () => {
currentRoute.set(parseWorkspaceShellRoute('/workspace/sources'));
service.onSearchEnter('theme');
expect(settingsSearch.search).not.toHaveBeenCalled();
expect(searchSync.applySearchQuery).toHaveBeenCalledWith('theme');
});
});
@@ -1152,7 +1152,7 @@ describe('WorkspaceShellFacade', () => {
expect(dialog.open).not.toHaveBeenCalled();
resolveSupport();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve));
expect(dialog.open).toHaveBeenCalledTimes(1);
});
@@ -546,6 +546,25 @@ describe('WorkspaceShellComponent', () => {
expect(event.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
// On settings the header search is the settings search, so find
// focuses it in place instead of leaving for global search.
facade.openGlobalSearch.mockClear();
header.focusSearchInput.mockClear();
facade.isSettingsRoute.set(true);
const settingsEvent = new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(settingsEvent);
jest.runOnlyPendingTimers();
expect(settingsEvent.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).not.toHaveBeenCalled();
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
jest.useRealTimers();
});
@@ -163,7 +163,11 @@ export class WorkspaceShellComponent {
}
event.preventDefault();
this.facade.openGlobalSearch(this.facade.searchQuery());
// On settings the header search already searches the settings, so
// find stays on the page instead of leaving for global search.
if (!this.facade.isSettingsRoute()) {
this.facade.openGlobalSearch(this.facade.searchQuery());
}
setTimeout(() => header?.focusSearchInput({ select: true }));
}
}
@@ -28,6 +28,9 @@ describe('workspace-shell-route.utils', () => {
kind: 'settings',
context: null,
contextPanel: 'settings',
// Settings search filters rows and keeps its term in `q`.
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/global-favorites')).toEqual(
@@ -182,6 +182,7 @@ function resolveRouteSearchMode(
if (
kind === 'sources' ||
kind === 'settings' ||
kind === 'downloads' ||
kind === 'global-favorites' ||
kind === 'global-recent'
@@ -16,12 +16,25 @@ export interface SettingsNavItem {
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
/**
* Settings search matches per section id while a search term is active,
* `null` otherwise. The panel shows the counts and mutes sections
* without matches.
*/
readonly matchCounts = signal<Readonly<Record<string, number>> | null>(
null
);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setMatchCounts(counts: Readonly<Record<string, number>> | null): void {
this.matchCounts.set(counts);
}
reset(): void {
this.sections.set([]);
this.matchCounts.set(null);
}
}
@@ -0,0 +1,5 @@
export * from './settings-search.types';
export * from './settings-search-sections';
export * from './settings-search-entries';
export * from './settings-search-rank';
export * from './settings-search.service';
@@ -0,0 +1,178 @@
import { SettingsSearchEntry } from './settings-search.types';
const MPV = ['mpv', 'external player'];
const VLC = ['vlc', 'external player'];
/** Playback section rows, in page order. */
export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
[
{
id: 'video-player',
section: 'playback',
labelKey: 'SETTINGS.VIDEO_PLAYER_LABEL',
descriptionKey: 'SETTINGS.VIDEO_PLAYER_DESCRIPTION',
keywords: [
'player',
'engine',
'mpv',
'vlc',
'videojs',
'html5',
'artplayer',
],
},
{
id: 'stream-format',
section: 'playback',
labelKey: 'SETTINGS.STREAM_FORMAT',
descriptionKey: 'SETTINGS.STREAM_FORMAT_DESCRIPTION',
keywords: ['hls', 'm3u8', 'mpeg-ts', 'ts', 'format'],
},
{
id: 'web-player-shared-controls',
section: 'playback',
labelKey: 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS',
descriptionKey: 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION',
keywords: ['controls', 'overlay', 'skin'],
fallbackId: 'video-player',
},
{
id: 'player-ambient-mode',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_AMBIENT_MODE',
descriptionKey: 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION',
keywords: ['ambient', 'glow', 'backlight'],
fallbackId: 'video-player',
},
{
id: 'player-up-next-rail',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_UP_NEXT_RAIL',
descriptionKey: 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION',
keywords: ['up next', 'next episode', 'autoplay'],
fallbackId: 'video-player',
},
{
id: 'fullscreen-channel-panel',
section: 'playback',
labelKey: 'SETTINGS.FULLSCREEN_CHANNEL_PANEL',
descriptionKey: 'SETTINGS.FULLSCREEN_CHANNEL_PANEL_DESCRIPTION',
keywords: ['fullscreen', 'channel list', 'zapping'],
fallbackId: 'video-player',
},
{
id: 'vod-auto-failover',
section: 'playback',
labelKey: 'SETTINGS.VOD_AUTO_FAILOVER',
descriptionKey: 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION',
keywords: ['failover', 'fallback', 'mirror', 'retry', 'movie'],
requires: ['vod-multi-source'],
fallbackId: 'video-player',
},
{
id: 'external-player-double-click',
section: 'playback',
labelKey: 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK',
descriptionKey:
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION',
keywords: ['double click', ...MPV, ...VLC],
requires: ['managed-external-players'],
fallbackId: 'video-player',
},
{
id: 'show-external-playback-bar',
section: 'playback',
labelKey: 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR',
descriptionKey: 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION',
keywords: ['playback bar', 'footer', ...MPV, ...VLC],
requires: ['desktop'],
},
{
id: 'embedded-mpv-frame-copy',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION',
keywords: ['mpv', 'gpu', 'rendering', 'compatibility'],
requires: ['embedded-mpv-frame-copy'],
},
{
id: 'embedded-mpv-extra-options',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION',
keywords: ['mpv', 'options', 'config', 'hwdec', 'arguments'],
requires: ['embedded-mpv'],
fallbackId: 'video-player',
},
{
id: 'embedded-mpv-auto-reconnect',
section: 'playback',
labelKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT',
descriptionKey: 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION',
keywords: ['mpv', 'reconnect', 'retry', 'buffering'],
requires: ['embedded-mpv'],
fallbackId: 'video-player',
},
{
id: 'recording-folder',
section: 'playback',
labelKey: 'SETTINGS.RECORDING_FOLDER_LABEL',
descriptionKey: 'SETTINGS.RECORDING_FOLDER_DESCRIPTION',
keywords: ['recording', 'record', 'dvr', 'folder', 'directory'],
requires: ['desktop'],
},
{
id: 'mpv-player-path',
section: 'playback',
labelKey: 'SETTINGS.MPV_PLAYER_PATH_LABEL',
descriptionKey: 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION',
keywords: [...MPV, 'path', 'executable', 'binary'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'mpv-player-arguments',
section: 'playback',
labelKey: 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL',
descriptionKey: 'SETTINGS.MPV_PLAYER_ARGUMENTS_DESCRIPTION',
keywords: [...MPV, 'arguments', 'flags', 'command line'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'mpv-reuse-instance',
section: 'playback',
labelKey: 'SETTINGS.MPV_REUSE_INSTANCE_LABEL',
descriptionKey: 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION',
keywords: [...MPV, 'single window', 'instance'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-player-path',
section: 'playback',
labelKey: 'SETTINGS.VLC_PLAYER_PATH_LABEL',
descriptionKey: 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION',
keywords: [...VLC, 'path', 'executable', 'binary'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-player-arguments',
section: 'playback',
labelKey: 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL',
descriptionKey: 'SETTINGS.VLC_PLAYER_ARGUMENTS_DESCRIPTION',
keywords: [...VLC, 'arguments', 'flags', 'command line'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'vlc-reuse-instance',
section: 'playback',
labelKey: 'SETTINGS.VLC_REUSE_INSTANCE_LABEL',
descriptionKey: 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION',
keywords: [...VLC, 'single window', 'instance'],
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
];
@@ -0,0 +1,259 @@
import { PLAYBACK_SETTINGS_SEARCH_ENTRIES } from './settings-search-entries-playback';
import { SettingsSearchEntry } from './settings-search.types';
const DASHBOARD_KEYWORDS = ['dashboard', 'home', 'rail', 'row'];
function dashboardRail(
id: string,
key: string,
keywords: readonly string[]
): SettingsSearchEntry {
return {
id: `dashboard-rail-${id}`,
section: 'dashboard',
labelKey: `SETTINGS.DASHBOARD_RAIL_${key}`,
descriptionKey: `SETTINGS.DASHBOARD_RAIL_${key}_DESCRIPTION`,
keywords: [...DASHBOARD_KEYWORDS, ...keywords],
fallbackId: 'show-dashboard',
};
}
/**
* Every searchable settings row, in page order (section order, then row
* order). Ties in search ranking keep this order, so results read like the
* settings page itself. A unit test in the web app checks this list against
* the `data-setting-id` anchors in the section templates.
*/
export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
{
id: 'language',
section: 'general',
labelKey: 'SETTINGS.LANGUAGE',
descriptionKey: 'SETTINGS.LANGUAGE_DESCRIPTION',
keywords: ['language', 'locale', 'translation'],
},
{
id: 'theme',
section: 'general',
labelKey: 'SETTINGS.THEME',
descriptionKey: 'SETTINGS.THEME_DESCRIPTION',
keywords: ['theme', 'dark', 'light', 'appearance', 'color'],
},
{
id: 'cover-size',
section: 'general',
labelKey: 'SETTINGS.COVER_SIZE',
descriptionKey: 'SETTINGS.COVER_SIZE_DESCRIPTION',
keywords: ['poster', 'cover', 'grid', 'card', 'size'],
},
{
id: 'cover-titles',
section: 'general',
labelKey: 'SETTINGS.COVER_TITLES',
descriptionKey: 'SETTINGS.COVER_TITLES_DESCRIPTION',
keywords: ['poster', 'cover', 'title', 'caption'],
},
{
id: 'startup-behavior',
section: 'general',
labelKey: 'SETTINGS.STARTUP_BEHAVIOR',
descriptionKey: 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION',
keywords: ['startup', 'launch', 'restore', 'last view'],
},
{
id: 'startup-window-mode',
section: 'general',
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE',
descriptionKey: 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION',
keywords: ['startup', 'window', 'fullscreen', 'maximized'],
requires: ['startup-window-mode'],
},
{
id: 'show-captions',
section: 'general',
labelKey: 'SETTINGS.SHOW_CAPTIONS',
descriptionKey: 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION',
keywords: ['subtitles', 'captions', 'cc'],
},
{
id: 'strip-country-prefix',
section: 'general',
labelKey: 'SETTINGS.STRIP_COUNTRY_PREFIX',
descriptionKey: 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION',
keywords: ['channel name', 'country', 'prefix', 'tag'],
},
{
id: 'portal-connectivity-guard',
section: 'general',
labelKey: 'SETTINGS.PORTAL_CONNECTIVITY_GUARD',
descriptionKey: 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION',
keywords: ['network', 'offline', 'connection', 'internet', 'vpn'],
requires: ['portal-connectivity-guard'],
},
...PLAYBACK_SETTINGS_SEARCH_ENTRIES,
{
id: 'epg-view-mode',
section: 'epg',
labelKey: 'SETTINGS.EPG_VIEW_MODE',
descriptionKey: 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION',
keywords: ['epg', 'tv guide', 'timeline', 'grid', 'programme'],
requires: ['epg'],
},
{
id: 'epg-offset',
section: 'epg',
labelKey: 'SETTINGS.EPG_OFFSET_TITLE',
descriptionKey: 'SETTINGS.EPG_OFFSET_HINT',
keywords: ['epg', 'timezone', 'time shift', 'offset', 'hours'],
requires: ['epg'],
},
{
id: 'epg-sources',
section: 'epg',
labelKey: 'SETTINGS.EPG_SOURCE_LIST_TITLE',
descriptionKey: 'SETTINGS.EPG_SOURCE_FORMATS_HINT',
keywords: ['epg', 'xmltv', 'tv guide', 'url', 'refresh', 'clear'],
requires: ['epg'],
},
{
id: 'prefer-uploaded-epg',
section: 'epg',
labelKey: 'SETTINGS.PREFER_UPLOADED_EPG_TITLE',
descriptionKey: 'SETTINGS.PREFER_UPLOADED_EPG_HINT',
keywords: ['epg', 'xmltv', 'priority', 'provider'],
requires: ['epg'],
},
{
id: 'show-dashboard',
section: 'dashboard',
labelKey: 'SETTINGS.SHOW_DASHBOARD',
descriptionKey: 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION',
keywords: ['dashboard', 'home', 'start page'],
},
{
id: 'dashboard-hero',
section: 'dashboard',
labelKey: 'SETTINGS.DASHBOARD_HERO',
descriptionKey: 'SETTINGS.DASHBOARD_HERO_DESCRIPTION',
keywords: [...DASHBOARD_KEYWORDS, 'banner', 'featured'],
fallbackId: 'show-dashboard',
},
dashboardRail('continue-watching', 'CONTINUE', ['resume', 'progress']),
dashboardRail('live-favorites', 'LIVE_FAVORITES', ['favorites', 'live']),
dashboardRail('recent-live', 'RECENT_LIVE', ['recent', 'live']),
dashboardRail('favorite-vod', 'FAVORITE_VOD', ['favorites', 'movies']),
dashboardRail('sources', 'SOURCES', ['playlists', 'sources']),
dashboardRail('xtream-added', 'XTREAM_ADDED', ['xtream', 'new']),
dashboardRail('tmdb-trending', 'TMDB_TRENDING', ['tmdb', 'trending']),
dashboardRail('tmdb-recommendations', 'TMDB_RECOMMENDATIONS', [
'tmdb',
'recommendations',
]),
{
id: 'remote-control',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_DESCRIPTION',
keywords: ['remote', 'phone', 'mobile', 'companion'],
requires: ['remote-control'],
},
{
id: 'remote-control-port',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL_PORT',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION',
keywords: ['remote', 'port', 'network'],
requires: ['remote-control'],
fallbackId: 'remote-control',
},
{
id: 'remote-control-url',
section: 'remote-control',
labelKey: 'SETTINGS.REMOTE_CONTROL_URL',
descriptionKey: 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION',
keywords: ['remote', 'qr code', 'address', 'ip'],
requires: ['remote-control'],
fallbackId: 'remote-control',
},
{
id: 'tmdb-enable',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_ENABLE',
descriptionKey: 'SETTINGS.TMDB_ENABLE_DESCRIPTION',
keywords: ['tmdb', 'themoviedb', 'metadata', 'posters', 'ratings'],
},
{
id: 'tmdb-api-key',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_API_KEY',
descriptionKey: 'SETTINGS.TMDB_API_KEY_DESCRIPTION',
keywords: ['tmdb', 'api key', 'token'],
fallbackId: 'tmdb-enable',
},
{
id: 'tmdb-m3u-vod-details',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_M3U_VOD_DETAILS',
descriptionKey: 'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION',
keywords: ['tmdb', 'm3u', 'movies', 'details'],
fallbackId: 'tmdb-enable',
},
{
id: 'tmdb-cache',
section: 'tmdb',
labelKey: 'SETTINGS.TMDB_CACHE',
descriptionKey: 'SETTINGS.TMDB_CACHE_DESCRIPTION',
keywords: ['tmdb', 'cache', 'clear', 'storage'],
},
{
id: 'import-export-data',
section: 'backup',
labelKey: 'SETTINGS.IMPORT_EXPORT_DATA',
descriptionKey: 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION',
keywords: ['backup', 'restore', 'import', 'export', 'migrate'],
},
{
id: 'remove-all-playlists',
section: 'reset',
labelKey: 'SETTINGS.REMOVE_ALL',
descriptionKey: 'SETTINGS.REMOVE_ALL_DESCRIPTION',
keywords: ['reset', 'delete', 'remove', 'wipe', 'playlists'],
},
{
id: 'version',
section: 'about',
labelKey: 'SETTINGS.VERSION',
descriptionKey: 'SETTINGS.VERSION_DESCRIPTION',
keywords: ['version', 'build', 'commit'],
},
{
id: 'app-update',
section: 'about',
labelKey: 'SETTINGS.APP_UPDATE_TITLE',
descriptionKey: 'SETTINGS.APP_UPDATE_DESCRIPTION',
keywords: ['update', 'upgrade', 'nightly', 'channel', 'release'],
requires: ['desktop'],
fallbackId: 'version',
},
{
id: 'tmdb-credits',
section: 'about',
labelKey: 'SETTINGS.TMDB_CREDITS',
descriptionKey: 'SETTINGS.TMDB_ATTRIBUTION',
keywords: ['tmdb', 'credits', 'attribution'],
},
{
id: 'community',
section: 'about',
labelKey: 'SETTINGS.COMMUNITY',
descriptionKey: 'SETTINGS.COMMUNITY_DESCRIPTION',
keywords: ['github', 'telegram', 'help', 'feedback', 'bug'],
},
{
id: 'support',
section: 'about',
labelKey: 'SETTINGS.SUPPORT_TITLE',
descriptionKey: 'SETTINGS.SUPPORT_DESCRIPTION',
keywords: ['donate', 'sponsor', 'support'],
},
];
@@ -0,0 +1,86 @@
import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank';
describe('tokenizeSearchQuery', () => {
it('folds case and splits on whitespace', () => {
expect(tokenizeSearchQuery(' Video PLAYER ')).toEqual([
'video',
'player',
]);
});
it('returns no tokens for a blank query', () => {
expect(tokenizeSearchQuery(' ')).toEqual([]);
});
it('folds the Turkish dotted capital I like the list filters do', () => {
expect(tokenizeSearchQuery('İnşaat')).toEqual(['inşaat']);
});
});
describe('rankSearchMatch', () => {
const rank = (
query: string,
fields: Parameters<typeof rankSearchMatch>[1]
) => rankSearchMatch(tokenizeSearchQuery(query), fields);
it('returns 0 without tokens or without a match', () => {
expect(rank('', { label: 'Theme' })).toBe(0);
expect(rank('volume', { label: 'Theme', keywords: ['dark'] })).toBe(0);
});
it('requires every token to match somewhere', () => {
const fields = {
label: 'Video player',
keywords: ['mpv'],
context: ['Playback'],
};
expect(rank('player mpv', fields)).toBeGreaterThan(0);
expect(rank('player vlc', fields)).toBe(0);
});
it('ranks label prefix over word start over inner substring', () => {
const prefix = rank('play', { label: 'Playback bar' });
const wordStart = rank('play', { label: 'External playback bar' });
const inner = rank('play', { label: 'Autoplay' });
expect(prefix).toBeGreaterThan(wordStart);
expect(wordStart).toBeGreaterThan(inner);
expect(inner).toBeGreaterThan(0);
});
it('prefers a word-start occurrence that follows an inner one', () => {
const laterWordStart = rank('art', { label: 'Smart artplayer' });
expect(laterWordStart).toBe(rank('art', { label: 'x artplayer' }));
expect(laterWordStart).toBeGreaterThan(rank('art', { label: 'Smart' }));
});
it('weights label hits over keywords over context', () => {
const label = rank('dark', { label: 'Dark mode' });
const keyword = rank('dark', { label: 'Theme', keywords: ['dark'] });
const context = rank('dark', {
label: 'Theme',
context: ['dark or light'],
});
expect(label).toBeGreaterThan(keyword);
expect(keyword).toBeGreaterThan(context);
expect(context).toBeGreaterThan(0);
});
it('rewards the whole query as a label phrase', () => {
const phrase = rank('video player', { label: 'Video player' });
const scattered = rank('video player', {
label: 'Player',
keywords: ['video'],
});
expect(phrase).toBeGreaterThan(scattered);
});
it('matches case-insensitively, accented letters included', () => {
expect(rank('epg', { label: 'EPG sources' })).toBeGreaterThan(0);
expect(rank('ÉPG', { label: 'épg' })).toBeGreaterThan(0);
});
});
@@ -0,0 +1,92 @@
import { foldSearchText } from '@iptvnator/shared/interfaces';
/**
* Texts of one searchable item, by how strongly a hit counts: the visible
* label first, then curated keywords, then supporting context (description,
* section name).
*/
export interface SearchRankFields {
readonly label: string;
readonly keywords?: readonly string[];
readonly context?: readonly string[];
}
const LABEL_WEIGHT = 12;
const KEYWORD_WEIGHT = 6;
const CONTEXT_WEIGHT = 2;
const LABEL_PHRASE_PREFIX_BONUS = 20;
const LABEL_PHRASE_BONUS = 8;
const LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
/** Folded, whitespace-separated query tokens; empty for a blank query. */
export function tokenizeSearchQuery(query: string): string[] {
return foldSearchText(query).split(/\s+/).filter(Boolean);
}
/**
* Relevance of `fields` for the query tokens; `0` means no match. Every
* token must match somewhere (AND semantics), so adding a word narrows the
* results. A hit at the start of a text beats a hit at a word start, which
* beats a hit inside a word; the whole query as a label prefix wins
* outright. Pure and locale-invariant (see `foldSearchText`).
*/
export function rankSearchMatch(
tokens: readonly string[],
fields: SearchRankFields
): number {
if (tokens.length === 0) {
return 0;
}
const label = foldSearchText(fields.label);
const keywords = (fields.keywords ?? []).map(foldSearchText);
const context = (fields.context ?? []).map(foldSearchText);
let score = 0;
for (const token of tokens) {
const tokenScore = Math.max(
scoreText(label, token, LABEL_WEIGHT),
...keywords.map((text) => scoreText(text, token, KEYWORD_WEIGHT)),
...context.map((text) => scoreText(text, token, CONTEXT_WEIGHT))
);
if (tokenScore === 0) {
return 0;
}
score += tokenScore;
}
const phrase = tokens.join(' ');
if (label.startsWith(phrase)) {
score += LABEL_PHRASE_PREFIX_BONUS;
} else if (label.includes(phrase)) {
score += LABEL_PHRASE_BONUS;
}
return score;
}
function scoreText(text: string, token: string, weight: number): number {
const index = text.indexOf(token);
if (index < 0) {
return 0;
}
if (index === 0) {
return weight * 3;
}
if (isWordStart(text, index)) {
return weight * 2;
}
// A later occurrence may still start a word ("art" in "smart artplayer").
let next = text.indexOf(token, index + 1);
while (next >= 0) {
if (isWordStart(text, next)) {
return weight * 2;
}
next = text.indexOf(token, next + 1);
}
return weight;
}
function isWordStart(text: string, index: number): boolean {
return !LETTER_OR_DIGIT.test(text.charAt(index - 1));
}
@@ -0,0 +1,47 @@
import { SettingsSectionDefinition } from './settings-search.types';
/**
* Settings section pages in navigation order. Single source for the settings
* page navigation, the settings search index and the command palette.
*/
export const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] =
[
{ id: 'general', navLabelKey: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'playback',
navLabelKey: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
},
{
id: 'epg',
navLabelKey: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
requires: ['epg'],
},
{
id: 'dashboard',
navLabelKey: 'SETTINGS.NAV_DASHBOARD',
icon: 'dashboard',
},
{
id: 'remote-control',
navLabelKey: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
requires: ['remote-control'],
},
{ id: 'tmdb', navLabelKey: 'SETTINGS.NAV_TMDB', icon: 'movie' },
{ id: 'backup', navLabelKey: 'SETTINGS.NAV_BACKUP', icon: 'backup' },
{
id: 'reset',
navLabelKey: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
},
{ id: 'about', navLabelKey: 'SETTINGS.NAV_ABOUT', icon: 'info' },
];
export function meetsSettingsRequirements(
requires: SettingsSectionDefinition['requires'],
capabilities: Readonly<Record<string, boolean>>
): boolean {
return (requires ?? []).every((requirement) => capabilities[requirement]);
}
@@ -0,0 +1,294 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
import { SETTINGS_SECTION_DEFINITIONS } from './settings-search-sections';
import { SettingsSearchService } from './settings-search.service';
const TRANSLATIONS: Record<string, string> = {
'SETTINGS.THEME': 'Theme',
'SETTINGS.THEME_DESCRIPTION': 'Light, dark or system',
'SETTINGS.VIDEO_PLAYER_LABEL': 'Video player',
'SETTINGS.VIDEO_PLAYER_DESCRIPTION': 'Engine used for playback',
'SETTINGS.EPG_VIEW_MODE': 'Programme guide layout',
'SETTINGS.REMOTE_CONTROL': 'Remote control',
'SETTINGS.NAV_GENERAL': 'General',
'SETTINGS.NAV_PLAYBACK': 'Playback',
};
interface RuntimeStub {
isElectron: boolean;
supportsEpgImport: boolean;
supportsEpgDataManagement: boolean;
supportsRemoteControl: boolean;
supportsStartupWindowMode: boolean;
supportsPortalConnectivityGuard: boolean;
supportsManagedExternalPlayers: boolean;
supportsExternalPlayerPathSettings: boolean;
supportsEmbeddedMpv: boolean;
}
function setup(runtime: Partial<RuntimeStub> = {}) {
const router = { navigate: jest.fn().mockResolvedValue(true) };
TestBed.configureTestingModule({
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: {
isElectron: false,
supportsEpgImport: false,
supportsEpgDataManagement: false,
supportsRemoteControl: false,
supportsStartupWindowMode: false,
supportsPortalConnectivityGuard: false,
supportsManagedExternalPlayers: false,
supportsExternalPlayerPathSettings: false,
supportsEmbeddedMpv: false,
...runtime,
},
},
{
provide: VodSourceDiscoveryService,
useValue: { isAvailable: false },
},
{ provide: Router, useValue: router },
{
provide: TranslateService,
useValue: {
instant: (key: string) => TRANSLATIONS[key] ?? key,
},
},
],
});
return { service: TestBed.inject(SettingsSearchService), router };
}
const DESKTOP: Partial<RuntimeStub> = {
isElectron: true,
supportsEpgImport: true,
supportsEpgDataManagement: true,
supportsRemoteControl: true,
supportsStartupWindowMode: true,
supportsPortalConnectivityGuard: true,
supportsManagedExternalPlayers: true,
supportsExternalPlayerPathSettings: true,
};
describe('SettingsSearchService', () => {
it('gives every entry a unique id and a known section', () => {
const ids = SETTINGS_SEARCH_ENTRIES.map((entry) => entry.id);
const sectionIds = new Set(
SETTINGS_SECTION_DEFINITIONS.map((section) => section.id)
);
expect(new Set(ids).size).toBe(ids.length);
for (const entry of SETTINGS_SEARCH_ENTRIES) {
expect(sectionIds.has(entry.section)).toBe(true);
}
});
it('points every fallback at a row of the same section', () => {
const byId = new Map(
SETTINGS_SEARCH_ENTRIES.map((entry) => [entry.id, entry])
);
for (const entry of SETTINGS_SEARCH_ENTRIES) {
if (entry.fallbackId) {
expect(byId.get(entry.fallbackId)?.section).toBe(entry.section);
// A fallback must itself always render, or it cannot help.
expect(byId.get(entry.fallbackId)?.fallbackId).toBeUndefined();
}
}
});
it('hides sections and rows the runtime cannot render', () => {
const { service } = setup();
const sections = service.visibleSections().map(({ id }) => id);
const entries = service.visibleEntries().map(({ id }) => id);
expect(sections).not.toContain('epg');
expect(sections).not.toContain('remote-control');
expect(entries).not.toContain('epg-view-mode');
expect(entries).not.toContain('recording-folder');
expect(entries).not.toContain('mpv-player-path');
expect(entries).toContain('theme');
expect(service.search('guide')).toEqual([]);
});
it('offers the desktop rows that need no probe on a capable runtime', () => {
const { service } = setup(DESKTOP);
const entries = service.visibleEntries().map(({ id }) => id);
expect(entries).toContain('epg-view-mode');
expect(entries).toContain('remote-control-port');
expect(entries).toContain('mpv-player-path');
expect(entries).not.toContain('vod-auto-failover');
// Embedded MPV rows wait for the support probe.
expect(entries).not.toContain('embedded-mpv-extra-options');
expect(entries).not.toContain('embedded-mpv-frame-copy');
expect(entries.length).toBe(SETTINGS_SEARCH_ENTRIES.length - 4);
});
describe('embedded MPV probe', () => {
const originalElectron = window.electron;
afterEach(() => {
window.electron = originalElectron;
});
function probeWith(support: {
supported: boolean;
frameCopyAvailable?: boolean;
}) {
const getEmbeddedMpvSupport = jest
.fn()
.mockResolvedValue({ platform: 'darwin', ...support });
window.electron = {
getEmbeddedMpvSupport,
} as unknown as typeof window.electron;
return getEmbeddedMpvSupport;
}
it('reveals embedded MPV rows once support resolves, frame copy only when available', async () => {
const getEmbeddedMpvSupport = probeWith({ supported: true });
const { service } = setup({
...DESKTOP,
supportsEmbeddedMpv: true,
});
await service.ensureEmbeddedMpvSupportLoaded();
const entries = service.visibleEntries().map(({ id }) => id);
expect(entries).toContain('embedded-mpv-extra-options');
expect(entries).toContain('embedded-mpv-auto-reconnect');
// Frame copy is not available on this machine, so the row the
// settings page never renders must not be offered either.
expect(entries).not.toContain('embedded-mpv-frame-copy');
expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
it('offers frame copy where the machine can run it', async () => {
probeWith({ supported: true, frameCopyAvailable: true });
const { service } = setup({
...DESKTOP,
supportsEmbeddedMpv: true,
});
await service.ensureEmbeddedMpvSupportLoaded();
expect(service.visibleEntries().map(({ id }) => id)).toContain(
'embedded-mpv-frame-copy'
);
});
it('does not probe where the runtime has no embedded MPV bridge', () => {
const getEmbeddedMpvSupport = probeWith({ supported: true });
const { service } = setup(DESKTOP);
expect(service.ensureEmbeddedMpvSupportLoaded()).toBeUndefined();
expect(getEmbeddedMpvSupport).not.toHaveBeenCalled();
});
});
it('returns translated, ranked results with their section', () => {
const { service } = setup();
const [first] = service.search('theme');
expect(first.entry.id).toBe('theme');
expect(first.label).toBe('Theme');
expect(first.description).toBe('Light, dark or system');
expect(first.sectionLabel).toBe('General');
expect(first.section.icon).toBe('tune');
});
it('matches untranslated keywords and caps results', () => {
const { service } = setup(DESKTOP);
expect(service.search('dark')[0].entry.id).toBe('theme');
expect(service.search('mpv').length).toBeGreaterThan(3);
expect(service.search('mpv', 2)).toHaveLength(2);
expect(service.search(' ')).toEqual([]);
});
it('keeps settings page order for equal scores', () => {
const { service } = setup(DESKTOP);
const ids = service.search('vlc').map(({ entry }) => entry.id);
const pageOrder = SETTINGS_SEARCH_ENTRIES.map(({ id }) => id).filter(
(id) => ids.includes(id)
);
expect(ids.slice().sort()).toEqual(pageOrder.slice().sort());
expect(ids.indexOf('vlc-player-path')).toBeLessThan(
ids.indexOf('vlc-reuse-instance')
);
});
it('navigates to the section and records a reveal request', () => {
const { service, router } = setup();
const entry = SETTINGS_SEARCH_ENTRIES.find(
({ id }) => id === 'tmdb-api-key'
);
if (!entry) {
throw new Error('missing tmdb-api-key entry');
}
service.reveal(entry);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace/settings',
'tmdb',
]);
expect(service.pendingReveal()).toEqual(
expect.objectContaining({
id: 'tmdb-api-key',
section: 'tmdb',
fallbackId: 'tmdb-enable',
})
);
});
it('notifies reveal listeners before navigating, until unsubscribed', () => {
const { service, router } = setup();
const calls: string[] = [];
router.navigate.mockImplementation(() => {
calls.push('navigate');
return Promise.resolve(true);
});
const stop = service.onReveal(() => calls.push('listener'));
service.reveal(SETTINGS_SEARCH_ENTRIES[0]);
stop();
service.reveal(SETTINGS_SEARCH_ENTRIES[1]);
expect(calls).toEqual(['listener', 'navigate', 'navigate']);
});
it('completes only the reveal request that is still pending', () => {
const { service } = setup();
const [first, second] = SETTINGS_SEARCH_ENTRIES;
service.reveal(first);
const stale = service.pendingReveal();
service.reveal(second);
if (!stale) {
throw new Error('expected a pending reveal');
}
service.completeReveal(stale);
expect(service.pendingReveal()?.id).toBe(second.id);
const current = service.pendingReveal();
if (!current) {
throw new Error('expected a pending reveal');
}
service.completeReveal(current);
expect(service.pendingReveal()).toBeNull();
});
});
@@ -0,0 +1,193 @@
import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
import { SETTINGS_SEARCH_ENTRIES } from './settings-search-entries';
import { rankSearchMatch, tokenizeSearchQuery } from './settings-search-rank';
import {
meetsSettingsRequirements,
SETTINGS_SECTION_DEFINITIONS,
} from './settings-search-sections';
import {
SettingsRevealRequest,
SettingsSearchCapabilities,
SettingsSearchEntry,
SettingsSearchResult,
SettingsSectionDefinition,
} from './settings-search.types';
/**
* Searchable index of the settings page, shared by the settings page search
* and the command palette so both return the same results in the same
* order. Also owns "reveal": navigating to a row's section page and asking
* the page to scroll to and highlight it.
*/
@Injectable({ providedIn: 'root' })
export class SettingsSearchService {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly revealRequest = signal<SettingsRevealRequest | null>(null);
private revealNonce = 0;
private readonly revealListeners = new Set<() => void>();
private readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(
null
);
private embeddedMpvSupportChecked = false;
private embeddedMpvSupportLoad: Promise<void> | undefined;
/** Row the settings page should scroll to and highlight next. */
readonly pendingReveal = this.revealRequest.asReadonly();
capabilities(): SettingsSearchCapabilities {
const runtime = this.runtime;
return {
desktop: runtime.isElectron,
epg: runtime.supportsEpgImport && runtime.supportsEpgDataManagement,
'remote-control': runtime.supportsRemoteControl,
'startup-window-mode': runtime.supportsStartupWindowMode,
'portal-connectivity-guard':
runtime.supportsPortalConnectivityGuard,
'managed-external-players': runtime.supportsManagedExternalPlayers,
'external-player-paths': runtime.supportsExternalPlayerPathSettings,
'vod-multi-source': this.vodSourceDiscovery.isAvailable,
'embedded-mpv': !!this.embeddedMpvSupport()?.supported,
'embedded-mpv-frame-copy':
!!this.embeddedMpvSupport()?.frameCopyAvailable,
};
}
/**
* Probes embedded MPV support once so rows that need it become
* searchable. Returns the pending probe, or `undefined` when there is
* nothing to wait for. Call it lazily (palette open, settings page),
* never from shell bootstrap: supported desktop builds may load the
* native addon while answering.
*/
ensureEmbeddedMpvSupportLoaded(): Promise<void> | undefined {
if (this.embeddedMpvSupportChecked) {
return undefined;
}
const electron =
typeof window === 'undefined' ? undefined : window.electron;
if (
!this.runtime.supportsEmbeddedMpv ||
typeof electron?.getEmbeddedMpvSupport !== 'function'
) {
this.embeddedMpvSupportChecked = true;
return undefined;
}
this.embeddedMpvSupportLoad ??= electron
.getEmbeddedMpvSupport()
.then((support) => this.embeddedMpvSupport.set(support))
.catch(() => this.embeddedMpvSupport.set(null))
.finally(() => {
this.embeddedMpvSupportChecked = true;
});
return this.embeddedMpvSupportLoad;
}
/**
* Runs `listener` synchronously before every reveal navigation, so the
* shell can drop a search keystroke still waiting for its debounce: if
* it applied later, its `q` navigation would supersede the reveal.
*/
onReveal(listener: () => void): () => void {
this.revealListeners.add(listener);
return () => this.revealListeners.delete(listener);
}
visibleSections(): SettingsSectionDefinition[] {
const capabilities = this.capabilities();
return SETTINGS_SECTION_DEFINITIONS.filter((section) =>
meetsSettingsRequirements(section.requires, capabilities)
);
}
/** Entries whose section and row render on this runtime. */
visibleEntries(): SettingsSearchEntry[] {
const capabilities = this.capabilities();
const sectionIds = new Set(
this.visibleSections().map((section) => section.id)
);
return SETTINGS_SEARCH_ENTRIES.filter(
(entry) =>
sectionIds.has(entry.section) &&
meetsSettingsRequirements(entry.requires, capabilities)
);
}
/**
* Ranked matches for `query` in the current UI language; best first,
* ties in settings page order. Empty for a blank query.
*/
search(query: string, limit = Infinity): SettingsSearchResult[] {
const tokens = tokenizeSearchQuery(query);
if (tokens.length === 0) {
return [];
}
const sections = new Map(
SETTINGS_SECTION_DEFINITIONS.map((section) => [section.id, section])
);
const results: SettingsSearchResult[] = [];
for (const entry of this.visibleEntries()) {
const section = sections.get(entry.section);
if (!section) {
continue;
}
const label = this.translate.instant(entry.labelKey);
const description = entry.descriptionKey
? this.translate.instant(entry.descriptionKey)
: '';
const sectionLabel = this.translate.instant(section.navLabelKey);
const score = rankSearchMatch(tokens, {
label,
keywords: entry.keywords,
context: [description, sectionLabel],
});
if (score > 0) {
results.push({
entry,
section,
label,
description,
sectionLabel,
score,
});
}
}
// Array#sort is stable, so equal scores keep settings page order.
return results.sort((a, b) => b.score - a.score).slice(0, limit);
}
/** Opens the row's section page and asks it to highlight the row. */
reveal(entry: SettingsSearchEntry): void {
this.revealListeners.forEach((listener) => listener());
this.revealNonce += 1;
this.revealRequest.set({
id: entry.id,
section: entry.section,
fallbackId: entry.fallbackId,
nonce: this.revealNonce,
});
void this.router.navigate(['/workspace/settings', entry.section]);
}
/** Called by the settings page once it has handled `request`. */
completeReveal(request: SettingsRevealRequest): void {
if (this.revealRequest()?.nonce === request.nonce) {
this.revealRequest.set(null);
}
}
}
@@ -0,0 +1,74 @@
/**
* Runtime capabilities a settings row can depend on. The settings search
* index must hide rows the settings page itself never renders on this
* runtime, otherwise search would "find" settings the user cannot reach.
*/
export type SettingsSearchRequirement =
| 'desktop'
| 'epg'
| 'remote-control'
| 'startup-window-mode'
| 'portal-connectivity-guard'
| 'managed-external-players'
| 'external-player-paths'
| 'vod-multi-source'
/** Probed lazily; false until `ensureEmbeddedMpvSupportLoaded` resolves. */
| 'embedded-mpv'
| 'embedded-mpv-frame-copy';
export type SettingsSearchCapabilities = Readonly<
Record<SettingsSearchRequirement, boolean>
>;
/** One routed settings section page (`/workspace/settings/:id`). */
export interface SettingsSectionDefinition {
readonly id: string;
/** Short label used by the section navigation. */
readonly navLabelKey: string;
readonly icon: string;
readonly requires?: readonly SettingsSearchRequirement[];
}
/**
* One searchable settings row. `id` is also the row's `data-setting-id`
* anchor on the settings page, which is how a search result scrolls to and
* highlights the row.
*/
export interface SettingsSearchEntry {
readonly id: string;
readonly section: string;
readonly labelKey: string;
readonly descriptionKey?: string;
/**
* Untranslated synonyms. They are matched in addition to the translated
* label and description, so English terms ("dark", "mpv", "subtitles")
* keep working under every UI language.
*/
readonly keywords?: readonly string[];
readonly requires?: readonly SettingsSearchRequirement[];
/**
* Row to reveal when this one is not rendered for the current form
* state, e.g. the VLC path row while another player is selected: the
* user lands on the control that makes the searched row appear.
*/
readonly fallbackId?: string;
}
/** A matched entry with its translated texts, ready to render. */
export interface SettingsSearchResult {
readonly entry: SettingsSearchEntry;
readonly section: SettingsSectionDefinition;
readonly label: string;
readonly description: string;
readonly sectionLabel: string;
readonly score: number;
}
/** Pending "scroll to and highlight this row" request for the settings page. */
export interface SettingsRevealRequest {
readonly id: string;
readonly section: string;
readonly fallbackId?: string;
/** Distinguishes repeated requests for the same row. */
readonly nonce: number;
}
+3
View File
@@ -76,6 +76,9 @@
"@iptvnator/workspace/shell/util": [
"libs/workspace/shell/util/src/index.ts"
],
"@iptvnator/workspace/shell/util/settings-search": [
"libs/workspace/shell/util/src/lib/settings-search/index.ts"
],
"@iptvnator/pipes": ["libs/ui/pipes/src/index.ts"],
"@iptvnator/ui/components": ["libs/ui/components/src/index.ts"],
"@iptvnator/m3u-state": ["libs/m3u-state/src/index.ts"],