mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(settings): search settings from the header and the command palette (#1714)
* feat(settings): search settings from the header and the command palette The header search on the Settings page was shown but disabled. It now searches a shared index of all 56 settings rows by translated title, description and English synonyms, replaces the section page with ranked results, and opens a result by scrolling to, focusing and briefly highlighting its row. Enter opens the best match, and the section navigation shows per-section match counts. The command palette gains a "Settings" group that lists the best six matches for a non-empty query, so any setting is one Ctrl/Cmd+K away. Rows hidden by the current form state fall back to the control that reveals them; rows the runtime cannot render are never returned. The index ships through a new @iptvnator/workspace/shell/util/settings-search sub-entrypoint so it stays out of the eager bundle, and a registry spec keeps it in step with the section templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): let search reveals win over pending input and gate embedded MPV rows - A reveal (result click, command palette, Enter) now cancels a search keystroke still waiting for its debounce, so its q navigation can no longer supersede the reveal and leave the results open. - Embedded MPV extra options and auto-reconnect require a lazily probed embedded MPV capability; frame copy also needs frameCopyAvailable, so search never offers a row the settings page cannot render. - Keyboard users keep a focus-visible ring on the revealed row after the highlight fades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): wait for palette probes without Promise.allSettled The web tsconfig lib predates Promise.allSettled; use Promise.all over rejection-safe probes instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
77 files changed
+3304
-316
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user