feat(settings): grouped navigation, cards and switches from the settings concept (#1853)

* feat(settings): grouped navigation, cards and switches from the settings concept

Implements the Claude Design "Settings concept" handoff.

Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices
and Data groups, with About pinned to the footer beside the installed version
and an update badge. The active item is a soft fill without a border. Esc
leaves settings like the header Back. Reset is no longer a page: its one
destructive action is the last card of "Backup & data".

Pages: a title and one-line subtitle, then rows grouped into titled cards
with one right-aligned control column. Selects are compact without floating
labels, checkboxes are switches, segmented controls are pills, and
descriptions are capped at 56ch. "Show subtitles" moves to Playback, the
Embedded MPV note becomes a callout under the player select (only while it
is selected), and the TMDB attribution becomes the About footer.

EPG: Add and Refresh all sit in the Sources header, the empty state has its
own Add, the format examples are one line, Clear EPG data is its own row and
the offset is a stepper. About: version, update state and channel share one
card; the nightly warning is a callout shown only while Nightly is selected,
with a shortcut to the backup page; the support buttons are neutral with
coloured icons.

Save model: the save bar stays (design option B) and now floats over the
content column, counts the staged changes, answers Cmd/Ctrl+S, and marks
pages with staged edits in the sidebar. A saved change that waits for a
restart shows a dismissible notice.

Rows stack under 600px of pane width (the page is a size container, so the
persistent sidebar is accounted for). Page-specific styles moved into the
About, EPG, Backup and Remote control section stylesheets to stay within the
component style budget. New SETTINGS keys are translated in all 19 locales
and NAV_RESET is removed; brand names and loanwords that stay English are in
the identical-value baseline.

E2E: Material slide toggles report aria-checked after the next render, so
both suites toggle through a setSwitch helper; the settings nav is a
navigation landmark, so the Dashboard rail link is scoped to the rail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): compare restart notices against the running app, fix CI fallout

The restart notice now lists only the restart controls whose saved value
differs from what the running app uses: the stored values at first load,
or what the embedded engine reports it actually runs for the frame-copy
opt-in. Saving the launch value back withdraws the notice instead of
leaving it up with no chip to explain it. The refresh reads the current
list untracked and writes only a changed one, because the engine probe
effect calls it.

CI: the zoneless checklist dropped the deleted Reset section component, and
the theme-tokens Electron E2E floats the recording folder label on the
Playback page now that the settings selects carry no floating label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep a dismissed restart reminder away until the setting changes

"Later" now records the saved value it dismissed, for the rest of the app
run, so an unrelated save (or reopening Settings) does not bring the same
reminder back. It returns once a restart setting is saved with another
value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-09 20:39:07 +02:00
1 parent 4c6540679d
commit 8030ced95a
90 files changed
+5957 -3617

No files matched your search

@@ -1,5 +1,7 @@
@use '../../nav-list.scss';
// Settings navigation. Not the shared nav-list: the active item here is a
// soft fill with tinted text and no border, the same treatment as a
// selected channel row, so the list does not shift by the 2px an outlined
// item adds.
:host {
display: flex;
flex-direction: column;
@@ -7,39 +9,148 @@
overflow: hidden;
}
// Quiet sentence-case rail header — matches the typography of the
// section nav items below ("Dashboard", "Sources", etc., all sentence
// case) instead of standing out as an uppercase eyebrow that didn't
// appear anywhere else in the workspace rail. The muted colour and
// small size still distinguish it from the clickable items so it reads
// as a section caption rather than competing for focus.
// The page title: one step above the 14px items below it, so the panel
// reads title → groups → items instead of a caption under the items.
.panel-title {
margin: 0;
padding: 20px 18px 10px;
font-size: 13px;
padding: 18px 18px 14px;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.005em;
color: var(--mat-sys-on-surface-variant);
letter-spacing: -0.2px;
line-height: 1.2;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.settings-panel-body {
.settings-nav {
flex: 1;
min-height: 0;
padding-top: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 2px;
padding: 0 8px;
overflow-y: auto;
scrollbar-width: thin;
}
// `height: 100%` here does not resolve against a stretched flex item, so the
// list kept its full content height and overflowed the panel whenever the
// panel was shorter than its sections (a phone, or a short desktop window).
.settings-sections-list {
.settings-nav__group {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-nav__group-label {
padding: 14px 10px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.7px;
text-transform: uppercase;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
.settings-nav__group:first-child & {
padding-top: 0;
}
}
.settings-nav__spacer {
flex: 1;
min-height: 0;
// The list now reaches the panel's bottom edge (Back moved to the
// header), so it clears the home indicator itself.
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
min-height: 8px;
}
.settings-nav__footer {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0 calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--app-separator);
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 34px;
padding: 0 10px;
border-radius: 8px;
box-sizing: border-box;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
mat-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
font-size: 18px;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
&:hover:not(.active) {
background: var(--app-hover-overlay);
color: var(--mat-sys-on-surface);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
&.active {
background: var(--app-selection-surface);
color: var(--app-selection-color);
mat-icon {
color: var(--app-selection-color);
}
}
}
.nav-item-label {
flex: 1;
min-width: 0;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item-meta {
flex-shrink: 0;
min-width: 2ch;
font-size: 0.8rem;
text-align: right;
opacity: 0.7;
}
// A page with staged edits, while the save bar is up.
.nav-item-dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--app-selection-color);
}
.nav-item-version {
flex-shrink: 0;
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
.nav-item-badge {
flex-shrink: 0;
padding: 1px 7px;
border-radius: 999px;
background: var(--app-expiry-warn-surface);
color: var(--app-expiry-warn-text);
font-size: 10.5px;
font-weight: 600;
}
// Sections without settings-search matches stay reachable but recede.
@@ -47,19 +158,15 @@
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
}
// On a landscape phone the panel is only ~108-148px tall, and its caption
// On a landscape phone the panel is only ~108-148px tall, and its title
// would take a third of that from the section list. The rail already labels
// the page, so the caption is what gives way.
// the page, so the title is what gives way.
@media (max-width: 640px) and (max-height: 599px) {
.panel-title {
display: none;
}
.settings-panel-body {
.settings-nav {
padding-top: 8px;
}
}
@@ -55,24 +55,42 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
});
const ctx = TestBed.inject(SettingsContextService);
ctx.setSections([
{ id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'general',
label: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
group: 'app',
},
{
id: 'playback',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
group: 'app',
},
{
id: 'backup',
label: 'SETTINGS.NAV_BACKUP',
icon: 'backup',
group: 'data',
},
{ id: 'about', label: 'SETTINGS.NAV_ABOUT', icon: 'info' },
]);
const fixture = TestBed.createComponent(
WorkspaceSettingsContextPanelComponent
);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const link = (id: string) =>
(fixture.nativeElement as HTMLElement).querySelector(
element.querySelector(
`[data-test-id="settings-section-${id}"]`
) as HTMLElement;
return { ctx, fixture, link };
return { ctx, fixture, element, link };
}
afterEach(() => {
TestBed.inject(SettingsContextService).reset();
});
it('offers Back in the header, beside the phone drawer toggle', () => {
const { fixture } = setup();
const back = jest
@@ -88,6 +106,8 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
// The drawer holds the sections, so its toggle must stay reachable.
expect(target?.phoneDrawerToggle).toBe('beside');
expect(target?.label()).toBeNull();
// Esc leaves settings; the header advertises it on the Back button.
expect(target?.escapeShortcut()).toBe(true);
backNavigation.goBack();
expect(back).toHaveBeenCalledTimes(1);
@@ -118,6 +138,59 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
});
});
it('groups the sections and pins ungrouped ones to the footer', () => {
const { element, link } = setup();
const groups = Array.from(
element.querySelectorAll('.settings-nav__group')
).map((group) => group.getAttribute('data-group'));
// Empty groups (library, devices) are left out.
expect(groups).toEqual(['app', 'data']);
expect(
element.querySelector('.settings-nav__footer')?.contains(link('about'))
).toBe(true);
expect(
element.querySelector('[data-group="app"]')?.contains(link('playback'))
).toBe(true);
});
it('shows the version beside About, replaced by a badge once an update waits', () => {
const { ctx, fixture, link } = setup();
ctx.setVersion('0.25.0');
fixture.detectChanges();
expect(
link('about').querySelector('[data-test-id="settings-nav-version"]')
?.textContent
).toBe('0.25.0');
expect(link('general').querySelector('.nav-item-version')).toBeNull();
ctx.setUpdateAvailable(true);
fixture.detectChanges();
expect(
link('about').querySelector(
'[data-test-id="settings-nav-update-badge"]'
)
).not.toBeNull();
expect(
link('about').querySelector('[data-test-id="settings-nav-version"]')
).toBeNull();
});
it('marks pages holding staged edits', () => {
const { ctx, fixture, link } = setup();
ctx.setDirtySections(new Set(['playback']));
fixture.detectChanges();
expect(
link('playback').querySelector(
'[data-test-id="settings-section-dirty-playback"]'
)
).not.toBeNull();
expect(link('general').querySelector('.nav-item-dot')).toBeNull();
});
it('shows no counts while settings search is idle', () => {
const { link } = setup();
@@ -144,4 +217,58 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
fixture.detectChanges();
expect(ctx.matchCounts()).toBeNull();
});
describe('Escape', () => {
function pressEscape(
init: KeyboardEventInit = {},
target?: Element,
handled = false
) {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
...init,
});
// An earlier listener (the shell closing its drawer) consumed it.
if (handled) event.preventDefault();
(target ?? document.body).dispatchEvent(event);
return event;
}
it('leaves settings like the header Back', () => {
setup();
const back = jest
.spyOn(TestBed.inject(Location), 'back')
.mockImplementation(() => undefined);
const event = pressEscape();
expect(event.defaultPrevented).toBe(true);
expect(back).toHaveBeenCalledTimes(1);
});
it('yields to handled keys, chords, open overlays and text fields', () => {
setup();
const back = jest
.spyOn(TestBed.inject(Location), 'back')
.mockImplementation(() => undefined);
pressEscape({}, undefined, true);
pressEscape({ shiftKey: true });
const input = document.createElement('input');
document.body.appendChild(input);
pressEscape({}, input);
input.remove();
const backdrop = document.createElement('div');
backdrop.className = 'cdk-overlay-backdrop';
document.body.appendChild(backdrop);
pressEscape();
backdrop.remove();
expect(back).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,12 @@
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import {
Component,
computed,
ElementRef,
inject,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@@ -10,57 +18,131 @@ import {
WorkspaceShellContextDrawerService,
WorkspaceStartupPreferencesService,
} from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import {
SettingsContextService,
SettingsNavItem,
} from '@iptvnator/workspace/shell/util/settings-context';
import { SETTINGS_NAV_GROUPS } from '@iptvnator/workspace/shell/util/settings-search';
interface SettingsNavGroupView {
readonly id: string;
readonly labelKey: string;
readonly sections: readonly SettingsNavItem[];
}
@Component({
selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule],
imports: [
MatIconModule,
NgTemplateOutlet,
RouterLink,
RouterLinkActive,
TranslateModule,
],
styleUrls: ['./workspace-settings-context-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
// Esc leaves settings, as the header Back advertises. Registered
// before any routed content's listener; see `onEscape`.
'(document:keydown.escape)': 'onEscape($event)',
},
template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="settings-panel-body">
<div class="nav-list settings-sections-list">
@for (section of ctx.sections(); track section.id) {
<!-- replaceUrl keeps a single settings entry in the
browser history: switching sections must not turn
"Back" (header or browser) into a walk through every
visited section page before finally leaving. -->
<a
class="nav-item settings-section-item"
routerLinkActive="active"
[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]
<nav
class="settings-nav"
[attr.aria-label]="'SETTINGS.TITLE' | translate"
>
@for (group of groups(); track group.id) {
<div class="settings-nav__group" [attr.data-group]="group.id">
<div class="settings-nav__group-label">
{{ group.labelKey | translate }}
</div>
@for (section of group.sections; track section.id) {
<ng-container
*ngTemplateOutlet="
sectionLink;
context: { $implicit: section }
"
/>
}
</div>
}
<div class="settings-nav__spacer"></div>
@if (footer().length > 0) {
<div class="settings-nav__footer">
@for (section of footer(); track section.id) {
<ng-container
*ngTemplateOutlet="
sectionLink;
context: { $implicit: section, footer: true }
"
/>
}
</div>
}
</nav>
<ng-template #sectionLink let-section let-footer="footer">
<!-- replaceUrl keeps a single settings entry in the browser
history: switching sections must not turn "Back" (header or
browser) into a walk through every visited section page
before finally leaving. -->
<a
class="nav-item settings-section-item"
routerLinkActive="active"
[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 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
"
(click)="onSectionClicked()"
>{{ counts[section.id] ?? 0 }}</span
>
} @else if (ctx.dirtySections().has(section.id)) {
<!-- Staged edits wait on this page for Save. -->
<span
class="nav-item-dot"
[attr.data-test-id]="
'settings-section-dirty-' + section.id
"
[attr.aria-label]="'SETTINGS.UNSAVED_CHANGES' | translate"
role="img"
></span>
} @else if (footer && ctx.updateAvailable()) {
<span
class="nav-item-badge"
data-test-id="settings-nav-update-badge"
>{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}</span
>
} @else if (footer && ctx.version(); as version) {
<span
class="nav-item-version"
data-test-id="settings-nav-version"
>{{ version }}</span
>
<mat-icon>{{ section.icon }}</mat-icon>
<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>
</div>
</a>
</ng-template>
`,
})
export class WorkspaceSettingsContextPanelComponent {
readonly ctx = inject(SettingsContextService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
private readonly startupPreferences = inject(
WorkspaceStartupPreferencesService
@@ -76,6 +158,21 @@ export class WorkspaceSettingsContextPanelComponent {
}
);
/** Grouped sections in group order; empty groups are left out. */
readonly groups = computed<readonly SettingsNavGroupView[]>(() => {
const sections = this.ctx.sections();
return SETTINGS_NAV_GROUPS.map((group) => ({
id: group.id,
labelKey: group.labelKey,
sections: sections.filter((section) => section.group === group.id),
})).filter((group) => group.sections.length > 0);
});
/** Sections without a group sit at the bottom (About). */
readonly footer = computed(() =>
this.ctx.sections().filter((section) => !section.group)
);
constructor() {
// The panel exists exactly while the settings route shows, so it
// offers the page's Back in the header. On a phone the toggle for
@@ -84,14 +181,52 @@ export class WorkspaceSettingsContextPanelComponent {
// workspace view (the dashboard, or sources when it is hidden).
registerWorkspaceBack({
phoneDrawerToggle: 'beside',
run: () =>
this.backNavigation.back(() =>
this.startupPreferences.resolveDashboardPath()
),
escapeShortcut: signal(true),
run: () => this.leaveSettings(),
});
}
onSectionClicked() {
this.contextDrawer?.close();
}
/**
* Esc leaves settings unless something closer owns it: a modifier chord,
* an already handled key (the shell closes the phone drawer first), an
* open overlay (select panel, menu, dialog), text being edited, or
* browser fullscreen.
*/
onEscape(event: Event): void {
const keyboard = event as KeyboardEvent;
if (
keyboard.defaultPrevented ||
keyboard.repeat ||
keyboard.altKey ||
keyboard.ctrlKey ||
keyboard.metaKey ||
keyboard.shiftKey
) {
return;
}
const document = this.host.nativeElement.ownerDocument;
if (document.fullscreenElement) return;
if (document.querySelector('.cdk-overlay-backdrop')) return;
const target = keyboard.target as HTMLElement | null;
if (
target?.isContentEditable ||
target?.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])'
)
) {
return;
}
keyboard.preventDefault();
this.leaveSettings();
}
private leaveSettings(): void {
this.backNavigation.back(() =>
this.startupPreferences.resolveDashboardPath()
);
}
}
@@ -4,6 +4,8 @@ export interface SettingsNavItem {
id: string;
label: string;
icon: string;
/** Navigation group id; items without one are pinned to the footer. */
group?: string;
}
/**
@@ -24,6 +26,12 @@ export class SettingsContextService {
readonly matchCounts = signal<Readonly<Record<string, number>> | null>(
null
);
/** Sections holding staged (unsaved) edits; the panel marks them. */
readonly dirtySections = signal<ReadonlySet<string>>(new Set());
/** Installed app version, shown beside the About entry. */
readonly version = signal<string | null>(null);
/** An update can be installed; the About entry carries a badge. */
readonly updateAvailable = signal(false);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
@@ -33,8 +41,23 @@ export class SettingsContextService {
this.matchCounts.set(counts);
}
setDirtySections(sections: ReadonlySet<string>): void {
this.dirtySections.set(sections);
}
setVersion(version: string | null): void {
this.version.set(version);
}
setUpdateAvailable(available: boolean): void {
this.updateAvailable.set(available);
}
reset(): void {
this.sections.set([]);
this.matchCounts.set(null);
this.dirtySections.set(new Set());
this.version.set(null);
this.updateAvailable.set(false);
}
}
@@ -3,7 +3,7 @@ import { SettingsSearchEntry } from './settings-search.types';
const MPV = ['mpv', 'external player'];
const VLC = ['vlc', 'external player'];
/** Playback section rows, in page order. */
/** Playback section rows, in page order: Player, Watching, Xtream, Desktop. */
export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
[
{
@@ -21,13 +21,6 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
'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',
@@ -44,47 +37,6 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
keywords: ['ambient', 'glow', 'backlight'],
fallbackId: 'video-player',
},
{
id: 'detail-trailer-backdrop',
section: 'playback',
labelKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND',
descriptionKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION',
keywords: ['trailer', 'youtube', 'backdrop', 'details'],
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: 'player-up-next-card',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_UP_NEXT_CARD',
descriptionKey: 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION',
keywords: ['up next', 'next episode', 'popup'],
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',
@@ -95,14 +47,6 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
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',
@@ -129,14 +73,6 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
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',
@@ -191,4 +127,75 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
requires: ['external-player-paths'],
fallbackId: 'video-player',
},
{
id: 'show-captions',
section: 'playback',
labelKey: 'SETTINGS.SHOW_CAPTIONS',
descriptionKey: 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION',
keywords: ['subtitles', 'captions', 'cc'],
},
{
id: 'detail-trailer-backdrop',
section: 'playback',
labelKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND',
descriptionKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION',
keywords: ['trailer', 'youtube', 'backdrop', 'details'],
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: 'player-up-next-card',
section: 'playback',
labelKey: 'SETTINGS.PLAYER_UP_NEXT_CARD',
descriptionKey: 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION',
keywords: ['up next', 'next episode', 'popup'],
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: 'stream-format',
section: 'playback',
labelKey: 'SETTINGS.STREAM_FORMAT',
descriptionKey: 'SETTINGS.STREAM_FORMAT_DESCRIPTION',
keywords: ['hls', 'm3u8', 'mpeg-ts', 'ts', 'format', 'xtream'],
},
{
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: 'recording-folder',
section: 'playback',
labelKey: 'SETTINGS.RECORDING_FOLDER_LABEL',
descriptionKey: 'SETTINGS.RECORDING_FOLDER_DESCRIPTION',
keywords: ['recording', 'record', 'dvr', 'folder', 'directory'],
requires: ['desktop'],
},
];
@@ -68,13 +68,6 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
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',
@@ -112,7 +105,7 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
section: 'epg',
labelKey: 'SETTINGS.EPG_SOURCE_LIST_TITLE',
descriptionKey: 'SETTINGS.EPG_SOURCE_FORMATS_HINT',
keywords: ['epg', 'xmltv', 'tv guide', 'url', 'refresh', 'clear'],
keywords: ['epg', 'xmltv', 'tv guide', 'url', 'refresh', 'add'],
requires: ['epg'],
},
{
@@ -123,6 +116,14 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
keywords: ['epg', 'xmltv', 'priority', 'provider'],
requires: ['epg'],
},
{
id: 'epg-clear-data',
section: 'epg',
labelKey: 'SETTINGS.CLEAR_EPG_DATA',
descriptionKey: 'SETTINGS.CLEAR_EPG_DATA_DESCRIPTION',
keywords: ['epg', 'clear', 'delete', 'programme', 'cache', 'reset'],
requires: ['epg'],
},
{
id: 'show-dashboard',
section: 'dashboard',
@@ -252,7 +253,7 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
},
{
id: 'remove-all-playlists',
section: 'reset',
section: 'backup',
labelKey: 'SETTINGS.REMOVE_ALL',
descriptionKey: 'SETTINGS.REMOVE_ALL_DESCRIPTION',
keywords: ['reset', 'delete', 'remove', 'wipe', 'playlists'],
@@ -269,7 +270,16 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [
section: 'about',
labelKey: 'SETTINGS.APP_UPDATE_TITLE',
descriptionKey: 'SETTINGS.APP_UPDATE_DESCRIPTION',
keywords: ['update', 'upgrade', 'nightly', 'channel', 'release'],
keywords: ['update', 'upgrade', 'release', 'download', 'install'],
requires: ['desktop'],
fallbackId: 'version',
},
{
id: 'app-update-channel',
section: 'about',
labelKey: 'SETTINGS.APP_UPDATE_CHANNEL',
descriptionKey: 'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION',
keywords: ['update', 'nightly', 'stable', 'channel', 'prerelease'],
requires: ['desktop'],
fallbackId: 'version',
},
@@ -1,45 +1,75 @@
import { SettingsSectionDefinition } from './settings-search.types';
import {
SettingsNavGroup,
SettingsSectionDefinition,
} from './settings-search.types';
/** Settings navigation groups in display order. */
export const SETTINGS_NAV_GROUPS: readonly {
readonly id: SettingsNavGroup;
readonly labelKey: string;
}[] = [
{ id: 'app', labelKey: 'SETTINGS.NAV_GROUP_APP' },
{ id: 'library', labelKey: 'SETTINGS.NAV_GROUP_LIBRARY' },
{ id: 'devices', labelKey: 'SETTINGS.NAV_GROUP_DEVICES' },
{ id: 'data', labelKey: 'SETTINGS.NAV_DATA' },
];
/**
* Settings section pages in navigation order. Single source for the settings
* page navigation, the settings search index and the command palette.
* Sections without a group (About) are pinned to the navigation footer.
*/
export const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] =
[
{ id: 'general', navLabelKey: 'SETTINGS.NAV_GENERAL', icon: 'tune' },
{
id: 'general',
navLabelKey: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
group: 'app',
},
{
id: 'playback',
navLabelKey: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
group: 'app',
},
{
id: 'epg',
navLabelKey: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
requires: ['epg'],
group: 'app',
},
{
id: 'dashboard',
navLabelKey: 'SETTINGS.NAV_DASHBOARD',
icon: 'dashboard',
group: 'app',
},
{
id: 'tmdb',
navLabelKey: 'SETTINGS.NAV_TMDB',
icon: 'movie',
group: 'library',
},
{
id: 'parental',
navLabelKey: 'SETTINGS.NAV_PARENTAL',
icon: 'family_restroom',
group: 'library',
},
{
id: 'remote-control',
navLabelKey: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
requires: ['remote-control'],
group: 'devices',
},
{ id: 'tmdb', navLabelKey: 'SETTINGS.NAV_TMDB', icon: 'movie' },
{
id: 'parental',
navLabelKey: 'SETTINGS.NAV_PARENTAL',
icon: 'family_restroom',
},
{ id: 'backup', navLabelKey: 'SETTINGS.NAV_BACKUP', icon: 'backup' },
{
id: 'reset',
navLabelKey: 'SETTINGS.NAV_RESET',
icon: 'delete_sweep',
id: 'backup',
navLabelKey: 'SETTINGS.NAV_BACKUP',
icon: 'backup',
group: 'data',
},
{ id: 'about', navLabelKey: 'SETTINGS.NAV_ABOUT', icon: 'info' },
];
@@ -20,6 +20,9 @@ export type SettingsSearchCapabilities = Readonly<
Record<SettingsSearchRequirement, boolean>
>;
/** Settings navigation groups; About has none and sits in the footer. */
export type SettingsNavGroup = 'app' | 'library' | 'devices' | 'data';
/** One routed settings section page (`/workspace/settings/:id`). */
export interface SettingsSectionDefinition {
readonly id: string;
@@ -27,6 +30,8 @@ export interface SettingsSectionDefinition {
readonly navLabelKey: string;
readonly icon: string;
readonly requires?: readonly SettingsSearchRequirement[];
/** Navigation group; omitted for sections pinned to the footer. */
readonly group?: SettingsNavGroup;
}
/**