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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-08 18:10:59 +02:00
1 parent 6c8f5028c7
commit 1f036d2a8e
87 files changed
+5745 -3614

No files matched your search

@@ -0,0 +1,11 @@
---
type: feature
area: settings
highlight: Reorganised settings
---
Settings are easier to scan: the sidebar groups pages into App, Library,
Devices and Data with About pinned at the bottom, each page sorts its rows into
titled cards with one aligned control column, and checkboxes became switches.
Reset now lives on "Backup & data", subtitles moved to Playback, and the save
bar counts your unsaved changes and answers Cmd/Ctrl+S. Esc leaves settings.
@@ -972,18 +972,33 @@ export async function openSettingsSection(
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
}
/**
* Material slide toggles render `aria-checked` after Angular's next change
* detection, later than Playwright's `check()` looks, which then reports
* that the click changed nothing. Click when needed and wait for the state.
*/
export async function setSwitch(
toggle: Locator,
checked: boolean
): Promise<void> {
if ((await toggle.isChecked()) !== checked) {
await toggle.click();
}
await expect(toggle).toBeChecked({ checked });
}
export async function enableRemoteControl(
page: Page,
port: number
): Promise<void> {
await openSettingsSection(page, 'remote-control');
const remoteControlCheckbox = page.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
const remoteControlSwitch = page.locator(
'mat-slide-toggle[formcontrolname="remoteControl"] [role="switch"]'
);
await remoteControlCheckbox.scrollIntoViewIfNeeded();
await remoteControlCheckbox.check();
await remoteControlSwitch.scrollIntoViewIfNeeded();
await setSwitch(remoteControlSwitch, true);
await page.locator('#remoteControlPort').fill(String(port));
}
+9 -6
View File
@@ -17,6 +17,7 @@ import {
openSettings,
openSettingsSection,
saveSettings,
setSwitch,
test,
} from './electron-test-fixtures';
@@ -161,8 +162,8 @@ test.describe('Electron EPG', () => {
});
const guard = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input');
await guard.uncheck();
.getByRole('switch');
await setSwitch(guard, false);
await expect(
app.mainWindow.getByTestId('settings-unsaved-bar')
).toBeVisible();
@@ -192,10 +193,12 @@ test.describe('Electron EPG', () => {
});
});
await openSettingsSection(app.mainWindow, 'general');
await app.mainWindow
.getByTestId('portal-connectivity-toggle')
.locator('input')
.uncheck();
await setSwitch(
app.mainWindow
.getByTestId('portal-connectivity-toggle')
.getByRole('switch'),
false
);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
@@ -10,6 +10,7 @@ import {
openSettings,
openSettingsSection,
saveSettings,
setSwitch,
test,
} from './electron-test-fixtures';
@@ -37,10 +38,10 @@ for (const player of ['html5', 'videojs', 'artplayer']) {
await openSettingsSection(page, 'playback');
await page.getByTestId('select-video-player').click();
await page.getByTestId(player).click();
await page
const sharedControlsSwitch = page
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]')
.setChecked(sharedControls);
.getByRole('switch');
await setSwitch(sharedControlsSwitch, sharedControls);
// A fresh profile defaults to Video.js with shared controls.
// For changed settings, await the save control through the
// helper; an immediate isVisible() can miss Angular rendering it.
@@ -7,6 +7,7 @@ import {
openSettings,
openSettingsSection,
saveSettings,
setSwitch,
test,
} from './electron-test-fixtures';
@@ -72,7 +73,7 @@ test('@settings @electron saves the portal cooldown preference and applies it wi
await openSettingsSection(app.mainWindow, 'general');
let checkbox = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.getByRole('checkbox');
.getByRole('switch');
await expect(checkbox).toBeChecked();
await requestFailure(app.mainWindow, origin);
await requestFailure(app.mainWindow, origin);
@@ -80,7 +81,7 @@ test('@settings @electron saves the portal cooldown preference and applies it wi
pausedMessage
);
await checkbox.uncheck();
await setSwitch(checkbox, false);
// An unsaved edit cannot alter backend policy.
expect(await requestFailure(app.mainWindow, origin)).toContain(
pausedMessage
@@ -114,9 +115,9 @@ test('@settings @electron saves the portal cooldown preference and applies it wi
await openSettingsSection(app.mainWindow, 'general');
checkbox = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.getByRole('checkbox');
.getByRole('switch');
await expect(checkbox).not.toBeChecked();
await checkbox.check();
await setSwitch(checkbox, true);
await saveSettings(app.mainWindow);
expect(await requestFailure(app.mainWindow, origin)).toContain(
xtreamNetworkFailure
+25 -22
View File
@@ -21,6 +21,7 @@ import {
resetMockServers,
restartElectronApp,
saveSettings,
setSwitch,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
@@ -94,7 +95,7 @@ test.describe('Electron Settings', () => {
'external-player-double-click-setting'
);
const doubleClickCheckbox = doubleClickSetting.locator(
'input[type="checkbox"]'
'[role="switch"]'
);
await expect(doubleClickSetting).toBeVisible();
@@ -122,7 +123,7 @@ test.describe('Electron Settings', () => {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await expect(doubleClickSetting).toBeVisible();
await doubleClickCheckbox.check();
await setSwitch(doubleClickCheckbox, true);
await saveSettings(app.mainWindow);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
@@ -241,12 +242,12 @@ test.describe('Electron Settings', () => {
);
const sharedControlsCheckbox = firstLaunch.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]');
.locator('[role="switch"]');
await expect(sharedControlsCheckbox).toBeVisible();
// Shared controls default ON — persist the discriminating opt-out
// so the relaunch proves an explicit false survives the
// absent-means-true coercion.
await sharedControlsCheckbox.uncheck();
await setSwitch(sharedControlsCheckbox, false);
await selectSettingsOption(
firstLaunch.mainWindow,
'select-stream-format',
@@ -254,9 +255,9 @@ test.describe('Electron Settings', () => {
);
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
'mat-slide-toggle[formcontrolname="showExternalPlaybackBar"] [role="switch"]'
)
.uncheck();
.click();
await enableRemoteControl(firstLaunch.mainWindow, 8877);
await openSettingsSection(firstLaunch.mainWindow, 'epg');
await firstLaunch.mainWindow
@@ -289,20 +290,20 @@ test.describe('Electron Settings', () => {
await expect(
secondLaunch.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]')
.locator('[role="switch"]')
).not.toBeChecked();
await expect(
secondLaunch.mainWindow.getByTestId('select-stream-format')
).toContainText('ts');
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
'mat-slide-toggle[formcontrolname="showExternalPlaybackBar"] [role="switch"]'
)
).not.toBeChecked();
await openSettingsSection(secondLaunch.mainWindow, 'remote-control');
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
'mat-slide-toggle[formcontrolname="remoteControl"] [role="switch"]'
)
).toBeChecked();
await expect(
@@ -333,9 +334,9 @@ test.describe('Electron Settings', () => {
);
const sharedControlsCheckbox = app.mainWindow
.getByTestId('web-player-shared-controls-setting')
.locator('input[type="checkbox"]');
.locator('[role="switch"]');
await expect(sharedControlsCheckbox).toBeVisible();
await sharedControlsCheckbox.check();
await setSwitch(sharedControlsCheckbox, true);
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
@@ -622,9 +623,9 @@ test.describe('Electron Settings', () => {
await openSettingsSection(firstLaunch.mainWindow, 'dashboard');
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
'mat-slide-toggle[formcontrolname="showDashboard"] [role="switch"]'
)
.uncheck();
.click();
for (const toggleId of [
'toggle-dashboard-hero',
'toggle-dashboard-rail-continue-watching',
@@ -639,7 +640,7 @@ test.describe('Electron Settings', () => {
await expect(
firstLaunch.mainWindow
.getByTestId(toggleId)
.locator('input[type="checkbox"]')
.locator('[role="switch"]')
).toBeDisabled();
}
await saveSettings(firstLaunch.mainWindow);
@@ -690,12 +691,14 @@ test.describe('Electron Settings', () => {
await expect(
app.mainWindow
.getByTestId('toggle-show-dashboard')
.locator('input[type="checkbox"]')
.locator('[role="switch"]')
).toBeChecked();
await app.mainWindow
.getByTestId('toggle-dashboard-rail-recent-sources')
.locator('input[type="checkbox"]')
.uncheck();
await setSwitch(
app.mainWindow
.getByTestId('toggle-dashboard-rail-recent-sources')
.locator('[role="switch"]'),
false
);
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
@@ -740,9 +743,9 @@ test.describe('Electron Settings', () => {
const toggle = app.mainWindow
.getByTestId('toggle-dashboard-rail-tmdb-recommendations')
.locator('input[type="checkbox"]');
.locator('[role="switch"]');
await expect(toggle).toBeChecked();
await toggle.uncheck();
await setSwitch(toggle, false);
await saveSettings(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
@@ -754,7 +757,7 @@ test.describe('Electron Settings', () => {
await expect(
app.mainWindow
.getByTestId('toggle-dashboard-rail-tmdb-recommendations')
.locator('input[type="checkbox"]')
.locator('[role="switch"]')
).not.toBeChecked();
} finally {
await closeElectronApp(app);
@@ -14,6 +14,7 @@ import {
openWorkspaceSection,
resetMockServers,
saveSettings,
setSwitch,
test,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
@@ -61,10 +62,12 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E
await app.mainWindow
.locator('.epg-source-row input')
.fill(source.resourceUrl);
await app.mainWindow
.getByTestId('toggle-prefer-uploaded-epg')
.locator('input')
.check();
await setSwitch(
app.mainWindow
.getByTestId('toggle-prefer-uploaded-epg')
.getByRole('switch'),
true
);
await saveSettings(app.mainWindow);
await expect
.poll(
+15
View File
@@ -21,6 +21,21 @@ import sharp from 'sharp';
* about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain
* Tab, but they get the unmodified key so their run stays a literal user Tab.
*/
/**
* Material slide toggles render `aria-checked` after Angular's next change
* detection, later than Playwright's `check()` looks, which then reports
* that the click changed nothing. Click when needed and wait for the state.
*/
export async function setSwitch(
toggle: Locator,
checked: boolean
): Promise<void> {
if ((await toggle.isChecked()) !== checked) {
await toggle.click();
}
await expect(toggle).toBeChecked({ checked });
}
export async function pressTab(
page: Page,
browserName: string,
+10 -6
View File
@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from './fixtures';
import { pressTab, waitForScrollIdle } from './e2e-helpers';
import { pressTab, waitForScrollIdle, setSwitch } from './e2e-helpers';
/**
* The M3U movie-recognition workflow end to end: a playlist entry whose URL
@@ -100,12 +100,15 @@ async function selectPlayer(
async function enableTmdb(page: Page): Promise<void> {
await page.goto('/workspace/settings/tmdb');
await page.locator('[data-test-id="tmdb-enabled"] input').check();
await setSwitch(
page.locator('[data-test-id="tmdb-enabled"] [role="switch"]'),
true
);
await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key');
// The M3U recognition toggle only appears once TMDB itself is on, and it
// ships enabled — assert rather than click, so a changed default fails.
await expect(
page.locator('[data-test-id="tmdb-m3u-vod-details"] input')
page.locator('[data-test-id="tmdb-m3u-vod-details"] [role="switch"]')
).toBeChecked();
await saveSettings(page);
}
@@ -222,9 +225,10 @@ async function configureMetadata(
if (mode === 'disabled') return;
await enableTmdb(page);
if (mode === 'details-disabled') {
await page
.locator('[data-test-id="tmdb-m3u-vod-details"] input')
.uncheck();
await setSwitch(
page.locator('[data-test-id="tmdb-m3u-vod-details"] [role="switch"]'),
false
);
await saveSettings(page);
}
}
+1 -1
View File
@@ -138,7 +138,7 @@ test.describe('portrait phone 375x812', () => {
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const listBox = await boxOf(panel.locator('.settings-nav'));
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
+75 -9
View File
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test';
import { join } from 'path';
import { setSwitch } from './e2e-helpers';
import { expect, test } from './fixtures';
async function openSettings(page: Page) {
@@ -96,12 +97,12 @@ test.describe('Settings', () => {
const setting = page.locator(
'[data-test-id="web-player-shared-controls-setting"]'
);
const checkbox = setting.locator('input[type="checkbox"]');
const checkbox = setting.getByRole('switch');
await expect(setting).toBeVisible();
// Shared controls default ON; the checkbox is the opt-out.
// Shared controls default ON; the switch is the opt-out.
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await setSwitch(checkbox, false);
await saveSettings(page);
await page.reload();
await openSettings(page);
@@ -175,13 +176,30 @@ test.describe('Settings', () => {
.click();
await expect(unsavedBar).toBeVisible();
// The bar counts the staged change and the sidebar marks its page.
await expect(
unsavedBar.locator('[data-test-id="settings-unsaved-message"]')
).toHaveText('1 unsaved change');
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toBeVisible();
// The staged edit belongs to the page, not the section — moving to
// another section page must keep the bar (and the pending change).
await openSettingsSection(page, 'playback');
await expect(unsavedBar).toBeVisible();
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toBeVisible();
await expect(
page.locator('[data-test-id="settings-section-dirty-playback"]')
).toHaveCount(0);
await page.locator('[data-test-id="discard-settings"]').click();
await expect(unsavedBar).toBeHidden();
await expect(
page.locator('[data-test-id="settings-section-dirty-general"]')
).toHaveCount(0);
await openSettingsSection(page, 'general');
await expect(
@@ -189,6 +207,50 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Ctrl+S saves the staged edits', async ({ page }) => {
await openSettings(page);
await page
.locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]')
.click();
const unsavedBar = page.locator(
'[data-test-id="settings-unsaved-bar"]'
);
await expect(unsavedBar).toBeVisible();
await page.keyboard.press('Control+s');
await expect(unsavedBar).toBeHidden();
await page.reload();
await openSettings(page);
await expect(
page
.locator('[data-test-id="select-theme"]')
.getByRole('radio', { name: 'Dark', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Esc leaves settings like the header Back', async ({
page,
}) => {
await openSettings(page);
// The sidebar groups the pages and pins About to the footer.
await expect(
page.locator('.settings-nav__group[data-group="app"]')
).toContainText('General');
await expect(
page.locator('.settings-nav__footer [data-test-id="settings-section-about"]')
).toBeVisible();
// The rail link's tooltip is still up after the click that opened
// settings, and a visible tooltip consumes Escape: move on and wait
// for it to finish closing first.
await page.locator('.settings-group__header h2').click();
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
await page.keyboard.press('Escape');
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@settings @web Leaving with unsaved edits asks for confirmation', async ({
page,
}) => {
@@ -203,7 +265,7 @@ test.describe('Settings', () => {
// Trying to leave the settings area surfaces the dialog.
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
const dialog = page.getByRole('dialog');
@@ -219,7 +281,7 @@ test.describe('Settings', () => {
// Leave without saving: the staged edit is discarded.
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
@@ -247,7 +309,7 @@ test.describe('Settings', () => {
.click();
await page
.getByRole('navigation')
.locator('app-workspace-shell-rail')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
@@ -349,7 +411,8 @@ test.describe('Settings', () => {
);
await expect(search).toBeEnabled();
// "subtitles" is a keyword of the "Show captions" row.
// "subtitles" is a keyword of the "Show subtitles" row, which lives
// on the Playback page: the result leads there from General.
await search.fill('subtitles');
await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/);
await expect(
@@ -357,14 +420,17 @@ test.describe('Settings', () => {
).toBeVisible();
await expect(page.locator('app-settings-general-section')).toHaveCount(0);
await expect(
page.locator('[data-test-id="settings-section-matches-general"]')
page.locator('[data-test-id="settings-section-matches-playback"]')
).toHaveText('1');
await expect(
page.locator('[data-test-id="settings-section-matches-general"]')
).toHaveText('0');
await page
.locator('[data-test-id="settings-search-result-show-captions"]')
.click();
await expect(page).toHaveURL(/\/workspace\/settings\/general$/);
await expect(page).toHaveURL(/\/workspace\/settings\/playback$/);
await expect(search).toHaveValue('');
const row = page.locator('[data-setting-id="show-captions"]');
await expect(row).toBeFocused();
@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test';
import { setSwitch } from './e2e-helpers';
import { expect } from './fixtures';
/** Complete lazy VOD season workflow, using fictional provider/TMDB replies. */
@@ -115,7 +116,10 @@ export async function verifyStalkerSeasonMarkers(
});
});
await page.goto('/workspace/settings/tmdb');
await page.locator('[data-test-id="tmdb-enabled"] input').check();
await setSwitch(
page.locator('[data-test-id="tmdb-enabled"] [role="switch"]'),
true
);
await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key');
const save = page.getByRole('button', { name: 'Save changes' });
await save.click();
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from './fixtures';
import { setInputValue } from './e2e-helpers';
import { setInputValue, setSwitch } from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
@@ -180,12 +180,12 @@ export async function selectWebPlayer(
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page.getByRole('option', { name: engine, exact: true }).click();
const toggle = page.locator(
'[data-test-id="web-player-shared-controls-toggle"]'
await setSwitch(
page.locator(
'[data-test-id="web-player-shared-controls-toggle"] [role="switch"]'
),
sharedControls
);
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
await toggle.click();
}
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
+1 -1
View File
@@ -912,7 +912,7 @@ test('@xtream posters-only wall — hides VOD titles behind a hover caption afte
const catalogUrl = page.url();
await page.goto('/workspace/settings/general');
const toggle = page.locator('[data-test-id="cover-titles-toggle"]');
await expect(toggle.locator('input')).toBeChecked();
await expect(toggle.getByRole('switch')).toBeChecked();
await toggle.click();
const saveButton = page.locator('[data-test-id="save-settings"]');
await saveButton.click();
+86 -302
View File
@@ -1,83 +1,84 @@
@use '@angular/material' as mat;
$settings-select-density: mat.define-theme(
(
density: (
scale: -5,
),
)
);
// Settings surfaces: rows in titled cards on the page background. Light
// keeps white cards with hairline borders; dark uses the widget surface with
// a faint light border, per the UI guidelines' theme split.
.settings-container {
// Compact switch, nearer the 22px toggle of the concept than Material's
// default 32px track; the 40px state layer keeps the touch target.
@include mat.slide-toggle-overrides(
(
track-width: 40px,
track-height: 24px,
selected-handle-size: 18px,
unselected-handle-size: 14px,
pressed-handle-size: 20px,
with-icon-handle-size: 18px,
)
);
// Compact select without a floating label (the row title names it).
.settings-select.mat-mdc-form-field {
@include mat.form-field-density($settings-select-density);
}
// Save is the page's one solid call to action: the app CTA pair (light
// on dark, dark on light), not the theme primary. Set through the button
// tokens, which a plain background rule cannot beat.
.settings-save-button {
@include mat.button-overrides(
(
filled-container-color: var(--app-cta-bg),
filled-label-text-color: var(--app-cta-fg),
filled-state-layer-color: var(--app-cta-fg),
filled-container-shape: 8px,
)
);
}
--settings-safe-bottom: env(safe-area-inset-bottom, 0px);
// Action bar is a full-width sticky footer, so it sits flush against
// the bottom edge of the scroll container — no hover offset. The
// previous 24px gap was tuned for the old floating-chip layout where
// the bar visually hovered above the bottom.
--settings-action-bar-offset: 0px;
// 0 by default — the action bar is now the last child of the form
// and acts as the natural bottom edge, so no padding-bottom is
// needed below it. The previous 112px/80px reservation existed to
// push content above the OLD floating-chip layout, but in the new
// sticky-footer model that just added dead scrollable space below
// the last setting row. The bar's own padding (12px) and the
// section title bottom-borders already provide visual breathing
// room when scrolled to the end.
--settings-action-bar-space: 0px;
--settings-page-header-bg: color-mix(
--settings-card-bg: var(--app-widget-bg, #ffffff);
--settings-card-border: rgba(0, 0, 0, 0.08);
--settings-item-border: rgba(0, 0, 0, 0.07);
--settings-control-bg: #ffffff;
--settings-control-border: rgba(0, 0, 0, 0.14);
--settings-control-hover-border: rgba(0, 0, 0, 0.26);
--settings-chip-bg: rgba(0, 0, 0, 0.07);
--settings-chip-fg: var(--app-body-color);
--settings-callout-info-bg: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 94%,
rgba(255, 255, 255, 0.98)
var(--app-selection-color) 6%,
var(--settings-card-bg)
);
--settings-page-header-border: color-mix(
--settings-callout-info-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
var(--app-selection-color) 24%,
transparent
);
--settings-page-header-shadow:
0 10px 20px -28px rgba(15, 23, 42, 0.16),
0 1px 0 rgba(255, 255, 255, 0.68) inset;
--settings-group-bg: color-mix(
in srgb,
var(--mat-sys-surface) 96%,
rgba(255, 255, 255, 0.96)
);
--settings-group-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 78%,
transparent
);
--settings-group-shadow: 0 14px 28px -32px rgba(15, 23, 42, 0.14);
--settings-group-active-bg: color-mix(
in srgb,
var(--app-selection-color) 2%,
rgba(255, 255, 255, 0.98)
);
--settings-group-active-ring: color-mix(
in srgb,
var(--app-selection-border) 68%,
var(--settings-group-border)
);
--settings-group-active-glow: color-mix(
in srgb,
var(--app-selection-glow) 28%,
transparent
);
--settings-group-active-shadow:
inset 0 0 0 1px var(--settings-group-active-ring),
0 12px 24px -28px var(--settings-group-active-glow);
--settings-item-border: var(--app-separator);
--settings-item-danger-bg: color-mix(
in srgb,
var(--mat-sys-error) 4%,
rgba(255, 255, 255, 0.96)
);
--settings-callout-warn-bg: color-mix(in srgb, #d97706 9%, transparent);
--settings-callout-warn-border: color-mix(in srgb, #d97706 32%, transparent);
--settings-callout-warn-icon: #8a5300;
--settings-item-danger-border: color-mix(
in srgb,
var(--mat-sys-error) 14%,
var(--mat-sys-error) 22%,
transparent
);
--settings-action-bar-bg: color-mix(
--settings-action-bar-bg: #ffffff;
--settings-action-bar-border: rgba(0, 0, 0, 0.1);
--settings-action-bar-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
--settings-restart-bar-bg: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 94%,
rgba(255, 255, 255, 0.9)
var(--app-selection-color) 8%,
var(--app-content-bg)
);
--settings-action-bar-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 62%,
transparent
);
--settings-action-bar-shadow: 0 10px 26px -26px rgba(15, 23, 42, 0.22);
--settings-muted: color-mix(
in srgb,
var(--mat-sys-on-surface) 64%,
@@ -87,250 +88,33 @@
:where(.dark-theme, [data-theme='dark'], .mat-app-background.dark)
.settings-container {
--settings-page-header-bg: color-mix(
in srgb,
var(--mat-sys-surface-container) 92%,
rgba(12, 16, 24, 0.94)
);
--settings-page-header-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 38%,
transparent
);
--settings-page-header-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 16px 26px -34px rgba(0, 0, 0, 0.4);
--settings-group-bg: color-mix(
in srgb,
var(--mat-sys-surface-container) 94%,
rgba(10, 14, 22, 0.82)
);
--settings-group-border: rgba(255, 255, 255, 0.08);
--settings-group-shadow: 0 16px 28px -32px rgba(0, 0, 0, 0.42);
--settings-group-active-bg: color-mix(
in srgb,
var(--app-selection-surface) 24%,
rgba(255, 255, 255, 0.015)
);
--settings-group-active-ring: color-mix(
in srgb,
var(--app-selection-border) 54%,
rgba(255, 255, 255, 0.08)
);
--settings-group-active-glow: color-mix(
in srgb,
var(--app-selection-glow) 22%,
transparent
);
--settings-group-active-shadow:
inset 0 0 0 1px var(--settings-group-active-ring),
0 10px 22px -28px var(--settings-group-active-glow);
--settings-item-danger-bg: color-mix(
in srgb,
var(--mat-sys-error) 8%,
rgba(24, 10, 12, 0.86)
);
--settings-card-bg: var(--app-widget-bg, #1e2330);
--settings-card-border: rgba(255, 255, 255, 0.07);
--settings-item-border: rgba(255, 255, 255, 0.06);
--settings-control-bg: color-mix(in srgb, var(--app-widget-bg) 90%, white 6%);
--settings-control-border: rgba(255, 255, 255, 0.12);
--settings-control-hover-border: rgba(255, 255, 255, 0.22);
--settings-chip-bg: rgba(255, 255, 255, 0.1);
--settings-chip-fg: var(--app-heading-color);
--settings-callout-warn-bg: color-mix(in srgb, #fbbf24 10%, transparent);
--settings-callout-warn-border: color-mix(in srgb, #fbbf24 30%, transparent);
--settings-callout-warn-icon: #fbbf24;
--settings-item-danger-border: color-mix(
in srgb,
var(--mat-sys-error) 24%,
var(--mat-sys-error) 30%,
transparent
);
--settings-action-bar-bg: color-mix(
--settings-action-bar-bg: var(--app-card-hover-bg, #252b3a);
--settings-action-bar-border: rgba(255, 255, 255, 0.1);
--settings-action-bar-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
--settings-restart-bar-bg: color-mix(
in srgb,
var(--mat-sys-surface-container) 92%,
transparent
var(--app-selection-color) 12%,
var(--app-content-bg)
);
--settings-action-bar-border: rgba(255, 255, 255, 0.08);
--settings-action-bar-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
--settings-muted: color-mix(
in srgb,
var(--mat-sys-on-surface) 68%,
transparent
);
}
.settings-container {
.support-card {
width: 100%;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px;
border-radius: 18px;
border: 1px solid color-mix(in srgb, #ff4d6d 26%, transparent);
background:
radial-gradient(
120% 140% at 0% 0%,
color-mix(in srgb, #ff4d6d 16%, transparent) 0%,
transparent 55%
),
radial-gradient(
120% 140% at 100% 100%,
color-mix(in srgb, #ffb347 14%, transparent) 0%,
transparent 60%
),
color-mix(
in srgb,
var(--mat-sys-surface-container-low) 92%,
transparent
);
overflow: hidden;
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
80% 100% at 50% -10%,
color-mix(in srgb, #ff4d6d 12%, transparent) 0%,
transparent 70%
);
opacity: 0.8;
}
}
.support-card__intro {
position: relative;
display: flex;
align-items: flex-start;
gap: 14px;
z-index: 1;
}
.support-card__emblem {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
background: linear-gradient(135deg, #ff4d6d, #ff8b5e);
color: #fff;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
0 10px 18px -10px rgba(255, 77, 109, 0.55);
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
}
}
.support-card__copy {
min-width: 0;
flex: 1 1 auto;
h4 {
margin: 0;
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 4px 0 0;
color: var(--settings-muted);
font-size: 0.86rem;
line-height: 1.5;
max-width: 64ch;
}
}
.support-card__actions {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.support-btn {
flex: 1 1 220px;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: 14px;
border: 1px solid transparent;
text-decoration: none;
color: #fff;
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
filter 0.18s ease;
&:hover {
transform: translateY(-1px);
filter: brightness(1.04);
}
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--mat-sys-surface),
0 0 0 4px
color-mix(
in srgb,
var(--app-selection-color) 40%,
transparent
);
}
}
.support-btn__glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.18);
color: #fff;
flex: 0 0 auto;
svg {
width: 18px;
height: 18px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.support-btn__text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 0.92rem;
font-weight: 700;
letter-spacing: -0.01em;
}
small {
color: rgba(255, 255, 255, 0.82);
font-size: 0.75rem;
line-height: 1.3;
}
}
.support-btn--sponsors {
background: linear-gradient(135deg, #d4337a, #ff4d6d);
box-shadow: 0 12px 22px -18px rgba(212, 51, 122, 0.7);
}
.support-btn--kofi {
background: linear-gradient(135deg, #13c3ff, #29abe0);
box-shadow: 0 12px 22px -18px rgba(19, 195, 255, 0.7);
}
}
@@ -1,437 +1,427 @@
<section class="settings-group" id="about">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>info</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.ABOUT' | translate }}</h2>
<p>{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}</p>
</header>
<div class="setting-item" data-setting-id="version">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VERSION' | translate }}</h4>
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control version-block">
<span data-test-id="app-version">
{{ version() }}
@if (buildCommitShort(); as commit) {
<span
class="build-commit"
data-test-id="app-build-commit"
[title]="buildCommit()"
>({{ commit }})</span
>
}
</span>
<small>{{ updateMessage() }}</small>
</div>
</div>
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
<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>
</div>
<div class="setting-item__control app-update-panel__control">
<div
class="app-update-status"
[class.app-update-status--stale]="hasPendingChannelChange()"
data-test-id="app-update-status"
>
@if (appliedChannelLabelKey(); as channelLabelKey) {
<span
class="app-update-status__channel"
data-test-id="app-update-status-channel"
>
{{ channelLabelKey | translate }}
</span>
}
<strong>
{{
appUpdateStatusLabelKey()
| translate
: {
version:
updateStatus.latestVersion ||
updateStatus.currentVersion,
}
}}
</strong>
@if (updateStatus.error) {
<span>{{ updateStatus.error }}</span>
}
@if (updateStatus.progress; as progress) {
<progress
[value]="progress.percent"
max="100"
data-test-id="app-update-progress"
></progress>
}
@if (hasPendingChannelChange()) {
<span data-test-id="app-update-channel-pending-hint">
{{
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
| translate
: {
current:
(channelLabelKey(
verdictChannel()
) | translate),
next:
(channelLabelKey(
draftUpdateChannel()
) | translate),
}
}}
</span>
} @else if (hasRetainedOtherChannelVerdict()) {
<span data-test-id="app-update-channel-retained-hint">
{{
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
| translate
: {
current:
(channelLabelKey(
verdictChannel()
) | translate),
next:
(channelLabelKey(
updateStatus.channel
) | translate),
}
}}
</span>
<!-- Version, update state and channel in one block. -->
<section class="settings-section">
<div class="settings-card">
<div class="setting-item settings-hero" data-setting-id="version">
<img
class="settings-hero__logo"
src="assets/icons/android-chrome-192x192.png"
alt=""
/>
<div class="settings-hero__text">
<div class="settings-hero__title">
<span>IPTVnator</span>
@if (updateStatusChip(); as chip) {
<span
class="settings-chip"
[class.settings-chip--success]="
chip.tone === 'success'
"
[class.settings-chip--accent]="
chip.tone === 'accent'
"
[class.settings-chip--warn]="chip.tone === 'warn'"
data-test-id="app-update-status-chip"
>{{ chip.labelKey | translate }}</span
>
}
</div>
<code class="settings-hero__version" data-test-id="app-version">
{{ version() }}
@if (buildCommitShort(); as commit) {
<span
class="build-commit"
data-test-id="app-build-commit"
[title]="buildCommit()"
>({{ commit }})</span
>
}
</code>
@if (!appUpdateStatus() && updateMessage()) {
<small class="settings-hero__note">{{
updateMessage()
}}</small>
}
</div>
@if (canSelectUpdateChannel()) {
<div
class="app-update-channel"
[formGroup]="form()!"
data-test-id="app-update-channel"
>
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.APP_UPDATE_CHANNEL' | translate
}}</mat-label>
<mat-select
formControlName="updateChannel"
data-test-id="select-update-channel"
@if (isDesktop() && appUpdateStatus()) {
<div class="settings-hero__actions">
@if (canOpenAppUpdateReleaseNotes()) {
<button
mat-button
type="button"
(click)="openAppUpdateReleaseNotes.emit()"
data-test-id="app-update-release-notes"
>
@for (
option of updateChannelOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'update-channel-' + option.value
"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
<small class="app-update-channel__note">
{{
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
| translate
}}
</small>
@if (
form()!.get('updateChannel')?.value === 'nightly'
) {
<small
class="app-update-channel__note"
data-test-id="app-update-channel-nightly-warning"
>
{{
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
| translate
}}
</small>
<mat-icon>article</mat-icon>
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
</button>
}
@if (isNightlyBuildOnStableChannel()) {
<small
class="app-update-channel__note"
data-test-id="app-update-nightly-on-stable-hint"
@if (canSaveAndCheckForAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="saveAndCheckForAppUpdate.emit()"
[disabled]="isAppUpdateBusy() || form()!.invalid"
data-test-id="app-update-save-and-check"
>
<mat-icon>save</mat-icon>
{{
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
| translate
: {
channel:
(channelLabelKey(
draftUpdateChannel()
) | translate),
}
}}
</small>
</button>
} @else {
<button
mat-stroked-button
type="button"
(click)="checkForAppUpdate.emit()"
[disabled]="isAppUpdateBusy()"
data-test-id="app-update-check"
>
<mat-icon>sync</mat-icon>
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
</button>
}
</div>
}
<div class="app-update-actions">
@if (canSaveAndCheckForAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="saveAndCheckForAppUpdate.emit()"
[disabled]="isAppUpdateBusy() || form()!.invalid"
data-test-id="app-update-save-and-check"
>
<mat-icon>save</mat-icon>
{{
'SETTINGS.APP_UPDATE_SAVE_AND_CHECK'
| translate
: {
channel:
(channelLabelKey(
draftUpdateChannel()
) | translate),
}
}}
</button>
} @else {
<button
mat-stroked-button
type="button"
(click)="checkForAppUpdate.emit()"
[disabled]="isAppUpdateBusy()"
data-test-id="app-update-check"
>
<mat-icon>sync</mat-icon>
{{ 'SETTINGS.APP_UPDATE_CHECK' | translate }}
</button>
}
@if (canDownloadAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="downloadAppUpdate.emit()"
data-test-id="app-update-download"
>
<mat-icon>download</mat-icon>
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
</button>
}
@if (canOpenAppUpdateReleaseNotes()) {
<button
mat-stroked-button
type="button"
(click)="openAppUpdateReleaseNotes.emit()"
data-test-id="app-update-release-notes"
>
<mat-icon>article</mat-icon>
{{
'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate
}}
</button>
}
@if (canInstallAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="installAppUpdate.emit()"
data-test-id="app-update-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
}
@if (canOpenManualAppUpdate()) {
<button
mat-stroked-button
type="button"
(click)="openManualAppUpdate.emit()"
data-test-id="app-update-open-release"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
</div>
</div>
</div>
}
@if (isDesktop()) {
<div class="settings-note">
<strong>{{
'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate
}}</strong>
<span>{{
'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate
}}</span>
</div>
}
@if (isDesktop() && appUpdateStatus(); as updateStatus) {
<div
class="setting-item app-update-panel"
data-setting-id="app-update"
>
<div class="setting-item__meta">
<div
class="app-update-status"
[class.app-update-status--stale]="
hasPendingChannelChange()
"
data-test-id="app-update-status"
>
@if (appliedChannelLabelKey(); as channelLabelKey) {
<span
class="app-update-status__channel"
data-test-id="app-update-status-channel"
>
{{ channelLabelKey | translate }}
</span>
}
<strong>
{{
appUpdateStatusLabelKey()
| translate
: {
version:
updateStatus.latestVersion ||
updateStatus.currentVersion,
}
}}
</strong>
@if (updateStatus.error) {
<span>{{ updateStatus.error }}</span>
}
@if (updateStatus.progress; as progress) {
<progress
[value]="progress.percent"
max="100"
data-test-id="app-update-progress"
></progress>
}
@if (hasPendingChannelChange()) {
<span data-test-id="app-update-channel-pending-hint">
{{
'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT'
| translate
: {
current:
(channelLabelKey(
verdictChannel()
) | translate),
next:
(channelLabelKey(
draftUpdateChannel()
) | translate),
}
}}
</span>
} @else if (hasRetainedOtherChannelVerdict()) {
<span
data-test-id="app-update-channel-retained-hint"
>
{{
'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT'
| translate
: {
current:
(channelLabelKey(
verdictChannel()
) | translate),
next:
(channelLabelKey(
updateStatus.channel
) | translate),
}
}}
</span>
}
</div>
</div>
<div class="setting-item__control app-update-actions">
@if (canDownloadAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="downloadAppUpdate.emit()"
data-test-id="app-update-download"
>
<mat-icon>download</mat-icon>
{{ 'SETTINGS.APP_UPDATE_DOWNLOAD' | translate }}
</button>
}
@if (canInstallAppUpdate()) {
<button
mat-flat-button
type="button"
(click)="installAppUpdate.emit()"
data-test-id="app-update-install"
>
<mat-icon>restart_alt</mat-icon>
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
</button>
}
@if (canOpenManualAppUpdate()) {
<button
mat-stroked-button
type="button"
(click)="openManualAppUpdate.emit()"
data-test-id="app-update-open-release"
>
<mat-icon>open_in_new</mat-icon>
{{ 'SETTINGS.APP_UPDATE_OPEN_RELEASE' | translate }}
</button>
}
</div>
</div>
<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>
@if (canSelectUpdateChannel()) {
<div
class="setting-item"
data-setting-id="app-update-channel"
data-test-id="app-update-channel"
[formGroup]="form()!"
>
<div class="setting-item__meta">
<h4 id="app-update-channel-label">
{{ 'SETTINGS.APP_UPDATE_CHANNEL' | translate }}
</h4>
<p>
{{
'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-select
formControlName="updateChannel"
aria-labelledby="app-update-channel-label"
data-test-id="select-update-channel"
>
@for (
option of updateChannelOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'update-channel-' + option.value
"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<!-- The warning belongs to the risky choice, and only
while it is selected. -->
@if (draftUpdateChannel() === 'nightly') {
<div class="setting-item__sub">
<div
class="settings-callout settings-callout--warn"
data-test-id="app-update-channel-nightly-warning"
>
<mat-icon>warning</mat-icon>
<div>
{{
'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING'
| translate
}}
<div class="settings-callout__actions">
<button
mat-stroked-button
type="button"
(click)="openBackup.emit()"
data-test-id="app-update-open-backup"
>
{{
'SETTINGS.APP_UPDATE_CHANNEL_OPEN_BACKUP'
| translate
}}
</button>
</div>
</div>
</div>
</div>
}
@if (isNightlyBuildOnStableChannel()) {
<div class="setting-item__sub">
<div
class="settings-callout settings-callout--info"
data-test-id="app-update-nightly-on-stable-hint"
>
<mat-icon>info</mat-icon>
<div>
{{
'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT'
| translate
}}
</div>
</div>
</div>
}
}
}
</div>
</section>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.COMMUNITY' | translate }}</h3>
</div>
<div
class="settings-card link-cards"
data-setting-id="community"
tabindex="-1"
>
<a
href="https://www.themoviedb.org"
class="link-card link-card--github"
href="https://github.com/4gray/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-github"
>
<img
src="assets/images/tmdb-logo.svg"
alt="TMDB"
style="height: 20px; margin-top: 8px"
/>
<span class="link-card__glyph">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>GitHub</strong>
<span>{{ 'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate }}</span>
</span>
</a>
<a
class="link-card link-card--blog"
href="https://4gray.github.io/iptvnator/"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-blog"
>
<span class="link-card__glyph">
<mat-icon>menu_book</mat-icon>
</span>
<span class="link-card__text">
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
<span>{{ 'SETTINGS.LINK_BLOG_DESCRIPTION' | translate }}</span>
</span>
</a>
<a
class="link-card link-card--telegram"
href="https://t.me/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-telegram"
>
<span class="link-card__glyph">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>Telegram</strong>
<span>{{
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
}}</span>
</span>
</a>
</div>
</div>
</section>
<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>
</div>
<div class="setting-item__control">
<div class="link-cards">
<a
class="link-card link-card--github"
href="https://github.com/4gray/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-github"
>
<span class="link-card__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.6.112.793-.26.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.52 11.52 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.565 21.796 24 17.3 24 12c0-6.627-5.373-12-12-12z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>GitHub</strong>
<span>{{
'SETTINGS.LINK_GITHUB_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
<a
class="link-card link-card--blog"
href="https://4gray.github.io/iptvnator/"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-blog"
>
<span class="link-card__glyph">
<mat-icon>menu_book</mat-icon>
</span>
<span class="link-card__text">
<strong>{{ 'SETTINGS.LINK_BLOG' | translate }}</strong>
<span>{{
'SETTINGS.LINK_BLOG_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
<a
class="link-card link-card--telegram"
href="https://t.me/iptvnator"
target="_blank"
rel="noreferrer noopener"
data-test-id="link-telegram"
>
<span class="link-card__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71L12.6 16.3l-1.99 1.93c-.23.23-.42.42-.83.42z"
/>
</svg>
</span>
<span class="link-card__text">
<strong>Telegram</strong>
<span>{{
'SETTINGS.LINK_TELEGRAM_DESCRIPTION' | translate
}}</span>
</span>
<mat-icon class="link-card__arrow">arrow_outward</mat-icon>
</a>
</div>
</div>
</div>
<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">
<mat-icon>favorite</mat-icon>
</div>
<div class="support-card__copy">
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
</div>
<section class="settings-section">
<div
class="settings-card support-card"
data-setting-id="support"
data-test-id="support-card"
tabindex="-1"
>
<div class="support-card__copy">
<h4>{{ 'SETTINGS.SUPPORT_TITLE' | translate }}</h4>
<p>{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}</p>
</div>
<div class="support-card__actions">
<a
mat-stroked-button
class="support-btn support-btn--sponsors"
href="https://github.com/sponsors/4gray"
target="_blank"
rel="noreferrer noopener"
data-test-id="support-sponsors"
>
<span class="support-btn__glyph">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</span>
<span class="support-btn__text">
<strong>GitHub Sponsors</strong>
<small>{{
'SETTINGS.SUPPORT_SPONSORS_HINT' | translate
}}</small>
</span>
<mat-icon class="support-btn__glyph">favorite</mat-icon>
GitHub Sponsors
</a>
<a
mat-stroked-button
class="support-btn support-btn--kofi"
href="https://ko-fi.com/4gray"
target="_blank"
rel="noreferrer noopener"
data-test-id="support-kofi"
>
<span class="support-btn__glyph">
<mat-icon>local_cafe</mat-icon>
</span>
<span class="support-btn__text">
<strong>Ko-fi</strong>
<small>{{
'SETTINGS.SUPPORT_KOFI_HINT' | translate
}}</small>
</span>
<mat-icon class="support-btn__glyph">local_cafe</mat-icon>
Ko-fi
</a>
</div>
</div>
</section>
<div class="settings-foot" data-setting-id="tmdb-credits" tabindex="-1">
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
</div>
</section>
@@ -0,0 +1,262 @@
// About page: version hero, update status, community links and support.
app-settings-about-section {
display: contents;
}
// ─── About ─────────────────────────────────────────────────────────────────
.settings-hero {
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 16px;
padding: 18px 16px;
}
.settings-hero__logo {
width: 56px;
height: 56px;
border-radius: 14px;
}
.settings-hero__text {
min-width: 0;
}
.settings-hero__title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 17px;
font-weight: 600;
color: var(--app-heading-color);
}
.settings-hero__version {
display: block;
margin-top: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--app-body-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.build-commit {
opacity: 0.7;
}
}
.settings-hero__note {
display: block;
margin-top: 4px;
color: var(--settings-muted);
font-size: 12px;
}
.settings-hero__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.app-update-status {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--settings-muted);
font-size: 12.5px;
line-height: 1.45;
strong {
color: var(--app-heading-color);
font-size: 13.5px;
font-weight: 500;
}
progress {
width: min(100%, 320px);
height: 8px;
accent-color: var(--app-selection-color);
}
}
.app-update-status__channel {
align-self: flex-start;
padding: 1px 7px;
border-radius: 999px;
background: var(--settings-chip-bg);
color: var(--settings-chip-fg);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-update-status--stale strong,
.app-update-status--stale .app-update-status__channel {
opacity: 0.55;
}
.app-update-actions {
flex-wrap: wrap;
gap: 8px;
&:empty {
display: none;
}
button {
app-region: no-drag;
}
}
// Project & community: three link tiles sharing one card.
.link-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.link-card {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 14px 16px;
color: var(--app-heading-color);
text-decoration: none;
& + & {
border-left: 1px solid var(--settings-item-border);
}
&:first-child {
border-radius: 12px 0 0 12px;
}
&:last-child {
border-radius: 0 12px 12px 0;
}
&:hover {
background: var(--app-hover-overlay);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
}
.link-card__glyph {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--settings-chip-bg);
color: var(--app-body-color);
svg,
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}
.link-card__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 13px;
font-weight: 500;
}
span {
color: var(--settings-muted);
font-size: 11.5px;
line-height: 1.35;
}
}
.support-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 16px;
}
.support-card__copy {
min-width: 0;
h4 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
color: var(--app-heading-color);
}
p {
margin: 3px 0 0;
max-width: 56ch;
color: var(--settings-muted);
font-size: 12.5px;
line-height: 1.5;
}
}
.support-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
// Neutral buttons whose icon carries the colour.
.support-btn--sponsors .support-btn__glyph {
color: #ff6b9a;
}
.support-btn--kofi .support-btn__glyph {
color: #29abe0;
}
// Pane width, see settings.component.scss.
@container (max-width: 600px) {
.settings-hero {
grid-template-columns: auto minmax(0, 1fr);
}
.settings-hero__actions {
grid-column: 1 / -1;
justify-content: flex-start;
}
.link-cards {
grid-template-columns: 1fr;
}
.link-card {
& + & {
border-left: 0;
border-top: 1px solid var(--settings-item-border);
}
&:first-child {
border-radius: 12px 12px 0 0;
}
&:last-child {
border-radius: 0 0 12px 12px;
}
}
.support-card {
grid-template-columns: 1fr;
}
}
@@ -21,6 +21,11 @@ import {
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
interface SettingsUpdateStatusChip {
readonly labelKey: string;
readonly tone: 'accent' | 'success' | 'warn' | 'neutral';
}
@Component({
selector: 'app-settings-about-section',
imports: [
@@ -34,15 +39,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
':host { display: contents; }',
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
'.app-update-channel { margin-top: 12px; }',
'.app-update-channel mat-form-field { width: 100%; max-width: 320px; }',
'.app-update-channel__note { display: block; margin-top: 4px; opacity: 0.75; font-size: 0.85em; }',
'.app-update-status__channel { align-self: flex-start; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: var(--mat-sys-on-surface-variant); background: color-mix(in srgb, var(--mat-sys-on-surface) 9%, transparent); }',
'.app-update-status--stale strong, .app-update-status--stale .app-update-status__channel { opacity: 0.55; }',
],
styleUrls: ['./settings-about-section.component.scss'],
})
export class SettingsAboutSectionComponent {
readonly isDesktop = input(false);
@@ -85,6 +82,8 @@ export class SettingsAboutSectionComponent {
readonly installAppUpdate = output<void>();
readonly openManualAppUpdate = output<void>();
readonly openAppUpdateReleaseNotes = output<void>();
/** The nightly warning offers the backup page before switching. */
readonly openBackup = output<void>();
readonly canSelectUpdateChannel = computed(
() =>
@@ -177,6 +176,38 @@ export class SettingsAboutSectionComponent {
return channel ? this.channelLabelKey(channel) : null;
});
/** The one-word verdict beside the app name in the hero. */
readonly updateStatusChip = computed<SettingsUpdateStatusChip | null>(
() => {
switch (this.appUpdateStatus()?.status) {
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
return {
labelKey: 'SETTINGS.UPDATE_AVAILABLE_CHIP',
tone: 'accent',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
return {
labelKey: 'SETTINGS.UPDATE_UP_TO_DATE',
tone: 'success',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
return {
labelKey: 'SETTINGS.APP_UPDATE_CHECKING',
tone: 'neutral',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
return {
labelKey: 'SETTINGS.APP_UPDATE_ERROR',
tone: 'warn',
};
default:
return null;
}
}
);
readonly isAppUpdateBusy = computed(() => {
const status = this.appUpdateStatus()?.status;
@@ -1,73 +1,173 @@
<section class="settings-group" id="backup">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>backup</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.NAV_BACKUP' | translate }}</h2>
<p>{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}</p>
</header>
<div class="setting-item" data-setting-id="import-export-data">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
<p>
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
</p>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.BACKUP' | translate }}</h3>
</div>
<div class="setting-item__control inline-actions">
<button
mat-button
class="inline-actions__button"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
importData.emit()
"
>
{{ 'SETTINGS.IMPORT_DATA' | translate }}
</button>
<button
mat-button
class="inline-actions__button"
[class.inline-actions__button--busy]="isExportingData()"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
[attr.aria-busy]="isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
exportData.emit()
"
>
@if (isExportingData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.EXPORTING_DATA' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.EXPORT_DATA' | translate
}}</span>
}
</button>
</div>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="import-export-data">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h4>
<p>
{{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control inline-actions">
<button
mat-stroked-button
class="inline-actions__button"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
importData.emit()
"
>
{{ 'SETTINGS.IMPORT_DATA' | translate }}
</button>
<button
mat-stroked-button
class="inline-actions__button"
[class.inline-actions__button--busy]="isExportingData()"
[disabled]="isRemovingAllPlaylists() || isExportingData()"
[attr.aria-busy]="isExportingData()"
(click)="
$event.preventDefault();
$event.stopPropagation();
exportData.emit()
"
>
@if (isExportingData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.EXPORTING_DATA' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.EXPORT_DATA' | translate
}}</span>
}
</button>
</div>
</div>
@if (isPwa()) {
<div class="settings-note">
{{ 'SETTINGS.EPG_NOTE' | translate }}
&nbsp;<a
href="https://github.com/4gray/iptvnator/releases"
target="_blank"
rel="noreferrer"
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
>
@if (isPwa()) {
<div class="setting-item__sub">
<div class="settings-callout settings-callout--info">
<mat-icon>info</mat-icon>
<div>
{{ 'SETTINGS.EPG_NOTE' | translate }}
<a
href="https://github.com/4gray/iptvnator/releases"
target="_blank"
rel="noreferrer"
>{{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }}</a
>
</div>
</div>
</div>
}
</div>
}
</section>
<!-- The former Reset page: one destructive action, so it is this
page's last card rather than a page of its own. -->
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
</div>
<p class="settings-section__desc">
{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}
</p>
<div class="settings-card settings-card--danger">
<div
class="setting-item danger-zone"
data-setting-id="remove-all-playlists"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOVE_ALL' | translate }}</h4>
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
<div class="danger-zone__summary" aria-live="polite">
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.m3u
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.M3U' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().m3u
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.xtream
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.XTREAM' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().xtream
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.stalker
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.STALKER' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().stalker
}}</span>
</span>
</div>
@if (removeAllProgressLabel(); as progressLabel) {
<p class="danger-zone__progress">
{{ progressLabel }}
</p>
}
</div>
<div class="setting-item__control">
<button
mat-stroked-button
class="danger-zone__button app-destructive-button"
[class.danger-zone__button--busy]="
isRemovingAllPlaylists()
"
[disabled]="!canRemoveAllPlaylists()"
[attr.aria-busy]="isRemovingAllPlaylists()"
(click)="
$event.preventDefault();
$event.stopPropagation();
removeAll.emit()
"
>
@if (isRemovingAllPlaylists()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_BUTTON' | translate
}}</span>
}
</button>
</div>
</div>
</div>
</section>
</section>
@@ -0,0 +1,50 @@
// Backup & data page: the playlist reset row with its summary pills.
app-settings-backup-section {
display: contents;
}
// ─── Reset (Backup & data) ────────────────────────────────────────────────
.danger-zone__summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 0;
}
.danger-zone__summary-pill {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 26px;
padding: 2px 8px 2px 8px;
border-radius: 999px;
background: var(--settings-chip-bg);
color: var(--app-heading-color);
font-size: 11.5px;
.danger-zone__summary-icon {
width: 14px;
height: 14px;
font-size: 14px;
color: var(--app-body-color);
}
.danger-zone__summary-count {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
}
.danger-zone__progress {
margin: 8px 0 0;
color: var(--settings-muted);
font-size: 12.5px;
}
.danger-zone__button {
flex-shrink: 0;
}
.danger-zone__button--busy {
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
}
@@ -9,7 +9,13 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { SettingsPlaylistDeleteSummary } from './settings.models';
/**
* Backup & data: import/export plus the one destructive reset action, which
* used to be a page of its own.
*/
@Component({
selector: 'app-settings-backup-section',
imports: [
@@ -21,13 +27,19 @@ import { TranslateModule } from '@ngx-translate/core';
templateUrl: './settings-backup-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
styleUrls: ['./settings-backup-section.component.scss'],
})
export class SettingsBackupSectionComponent {
readonly sourceIcons = SOURCE_TYPE_ICONS;
readonly isPwa = input(false);
readonly isRemovingAllPlaylists = input(false);
readonly isExportingData = input(false);
readonly canRemoveAllPlaylists = input(false);
readonly playlistDeleteSummary =
input.required<SettingsPlaylistDeleteSummary>();
readonly removeAllProgressLabel = input<string | null>(null);
readonly importData = output<void>();
readonly exportData = output<void>();
readonly removeAll = output<void>();
}
@@ -0,0 +1,64 @@
import { createSettingsForm } from './settings-form.utils';
import { FormBuilder } from '@angular/forms';
import {
diffSettingsValues,
sectionOfSettingsPath,
SETTINGS_CONTROL_SECTIONS,
SETTINGS_RESTART_CONTROLS,
} from './settings-change-tracking';
describe('settings change tracking', () => {
it('lists the leaf values that differ, nested groups per leaf', () => {
const saved = {
theme: 'dark',
dashboardRails: { hero: true, continueWatching: true },
epgUrl: ['a'],
tmdb: { enabled: false, apiKey: '' },
};
const current = {
theme: 'light',
dashboardRails: { hero: false, continueWatching: false },
epgUrl: ['a', 'b'],
tmdb: { enabled: false, apiKey: '' },
};
expect(diffSettingsValues(current, saved)).toEqual([
'theme',
'dashboardRails.hero',
'dashboardRails.continueWatching',
'epgUrl',
]);
expect(diffSettingsValues(saved, saved)).toEqual([]);
});
it('treats a missing value and null alike, and arrays as one value', () => {
expect(diffSettingsValues({ a: null }, { a: undefined })).toEqual([]);
expect(diffSettingsValues({ a: [1, 2] }, { a: [2, 1] })).toEqual([
'a',
]);
expect(diffSettingsValues({ a: 1 }, {})).toEqual(['a']);
});
it('maps a changed path to the page it is edited on', () => {
expect(sectionOfSettingsPath('dashboardRails.hero')).toBe('dashboard');
expect(sectionOfSettingsPath('tmdb.apiKey')).toBe('tmdb');
expect(sectionOfSettingsPath('showCaptions')).toBe('playback');
expect(sectionOfSettingsPath('unknownControl')).toBeNull();
});
it('maps every control of the settings form to a page', () => {
const form = createSettingsForm(new FormBuilder(), true);
const unmapped = Object.keys(form.controls).filter(
(control) => !(control in SETTINGS_CONTROL_SECTIONS)
);
expect(unmapped).toEqual([]);
});
it('names the row label for every restart control', () => {
for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) {
expect(control in SETTINGS_CONTROL_SECTIONS).toBe(true);
expect(SETTINGS_RESTART_CONTROLS[control]).toMatch(/^SETTINGS\./);
}
});
});
@@ -0,0 +1,96 @@
/**
* Which settings page each top-level form control is edited on. The save
* bar counts staged changes and the navigation marks their pages with it.
*/
export const SETTINGS_CONTROL_SECTIONS: Readonly<Record<string, string>> = {
language: 'general',
theme: 'general',
coverSize: 'general',
showCoverTitles: 'general',
startupBehavior: 'general',
startupWindowMode: 'general',
stripCountryPrefix: 'general',
portalConnectivityGuard: 'general',
player: 'playback',
streamFormat: 'playback',
showCaptions: 'playback',
webPlayerSharedControls: 'playback',
playerAmbientMode: 'playback',
playerUpNextRail: 'playback',
playerUpNextCard: 'playback',
detailTrailerBackdrop: 'playback',
fullscreenChannelPanel: 'playback',
vodAutoFailover: 'playback',
openStreamOnDoubleClick: 'playback',
showExternalPlaybackBar: 'playback',
embeddedMpvFrameCopy: 'playback',
embeddedMpvExtraOptions: 'playback',
embeddedMpvAutoReconnect: 'playback',
recordingFolder: 'playback',
mpvPlayerPath: 'playback',
mpvPlayerArguments: 'playback',
mpvReuseInstance: 'playback',
vlcPlayerPath: 'playback',
vlcPlayerArguments: 'playback',
vlcReuseInstance: 'playback',
epgUrl: 'epg',
epgViewMode: 'epg',
epgOffsetMinutes: 'epg',
preferUploadedEpgOverXtream: 'epg',
showDashboard: 'dashboard',
dashboardRails: 'dashboard',
remoteControl: 'remote-control',
remoteControlPort: 'remote-control',
tmdb: 'tmdb',
m3uVodDetails: 'tmdb',
updateChannel: 'about',
};
/**
* Controls whose saved value only takes effect after the app restarts,
* with the label of the row that carries the restart chip.
*/
export const SETTINGS_RESTART_CONTROLS: Readonly<Record<string, string>> = {
startupWindowMode: 'SETTINGS.STARTUP_WINDOW_MODE',
embeddedMpvFrameCopy: 'SETTINGS.EMBEDDED_MPV_FRAME_COPY',
};
/**
* Dotted paths of the leaf values that differ between the staged form value
* and the saved one. Nested groups (dashboard rails, TMDB) count per leaf,
* so "3 unsaved changes" means three switches, not one group.
*/
export function diffSettingsValues(
current: unknown,
saved: unknown,
path = ''
): string[] {
if (isPlainObject(current) && isPlainObject(saved)) {
const keys = new Set([...Object.keys(current), ...Object.keys(saved)]);
const changed: string[] = [];
for (const key of keys) {
changed.push(
...diffSettingsValues(
current[key],
saved[key],
path ? `${path}.${key}` : key
)
);
}
return changed;
}
return JSON.stringify(current ?? null) === JSON.stringify(saved ?? null)
? []
: [path];
}
/** Section page of a changed path, `null` for an unmapped control. */
export function sectionOfSettingsPath(path: string): string | null {
return SETTINGS_CONTROL_SECTIONS[path.split('.')[0]] ?? null;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
}
@@ -1,212 +1,236 @@
<section class="settings-group" id="dashboard" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>dashboard</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.DASHBOARD' | translate }}</h3>
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.DASHBOARD' | translate }}</h2>
<p>{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}</p>
</header>
<div class="setting-item" data-setting-id="show-dashboard">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}</h4>
<p>
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
</p>
<section class="settings-section">
<div class="settings-card">
<div class="setting-item" data-setting-id="show-dashboard">
<div class="setting-item__meta">
<h4 id="show-dashboard-label">
{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}
</h4>
<p>
{{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="showDashboard"
aria-labelledby="show-dashboard-label"
data-test-id="toggle-show-dashboard"
></mat-slide-toggle>
</div>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showDashboard"
data-test-id="toggle-show-dashboard"
></mat-checkbox>
</div>
</div>
</section>
<div formGroupName="dashboardRails">
<div class="setting-item" data-setting-id="dashboard-hero">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_HERO' | translate }}</h4>
<p>
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="hero"
data-test-id="toggle-dashboard-hero"
></mat-checkbox>
</div>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_RAILS' | translate }}</h3>
</div>
<div class="settings-card" formGroupName="dashboardRails">
<div class="setting-item" data-setting-id="dashboard-hero">
<div class="setting-item__meta">
<h4 id="dashboard-hero-label">
{{ 'SETTINGS.DASHBOARD_HERO' | translate }}
</h4>
<p>
{{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="hero"
aria-labelledby="dashboard-hero-label"
data-test-id="toggle-dashboard-hero"
></mat-slide-toggle>
</div>
</div>
<div
class="setting-item"
data-setting-id="dashboard-rail-continue-watching"
>
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
| translate
}}
</p>
<div
class="setting-item"
data-setting-id="dashboard-rail-continue-watching"
>
<div class="setting-item__meta">
<h4 id="dashboard-rail-continue-watching-label">
{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="continueWatching"
aria-labelledby="dashboard-rail-continue-watching-label"
data-test-id="toggle-dashboard-rail-continue-watching"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="continueWatching"
data-test-id="toggle-dashboard-rail-continue-watching"
></mat-checkbox>
</div>
</div>
<div
class="setting-item"
data-setting-id="dashboard-rail-live-favorites"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
| translate
}}
</p>
<div
class="setting-item"
data-setting-id="dashboard-rail-live-favorites"
>
<div class="setting-item__meta">
<h4 id="dashboard-rail-live-favorites-label">
{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="liveFavorites"
aria-labelledby="dashboard-rail-live-favorites-label"
data-test-id="toggle-dashboard-rail-live-favorites"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="liveFavorites"
data-test-id="toggle-dashboard-rail-live-favorites"
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
| translate
}}
</p>
<div class="setting-item" data-setting-id="dashboard-rail-recent-live">
<div class="setting-item__meta">
<h4 id="dashboard-rail-recent-live-label">
{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="recentlyWatchedLive"
aria-labelledby="dashboard-rail-recent-live-label"
data-test-id="toggle-dashboard-rail-recently-watched-live"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="recentlyWatchedLive"
data-test-id="toggle-dashboard-rail-recently-watched-live"
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
| translate
}}
</p>
<div class="setting-item" data-setting-id="dashboard-rail-favorite-vod">
<div class="setting-item__meta">
<h4 id="dashboard-rail-favorite-vod-label">
{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="favoriteMoviesAndSeries"
aria-labelledby="dashboard-rail-favorite-vod-label"
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="favoriteMoviesAndSeries"
data-test-id="toggle-dashboard-rail-favorite-movies-and-series"
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="dashboard-rail-sources">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
| translate
}}
</p>
<div class="setting-item" data-setting-id="dashboard-rail-sources">
<div class="setting-item__meta">
<h4 id="dashboard-rail-sources-label">
{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="recentSources"
aria-labelledby="dashboard-rail-sources-label"
data-test-id="toggle-dashboard-rail-recent-sources"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="recentSources"
data-test-id="toggle-dashboard-rail-recent-sources"
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
| translate
}}
</p>
<div class="setting-item" data-setting-id="dashboard-rail-xtream-added">
<div class="setting-item__meta">
<h4 id="dashboard-rail-xtream-added-label">
{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="xtreamRecentlyAdded"
aria-labelledby="dashboard-rail-xtream-added-label"
data-test-id="toggle-dashboard-rail-xtream-recently-added"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="xtreamRecentlyAdded"
data-test-id="toggle-dashboard-rail-xtream-recently-added"
></mat-checkbox>
</div>
</div>
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-trending"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
| translate
}}
</p>
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-trending"
>
<div class="setting-item__meta">
<h4 id="dashboard-rail-tmdb-trending-label">
{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="tmdbTrending"
aria-labelledby="dashboard-rail-tmdb-trending-label"
data-test-id="toggle-dashboard-rail-tmdb-trending"
></mat-slide-toggle>
</div>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="tmdbTrending"
data-test-id="toggle-dashboard-rail-tmdb-trending"
></mat-checkbox>
</div>
</div>
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-recommendations"
>
<div class="setting-item__meta">
<h4>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
| translate
}}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="tmdbRecommendations"
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
></mat-checkbox>
<div
class="setting-item"
data-setting-id="dashboard-rail-tmdb-recommendations"
>
<div class="setting-item__meta">
<h4 id="dashboard-rail-tmdb-recommendations-label">
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS'
| translate
}}
</h4>
<p>
{{
'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="tmdbRecommendations"
aria-labelledby="dashboard-rail-tmdb-recommendations-label"
data-test-id="toggle-dashboard-rail-tmdb-recommendations"
></mat-slide-toggle>
</div>
</div>
</div>
</div>
</section>
</section>
@@ -6,8 +6,8 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@@ -15,8 +15,8 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
selector: 'app-settings-dashboard-section',
imports: [
CommonModule,
MatCheckboxModule,
MatIconModule,
MatSlideToggleModule,
ReactiveFormsModule,
TranslateModule,
],
@@ -1,238 +1,330 @@
<section class="settings-group" id="epg" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>list_alt</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.NAV_EPG' | translate }}</h2>
<p>{{ 'SETTINGS.EPG_SUBTITLE' | translate }}</p>
</header>
<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>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_GUIDE' | translate }}</h3>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
data-test-id="select-epg-view-mode"
>
@for (option of epgViewModeOptions(); track option.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.epgViewMode === option.value
"
[attr.aria-checked]="
form().value.epgViewMode === option.value
"
[attr.data-test-id]="'epg-view-mode-' + option.value"
role="radio"
(click)="selectEpgViewMode.emit(option.value)"
<div class="settings-card">
<div class="setting-item" 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>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
data-test-id="select-epg-view-mode"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
}
@for (option of epgViewModeOptions(); track option.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.epgViewMode === option.value
"
[attr.aria-checked]="
form().value.epgViewMode === option.value
"
[attr.data-test-id]="
'epg-view-mode-' + option.value
"
role="radio"
(click)="selectEpgViewMode.emit(option.value)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<div class="setting-item" data-setting-id="epg-offset">
<div class="setting-item__meta">
<h4 id="epg-offset-label">
{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}
</h4>
<p>{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}</p>
</div>
<div class="setting-item__control setting-item__control--stack">
<div
class="settings-stepper"
[class.settings-stepper--invalid]="
offsetControl?.invalid
"
>
<button
type="button"
class="settings-stepper__button"
[attr.aria-label]="
'SETTINGS.EPG_OFFSET_STEP_DOWN'
| translate: { minutes: offsetStep }
"
(click)="stepOffset(-offsetStep)"
>
−
</button>
<input
type="number"
min="-720"
max="720"
[step]="offsetStep"
formControlName="epgOffsetMinutes"
aria-labelledby="epg-offset-label"
data-test-id="epg-offset-minutes"
/>
<span class="settings-stepper__unit">min</span>
<button
type="button"
class="settings-stepper__button"
[attr.aria-label]="
'SETTINGS.EPG_OFFSET_STEP_UP'
| translate: { minutes: offsetStep }
"
(click)="stepOffset(offsetStep)"
>
+
</button>
</div>
@if (offsetControl?.invalid) {
<p class="settings-field-error" role="alert">
{{ 'SETTINGS.EPG_OFFSET_ERROR' | translate }}
</p>
}
</div>
</div>
</div>
</div>
</section>
<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>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline">
<mat-label>{{
'SETTINGS.EPG_OFFSET_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
min="-720"
max="720"
step="15"
formControlName="epgOffsetMinutes"
data-test-id="epg-offset-minutes"
/>
<mat-error>{{
'SETTINGS.EPG_OFFSET_ERROR' | translate
}}</mat-error>
</mat-form-field>
</div>
</div>
<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>
<p class="epg-sources__formats-examples">
<span>{{
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_SOURCES' | translate }}</h3>
@if (epgUrl().length > 0) {
<span class="settings-section__count">{{
epgUrl().length
}}</span>
<code>https://example.org/guide.xml.gz</code>
<code>/home/you/epg/guide.xml</code>
<code>C:\epg\guide.xml.gz</code>
<code>file:///…/guide.xml</code>
</p>
</div>
<div class="setting-item__control">
<ng-container formArrayName="epgUrl">
@if (epgUrl().length === 0) {
<div
class="epg-sources-empty"
data-test-id="epg-sources-empty"
<div class="settings-section__actions">
<button
mat-button
type="button"
[disabled]="isClearingEpgData()"
(click)="refreshAllEpg.emit()"
>
<mat-icon class="epg-sources-empty__icon"
>event_busy</mat-icon
<mat-icon>refresh</mat-icon>
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
</button>
<button
mat-stroked-button
type="button"
(click)="addEpgSource.emit()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
</div>
}
</div>
<p class="settings-section__desc">
{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}
</p>
<div class="settings-card">
<div
class="setting-item setting-item--list"
data-setting-id="epg-sources"
>
<ng-container formArrayName="epgUrl">
@if (epgUrl().length === 0) {
<div
class="epg-sources-empty"
data-test-id="epg-sources-empty"
>
<h4 class="epg-sources-empty__title">
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
</h4>
<p class="epg-sources-empty__hint">
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
</p>
</div>
} @else {
<div class="epg-sources">
@for (_ of epgUrl().controls; track _; let i = $index) {
<div class="epg-source-row">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="w-full"
>
<mat-label>{{
'SETTINGS.EPG_URL_PLACEHOLDER'
| translate
}}</mat-label>
<input
matInput
type="text"
spellcheck="false"
[formControlName]="i"
#epgField
/>
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
</mat-form-field>
@if (epgField.value) {
<app-epg-source-status
[url]="epgField.value"
/>
}
@if (canBrowseFiles()) {
<mat-icon class="epg-sources-empty__icon"
>event_busy</mat-icon
>
<p class="epg-sources-empty__title">
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
</p>
<p class="epg-sources-empty__hint">
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
</p>
<button
mat-flat-button
type="button"
(click)="addEpgSource.emit()"
>
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
</div>
} @else {
<div class="epg-sources">
@for (
_ of epgUrl().controls;
track _;
let i = $index
) {
<div class="epg-source-row">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
class="w-full"
>
<input
matInput
type="text"
spellcheck="false"
[formControlName]="i"
[placeholder]="
'SETTINGS.EPG_URL_PLACEHOLDER'
| translate
"
[attr.aria-label]="
'SETTINGS.EPG_URL_LABEL'
| translate
"
#epgField
/>
<mat-error>{{
'SETTINGS.EPG_URL_ERROR' | translate
}}</mat-error>
</mat-form-field>
@if (epgField.value) {
<app-epg-source-status
[url]="epgField.value"
/>
}
@if (canBrowseFiles()) {
<button
mat-icon-button
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
| translate
"
type="button"
data-test-id="epg-source-browse"
(click)="browseEpgSource.emit(i)"
>
<mat-icon>folder_open</mat-icon>
</button>
}
<button
mat-icon-button
[matTooltip]="
'SETTINGS.BROWSE_EPG_FILE'
'SETTINGS.REFRESH_EPG' | translate
"
[attr.aria-label]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
data-test-id="epg-source-refresh"
[disabled]="epgField.value === ''"
(click)="
refreshEpg.emit(epgUrl().value[i])
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE'
| translate
"
[attr.aria-label]="
'SETTINGS.BROWSE_EPG_FILE'
'SETTINGS.REMOVE_EPG_SOURCE'
| translate
"
type="button"
data-test-id="epg-source-browse"
(click)="browseEpgSource.emit(i)"
data-test-id="epg-source-remove"
(click)="removeEpgSource.emit(i)"
>
<mat-icon>folder_open</mat-icon>
<mat-icon>remove</mat-icon>
</button>
}
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REFRESH_EPG' | translate
"
type="button"
data-test-id="epg-source-refresh"
[disabled]="epgField.value === ''"
(click)="refreshEpg.emit(epgUrl().value[i])"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
[matTooltip]="
'SETTINGS.REMOVE_EPG_SOURCE' | translate
"
type="button"
data-test-id="epg-source-remove"
(click)="removeEpgSource.emit(i)"
>
<mat-icon>remove</mat-icon>
</button>
</div>
</div>
}
</div>
}
</ng-container>
<p
class="epg-sources__formats-examples"
data-test-id="epg-source-formats"
>
<span>{{
'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate
}}</span>
<code>https://example.org/guide.xml.gz</code>
<code>/home/you/epg/guide.xml</code>
<code>C:\epg\guide.xml.gz</code>
<code>file:///…/guide.xml</code>
</p>
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_XTREAM_PLAYLISTS' | translate }}</h3>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="prefer-uploaded-epg">
<div class="setting-item__meta">
<h4 id="prefer-uploaded-epg-label">
{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}
</h4>
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="preferUploadedEpgOverXtream"
aria-labelledby="prefer-uploaded-epg-label"
data-test-id="toggle-prefer-uploaded-epg"
></mat-slide-toggle>
</div>
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.DATA' | translate }}</h3>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="epg-clear-data">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}</h4>
<p>{{ 'SETTINGS.CLEAR_EPG_DATA_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
class="app-destructive-button"
type="button"
[disabled]="isClearingEpgData()"
(click)="clearEpgData.emit()"
>
@if (isClearingEpgData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
} @else {
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
}
</div>
}
</ng-container>
</button>
</div>
</div>
</div>
</div>
<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>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="preferUploadedEpgOverXtream"
data-test-id="toggle-prefer-uploaded-epg"
></mat-checkbox>
</div>
</div>
<footer class="settings-group__footer">
<div class="inline-actions">
<button mat-button type="button" (click)="addEpgSource.emit()">
<mat-icon>add</mat-icon>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-button
type="button"
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
(click)="refreshAllEpg.emit()"
>
<mat-icon>refresh</mat-icon>
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
</button>
<button
mat-button
class="app-destructive-button"
type="button"
[disabled]="isClearingEpgData()"
(click)="clearEpgData.emit()"
>
@if (isClearingEpgData()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
} @else {
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
}
</button>
</div>
</footer>
</section>
</section>
@@ -0,0 +1,169 @@
// EPG page: the offset stepper and the sources list. Shared row and card
// styles come from settings.component.scss (ViewEncapsulation.None).
app-settings-epg-section {
display: contents;
}
// Number stepper (EPG offset).
.settings-stepper {
display: flex;
align-items: center;
height: 34px;
border: 1px solid var(--settings-control-border);
border-radius: 8px;
background: var(--settings-control-bg);
overflow: hidden;
&:focus-within {
border-color: var(--app-selection-color);
}
input {
width: 64px;
border: 0;
background: none;
color: var(--app-heading-color);
font: inherit;
font-size: 13px;
text-align: center;
font-variant-numeric: tabular-nums;
appearance: textfield;
-moz-appearance: textfield;
&:focus {
outline: none;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
display: none;
}
}
}
.settings-stepper--invalid {
border-color: var(--mat-sys-error);
}
.settings-stepper__button {
width: 32px;
height: 100%;
border: 0;
background: none;
color: var(--app-body-color);
font-size: 15px;
cursor: pointer;
&:hover {
background: var(--app-hover-overlay);
color: var(--app-heading-color);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
}
.settings-stepper__unit {
padding-right: 10px;
color: var(--settings-muted);
font-size: 12px;
}
.settings-field-error {
margin: 0;
color: var(--mat-sys-error);
font-size: 12px;
line-height: 1.4;
text-align: right;
}
// ─── EPG sources ───────────────────────────────────────────────────────────
.epg-sources {
display: flex;
flex-direction: column;
}
.epg-source-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px 8px 16px;
& + & {
border-top: 1px solid var(--settings-item-border);
}
.w-full {
width: 100%;
}
app-epg-source-status {
flex-shrink: 0;
}
}
.epg-sources-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 34px 20px 24px;
color: var(--settings-muted);
text-align: center;
line-height: 1.55;
.epg-sources-empty__icon {
width: 28px;
height: 28px;
font-size: 28px;
margin-bottom: 8px;
color: var(--app-muted-color);
}
.epg-sources-empty__title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--app-heading-color);
}
.epg-sources-empty__hint {
margin: 0;
max-width: 42ch;
font-size: 12.5px;
}
button {
margin-top: 14px;
}
}
.epg-sources__formats-examples {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
margin: 0;
padding: 10px 16px 12px;
border-top: 1px solid var(--settings-item-border);
color: var(--settings-muted);
font-size: 11.5px;
code {
padding: 1px 6px;
border-radius: 6px;
background: var(--settings-chip-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
white-space: nowrap;
}
}
// Pane width, see settings.component.scss.
@container (max-width: 600px) {
.settings-field-error {
text-align: left;
}
}
@@ -0,0 +1,105 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
import { SETTINGS_EPG_VIEW_MODE_OPTIONS } from './settings-options';
function createForm(offset = 0): FormGroup {
return new FormGroup({
epgUrl: new FormArray<FormControl<string | null>>([]),
epgViewMode: new FormControl('timeline'),
epgOffsetMinutes: new FormControl<number>(offset, [
Validators.required,
Validators.min(-720),
Validators.max(720),
]),
preferUploadedEpgOverXtream: new FormControl(false),
});
}
describe('SettingsEpgSectionComponent offset stepper', () => {
let fixture: ComponentFixture<SettingsEpgSectionComponent>;
function configure(form: FormGroup) {
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('epgUrl', form.get('epgUrl'));
fixture.componentRef.setInput(
'epgViewModeOptions',
SETTINGS_EPG_VIEW_MODE_OPTIONS
);
fixture.detectChanges();
return form;
}
const query = (selector: string) =>
(fixture.nativeElement as HTMLElement).querySelector(selector);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SettingsEpgSectionComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(SettingsEpgSectionComponent);
});
it('moves the offset by one step and stages the change', () => {
const form = configure(createForm(0));
fixture.componentInstance.stepOffset(30);
expect(form.get('epgOffsetMinutes')?.value).toBe(30);
expect(form.get('epgOffsetMinutes')?.dirty).toBe(true);
expect(form.dirty).toBe(true);
fixture.componentInstance.stepOffset(-30);
fixture.componentInstance.stepOffset(-30);
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
});
it('clamps the buttons to the accepted range and recovers a bad value', () => {
const form = configure(createForm(710));
fixture.componentInstance.stepOffset(30);
expect(form.get('epgOffsetMinutes')?.value).toBe(720);
form.get('epgOffsetMinutes')?.setValue(Number.NaN);
fixture.componentInstance.stepOffset(-30);
expect(form.get('epgOffsetMinutes')?.value).toBe(-30);
});
it('shows the range error under the stepper for a typed value out of range', () => {
const form = configure(createForm(0));
expect(query('.settings-field-error')).toBeNull();
form.get('epgOffsetMinutes')?.setValue(900);
fixture.detectChanges();
expect(query('.settings-stepper--invalid')).not.toBeNull();
expect(query('.settings-field-error')).not.toBeNull();
});
it('offers Add in the empty state and in the header once a source exists', () => {
const form = configure(createForm());
const addButtons = () =>
Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('button')
).filter((button) =>
button.textContent?.includes('SETTINGS.ADD_EPG_SOURCE')
);
expect(query('[data-test-id="epg-sources-empty"]')).not.toBeNull();
expect(addButtons()).toHaveLength(1);
expect(query('.settings-section__actions')).toBeNull();
(form.get('epgUrl') as FormArray).push(new FormControl('https://x'));
fixture.detectChanges();
expect(query('[data-test-id="epg-sources-empty"]')).toBeNull();
expect(addButtons()).toHaveLength(1);
expect(
query('.settings-section__actions')?.contains(addButtons()[0])
).toBe(true);
expect(query('.settings-section__count')?.textContent?.trim()).toBe(
'1'
);
});
});
@@ -5,13 +5,18 @@ import {
ViewEncapsulation,
ChangeDetectionStrategy,
} from '@angular/core';
import { FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
import {
AbstractControl,
FormArray,
FormGroup,
ReactiveFormsModule,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { EpgViewMode } from '@iptvnator/shared/interfaces';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
@@ -24,11 +29,11 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
imports: [
EpgSourceStatusComponent,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSlideToggleModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
@@ -36,7 +41,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
templateUrl: './settings-epg-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
styleUrls: ['./settings-epg-section.component.scss'],
})
export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>();
@@ -59,4 +64,35 @@ export class SettingsEpgSectionComponent {
readonly refreshAllEpg = output<void>();
readonly clearEpgData = output<void>();
readonly selectEpgViewMode = output<EpgViewMode>();
/** Minutes one stepper click shifts the displayed times by */
readonly offsetStep = 30;
get offsetControl(): AbstractControl | null {
return this.form().get('epgOffsetMinutes');
}
/**
* Moves the offset by one step within the accepted range. Typed values
* keep going through the control's own validation; only the buttons
* clamp, so an out-of-range typed value snaps back into range.
*/
stepOffset(delta: number): void {
const control = this.offsetControl;
if (!control) return;
const current = Number(control.value);
const next = Math.max(
EPG_OFFSET_MIN,
Math.min(
EPG_OFFSET_MAX,
(Number.isFinite(current) ? current : 0) + delta
)
);
control.setValue(next);
control.markAsDirty();
this.form().markAsDirty();
}
}
const EPG_OFFSET_MIN = -720;
const EPG_OFFSET_MAX = 720;
@@ -1,4 +1,4 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder } from '@angular/forms';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
@@ -16,6 +16,10 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import { AppDateLocaleService } from '../app-date-locales';
import {
diffSettingsValues,
SETTINGS_RESTART_CONTROLS,
} from './settings-change-tracking';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
@@ -50,6 +54,24 @@ export class SettingsFormFacade {
/** Form array with epg sources — absent when EPG is unsupported */
readonly epgUrl = this.form.get('epgUrl') as FormArray;
/**
* Dotted paths of the staged values that differ from the saved ones.
* Drives the save bar's change count and the dirty marks in the nav;
* the form's own `dirty` flag stays what the save/leave guards read.
*/
readonly changedPaths = signal<readonly string[]>([]);
/** Controls whose saved change waits for a restart (cleared on Later). */
readonly restartPendingControls = signal<readonly string[]>([]);
private savedSnapshot: unknown = {};
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshChangedPaths());
}
/** Trimmed, non-empty EPG source URLs currently in the form */
get epgUrls(): string[] {
return ((this.epgUrl?.value as string[] | undefined) ?? [])
@@ -82,6 +104,7 @@ export class SettingsFormFacade {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
this.takeSavedSnapshot();
}
bindDashboardControlsEnabledState(): void {
@@ -164,6 +187,10 @@ export class SettingsFormFacade {
this.form,
this.settingsStore.getSettings()
);
// Read before the write: a successful save resets the diff.
const restartControls = this.changedPaths()
.map((path) => path.split('.')[0])
.filter((control) => control in SETTINGS_RESTART_CONTROLS);
let cleanupError: EpgSourceReconciliationError | undefined;
try {
@@ -176,7 +203,17 @@ export class SettingsFormFacade {
// committed values, but retain the dirty form for cleanup retry.
cleanupError = error;
}
if (!cleanupError) onSaved();
if (!cleanupError) {
onSaved();
if (restartControls.length > 0) {
this.restartPendingControls.set([
...new Set([
...this.restartPendingControls(),
...restartControls,
]),
]);
}
}
if (window.electron) {
window.electron.updateSettings(settings);
@@ -189,9 +226,14 @@ export class SettingsFormFacade {
if (cleanupError) throw cleanupError;
}
dismissRestartNotice(): void {
this.restartPendingControls.set([]);
}
/** Applies the saved language/theme and resets the dirty state */
applySavedSettings(): void {
this.form.markAsPristine();
this.takeSavedSnapshot();
// The switch re-renders every date with the new locale; its data is
// a lazy chunk that must be registered first, and a newer choice
// must win over an older one whose data arrives later.
@@ -201,6 +243,17 @@ export class SettingsFormFacade {
);
}
private takeSavedSnapshot(): void {
this.savedSnapshot = this.form.getRawValue();
this.refreshChangedPaths();
}
private refreshChangedPaths(): void {
this.changedPaths.set(
diffSettingsValues(this.form.getRawValue(), this.savedSnapshot)
);
}
private patchAndMarkDirty(
value: SettingsFormPatch,
controlName: string
@@ -1,276 +1,311 @@
<section class="settings-group" id="general" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.GENERAL' | translate }}</h3>
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.GENERAL' | translate }}</h2>
<p>{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}</p>
</header>
<div class="setting-item" data-setting-id="language">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.LANGUAGE' | translate }}</h4>
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_APPEARANCE' | translate }}</h3>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
}}</mat-label>
<mat-select
formControlName="language"
data-test-id="select-language"
>
@for (
language of languageEnum() | keyvalue;
track language
) {
<mat-option
[value]="language.value"
[attr.data-test-id]="language.value"
>{{
'LANGUAGES.' + language.key | translate
}}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
</div>
<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>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.THEME' | translate"
data-test-id="select-theme"
>
@for (themeOption of themeOptions(); track themeOption.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.theme === themeOption.value
"
[attr.aria-checked]="
form().value.theme === themeOption.value
"
[attr.data-test-id]="themeOption.value"
role="radio"
(click)="selectTheme.emit(themeOption.value)"
<div class="settings-card">
<div class="setting-item" data-setting-id="language">
<div class="setting-item__meta">
<h4 id="language-label">
{{ 'SETTINGS.LANGUAGE' | translate }}
</h4>
<p>{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-icon>{{ themeOption.icon }}</mat-icon>
<span>{{ themeOption.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<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>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
data-test-id="select-cover-size"
>
@for (option of coverSizeOptions(); track option.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.coverSize === option.value
"
[attr.aria-checked]="
form().value.coverSize === option.value
"
[attr.data-test-id]="'cover-size-' + option.value"
role="radio"
(click)="selectCoverSize.emit(option.value)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<div class="setting-item" data-setting-id="cover-titles">
<div class="setting-item__meta">
<h4 id="cover-titles-label">
{{ 'SETTINGS.COVER_TITLES' | translate }}
</h4>
<p id="cover-titles-description">
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="showCoverTitles"
aria-labelledby="cover-titles-label"
aria-describedby="cover-titles-description"
data-test-id="cover-titles-toggle"
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="startup-behavior">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}</h4>
<p>
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.STARTUP_BEHAVIOR' | translate
}}</mat-label>
<mat-select
formControlName="startupBehavior"
data-test-id="select-startup-behavior"
>
@for (
option of startupBehaviorOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="option.value"
<mat-select
formControlName="language"
aria-labelledby="language-label"
data-test-id="select-language"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (supportsStartupWindowMode()) {
<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>
{{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }}
</p>
@for (
language of languageEnum() | keyvalue;
track language
) {
<mat-option
[value]="language.value"
[attr.data-test-id]="language.value"
>{{
'LANGUAGES.' + language.key | translate
}}</mat-option
>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.STARTUP_WINDOW_MODE' | translate
}}</mat-label>
<mat-select
formControlName="startupWindowMode"
data-test-id="select-startup-window-mode"
<div class="setting-item" data-setting-id="theme">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.THEME' | translate }}</h4>
<p>{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.THEME' | translate"
data-test-id="select-theme"
>
@for (
option of startupWindowModeOptions();
track option.value
themeOption of themeOptions();
track themeOption.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'startup-window-mode-' + option.value
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.theme === themeOption.value
"
[attr.aria-checked]="
form().value.theme === themeOption.value
"
[attr.data-test-id]="themeOption.value"
role="radio"
(click)="selectTheme.emit(themeOption.value)"
>
{{ option.labelKey | translate }}
</mat-option>
<mat-icon>{{ themeOption.icon }}</mat-icon>
<span>{{ themeOption.labelKey | translate }}</span>
</button>
}
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</div>
}
<div class="setting-item" data-setting-id="show-captions">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
<p>
{{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
</div>
</div>
<div class="setting-item" data-setting-id="cover-size">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COVER_SIZE' | translate }}</h4>
<p>{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control">
<div
class="theme-switcher"
role="radiogroup"
[attr.aria-label]="'SETTINGS.COVER_SIZE' | translate"
data-test-id="select-cover-size"
>
@for (option of coverSizeOptions(); track option.value) {
<button
type="button"
class="theme-switcher__option"
[class.theme-switcher__option--selected]="
form().value.coverSize === option.value
"
[attr.aria-checked]="
form().value.coverSize === option.value
"
[attr.data-test-id]="
'cover-size-' + option.value
"
role="radio"
(click)="selectCoverSize.emit(option.value)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
</div>
</div>
<div class="setting-item" data-setting-id="strip-country-prefix">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
<p>
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="stripCountryPrefix"></mat-checkbox>
</div>
</div>
</section>
@if (supportsPortalConnectivityGuard()) {
<section
class="settings-group"
[formGroup]="form()"
data-test-id="portal-connectivity-setting"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>hub</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
</div>
</header>
<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 }}
</h4>
<p id="portal-connectivity-description">
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
| translate
}}
</p>
@if (form().get('portalConnectivityGuard')?.value === false) {
<p>
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
| translate
}}
<div class="setting-item" data-setting-id="cover-titles">
<div class="setting-item__meta">
<h4 id="cover-titles-label">
{{ 'SETTINGS.COVER_TITLES' | translate }}
</h4>
<p id="cover-titles-description">
{{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}
</p>
}
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="portalConnectivityGuard"
aria-labelledby="portal-connectivity-label"
aria-describedby="portal-connectivity-description"
data-test-id="portal-connectivity-toggle"
></mat-checkbox>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="showCoverTitles"
aria-labelledby="cover-titles-label"
aria-describedby="cover-titles-description"
data-test-id="cover-titles-toggle"
></mat-slide-toggle>
</div>
</div>
</div>
</section>
}
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_STARTUP' | translate }}</h3>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="startup-behavior">
<div class="setting-item__meta">
<h4 id="startup-behavior-label">
{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}
</h4>
<p>
{{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-select
formControlName="startupBehavior"
aria-labelledby="startup-behavior-label"
data-test-id="select-startup-behavior"
>
@for (
option of startupBehaviorOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="option.value"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
@if (supportsStartupWindowMode()) {
<div
class="setting-item"
data-setting-id="startup-window-mode"
data-test-id="startup-window-mode-setting"
>
<div class="setting-item__meta">
<h4 id="startup-window-mode-label">
{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}
<span class="settings-chip">{{
'SETTINGS.CHIP_RESTART' | translate
}}</span>
</h4>
<p>
{{
'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-select
formControlName="startupWindowMode"
aria-labelledby="startup-window-mode-label"
data-test-id="select-startup-window-mode"
>
@for (
option of startupWindowModeOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'startup-window-mode-' +
option.value
"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</div>
</section>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_CHANNELS' | translate }}</h3>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="strip-country-prefix">
<div class="setting-item__meta">
<h4 id="strip-country-prefix-label">
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}
</h4>
<p id="strip-country-prefix-description">
{{
'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="stripCountryPrefix"
aria-labelledby="strip-country-prefix-label"
aria-describedby="strip-country-prefix-description"
data-test-id="strip-country-prefix-toggle"
></mat-slide-toggle>
</div>
</div>
</div>
</section>
@if (supportsPortalConnectivityGuard()) {
<section
class="settings-section"
data-test-id="portal-connectivity-setting"
>
<div class="settings-section__header">
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
</div>
<div class="settings-card">
<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 }}
</h4>
<p id="portal-connectivity-description">
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
| translate
}}
</p>
@if (
form().get('portalConnectivityGuard')?.value ===
false
) {
<p>
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
| translate
}}
</p>
}
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="portalConnectivityGuard"
aria-labelledby="portal-connectivity-label"
aria-describedby="portal-connectivity-description"
data-test-id="portal-connectivity-toggle"
></mat-slide-toggle>
</div>
</div>
</div>
</section>
}
</section>
@@ -7,10 +7,10 @@ import {
ChangeDetectionStrategy,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
import {
@@ -25,10 +25,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
selector: 'app-settings-general-section',
imports: [
CommonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule,
TranslateModule,
],
@@ -92,12 +92,26 @@ describe('buildSettingsSectionNavItems', () => {
'playback',
'epg',
'dashboard',
'remote-control',
'tmdb',
'parental',
'remote-control',
'backup',
'reset',
'about',
]);
});
it('groups the sections and pins About to the footer', () => {
const items = buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
});
const groupOf = (id: string) =>
items.find((item) => item.id === id)?.group;
expect(groupOf('general')).toBe('app');
expect(groupOf('tmdb')).toBe('library');
expect(groupOf('remote-control')).toBe('devices');
expect(groupOf('backup')).toBe('data');
expect(groupOf('about')).toBeUndefined();
});
});
@@ -177,5 +177,6 @@ export function buildSettingsSectionNavItems({
label: section.navLabelKey,
icon: section.icon,
visible: meetsSettingsRequirements(section.requires, capabilities),
...(section.group ? { group: section.group } : {}),
}));
}
@@ -1,145 +1,151 @@
<section class="settings-group" id="parental">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>family_restroom</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h3>
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}</h2>
<p>{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}</p>
</header>
<div class="setting-item" data-setting-id="parental-lock-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
<p>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
data-test-id="parental-lock-enabled"
[checked]="enabled()"
[disabled]="busy()"
(change)="onToggleEnabled($event)"
[attr.aria-label]="'SETTINGS.PARENTAL_LOCK.ENABLE' | translate"
></mat-slide-toggle>
</div>
</div>
@if (enabled()) {
<div class="setting-item" data-setting-id="parental-lock-status">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
<p>
{{
(unlocked()
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
) | translate
}}
</p>
</div>
<div class="setting-item__control">
@if (unlocked()) {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-lock-now"
<section class="settings-section">
<div class="settings-card">
<div class="setting-item" data-setting-id="parental-lock-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}</h4>
<p>
{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
data-test-id="parental-lock-enabled"
[checked]="enabled()"
[disabled]="busy()"
(click)="lockNow.emit()"
>
<mat-icon>lock</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
</button>
} @else {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-unlock"
[disabled]="busy()"
(click)="unlock.emit()"
>
<mat-icon>lock_open</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
</button>
}
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-relock">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.PARENTAL_LOCK.RELOCK' | translate
}}</mat-label>
<mat-select
data-test-id="parental-lock-relock"
[value]="relockMinutes()"
[disabled]="busy() || !unlocked()"
(selectionChange)="
relockMinutesChanged.emit($event.value)
(change)="onToggleEnabled($event)"
[attr.aria-label]="
'SETTINGS.PARENTAL_LOCK.ENABLE' | translate
"
>
@for (option of relockOptions(); track option) {
<mat-option [value]="option">
{{
relockLabelKey(option)
| translate: { minutes: option }
}}
</mat-option>
></mat-slide-toggle>
</div>
</div>
@if (enabled()) {
<div class="setting-item" data-setting-id="parental-lock-status">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}</h4>
<p>
{{
(unlocked()
? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED'
: 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED'
) | translate
}}
</p>
</div>
<div class="setting-item__control">
@if (unlocked()) {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-lock-now"
[disabled]="busy()"
(click)="lockNow.emit()"
>
<mat-icon>lock</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.LOCK_NOW' | translate }}
</button>
} @else {
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-unlock"
[disabled]="busy()"
(click)="unlock.emit()"
>
<mat-icon>lock_open</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.UNLOCK' | translate }}
</button>
}
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-change-pin">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-change-pin"
[disabled]="busy() || !hasPin()"
(click)="changePin.emit()"
>
<mat-icon>password</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
</button>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-relock">
<div class="setting-item__meta">
<h4 id="parental-lock-relock-label">
{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}
</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-select
data-test-id="parental-lock-relock"
aria-labelledby="parental-lock-relock-label"
[value]="relockMinutes()"
[disabled]="busy() || !unlocked()"
(selectionChange)="
relockMinutesChanged.emit($event.value)
"
>
@for (option of relockOptions(); track option) {
<mat-option [value]="option">
{{
relockLabelKey(option)
| translate: { minutes: option }
}}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="setting-item" data-setting-id="parental-lock-how-to">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
| translate
}}
</p>
</div>
</div>
}
<div class="setting-item" data-setting-id="parental-lock-change-pin">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
data-test-id="parental-lock-change-pin"
[disabled]="busy() || !hasPin()"
(click)="changePin.emit()"
>
<mat-icon>password</mat-icon>
{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}
</button>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<p>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</p>
<div class="setting-item" data-setting-id="parental-lock-how-to">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}</h4>
<p>
{{
'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION'
| translate
}}
</p>
</div>
</div>
}
</div>
</section>
<div class="settings-foot">
<span>{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}</span>
</div>
</section>
@@ -74,6 +74,7 @@ function createForm(player: VideoPlayer): FormGroup {
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
detailTrailerBackdrop: new FormControl(false),
showCaptions: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
File diff suppressed because it is too large. Load diff
@@ -167,31 +167,31 @@ describe('SettingsPlaybackSectionComponent', () => {
}
);
it('binds the shared web controls checkbox to the settings form', () => {
it('binds the shared web controls switch to the settings form', () => {
const form = createForm();
form.controls['webPlayerSharedControls'].setValue(true);
fixture.componentRef.setInput('form', form);
fixture.detectChanges();
const checkbox = (
const toggle = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
).querySelector<HTMLButtonElement>(
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
);
expect(checkbox?.checked).toBe(true);
expect(toggle?.getAttribute('aria-checked')).toBe('true');
});
it('labels the rendered native shared web controls checkbox', () => {
it('labels the rendered shared web controls switch', () => {
fixture.detectChanges();
const checkbox = (
const toggle = (
fixture.nativeElement as HTMLElement
).querySelector<HTMLInputElement>(
'[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]'
).querySelector<HTMLButtonElement>(
'[data-test-id="web-player-shared-controls-toggle"] button[role="switch"]'
);
expect(checkbox?.getAttribute('aria-label')).toBe(
expect(toggle?.getAttribute('aria-label')).toBe(
WEB_PLAYER_SHARED_CONTROLS_LABEL
);
});
@@ -458,6 +458,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
detailTrailerBackdrop: new FormControl(false),
showCaptions: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
@@ -8,11 +8,12 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
StreamFormat,
@@ -27,11 +28,12 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
imports: [
CommonModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatSlideToggleModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
@@ -114,4 +116,8 @@ export class SettingsPlaybackSectionComponent {
const player = this.form().value.player;
return player === VideoPlayer.MPV || player === VideoPlayer.VLC;
}
isEmbeddedMpvSelected(): boolean {
return this.form().value.player === VideoPlayer.EmbeddedMpv;
}
}
@@ -1,134 +1,153 @@
<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>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h3>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.REMOTE_CONTROL' | translate }}</h2>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
</header>
<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>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="remoteControl"
data-test-id="remote-control-enabled"
></mat-checkbox>
</div>
</div>
@if (form().value.remoteControl === true) {
<div class="setting-item" data-setting-id="remote-control-port">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
<p>
{{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.PORT_RANGE_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
id="remoteControlPort"
data-test-id="remote-control-port"
formControlName="remoteControlPort"
min="1"
max="65535"
step="1"
/>
@if (
form().get('remoteControlPort')?.hasError('required')
) {
<mat-error>{{
'SETTINGS.PORT_REQUIRED' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('min')) {
<mat-error>{{
'SETTINGS.PORT_MIN' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('max')) {
<mat-error>{{
'SETTINGS.PORT_MAX' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('pattern')) {
<mat-error>{{
'SETTINGS.PORT_PATTERN' | translate
}}</mat-error>
}
</mat-form-field>
</div>
</div>
@if (localIpAddresses().length > 0) {
<div class="setting-item" data-setting-id="remote-control-url">
<section class="settings-section">
<div class="settings-card">
<div class="setting-item" data-setting-id="remote-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
<p>
{{
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
| translate
}}
</p>
<h4 id="remote-control-label">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
</h4>
<p>{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control remote-control-urls">
@for (ip of localIpAddresses(); track ip) {
<div class="remote-control-url-item">
<div class="url-row">
<a
[href]="
'http://' +
ip +
':' +
form().value.remoteControlPort
"
target="_blank"
class="remote-control-url"
>
http://{{ ip }}:{{
form().value.remoteControlPort
}}
</a>
<button
mat-icon-button
type="button"
(click)="toggleQrCode.emit(ip)"
[matTooltip]="
'SETTINGS.SHOW_QR_CODE' | translate
"
>
<mat-icon>qr_code_2</mat-icon>
</button>
</div>
@if (visibleQrCodeIp() === ip) {
<div class="qr-code-container">
<qrcode
[qrdata]="
'http://' +
ip +
':' +
form().value.remoteControlPort
"
[width]="150"
errorCorrectionLevel="M"
></qrcode>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="remoteControl"
aria-labelledby="remote-control-label"
data-test-id="remote-control-enabled"
></mat-slide-toggle>
</div>
</div>
@if (form().value.remoteControl === true) {
<div class="setting-item" data-setting-id="remote-control-port">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}</h4>
<p>
{{
'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
class="settings-select"
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.PORT_RANGE_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
id="remoteControlPort"
data-test-id="remote-control-port"
formControlName="remoteControlPort"
min="1"
max="65535"
step="1"
/>
@if (
form()
.get('remoteControlPort')
?.hasError('required')
) {
<mat-error>{{
'SETTINGS.PORT_REQUIRED' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('min')) {
<mat-error>{{
'SETTINGS.PORT_MIN' | translate
}}</mat-error>
}
@if (form().get('remoteControlPort')?.hasError('max')) {
<mat-error>{{
'SETTINGS.PORT_MAX' | translate
}}</mat-error>
}
@if (
form()
.get('remoteControlPort')
?.hasError('pattern')
) {
<mat-error>{{
'SETTINGS.PORT_PATTERN' | translate
}}</mat-error>
}
</mat-form-field>
</div>
</div>
@if (localIpAddresses().length > 0) {
<div class="setting-item" data-setting-id="remote-control-url">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}</h4>
<p>
{{
'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control remote-control-urls">
@for (ip of localIpAddresses(); track ip) {
<div class="remote-control-url-item">
<div class="url-row">
<a
[href]="
'http://' +
ip +
':' +
form().value.remoteControlPort
"
target="_blank"
class="remote-control-url"
>
http://{{ ip }}:{{
form().value.remoteControlPort
}}
</a>
<button
mat-icon-button
type="button"
(click)="toggleQrCode.emit(ip)"
[matTooltip]="
'SETTINGS.SHOW_QR_CODE' | translate
"
[attr.aria-label]="
'SETTINGS.SHOW_QR_CODE' | translate
"
>
<mat-icon>qr_code_2</mat-icon>
</button>
</div>
@if (visibleQrCodeIp() === ip) {
<div class="qr-code-container">
<qrcode
[qrdata]="
'http://' +
ip +
':' +
form().value
.remoteControlPort
"
[width]="150"
errorCorrectionLevel="M"
></qrcode>
</div>
}
</div>
}
</div>
}
</div>
</div>
}
}
</div>
}
}
</div>
</section>
</section>
@@ -0,0 +1,45 @@
// Remote control page: the address list with QR codes.
app-settings-remote-control-section {
display: contents;
}
// ─── Remote control ────────────────────────────────────────────────────────
.remote-control-urls {
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.url-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.remote-control-url {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
color: var(--app-heading-color);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.qr-code-container {
display: flex;
justify-content: flex-end;
padding: 8px 0;
}
// Pane width, see settings.component.scss.
@container (max-width: 600px) {
.remote-control-urls,
.qr-code-container,
.url-row {
align-items: flex-start;
justify-content: flex-start;
}
}
@@ -7,10 +7,10 @@ import {
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
@@ -20,10 +20,10 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
selector: 'app-settings-remote-control-section',
imports: [
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSlideToggleModule,
MatTooltipModule,
QRCodeComponent,
ReactiveFormsModule,
@@ -32,7 +32,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
templateUrl: './settings-remote-control-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
styleUrls: ['./settings-remote-control-section.component.scss'],
})
export class SettingsRemoteControlSectionComponent {
readonly form = input.required<FormGroup>();
@@ -1,90 +0,0 @@
<section class="settings-group" id="reset">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>delete_sweep</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.RESET' | translate }}</h3>
<p>{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}</p>
</div>
</header>
<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>
<p>{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}</p>
</div>
<button
mat-button
class="danger-zone__button app-destructive-button"
[class.danger-zone__button--busy]="isRemovingAllPlaylists()"
[disabled]="!canRemoveAllPlaylists()"
[attr.aria-busy]="isRemovingAllPlaylists()"
(click)="
$event.preventDefault();
$event.stopPropagation();
removeAll.emit()
"
>
@if (isRemovingAllPlaylists()) {
<mat-progress-spinner
class="button-spinner"
diameter="16"
mode="indeterminate"
/>
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_IN_PROGRESS' | translate
}}</span>
} @else {
<span class="inline-actions__label">{{
'SETTINGS.REMOVE_ALL_BUTTON' | translate
}}</span>
}
</button>
</div>
<div class="danger-zone__summary" aria-live="polite">
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.m3u
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.M3U' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().m3u
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.xtream
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.XTREAM' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().xtream
}}</span>
</span>
<span class="danger-zone__summary-pill">
<mat-icon class="danger-zone__summary-icon">{{
sourceIcons.stalker
}}</mat-icon>
<span class="danger-zone__summary-label">{{
'HOME.PLAYLIST_TYPES.STALKER' | translate
}}</span>
<span class="danger-zone__summary-count">{{
playlistDeleteSummary().stalker
}}</span>
</span>
</div>
@if (removeAllProgressLabel(); as progressLabel) {
<p class="danger-zone__progress">
{{ progressLabel }}
</p>
}
</div>
</section>
@@ -1,37 +0,0 @@
import {
Component,
input,
output,
ViewEncapsulation,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { SettingsPlaylistDeleteSummary } from './settings.models';
@Component({
selector: 'app-settings-reset-section',
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
TranslateModule,
],
templateUrl: './settings-reset-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsResetSectionComponent {
readonly sourceIcons = SOURCE_TYPE_ICONS;
readonly isRemovingAllPlaylists = input(false);
readonly canRemoveAllPlaylists = input(false);
readonly playlistDeleteSummary =
input.required<SettingsPlaylistDeleteSummary>();
readonly removeAllProgressLabel = input<string | null>(null);
readonly removeAll = output<void>();
}
@@ -21,6 +21,11 @@ const EN = JSON.parse(
readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8')
);
/**
* Reveal anchors: any element carrying `data-setting-id` (rows, but also
* the About cards and footer), plus every `setting-item` row so a titled
* row without an anchor is caught too.
*/
function readTemplateRows(): TemplateRow[] {
const rows: TemplateRow[] = [];
const files = readdirSync(SETTINGS_DIR).filter((file) =>
@@ -31,7 +36,9 @@ function readTemplateRows(): TemplateRow[] {
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),
...html.matchAll(
/<\w+\b(?:[^>]*class="setting-item(?:\s[^"]*)?"|[^>]*data-setting-id=")/g
),
];
starts.forEach((match, index) => {
const end = starts[index + 1]?.index ?? html.length;
@@ -43,11 +50,11 @@ function readTemplateRows(): TemplateRow[] {
id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null,
labelKey:
chunk.match(
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
/<h4[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
)?.[1] ?? null,
descriptionKey:
meta.match(
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/
/<p[^>]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/
)?.[1] ?? null,
});
});
@@ -4,21 +4,19 @@
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>
<h2 id="settings-search-results-title">
{{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }}
</h2>
<p aria-live="polite" data-test-id="settings-search-summary">
{{
'SETTINGS.SEARCH_RESULTS_COUNT'
| translate: { count: results().length, query: query() }
}}
</p>
</header>
@if (results().length > 0) {
<ul class="settings-search-results__list">
<ul class="settings-search-results__list settings-card">
@for (result of results(); track result.entry.id) {
<li>
<button
@@ -14,7 +14,7 @@
gap: 14px;
width: 100%;
min-height: 56px;
padding: 12px 10px;
padding: 12px 16px;
border: 0;
border-top: 1px solid var(--settings-item-border);
border-radius: 0;
@@ -37,6 +37,11 @@
li:first-child .settings-search-result {
border-top: 0;
border-radius: 12px 12px 0 0;
}
li:last-child .settings-search-result {
border-radius: 0 0 12px 12px;
}
.settings-search-result__icon,
@@ -54,16 +59,15 @@ li:first-child .settings-search-result {
}
.settings-search-result__label {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.01em;
font-size: 13.5px;
font-weight: 500;
color: var(--app-heading-color);
}
.settings-search-result__description {
overflow: hidden;
color: var(--settings-muted);
font-size: 0.84rem;
font-size: 12.5px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
@@ -71,11 +75,12 @@ li:first-child .settings-search-result {
.settings-search-result__section {
flex-shrink: 0;
padding: 2px 10px;
border: 1px solid var(--settings-item-border);
padding: 1px 8px;
border-radius: 999px;
color: var(--settings-muted);
font-size: 0.78rem;
background: var(--settings-chip-bg);
color: var(--settings-chip-fg);
font-size: 10.5px;
font-weight: 500;
white-space: nowrap;
}
@@ -1,176 +1,177 @@
<section class="settings-group" id="tmdb" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>movie</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.TMDB' | translate }}</h3>
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
</div>
<h2>{{ 'SETTINGS.TMDB' | translate }}</h2>
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
</header>
<div formGroupName="tmdb">
<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>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="enabled"
data-test-id="tmdb-enabled"
></mat-checkbox>
</div>
</div>
@if (form().value.tmdb?.enabled === true) {
<div class="setting-item" data-setting-id="tmdb-api-key">
<section class="settings-section">
<div class="settings-card" formGroupName="tmdb">
<div class="setting-item" data-setting-id="tmdb-enable">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
<p>
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noreferrer noopener"
data-test-id="tmdb-get-key-link"
<h4 id="tmdb-enable-label">
{{ 'SETTINGS.TMDB_ENABLE' | translate }}
</h4>
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
formControlName="enabled"
aria-labelledby="tmdb-enable-label"
data-test-id="tmdb-enabled"
></mat-slide-toggle>
</div>
</div>
@if (form().value.tmdb?.enabled === true) {
<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>
<p>
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noreferrer noopener"
data-test-id="tmdb-get-key-link"
>
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
</a>
</p>
</div>
<div class="setting-item__control setting-item__control--stack">
<mat-form-field
class="settings-text-field"
appearance="outline"
subscriptSizing="dynamic"
>
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
</a>
<mat-label>{{
'SETTINGS.TMDB_API_KEY' | translate
}}</mat-label>
<input
matInput
type="text"
autocomplete="off"
formControlName="apiKey"
data-test-id="tmdb-api-key"
/>
</mat-form-field>
<div class="tmdb-key-test">
<button
mat-stroked-button
type="button"
[disabled]="
!enteredApiKey || keyTestState() === 'testing'
"
(click)="testApiKey()"
data-test-id="tmdb-test-key"
>
@if (keyTestState() === 'testing') {
<mat-spinner diameter="16" />
} @else {
<mat-icon>network_check</mat-icon>
}
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
</button>
@if (keyTestState() === 'success') {
<span
class="tmdb-key-test__result tmdb-key-test__result--success"
data-test-id="tmdb-test-success"
>
<mat-icon>check_circle</mat-icon>
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
</span>
} @else if (keyTestState() === 'error') {
<span
class="tmdb-key-test__result tmdb-key-test__result--error"
data-test-id="tmdb-test-error"
>
<mat-icon>error</mat-icon>
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
</span>
}
</div>
</div>
</div>
@if (m3uVodDetailsControl; as m3uVodDetails) {
<div
class="setting-item"
data-setting-id="tmdb-m3u-vod-details"
>
<div class="setting-item__meta">
<h4 id="tmdb-m3u-vod-details-label">
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
</h4>
<p>
{{
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-slide-toggle
[formControl]="m3uVodDetails"
aria-labelledby="tmdb-m3u-vod-details-label"
data-test-id="tmdb-m3u-vod-details"
></mat-slide-toggle>
</div>
</div>
}
}
</div>
</section>
<section class="settings-section">
<div class="settings-section__header">
<h3>{{ 'SETTINGS.CARD_CACHE' | translate }}</h3>
</div>
<div class="settings-card">
<div class="setting-item" data-setting-id="tmdb-cache">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
<p>
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
</p>
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
@if (cacheError()) {
<span class="tmdb-cache__error">
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
</span>
} @else if (cacheStats(); as stats) {
{{
'SETTINGS.TMDB_CACHE_SIZE'
| translate
: {
entries: stats.entries,
size: formatBytes(stats.bytes),
}
}}
}
</p>
</div>
<div class="setting-item__control">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
<button
mat-stroked-button
type="button"
[disabled]="isClearing() || cacheStats()?.entries === 0"
(click)="clearCache()"
data-test-id="tmdb-clear-cache"
>
<mat-label>{{
'SETTINGS.TMDB_API_KEY' | translate
}}</mat-label>
<input
matInput
type="text"
autocomplete="off"
formControlName="apiKey"
data-test-id="tmdb-api-key"
/>
</mat-form-field>
<div class="tmdb-key-test">
<button
mat-stroked-button
type="button"
[disabled]="
!enteredApiKey || keyTestState() === 'testing'
"
(click)="testApiKey()"
data-test-id="tmdb-test-key"
>
@if (keyTestState() === 'testing') {
<mat-spinner diameter="16" />
} @else {
<mat-icon>network_check</mat-icon>
}
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
</button>
@if (keyTestState() === 'success') {
<span
class="tmdb-key-test__result tmdb-key-test__result--success"
data-test-id="tmdb-test-success"
>
<mat-icon>check_circle</mat-icon>
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
</span>
} @else if (keyTestState() === 'error') {
<span
class="tmdb-key-test__result tmdb-key-test__result--error"
data-test-id="tmdb-test-error"
>
<mat-icon>error</mat-icon>
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
</span>
@if (isClearing()) {
<mat-spinner diameter="16" />
} @else {
<mat-icon>delete_sweep</mat-icon>
}
</div>
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
</button>
</div>
</div>
}
@if (form().value.tmdb?.enabled === true) {
@if (m3uVodDetailsControl; as m3uVodDetails) {
<div
class="setting-item"
data-setting-id="tmdb-m3u-vod-details"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
</h4>
<p>
{{
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[formControl]="m3uVodDetails"
data-test-id="tmdb-m3u-vod-details"
></mat-checkbox>
</div>
</div>
}
}
<div class="setting-item" data-setting-id="tmdb-cache">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
<p>
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
</p>
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
@if (cacheError()) {
<span class="tmdb-cache__error">
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
</span>
} @else if (cacheStats(); as stats) {
{{
'SETTINGS.TMDB_CACHE_SIZE'
| translate
: {
entries: stats.entries,
size: formatBytes(stats.bytes),
}
}}
}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
[disabled]="isClearing() || cacheStats()?.entries === 0"
(click)="clearCache()"
data-test-id="tmdb-clear-cache"
>
@if (isClearing()) {
<mat-spinner diameter="16" />
} @else {
<mat-icon>delete_sweep</mat-icon>
}
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
</button>
</div>
</div>
</section>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<img
src="assets/images/tmdb-logo.svg"
alt="TMDB"
style="height: 20px; margin-bottom: 8px"
/>
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
</div>
</div>
<div class="settings-foot">
<img src="assets/images/tmdb-logo.svg" alt="TMDB" />
<span>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</span>
</div>
</section>
@@ -93,7 +93,9 @@ describe('SettingsTmdbSectionComponent', () => {
const checkbox = queryByTestId('tmdb-m3u-vod-details');
expect(checkbox).not.toBeNull();
(checkbox?.querySelector('input') as HTMLInputElement).click();
(
checkbox?.querySelector('button[role="switch"]') as HTMLButtonElement
).click();
fixture.detectChanges();
expect(fixture.componentInstance.m3uVodDetailsControl?.value).toBe(
@@ -8,11 +8,11 @@ import {
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
@@ -24,11 +24,11 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
selector: 'app-settings-tmdb-section',
imports: [
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSlideToggleModule,
ReactiveFormsModule,
TranslateModule,
],
@@ -9,6 +9,7 @@ import {
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
import {
@@ -62,10 +63,10 @@ describe('SettingsComponent form', () => {
it('stages the desktop portal pause setting until Save', async () => {
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="portal-connectivity-toggle"] input'
) as HTMLInputElement;
'[data-test-id="portal-connectivity-toggle"] button[role="switch"]'
) as HTMLButtonElement;
expect(checkbox).not.toBeNull();
expect(checkbox.checked).toBe(true);
expect(checkbox.getAttribute('aria-checked')).toBe('true');
checkbox.click();
fixture.detectChanges();
expect(
@@ -74,7 +75,7 @@ describe('SettingsComponent form', () => {
expect(window.electron.updateSettings).not.toHaveBeenCalled();
component.form.hydrateFromStore();
fixture.detectChanges();
expect(checkbox.checked).toBe(true);
expect(checkbox.getAttribute('aria-checked')).toBe('true');
checkbox.click();
await component.form.save(() => undefined);
expect(window.electron.updateSettings).toHaveBeenCalledWith(
@@ -84,10 +85,10 @@ describe('SettingsComponent form', () => {
it('stages the posters-only cover wall until Save', async () => {
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="cover-titles-toggle"] input'
) as HTMLInputElement;
'[data-test-id="cover-titles-toggle"] button[role="switch"]'
) as HTMLButtonElement;
expect(checkbox).not.toBeNull();
expect(checkbox.checked).toBe(true);
expect(checkbox.getAttribute('aria-checked')).toBe('true');
checkbox.click();
fixture.detectChanges();
@@ -377,6 +378,143 @@ describe('SettingsComponent form', () => {
expect(unsavedBar()).toBeNull();
});
it('counts the staged changes and marks their pages in the navigation', () => {
const settingsCtx = TestBed.inject(SettingsContextService);
const message = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="settings-unsaved-message"]'
)?.textContent;
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
fixture.detectChanges();
expect(component.changeCount()).toBe(1);
expect(component.unsavedMessageKey()).toBe(
'SETTINGS.UNSAVED_CHANGES_ONE'
);
expect(settingsCtx.dirtySections()).toEqual(new Set(['general']));
component.settingsForm
.get('dashboardRails.hero')
?.setValue(!DEFAULT_DASHBOARD_RAILS.hero);
component.settingsForm.get('showCaptions')?.setValue(true);
fixture.detectChanges();
expect(component.changeCount()).toBe(3);
expect(component.unsavedMessageKey()).toBe(
'SETTINGS.UNSAVED_CHANGES_COUNT'
);
expect(message()).toContain('SETTINGS.UNSAVED_CHANGES_COUNT');
expect(settingsCtx.dirtySections()).toEqual(
new Set(['general', 'dashboard', 'playback'])
);
// Reverting by hand leaves the form dirty but counts nothing.
component.settingsForm.get('theme')?.setValue(Theme.SystemTheme);
component.settingsForm
.get('dashboardRails.hero')
?.setValue(DEFAULT_DASHBOARD_RAILS.hero);
component.settingsForm.get('showCaptions')?.setValue(false);
fixture.detectChanges();
expect(component.changeCount()).toBe(0);
expect(component.unsavedMessageKey()).toBe(
'SETTINGS.UNSAVED_CHANGES'
);
expect(settingsCtx.dirtySections().size).toBe(0);
expect(unsavedBar()).not.toBeNull();
});
it('saves on Ctrl/Cmd+S while valid and dirty, and swallows the browser save otherwise', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const press = (init: KeyboardEventInit) => {
const event = new KeyboardEvent('keydown', {
key: 's',
bubbles: true,
cancelable: true,
...init,
});
document.dispatchEvent(event);
return event;
};
// Nothing staged: the page still keeps the browser dialog away.
expect(press({ ctrlKey: true }).defaultPrevented).toBe(true);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
fixture.detectChanges();
// A plain "s" or a chord with Shift is not the shortcut.
expect(press({}).defaultPrevented).toBe(false);
expect(press({ metaKey: true, shiftKey: true }).defaultPrevented).toBe(
false
);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
// The phone drawer makes the page inert: the chord is not ours.
const host = fixture.nativeElement as HTMLElement;
host.setAttribute('inert', '');
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
host.removeAttribute('inert');
// Nor while a dialog (the leave dialog) or a select panel is open.
const backdrop = document.createElement('div');
backdrop.className = 'cdk-overlay-backdrop';
document.body.appendChild(backdrop);
expect(press({ metaKey: true }).defaultPrevented).toBe(false);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
backdrop.remove();
expect(press({ metaKey: true }).defaultPrevented).toBe(true);
await fixture.whenStable();
expect(settingsStore.updateSettings).toHaveBeenCalledTimes(1);
expect(component.settingsForm.pristine).toBe(true);
});
it('notices a saved change that waits for a restart until dismissed', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
const notice = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="settings-restart-notice"]'
);
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
fixture.detectChanges();
// A live change says nothing about restarting.
expect(notice()).toBeNull();
component.settingsForm
.get('startupWindowMode')
?.setValue('maximized');
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
fixture.detectChanges();
expect(component.form.restartPendingControls()).toEqual([
'startupWindowMode',
]);
expect(notice()).not.toBeNull();
(
notice()?.querySelector(
'[data-test-id="settings-restart-later"]'
) as HTMLButtonElement
).click();
fixture.detectChanges();
expect(notice()).toBeNull();
});
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
const largeCoverButton = (
fixture.nativeElement as HTMLElement
@@ -12,6 +12,30 @@
{{ 'SETTINGS.TITLE' | translate }}
</span>
<!-- A saved change that only applies after a restart: said once,
at the top, until dismissed. -->
@if (form.restartPendingControls().length > 0) {
<div
class="settings-restart-bar"
role="status"
data-test-id="settings-restart-notice"
>
<mat-icon>restart_alt</mat-icon>
<span>{{
'SETTINGS.RESTART_NOTICE'
| translate: { settings: restartPendingLabels() }
}}</span>
<button
mat-button
type="button"
(click)="form.dismissRestartNotice()"
data-test-id="settings-restart-later"
>
{{ 'SETTINGS.RESTART_LATER' | translate }}
</button>
</div>
}
<div class="settings-content">
<!-- Only the routed section renders. The parent keeps the shared
form alive across section switches, so edits staged on one
@@ -96,21 +120,14 @@
/>
}
@case ('backup') {
<!-- Backup & data: the former Reset page is this
page's last card, since it is one action. -->
<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()
"
@@ -120,6 +137,8 @@
[removeAllProgressLabel]="
playlistReset.removeAllProgressLabel()
"
(importData)="importData()"
(exportData)="exportData()"
(removeAll)="removeAll()"
/>
}
@@ -142,6 +161,7 @@
(openAppUpdateReleaseNotes)="
appUpdate.openReleaseNotes()
"
(openBackup)="openSection('backup')"
/>
}
@default {
@@ -168,16 +188,19 @@
}
</div>
<!-- Discord-style unsaved-changes bar: appears the moment any edit
makes the form dirty — on whichever section page — and vanishes
once saved or discarded. It replaces the always-visible footer
Save button of the single-scroll layout. -->
<!-- Save bar: appears the moment any edit makes the form dirty — on
whichever section page — and vanishes once saved or discarded.
It floats over the content column and counts the staged
changes; the navigation marks the pages they are on. -->
@if (!settingsForm.pristine) {
<footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
<div class="unsaved-bar__message">
<mat-icon>edit</mat-icon>
<span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
</div>
<span class="unsaved-bar__dot" aria-hidden="true"></span>
<span
class="unsaved-bar__message"
data-test-id="settings-unsaved-message"
>
{{ unsavedMessageKey() | translate: { count: changeCount() } }}
</span>
<div class="unsaved-bar__actions">
<button
mat-button
@@ -194,8 +217,10 @@
[disabled]="!settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
<kbd class="settings-kbd" aria-hidden="true">{{
saveShortcutLabel
}}</kbd>
</button>
</div>
</footer>
File diff suppressed because it is too large. Load diff
@@ -151,10 +151,10 @@ describe('SettingsComponent search', () => {
it('scrolls to, focuses and briefly highlights a revealed row', () => {
jest.useFakeTimers();
settingsSearch.reveal(entry('show-captions'));
settingsSearch.reveal(entry('cover-titles'));
fixture.detectChanges();
const row = query('[data-setting-id="show-captions"]') as HTMLElement;
const row = query('[data-setting-id="cover-titles"]') as HTMLElement;
expect(scrollIntoView).toHaveBeenCalled();
expect(scrollIntoView.mock.contexts[0]).toBe(row);
expect(row.classList).toContain(SETTINGS_REVEALED_CLASS);
@@ -239,7 +239,7 @@ describe('SettingsComponent', () => {
});
it('enables the global wipe action only once a playlist exists', () => {
setSettingsSection('reset');
setSettingsSection('backup');
fixture.detectChanges();
const deleteButton = () =>
+101 -4
View File
@@ -3,6 +3,7 @@ import {
computed,
effect,
ElementRef,
HostListener,
inject,
OnDestroy,
OnInit,
@@ -23,12 +24,20 @@ import {
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { firstValueFrom, map } from 'rxjs';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
Language,
StreamFormat,
} from '@iptvnator/shared/interfaces';
import { firstValueFrom, map, startWith } from 'rxjs';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
import {
sectionOfSettingsPath,
SETTINGS_RESTART_CONTROLS,
} from './settings-change-tracking';
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
import { SettingsEpgFacade } from './settings-epg.facade';
@@ -51,7 +60,6 @@ import { SettingsParentalLockSectionComponent } from './settings-parental-sectio
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
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';
@@ -98,7 +106,6 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsParentalLockSectionComponent,
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
SettingsSearchResultsComponent,
SettingsTmdbSectionComponent,
],
@@ -179,6 +186,42 @@ export class SettingsComponent
/** Git commit the app was built from (CI builds only) */
readonly buildCommit = BUILD_COMMIT;
/** Staged leaf values that differ from the saved settings */
readonly changeCount = computed(() => this.form.changedPaths().length);
/** Save bar text: a count once something actually differs */
readonly unsavedMessageKey = computed(() => {
const count = this.changeCount();
if (count === 1) return 'SETTINGS.UNSAVED_CHANGES_ONE';
return count > 1
? 'SETTINGS.UNSAVED_CHANGES_COUNT'
: 'SETTINGS.UNSAVED_CHANGES';
});
/** Re-reads translated text after a language switch */
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
/** Rows whose saved change waits for a restart, as one readable list */
readonly restartPendingLabels = computed(() => {
this.languageTick();
return this.form
.restartPendingControls()
.map((control) =>
this.translate.instant(SETTINGS_RESTART_CONTROLS[control])
)
.join(', ');
});
/** The Save button advertises the platform's save chord */
readonly saveShortcutLabel = /mac|iphone|ipad/i.test(
navigator.platform || navigator.userAgent
)
? '⌘S'
: 'Ctrl+S';
readonly themeOptions = SETTINGS_THEME_OPTIONS;
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
@@ -242,6 +285,53 @@ export class SettingsComponent
activeSection: this.activeSection,
ready: this.formReady.asReadonly(),
});
// The navigation marks pages with staged edits and shows the
// installed version (with an update badge) beside About.
effect(() => {
const sections = new Set<string>();
for (const path of this.form.changedPaths()) {
const section = sectionOfSettingsPath(path);
if (section) sections.add(section);
}
this.settingsCtx.setDirtySections(sections);
});
effect(() => {
const status = this.appUpdate.status()?.status;
this.settingsCtx.setVersion(this.appUpdate.version() || null);
this.settingsCtx.setUpdateAvailable(
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
}
/**
* Cmd/Ctrl+S saves the staged edits. The browser's own save-page dialog
* is suppressed on every settings page, also while nothing is staged.
* While the page is inert (the phone drawer is open over it) or an
* overlay with a backdrop is up (the unsaved-changes dialog, a select
* panel), the chord belongs to that surface, not to the obscured form:
* saving under the leave dialog would make its Discard keep the edits.
*/
@HostListener('document:keydown', ['$event'])
onSaveShortcut(event: KeyboardEvent): void {
const host = this.hostElement.nativeElement;
if (
event.defaultPrevented ||
!(event.metaKey || event.ctrlKey) ||
event.altKey ||
event.shiftKey ||
event.key.toLowerCase() !== 's' ||
host.closest('[inert]') ||
host.ownerDocument.querySelector('.cdk-overlay-backdrop')
) {
return;
}
event.preventDefault();
if (!this.settingsForm.pristine && this.settingsForm.valid) {
this.onSubmit();
}
}
/**
@@ -274,6 +364,13 @@ export class SettingsComponent
this.settingsCtx.reset();
}
/** Switches section page in place, like the sidebar links do */
openSection(sectionId: string): void {
void this.router.navigate(['/workspace/settings', sectionId], {
replaceUrl: true,
});
}
/** Picks a recording folder in the desktop shell and stages it in the form */
async selectRecordingFolder(): Promise<void> {
const folder = await this.embeddedMpv.selectRecordingFolder();
@@ -13,6 +13,8 @@ export interface SettingsSection {
label: string;
icon: string;
visible: boolean;
/** Navigation group; absent for sections pinned to the nav footer. */
group?: string;
}
export interface ThemeOption {
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "الإعدادات المطابقة لـ «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "لا توجد إعدادات مطابقة. جرّب كلمة أخرى، مثل المشغل أو السمة أو EPG.",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "تغيير تكوين التطبيق",
"GENERAL_SUBTITLE": "اللغة والمظهر وطريقة بدء التطبيق.",
"PLAYBACK": "التشغيل",
"EPG_SOURCES": "مصادر EPG",
"EPG_SOURCES_DESCRIPTION": "أضف رابطًا واحدًا أو أكثر كمصادر لـ EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "عن بعد",
"NAV_DATA": "البيانات",
"NAV_BACKUP": "نسخ احتياطي",
"NAV_RESET": "إعادة تعيين",
"NAV_BACKUP": "النسخ الاحتياطي والبيانات",
"NAV_ABOUT": "حول",
"NAV_GROUP_APP": "التطبيق",
"NAV_GROUP_LIBRARY": "المكتبة",
"NAV_GROUP_DEVICES": "الأجهزة",
"CARD_APPEARANCE": "المظهر",
"CARD_STARTUP": "بدء التشغيل",
"CARD_CHANNELS": "القنوات",
"CARD_PLAYER": "المشغّل",
"CARD_WATCHING": "المشاهدة",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "سطح المكتب",
"CARD_GUIDE": "الدليل",
"CARD_SOURCES": "المصادر",
"CARD_XTREAM_PLAYLISTS": "قوائم تشغيل Xtream",
"CARD_RAILS": "الصفوف",
"CARD_CACHE": "ذاكرة التخزين المؤقت",
"EPG_SUBTITLE": "عرض دليل البرامج ومصادر XMLTV.",
"PLAYBACK_SUBTITLE": "المشغّل والترجمات وتنسيق البث.",
"CLEAR_EPG_DATA_DESCRIPTION": "يزيل جميع بيانات البرامج التي تم تنزيلها. تبقى المصادر ويمكن تحديثها.",
"EPG_OFFSET_STEP_DOWN": "أبكر بـ {{minutes}} دقيقة",
"EPG_OFFSET_STEP_UP": "أبعد بـ {{minutes}} دقيقة",
"CHIP_RESTART": "إعادة تشغيل",
"CHIP_EXPERIMENTAL": "تجريبي",
"UPDATE_UP_TO_DATE": "محدّث",
"UPDATE_AVAILABLE_CHIP": "يتوفر تحديث",
"UNSAVED_CHANGES_ONE": "تغيير واحد غير محفوظ",
"UNSAVED_CHANGES_COUNT": "تغييرات غير محفوظة: {{count}}",
"RESTART_NOTICE": "سيتم تطبيق {{settings}} بعد إعادة التشغيل.",
"RESTART_LATER": "لاحقاً",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "فتح النسخ الاحتياطي والبيانات",
"NAV_TMDB": "البيانات الوصفية",
"TMDB": "البيانات الوصفية (TMDB)",
"TMDB_DESCRIPTION": "إثراء تفاصيل الأفلام والمسلسلات ببيانات من The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "الإعدادات اللي كتطابق «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "ما كاين حتى إعداد مطابق. جرب كلمة أخرى، بحال المشغل ولا المظهر ولا EPG.",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "بدل تكوين التطبيق",
"GENERAL_SUBTITLE": "اللغة والمظهر وكيفاش كيبدا التطبيق.",
"PLAYBACK": "التشغيل",
"EPG_SOURCES": "مصادر EPG",
"EPG_SOURCES_DESCRIPTION": "زيد رابط ولا أكثر كمصادر EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "التحكم عن بعد",
"NAV_DATA": "البيانات",
"NAV_BACKUP": "نسخة احتياطية",
"NAV_RESET": "إعادة الضبط",
"NAV_BACKUP": "النسخة الاحتياطية والمعطيات",
"NAV_ABOUT": "حول",
"NAV_GROUP_APP": "التطبيق",
"NAV_GROUP_LIBRARY": "المكتبة",
"NAV_GROUP_DEVICES": "الأجهزة",
"CARD_APPEARANCE": "المظهر",
"CARD_STARTUP": "بدء التشغيل",
"CARD_CHANNELS": "القنوات",
"CARD_PLAYER": "المشغّل",
"CARD_WATCHING": "المشاهدة",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "سطح المكتب",
"CARD_GUIDE": "الدليل",
"CARD_SOURCES": "المصادر",
"CARD_XTREAM_PLAYLISTS": "ليستات Xtream",
"CARD_RAILS": "الصفوف",
"CARD_CACHE": "الكاش",
"EPG_SUBTITLE": "عرض دليل البرامج ومصادر XMLTV.",
"PLAYBACK_SUBTITLE": "المشغّل والترجمات وتنسيق البث.",
"CLEAR_EPG_DATA_DESCRIPTION": "كيمسح جميع معطيات البرامج اللي تحملات. المصادر كتبقى وتقدر تحدثها.",
"EPG_OFFSET_STEP_DOWN": "قبل بـ {{minutes}} دقيقة",
"EPG_OFFSET_STEP_UP": "من بعد بـ {{minutes}} دقيقة",
"CHIP_RESTART": "إعادة تشغيل",
"CHIP_EXPERIMENTAL": "تجريبي",
"UPDATE_UP_TO_DATE": "محدّث",
"UPDATE_AVAILABLE_CHIP": "كاين تحديث",
"UNSAVED_CHANGES_ONE": "تغيير واحد ماتسجلش",
"UNSAVED_CHANGES_COUNT": "تغييرات ماتسجلاتش: {{count}}",
"RESTART_NOTICE": "{{settings}} غادي يتطبق من بعد ما تعاود تشغل التطبيق.",
"RESTART_LATER": "من بعد",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "فتح النسخة الاحتياطية والمعطيات",
"NAV_TMDB": "الميتاداتا",
"TMDB": "الميتاداتا (TMDB)",
"TMDB_DESCRIPTION": "غنّي تفاصيل الأفلام والسلاسل بمعطيات من The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Налады па запыце «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Нічога не знойдзена. Паспрабуйце іншае слова, напрыклад «прайгравальнік», «тэма» ці «EPG».",
"GENERAL": "Агульныя",
"GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы",
"GENERAL_SUBTITLE": "Мова, знешні выгляд і запуск праграмы.",
"PLAYBACK": "Прайграванне",
"EPG_SOURCES": "Крыніцы EPG",
"EPG_SOURCES_DESCRIPTION": "Дадайце адзін або некалькі URL у якасці крыніц EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Кіраванне",
"NAV_DATA": "Дадзеныя",
"NAV_BACKUP": "Рэзервовая копія",
"NAV_RESET": "Скід",
"NAV_BACKUP": "Рэзервовая копія і дадзеныя",
"NAV_ABOUT": "Аб праграме",
"NAV_GROUP_APP": "Праграма",
"NAV_GROUP_LIBRARY": "Бібліятэка",
"NAV_GROUP_DEVICES": "Прылады",
"CARD_APPEARANCE": "Знешні выгляд",
"CARD_STARTUP": "Запуск",
"CARD_CHANNELS": "Каналы",
"CARD_PLAYER": "Плэер",
"CARD_WATCHING": "Прагляд",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Настольная версія",
"CARD_GUIDE": "Тэлегід",
"CARD_SOURCES": "Крыніцы",
"CARD_XTREAM_PLAYLISTS": "Плэйлісты Xtream",
"CARD_RAILS": "Шэрагі",
"CARD_CACHE": "Кэш",
"EPG_SUBTITLE": "Адлюстраванне тэлегіда і крыніцы XMLTV.",
"PLAYBACK_SUBTITLE": "Плэер, субтытры і фармат патоку.",
"CLEAR_EPG_DATA_DESCRIPTION": "Выдаляе ўсе спампаваныя даныя тэлепраграмы. Крыніцы застаюцца, іх можна абнавіць.",
"EPG_OFFSET_STEP_DOWN": "На {{minutes}} хвілін раней",
"EPG_OFFSET_STEP_UP": "На {{minutes}} хвілін пазней",
"CHIP_RESTART": "Перазапуск",
"CHIP_EXPERIMENTAL": "Эксперыментальна",
"UPDATE_UP_TO_DATE": "Актуальная версія",
"UPDATE_AVAILABLE_CHIP": "Даступна абнаўленне",
"UNSAVED_CHANGES_ONE": "1 незахаваная змена",
"UNSAVED_CHANGES_COUNT": "Незахаваныя змены: {{count}}",
"RESTART_NOTICE": "{{settings}} уступіць у сілу пасля перазапуску.",
"RESTART_LATER": "Пазней",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Адкрыць «Рэзервовая копія і дадзеныя»",
"NAV_TMDB": "Метаданыя",
"TMDB": "Метаданыя (TMDB)",
"TMDB_DESCRIPTION": "Узбагачайце дэталі фільмаў і серыялаў дадзенымі з The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Einstellungen zu „{{query}}“: {{count}}",
"SEARCH_EMPTY_HINT": "Keine passenden Einstellungen. Ein anderes Wort versuchen, etwa Player, Design oder EPG.",
"GENERAL": "Allgemein",
"GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern",
"GENERAL_SUBTITLE": "Sprache, Darstellung und Verhalten beim Start.",
"PLAYBACK": "Wiedergabe",
"EPG_SOURCES": "EPG-Quellen",
"EPG_SOURCES_DESCRIPTION": "Eine oder mehrere XMLTV-Quellen hinzufügen: Links oder lokale Dateien",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remote",
"NAV_DATA": "Daten",
"NAV_BACKUP": "Datensicherung",
"NAV_RESET": "Zurücksetzen",
"NAV_BACKUP": "Datensicherung & Daten",
"NAV_ABOUT": "Info",
"NAV_GROUP_APP": "App",
"NAV_GROUP_LIBRARY": "Bibliothek",
"NAV_GROUP_DEVICES": "Geräte",
"CARD_APPEARANCE": "Darstellung",
"CARD_STARTUP": "Start",
"CARD_CHANNELS": "Sender",
"CARD_PLAYER": "Player",
"CARD_WATCHING": "Ansehen",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Desktop",
"CARD_GUIDE": "Programmführer",
"CARD_SOURCES": "Quellen",
"CARD_XTREAM_PLAYLISTS": "Xtream-Playlists",
"CARD_RAILS": "Reihen",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Darstellung des Programmführers und XMLTV-Quellen.",
"PLAYBACK_SUBTITLE": "Player, Untertitel und Stream-Format.",
"CLEAR_EPG_DATA_DESCRIPTION": "Entfernt alle heruntergeladenen Programmdaten. Die Quellen bleiben erhalten und können aktualisiert werden.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} Minuten früher",
"EPG_OFFSET_STEP_UP": "{{minutes}} Minuten später",
"CHIP_RESTART": "Neustart",
"CHIP_EXPERIMENTAL": "Experimentell",
"UPDATE_UP_TO_DATE": "Aktuell",
"UPDATE_AVAILABLE_CHIP": "Update verfügbar",
"UNSAVED_CHANGES_ONE": "1 ungespeicherte Änderung",
"UNSAVED_CHANGES_COUNT": "{{count}} ungespeicherte Änderungen",
"RESTART_NOTICE": "{{settings}} wird nach einem Neustart übernommen.",
"RESTART_LATER": "Später",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Datensicherung & Daten öffnen",
"NAV_TMDB": "Metadaten",
"TMDB": "Metadaten (TMDB)",
"TMDB_DESCRIPTION": "Film- und Seriendetails mit Daten von The Movie Database anreichern",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Ρυθμίσεις για «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Δεν βρέθηκαν ρυθμίσεις. Δοκιμάστε άλλη λέξη, π.χ. αναπαραγωγή, θέμα ή EPG.",
"GENERAL": "Γενικά",
"GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
"GENERAL_SUBTITLE": "Γλώσσα, εμφάνιση και εκκίνηση της εφαρμογής.",
"PLAYBACK": "Αναπαραγωγή",
"EPG_SOURCES": "Πηγές EPG",
"EPG_SOURCES_DESCRIPTION": "Προσθέστε μία ή περισσότερες διευθύνσεις URL ως πηγές EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Τηλεχειριστήριο",
"NAV_DATA": "Δεδομένα",
"NAV_BACKUP": "Αντίγραφο ασφαλείας",
"NAV_RESET": "Επαναφορά",
"NAV_BACKUP": "Αντίγραφο ασφαλείας και δεδομένα",
"NAV_ABOUT": "Πληροφορίες",
"NAV_GROUP_APP": "Εφαρμογή",
"NAV_GROUP_LIBRARY": "Βιβλιοθήκη",
"NAV_GROUP_DEVICES": "Συσκευές",
"CARD_APPEARANCE": "Εμφάνιση",
"CARD_STARTUP": "Εκκίνηση",
"CARD_CHANNELS": "Κανάλια",
"CARD_PLAYER": "Πρόγραμμα αναπαραγωγής",
"CARD_WATCHING": "Παρακολούθηση",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Επιφάνεια εργασίας",
"CARD_GUIDE": "Οδηγός",
"CARD_SOURCES": "Πηγές",
"CARD_XTREAM_PLAYLISTS": "Λίστες Xtream",
"CARD_RAILS": "Σειρές",
"CARD_CACHE": "Προσωρινή μνήμη",
"EPG_SUBTITLE": "Εμφάνιση οδηγού προγράμματος και πηγές XMLTV.",
"PLAYBACK_SUBTITLE": "Αναπαραγωγή, υπότιτλοι και μορφή ροής.",
"CLEAR_EPG_DATA_DESCRIPTION": "Αφαιρεί όλα τα ληφθέντα δεδομένα προγράμματος. Οι πηγές παραμένουν και μπορούν να ανανεωθούν.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} λεπτά νωρίτερα",
"EPG_OFFSET_STEP_UP": "{{minutes}} λεπτά αργότερα",
"CHIP_RESTART": "Επανεκκίνηση",
"CHIP_EXPERIMENTAL": "Πειραματικό",
"UPDATE_UP_TO_DATE": "Ενημερωμένο",
"UPDATE_AVAILABLE_CHIP": "Διαθέσιμη ενημέρωση",
"UNSAVED_CHANGES_ONE": "1 μη αποθηκευμένη αλλαγή",
"UNSAVED_CHANGES_COUNT": "Μη αποθηκευμένες αλλαγές: {{count}}",
"RESTART_NOTICE": "{{settings}} θα εφαρμοστεί μετά την επανεκκίνηση.",
"RESTART_LATER": "Αργότερα",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Άνοιγμα Αντίγραφο ασφαλείας και δεδομένα",
"NAV_TMDB": "Μεταδεδομένα",
"TMDB": "Μεταδεδομένα (TMDB)",
"TMDB_DESCRIPTION": "Εμπλουτίστε τις λεπτομέρειες ταινιών και σειρών με δεδομένα από το The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Settings matching “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "No settings match. Try another word, such as player, theme or EPG.",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Change the configuration of the application",
"GENERAL_SUBTITLE": "Language, appearance and how the app starts.",
"PLAYBACK": "Playback",
"EPG_SOURCES": "EPG sources",
"EPG_SOURCES_DESCRIPTION": "Add one or more XMLTV sources: remote links or local files",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remote",
"NAV_DATA": "Data",
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_BACKUP": "Backup & data",
"NAV_ABOUT": "About",
"NAV_GROUP_APP": "App",
"NAV_GROUP_LIBRARY": "Library",
"NAV_GROUP_DEVICES": "Devices",
"CARD_APPEARANCE": "Appearance",
"CARD_STARTUP": "Startup",
"CARD_CHANNELS": "Channels",
"CARD_PLAYER": "Player",
"CARD_WATCHING": "Watching",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Desktop",
"CARD_GUIDE": "Guide",
"CARD_SOURCES": "Sources",
"CARD_XTREAM_PLAYLISTS": "Xtream playlists",
"CARD_RAILS": "Rails",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Programme guide display and XMLTV sources.",
"PLAYBACK_SUBTITLE": "Player, subtitles and stream format.",
"CLEAR_EPG_DATA_DESCRIPTION": "Removes all downloaded programme data. Sources stay and can be refreshed.",
"EPG_OFFSET_STEP_DOWN": "Earlier by {{minutes}} minutes",
"EPG_OFFSET_STEP_UP": "Later by {{minutes}} minutes",
"CHIP_RESTART": "Restart",
"CHIP_EXPERIMENTAL": "Experimental",
"UPDATE_UP_TO_DATE": "Up to date",
"UPDATE_AVAILABLE_CHIP": "Update available",
"UNSAVED_CHANGES_ONE": "1 unsaved change",
"UNSAVED_CHANGES_COUNT": "{{count}} unsaved changes",
"RESTART_NOTICE": "{{settings}} will apply after restart.",
"RESTART_LATER": "Later",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Open Backup & data",
"NAV_TMDB": "Metadata",
"TMDB": "Metadata (TMDB)",
"TMDB_DESCRIPTION": "Enrich movie and series details with data from The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Ajustes que coinciden con «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Ningún ajuste coincide. Prueba con otra palabra, como reproductor, tema o EPG.",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion",
"GENERAL_SUBTITLE": "Idioma, apariencia y cómo se inicia la aplicación.",
"PLAYBACK": "Reproduccion",
"EPG_SOURCES": "Fuentes EPG",
"EPG_SOURCES_DESCRIPTION": "Agrega una o varias URL como fuentes EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remoto",
"NAV_DATA": "Datos",
"NAV_BACKUP": "Copia de seguridad",
"NAV_RESET": "Restablecer",
"NAV_BACKUP": "Copia de seguridad y datos",
"NAV_ABOUT": "Acerca de",
"NAV_GROUP_APP": "Aplicación",
"NAV_GROUP_LIBRARY": "Biblioteca",
"NAV_GROUP_DEVICES": "Dispositivos",
"CARD_APPEARANCE": "Apariencia",
"CARD_STARTUP": "Inicio",
"CARD_CHANNELS": "Canales",
"CARD_PLAYER": "Reproductor",
"CARD_WATCHING": "Visualización",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Escritorio",
"CARD_GUIDE": "Guía",
"CARD_SOURCES": "Fuentes",
"CARD_XTREAM_PLAYLISTS": "Listas Xtream",
"CARD_RAILS": "Filas",
"CARD_CACHE": "Caché",
"EPG_SUBTITLE": "Visualización de la guía de programación y fuentes XMLTV.",
"PLAYBACK_SUBTITLE": "Reproductor, subtítulos y formato de transmisión.",
"CLEAR_EPG_DATA_DESCRIPTION": "Elimina todos los datos de programación descargados. Las fuentes se conservan y pueden actualizarse.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutos antes",
"EPG_OFFSET_STEP_UP": "{{minutes}} minutos después",
"CHIP_RESTART": "Reinicio",
"CHIP_EXPERIMENTAL": "Experimental",
"UPDATE_UP_TO_DATE": "Actualizado",
"UPDATE_AVAILABLE_CHIP": "Actualización disponible",
"UNSAVED_CHANGES_ONE": "1 cambio sin guardar",
"UNSAVED_CHANGES_COUNT": "{{count}} cambios sin guardar",
"RESTART_NOTICE": "{{settings}} se aplicará tras reiniciar.",
"RESTART_LATER": "Más tarde",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Abrir Copia de seguridad y datos",
"NAV_TMDB": "Metadatos",
"TMDB": "Metadatos (TMDB)",
"TMDB_DESCRIPTION": "Enriquece los detalles de películas y series con datos de The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Paramètres correspondant à « {{query}} » : {{count}}",
"SEARCH_EMPTY_HINT": "Aucun paramètre ne correspond. Essayez un autre mot, par exemple lecteur, thème ou EPG.",
"GENERAL": "Général",
"GENERAL_SUBTITLE": "Modifier la configuration de l'application",
"GENERAL_SUBTITLE": "Langue, apparence et démarrage de l'application.",
"PLAYBACK": "Lecture",
"EPG_SOURCES": "Sources EPG",
"EPG_SOURCES_DESCRIPTION": "Ajoutez une ou plusieurs URL comme sources EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Télécommande",
"NAV_DATA": "Données",
"NAV_BACKUP": "Sauvegarde",
"NAV_RESET": "Réinitialiser",
"NAV_BACKUP": "Sauvegarde et données",
"NAV_ABOUT": "À propos",
"NAV_GROUP_APP": "Application",
"NAV_GROUP_LIBRARY": "Bibliothèque",
"NAV_GROUP_DEVICES": "Appareils",
"CARD_APPEARANCE": "Apparence",
"CARD_STARTUP": "Démarrage",
"CARD_CHANNELS": "Chaînes",
"CARD_PLAYER": "Lecteur",
"CARD_WATCHING": "Visionnage",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Bureau",
"CARD_GUIDE": "Guide",
"CARD_SOURCES": "Sources",
"CARD_XTREAM_PLAYLISTS": "Listes Xtream",
"CARD_RAILS": "Rangées",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Affichage du guide des programmes et sources XMLTV.",
"PLAYBACK_SUBTITLE": "Lecteur, sous-titres et format de flux.",
"CLEAR_EPG_DATA_DESCRIPTION": "Supprime toutes les données de programmes téléchargées. Les sources restent et peuvent être actualisées.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutes plus tôt",
"EPG_OFFSET_STEP_UP": "{{minutes}} minutes plus tard",
"CHIP_RESTART": "Redémarrage",
"CHIP_EXPERIMENTAL": "Expérimental",
"UPDATE_UP_TO_DATE": "À jour",
"UPDATE_AVAILABLE_CHIP": "Mise à jour disponible",
"UNSAVED_CHANGES_ONE": "1 modification non enregistrée",
"UNSAVED_CHANGES_COUNT": "{{count}} modifications non enregistrées",
"RESTART_NOTICE": "{{settings}} s'appliquera après un redémarrage.",
"RESTART_LATER": "Plus tard",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Ouvrir Sauvegarde et données",
"NAV_TMDB": "Métadonnées",
"TMDB": "Métadonnées (TMDB)",
"TMDB_DESCRIPTION": "Enrichir les détails des films et séries avec les données de The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "„{{query}}” kifejezésre illeszkedő beállítások: {{count}}",
"SEARCH_EMPTY_HINT": "Nincs illeszkedő beállítás. Próbáljon másik szót, például lejátszó, téma vagy EPG.",
"GENERAL": "Általános",
"GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása",
"GENERAL_SUBTITLE": "Nyelv, megjelenés és az alkalmazás indítása.",
"PLAYBACK": "Lejátszás",
"EPG_SOURCES": "EPG-források",
"EPG_SOURCES_DESCRIPTION": "Egy vagy több URL-cím hozzáadása EPG-forrásként",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Távvezérlés",
"NAV_DATA": "Adatok",
"NAV_BACKUP": "Biztonsági mentés",
"NAV_RESET": "Alaphelyzetbe állítás",
"NAV_BACKUP": "Biztonsági mentés és adatok",
"NAV_ABOUT": "Névjegy",
"NAV_GROUP_APP": "Alkalmazás",
"NAV_GROUP_LIBRARY": "Könyvtár",
"NAV_GROUP_DEVICES": "Eszközök",
"CARD_APPEARANCE": "Megjelenés",
"CARD_STARTUP": "Indítás",
"CARD_CHANNELS": "Csatornák",
"CARD_PLAYER": "Lejátszó",
"CARD_WATCHING": "Megtekintés",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Asztali",
"CARD_GUIDE": "Műsorújság",
"CARD_SOURCES": "Források",
"CARD_XTREAM_PLAYLISTS": "Xtream lejátszási listák",
"CARD_RAILS": "Sorok",
"CARD_CACHE": "Gyorsítótár",
"EPG_SUBTITLE": "Műsorújság megjelenítése és XMLTV-források.",
"PLAYBACK_SUBTITLE": "Lejátszó, feliratok és streamformátum.",
"CLEAR_EPG_DATA_DESCRIPTION": "Eltávolítja az összes letöltött műsoradatot. A források megmaradnak, és frissíthetők.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} perccel korábban",
"EPG_OFFSET_STEP_UP": "{{minutes}} perccel később",
"CHIP_RESTART": "Újraindítás",
"CHIP_EXPERIMENTAL": "Kísérleti",
"UPDATE_UP_TO_DATE": "Naprakész",
"UPDATE_AVAILABLE_CHIP": "Frissítés érhető el",
"UNSAVED_CHANGES_ONE": "1 nem mentett módosítás",
"UNSAVED_CHANGES_COUNT": "{{count}} nem mentett módosítás",
"RESTART_NOTICE": "{{settings}} újraindítás után lép érvénybe.",
"RESTART_LATER": "Később",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Biztonsági mentés és adatok megnyitása",
"NAV_TMDB": "Metaadatok",
"TMDB": "Metaadatok (TMDB)",
"TMDB_DESCRIPTION": "Filmek és sorozatok adatainak kiegészítése a The Movie Database adataival",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Impostazioni corrispondenti a “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Nessuna impostazione corrisponde. Prova un'altra parola, ad esempio riproduttore, tema o EPG.",
"GENERAL": "Generale",
"GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione",
"GENERAL_SUBTITLE": "Lingua, aspetto e avvio dell'app.",
"PLAYBACK": "Riproduzione",
"EPG_SOURCES": "Sorgenti EPG",
"EPG_SOURCES_DESCRIPTION": "Aggiungi uno o piu URL come sorgenti EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remote",
"NAV_DATA": "Dati",
"NAV_BACKUP": "Copia di sicurezza",
"NAV_RESET": "Reimpostazione",
"NAV_BACKUP": "Copia di sicurezza e dati",
"NAV_ABOUT": "Info",
"NAV_GROUP_APP": "App",
"NAV_GROUP_LIBRARY": "Libreria",
"NAV_GROUP_DEVICES": "Dispositivi",
"CARD_APPEARANCE": "Aspetto",
"CARD_STARTUP": "Avvio",
"CARD_CHANNELS": "Canali",
"CARD_PLAYER": "Lettore",
"CARD_WATCHING": "Visione",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Desktop",
"CARD_GUIDE": "Guida",
"CARD_SOURCES": "Fonti",
"CARD_XTREAM_PLAYLISTS": "Playlist Xtream",
"CARD_RAILS": "Righe",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Visualizzazione della guida programmi e fonti XMLTV.",
"PLAYBACK_SUBTITLE": "Lettore, sottotitoli e formato dello stream.",
"CLEAR_EPG_DATA_DESCRIPTION": "Rimuove tutti i dati dei programmi scaricati. Le fonti restano e possono essere aggiornate.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minuti prima",
"EPG_OFFSET_STEP_UP": "{{minutes}} minuti dopo",
"CHIP_RESTART": "Riavvio",
"CHIP_EXPERIMENTAL": "Sperimentale",
"UPDATE_UP_TO_DATE": "Aggiornato",
"UPDATE_AVAILABLE_CHIP": "Aggiornamento disponibile",
"UNSAVED_CHANGES_ONE": "1 modifica non salvata",
"UNSAVED_CHANGES_COUNT": "{{count}} modifiche non salvate",
"RESTART_NOTICE": "{{settings}} verrà applicato dopo il riavvio.",
"RESTART_LATER": "Più tardi",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Apri Copia di sicurezza e dati",
"NAV_TMDB": "Metadati",
"TMDB": "Metadati (TMDB)",
"TMDB_DESCRIPTION": "Arricchisci i dettagli di film e serie con dati da The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "「{{query}}」に一致する設定: {{count}}",
"SEARCH_EMPTY_HINT": "一致する設定がありません。「プレーヤー」「テーマ」「EPG」など別の言葉を試してください。",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "アプリケーションの設定を変更",
"GENERAL_SUBTITLE": "言語、外観、アプリの起動方法。",
"PLAYBACK": "再生",
"EPG_SOURCES": "EPGソース",
"EPG_SOURCES_DESCRIPTION": "EPGソースとして1つ以上のURLを追加",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "リモート",
"NAV_DATA": "データ",
"NAV_BACKUP": "バックアップ",
"NAV_RESET": "リセット",
"NAV_BACKUP": "バックアップとデータ",
"NAV_ABOUT": "アプリについて",
"NAV_GROUP_APP": "アプリ",
"NAV_GROUP_LIBRARY": "ライブラリ",
"NAV_GROUP_DEVICES": "デバイス",
"CARD_APPEARANCE": "外観",
"CARD_STARTUP": "起動",
"CARD_CHANNELS": "チャンネル",
"CARD_PLAYER": "プレーヤー",
"CARD_WATCHING": "視聴",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "デスクトップ",
"CARD_GUIDE": "番組表",
"CARD_SOURCES": "ソース",
"CARD_XTREAM_PLAYLISTS": "Xtream プレイリスト",
"CARD_RAILS": "レール",
"CARD_CACHE": "キャッシュ",
"EPG_SUBTITLE": "番組表の表示と XMLTV ソース。",
"PLAYBACK_SUBTITLE": "プレーヤー、字幕、ストリーム形式。",
"CLEAR_EPG_DATA_DESCRIPTION": "ダウンロード済みの番組データをすべて削除します。ソースは残り、再取得できます。",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} 分早める",
"EPG_OFFSET_STEP_UP": "{{minutes}} 分遅らせる",
"CHIP_RESTART": "再起動",
"CHIP_EXPERIMENTAL": "試験的",
"UPDATE_UP_TO_DATE": "最新",
"UPDATE_AVAILABLE_CHIP": "アップデートあり",
"UNSAVED_CHANGES_ONE": "未保存の変更: 1件",
"UNSAVED_CHANGES_COUNT": "未保存の変更: {{count}}件",
"RESTART_NOTICE": "{{settings}} は再起動後に適用されます。",
"RESTART_LATER": "後で",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "バックアップとデータを開く",
"NAV_TMDB": "メタデータ",
"TMDB": "メタデータ(TMDB)",
"TMDB_DESCRIPTION": "The Movie Database のデータで映画やシリーズの詳細情報を充実させます",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "“{{query}}”와 일치하는 설정: {{count}}",
"SEARCH_EMPTY_HINT": "일치하는 설정이 없습니다. 플레이어, 테마, EPG 같은 다른 단어로 검색해 보세요.",
"GENERAL": "일반",
"GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다",
"GENERAL_SUBTITLE": "언어, 모양 및 앱 시작 방식.",
"PLAYBACK": "재생",
"EPG_SOURCES": "EPG 소스",
"EPG_SOURCES_DESCRIPTION": "하나 이상의 URL을 EPG 소스로 추가합니다",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "원격",
"NAV_DATA": "데이터",
"NAV_BACKUP": "백업",
"NAV_RESET": "초기화",
"NAV_BACKUP": "백업 및 데이터",
"NAV_ABOUT": "정보",
"NAV_GROUP_APP": "앱",
"NAV_GROUP_LIBRARY": "라이브러리",
"NAV_GROUP_DEVICES": "기기",
"CARD_APPEARANCE": "모양",
"CARD_STARTUP": "시작",
"CARD_CHANNELS": "채널",
"CARD_PLAYER": "플레이어",
"CARD_WATCHING": "시청",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "데스크톱",
"CARD_GUIDE": "편성표",
"CARD_SOURCES": "소스",
"CARD_XTREAM_PLAYLISTS": "Xtream 재생목록",
"CARD_RAILS": "레일",
"CARD_CACHE": "캐시",
"EPG_SUBTITLE": "편성표 표시 및 XMLTV 소스.",
"PLAYBACK_SUBTITLE": "플레이어, 자막 및 스트림 형식.",
"CLEAR_EPG_DATA_DESCRIPTION": "다운로드한 편성 데이터를 모두 삭제합니다. 소스는 유지되며 다시 불러올 수 있습니다.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}}분 앞당기기",
"EPG_OFFSET_STEP_UP": "{{minutes}}분 늦추기",
"CHIP_RESTART": "다시 시작",
"CHIP_EXPERIMENTAL": "실험적",
"UPDATE_UP_TO_DATE": "최신 상태",
"UPDATE_AVAILABLE_CHIP": "업데이트 가능",
"UNSAVED_CHANGES_ONE": "저장되지 않은 변경 사항 1개",
"UNSAVED_CHANGES_COUNT": "저장되지 않은 변경 사항 {{count}}개",
"RESTART_NOTICE": "{{settings}} 설정은 다시 시작한 후 적용됩니다.",
"RESTART_LATER": "나중에",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "백업 및 데이터 열기",
"NAV_TMDB": "메타데이터",
"TMDB": "메타데이터 (TMDB)",
"TMDB_DESCRIPTION": "The Movie Database의 데이터로 영화 및 시리즈 상세 정보를 보강합니다",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Instellingen voor “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Geen instellingen gevonden. Probeer een ander woord, zoals speler, thema of EPG.",
"GENERAL": "Algemeen",
"GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie",
"GENERAL_SUBTITLE": "Taal, weergave en hoe de app start.",
"PLAYBACK": "Afspelen",
"EPG_SOURCES": "EPG-bronnen",
"EPG_SOURCES_DESCRIPTION": "Voeg een of meer URL's toe als EPG-bron",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Afstandsbediening",
"NAV_DATA": "Gegevens",
"NAV_BACKUP": "Back-up",
"NAV_RESET": "Resetten",
"NAV_BACKUP": "Back-up en gegevens",
"NAV_ABOUT": "Over",
"NAV_GROUP_APP": "App",
"NAV_GROUP_LIBRARY": "Bibliotheek",
"NAV_GROUP_DEVICES": "Apparaten",
"CARD_APPEARANCE": "Weergave",
"CARD_STARTUP": "Opstarten",
"CARD_CHANNELS": "Zenders",
"CARD_PLAYER": "Speler",
"CARD_WATCHING": "Kijken",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Desktop",
"CARD_GUIDE": "Gids",
"CARD_SOURCES": "Bronnen",
"CARD_XTREAM_PLAYLISTS": "Xtream-afspeellijsten",
"CARD_RAILS": "Rijen",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Weergave van de programmagids en XMLTV-bronnen.",
"PLAYBACK_SUBTITLE": "Speler, ondertitels en streamformaat.",
"CLEAR_EPG_DATA_DESCRIPTION": "Verwijdert alle gedownloade programmagegevens. De bronnen blijven staan en kunnen worden vernieuwd.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minuten eerder",
"EPG_OFFSET_STEP_UP": "{{minutes}} minuten later",
"CHIP_RESTART": "Herstart",
"CHIP_EXPERIMENTAL": "Experimenteel",
"UPDATE_UP_TO_DATE": "Up-to-date",
"UPDATE_AVAILABLE_CHIP": "Update beschikbaar",
"UNSAVED_CHANGES_ONE": "1 niet-opgeslagen wijziging",
"UNSAVED_CHANGES_COUNT": "{{count}} niet-opgeslagen wijzigingen",
"RESTART_NOTICE": "{{settings}} wordt toegepast na een herstart.",
"RESTART_LATER": "Later",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Back-up en gegevens openen",
"NAV_TMDB": "Metadata",
"TMDB": "Metadata (TMDB)",
"TMDB_DESCRIPTION": "Verrijk film- en seriedetails met gegevens van The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Ustawienia pasujące do „{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Brak pasujących ustawień. Spróbuj innego słowa, np. odtwarzacz, motyw lub EPG.",
"GENERAL": "Ogólne",
"GENERAL_SUBTITLE": "Zmień konfigurację aplikacji",
"GENERAL_SUBTITLE": "Język, wygląd i sposób uruchamiania aplikacji.",
"PLAYBACK": "Odtwarzanie",
"EPG_SOURCES": "Źródła EPG",
"EPG_SOURCES_DESCRIPTION": "Dodaj jeden lub więcej adresów URL jako źródła EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Pilot",
"NAV_DATA": "Dane",
"NAV_BACKUP": "Kopia zapasowa",
"NAV_RESET": "Resetowanie",
"NAV_BACKUP": "Kopia zapasowa i dane",
"NAV_ABOUT": "O aplikacji",
"NAV_GROUP_APP": "Aplikacja",
"NAV_GROUP_LIBRARY": "Biblioteka",
"NAV_GROUP_DEVICES": "Urządzenia",
"CARD_APPEARANCE": "Wygląd",
"CARD_STARTUP": "Uruchamianie",
"CARD_CHANNELS": "Kanały",
"CARD_PLAYER": "Odtwarzacz",
"CARD_WATCHING": "Oglądanie",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Komputer",
"CARD_GUIDE": "Przewodnik",
"CARD_SOURCES": "Źródła",
"CARD_XTREAM_PLAYLISTS": "Playlisty Xtream",
"CARD_RAILS": "Wiersze",
"CARD_CACHE": "Pamięć podręczna",
"EPG_SUBTITLE": "Wyświetlanie przewodnika po programach i źródła XMLTV.",
"PLAYBACK_SUBTITLE": "Odtwarzacz, napisy i format strumienia.",
"CLEAR_EPG_DATA_DESCRIPTION": "Usuwa wszystkie pobrane dane programów. Źródła pozostają i można je odświeżyć.",
"EPG_OFFSET_STEP_DOWN": "O {{minutes}} minut wcześniej",
"EPG_OFFSET_STEP_UP": "O {{minutes}} minut później",
"CHIP_RESTART": "Restart",
"CHIP_EXPERIMENTAL": "Eksperymentalne",
"UPDATE_UP_TO_DATE": "Aktualna",
"UPDATE_AVAILABLE_CHIP": "Dostępna aktualizacja",
"UNSAVED_CHANGES_ONE": "1 niezapisana zmiana",
"UNSAVED_CHANGES_COUNT": "Niezapisane zmiany: {{count}}",
"RESTART_NOTICE": "{{settings}} zostanie zastosowane po ponownym uruchomieniu.",
"RESTART_LATER": "Później",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Otwórz Kopia zapasowa i dane",
"NAV_TMDB": "Metadane",
"TMDB": "Metadane (TMDB)",
"TMDB_DESCRIPTION": "Wzbogacaj szczegóły filmów i seriali o dane z The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Configurações correspondentes a “{{query}}”: {{count}}",
"SEARCH_EMPTY_HINT": "Nenhuma configuração corresponde. Tente outra palavra, como reprodutor, tema ou EPG.",
"GENERAL": "Geral",
"GENERAL_SUBTITLE": "Altere a configuração do aplicativo",
"GENERAL_SUBTITLE": "Idioma, aparência e como o aplicativo inicia.",
"PLAYBACK": "Reprodução",
"EPG_SOURCES": "Fontes de EPG",
"EPG_SOURCES_DESCRIPTION": "Adicione uma ou mais URLs como fontes de EPG",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Remoto",
"NAV_DATA": "Dados",
"NAV_BACKUP": "Cópia de segurança",
"NAV_RESET": "Redefinir",
"NAV_BACKUP": "Cópia de segurança e dados",
"NAV_ABOUT": "Sobre",
"NAV_GROUP_APP": "Aplicativo",
"NAV_GROUP_LIBRARY": "Biblioteca",
"NAV_GROUP_DEVICES": "Dispositivos",
"CARD_APPEARANCE": "Aparência",
"CARD_STARTUP": "Inicialização",
"CARD_CHANNELS": "Canais",
"CARD_PLAYER": "Reprodutor",
"CARD_WATCHING": "Visualização",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Área de trabalho",
"CARD_GUIDE": "Guia",
"CARD_SOURCES": "Fontes",
"CARD_XTREAM_PLAYLISTS": "Listas Xtream",
"CARD_RAILS": "Fileiras",
"CARD_CACHE": "Cache",
"EPG_SUBTITLE": "Exibição do guia de programação e fontes XMLTV.",
"PLAYBACK_SUBTITLE": "Reprodutor, legendas e formato de transmissão.",
"CLEAR_EPG_DATA_DESCRIPTION": "Remove todos os dados de programação baixados. As fontes permanecem e podem ser atualizadas.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} minutos antes",
"EPG_OFFSET_STEP_UP": "{{minutes}} minutos depois",
"CHIP_RESTART": "Reinício",
"CHIP_EXPERIMENTAL": "Experimental",
"UPDATE_UP_TO_DATE": "Atualizado",
"UPDATE_AVAILABLE_CHIP": "Atualização disponível",
"UNSAVED_CHANGES_ONE": "1 alteração não salva",
"UNSAVED_CHANGES_COUNT": "{{count}} alterações não salvas",
"RESTART_NOTICE": "{{settings}} será aplicado após reiniciar.",
"RESTART_LATER": "Mais tarde",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Abrir Cópia de segurança e dados",
"NAV_TMDB": "Metadados",
"TMDB": "Metadados (TMDB)",
"TMDB_DESCRIPTION": "Enriqueça os detalhes de filmes e séries com dados do The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "Настройки по запросу «{{query}}»: {{count}}",
"SEARCH_EMPTY_HINT": "Ничего не найдено. Попробуйте другое слово, например «плеер», «тема» или «EPG».",
"GENERAL": "Общие",
"GENERAL_SUBTITLE": "Изменение основных параметров приложения",
"GENERAL_SUBTITLE": "Язык, внешний вид и запуск приложения.",
"PLAYBACK": "Воспроизведение",
"EPG_SOURCES": "Источники EPG",
"EPG_SOURCES_DESCRIPTION": "Добавьте один или несколько источников XMLTV: ссылки или локальные файлы",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Пульт",
"NAV_DATA": "Данные",
"NAV_BACKUP": "Резервная копия",
"NAV_RESET": "Сброс",
"NAV_BACKUP": "Резервная копия и данные",
"NAV_ABOUT": "Инфо",
"NAV_GROUP_APP": "Приложение",
"NAV_GROUP_LIBRARY": "Библиотека",
"NAV_GROUP_DEVICES": "Устройства",
"CARD_APPEARANCE": "Внешний вид",
"CARD_STARTUP": "Запуск",
"CARD_CHANNELS": "Каналы",
"CARD_PLAYER": "Плеер",
"CARD_WATCHING": "Просмотр",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Настольная версия",
"CARD_GUIDE": "Телегид",
"CARD_SOURCES": "Источники",
"CARD_XTREAM_PLAYLISTS": "Плейлисты Xtream",
"CARD_RAILS": "Ряды",
"CARD_CACHE": "Кэш",
"EPG_SUBTITLE": "Отображение телегида и источники XMLTV.",
"PLAYBACK_SUBTITLE": "Плеер, субтитры и формат потока.",
"CLEAR_EPG_DATA_DESCRIPTION": "Удаляет все загруженные данные телепрограммы. Источники остаются, их можно обновить.",
"EPG_OFFSET_STEP_DOWN": "На {{minutes}} минут раньше",
"EPG_OFFSET_STEP_UP": "На {{minutes}} минут позже",
"CHIP_RESTART": "Перезапуск",
"CHIP_EXPERIMENTAL": "Экспериментально",
"UPDATE_UP_TO_DATE": "Актуальная версия",
"UPDATE_AVAILABLE_CHIP": "Доступно обновление",
"UNSAVED_CHANGES_ONE": "1 несохранённое изменение",
"UNSAVED_CHANGES_COUNT": "Несохранённые изменения: {{count}}",
"RESTART_NOTICE": "{{settings}} вступит в силу после перезапуска.",
"RESTART_LATER": "Позже",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Открыть «Резервная копия и данные»",
"NAV_TMDB": "Метаданные",
"TMDB": "Метаданные (TMDB)",
"TMDB_DESCRIPTION": "Обогащение информации о фильмах и сериалах данными из The Movie Database",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "“{{query}}” ile eşleşen ayarlar: {{count}}",
"SEARCH_EMPTY_HINT": "Eşleşen ayar yok. Oynatıcı, tema veya EPG gibi başka bir kelime deneyin.",
"GENERAL": "Genel",
"GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin",
"GENERAL_SUBTITLE": "Dil, görünüm ve uygulamanın nasıl başlayacağı.",
"PLAYBACK": "Oynatma",
"EPG_SOURCES": "EPG kaynakları",
"EPG_SOURCES_DESCRIPTION": "EPG kaynağı olarak bir veya daha fazla URL ekleyin",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "Uzaktan",
"NAV_DATA": "Veri",
"NAV_BACKUP": "Yedekleme",
"NAV_RESET": "Sıfırla",
"NAV_BACKUP": "Yedekleme ve veriler",
"NAV_ABOUT": "Hakkında",
"NAV_GROUP_APP": "Uygulama",
"NAV_GROUP_LIBRARY": "Kitaplık",
"NAV_GROUP_DEVICES": "Cihazlar",
"CARD_APPEARANCE": "Görünüm",
"CARD_STARTUP": "Başlangıç",
"CARD_CHANNELS": "Kanallar",
"CARD_PLAYER": "Oynatıcı",
"CARD_WATCHING": "İzleme",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "Masaüstü",
"CARD_GUIDE": "Rehber",
"CARD_SOURCES": "Kaynaklar",
"CARD_XTREAM_PLAYLISTS": "Xtream oynatma listeleri",
"CARD_RAILS": "Satırlar",
"CARD_CACHE": "Önbellek",
"EPG_SUBTITLE": "Program rehberi görünümü ve XMLTV kaynakları.",
"PLAYBACK_SUBTITLE": "Oynatıcı, altyazılar ve akış biçimi.",
"CLEAR_EPG_DATA_DESCRIPTION": "İndirilen tüm program verilerini kaldırır. Kaynaklar kalır ve yenilenebilir.",
"EPG_OFFSET_STEP_DOWN": "{{minutes}} dakika önce",
"EPG_OFFSET_STEP_UP": "{{minutes}} dakika sonra",
"CHIP_RESTART": "Yeniden başlatma",
"CHIP_EXPERIMENTAL": "Deneysel",
"UPDATE_UP_TO_DATE": "Güncel",
"UPDATE_AVAILABLE_CHIP": "Güncelleme mevcut",
"UNSAVED_CHANGES_ONE": "1 kaydedilmemiş değişiklik",
"UNSAVED_CHANGES_COUNT": "{{count}} kaydedilmemiş değişiklik",
"RESTART_NOTICE": "{{settings}} yeniden başlatmanın ardından uygulanacak.",
"RESTART_LATER": "Daha sonra",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "Yedekleme ve verileri aç",
"NAV_TMDB": "Meta veri",
"TMDB": "Meta veri (TMDB)",
"TMDB_DESCRIPTION": "Film ve dizi ayrıntılarını The Movie Database verileriyle zenginleştirin",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "与“{{query}}”匹配的设置:{{count}}",
"SEARCH_EMPTY_HINT": "没有匹配的设置。试试其他词,例如“播放器”“主题”或“EPG”。",
"GENERAL": "通用",
"GENERAL_SUBTITLE": "更改应用程序的配置",
"GENERAL_SUBTITLE": "语言、外观和应用启动方式。",
"PLAYBACK": "播放",
"EPG_SOURCES": "EPG 源",
"EPG_SOURCES_DESCRIPTION": "添加一个或多个 URL 作为 EPG 源",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "远程",
"NAV_DATA": "数据",
"NAV_BACKUP": "备份",
"NAV_RESET": "重置",
"NAV_BACKUP": "备份与数据",
"NAV_ABOUT": "关于",
"NAV_GROUP_APP": "应用",
"NAV_GROUP_LIBRARY": "媒体库",
"NAV_GROUP_DEVICES": "设备",
"CARD_APPEARANCE": "外观",
"CARD_STARTUP": "启动",
"CARD_CHANNELS": "频道",
"CARD_PLAYER": "播放器",
"CARD_WATCHING": "观看",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "桌面版",
"CARD_GUIDE": "节目指南",
"CARD_SOURCES": "来源",
"CARD_XTREAM_PLAYLISTS": "Xtream 播放列表",
"CARD_RAILS": "内容行",
"CARD_CACHE": "缓存",
"EPG_SUBTITLE": "节目指南显示与 XMLTV 来源。",
"PLAYBACK_SUBTITLE": "播放器、字幕和流格式。",
"CLEAR_EPG_DATA_DESCRIPTION": "删除所有已下载的节目数据。来源将保留并可刷新。",
"EPG_OFFSET_STEP_DOWN": "提前 {{minutes}} 分钟",
"EPG_OFFSET_STEP_UP": "推后 {{minutes}} 分钟",
"CHIP_RESTART": "需重启",
"CHIP_EXPERIMENTAL": "实验性",
"UPDATE_UP_TO_DATE": "已是最新",
"UPDATE_AVAILABLE_CHIP": "有可用更新",
"UNSAVED_CHANGES_ONE": "1 项未保存的更改",
"UNSAVED_CHANGES_COUNT": "{{count}} 项未保存的更改",
"RESTART_NOTICE": "{{settings}} 将在重启后生效。",
"RESTART_LATER": "稍后",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "打开“备份与数据”",
"NAV_TMDB": "元数据",
"TMDB": "元数据(TMDB)",
"TMDB_DESCRIPTION": "使用来自 The Movie Database 的数据丰富电影和剧集详情",
+31 -3
View File
@@ -270,7 +270,7 @@
"SEARCH_RESULTS_COUNT": "符合「{{query}}」的設定:{{count}}",
"SEARCH_EMPTY_HINT": "沒有符合的設定。試試其他詞,例如「播放器」「主題」或「EPG」。",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "更改應用程式的配置",
"GENERAL_SUBTITLE": "語言、外觀與應用程式的啟動方式。",
"PLAYBACK": "播放",
"EPG_SOURCES": "EPG 來源",
"EPG_SOURCES_DESCRIPTION": "新增一個或多個網址作為 EPG 來源",
@@ -290,9 +290,37 @@
"NAV_EPG": "EPG",
"NAV_REMOTE": "遠端",
"NAV_DATA": "資料",
"NAV_BACKUP": "備份",
"NAV_RESET": "重設",
"NAV_BACKUP": "備份與資料",
"NAV_ABOUT": "關於",
"NAV_GROUP_APP": "應用程式",
"NAV_GROUP_LIBRARY": "媒體庫",
"NAV_GROUP_DEVICES": "裝置",
"CARD_APPEARANCE": "外觀",
"CARD_STARTUP": "啟動",
"CARD_CHANNELS": "頻道",
"CARD_PLAYER": "播放器",
"CARD_WATCHING": "觀看",
"CARD_XTREAM": "Xtream",
"CARD_DESKTOP": "桌面版",
"CARD_GUIDE": "節目表",
"CARD_SOURCES": "來源",
"CARD_XTREAM_PLAYLISTS": "Xtream 播放清單",
"CARD_RAILS": "內容列",
"CARD_CACHE": "快取",
"EPG_SUBTITLE": "節目表顯示與 XMLTV 來源。",
"PLAYBACK_SUBTITLE": "播放器、字幕與串流格式。",
"CLEAR_EPG_DATA_DESCRIPTION": "刪除所有已下載的節目資料。來源會保留並可重新整理。",
"EPG_OFFSET_STEP_DOWN": "提前 {{minutes}} 分鐘",
"EPG_OFFSET_STEP_UP": "延後 {{minutes}} 分鐘",
"CHIP_RESTART": "需重新啟動",
"CHIP_EXPERIMENTAL": "實驗性",
"UPDATE_UP_TO_DATE": "已是最新",
"UPDATE_AVAILABLE_CHIP": "有可用更新",
"UNSAVED_CHANGES_ONE": "1 項未儲存的變更",
"UNSAVED_CHANGES_COUNT": "{{count}} 項未儲存的變更",
"RESTART_NOTICE": "{{settings}} 將在重新啟動後生效。",
"RESTART_LATER": "稍後",
"APP_UPDATE_CHANNEL_OPEN_BACKUP": "開啟「備份與資料」",
"NAV_TMDB": "中繼資料",
"TMDB": "中繼資料(TMDB)",
"TMDB_DESCRIPTION": "使用 The Movie Database 的資料豐富電影與影集的詳細資訊",
@@ -742,6 +742,37 @@ flat ancestor-color compositing is only appropriate outside that layered hero.
## Settings Surfaces
Settings use the same system but are flatter than content-heavy views.
Reference: `apps/web/src/app/settings/settings.component.scss` and the
per-theme tokens in `apps/web/src/_settings-theme.scss`.
### Structure
- The sidebar is a 20px "Settings" title over four groups (App, Library,
Devices, Data), with About pinned to the footer beside the installed
version and an update badge. The active item is a soft selection fill with
tinted text and no border, so the list never shifts. Back lives in the
workspace header and Esc runs it. Section pages are defined once in
`SETTINGS_SECTION_DEFINITIONS` (`libs/workspace/shell/util`).
- Each page is a title (26px) and one-line subtitle, then sections: a 14px
heading, an optional description and action buttons, and a card.
- Rows (`.setting-item`) are a label column (13.5px title, description
capped at 56ch) and one right-aligned control column. Put every control in
that column, including segmented controls; the page is a size container
and rows stack under 600px of pane width. Keep `data-setting-id` on every row: settings search and the
command palette reveal rows by it, and the registry spec checks the index
against the templates.
- Controls: `.settings-select` is a 220px outlined select without a floating
label (the row title names it; pass `aria-labelledby`); boolean settings
are `mat-slide-toggle` switches; segmented choices use `.theme-switcher`
as a pill; `.settings-chip` marks rows that apply after a restart or are
experimental; `.settings-callout` (`--info`, `--warn`) sits under the row
it explains inside `.setting-item__sub` and only while that choice is made.
- Destructive actions keep the confirm dialog from the Dialogs section; the
row itself uses `.app-destructive-button` on a stroked button.
- The save bar floats over the content column while the form is dirty with
the change count, Discard and Save (Cmd/Ctrl+S); the sidebar marks pages
with staged edits. A saved change that waits for a restart shows a sticky
notice at the top of the page.
### Light Theme
@@ -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;
}
/**
+36
View File
@@ -42,6 +42,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -149,6 +150,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -257,6 +259,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -372,8 +375,13 @@
"PORTALS.SIDEBAR.LIVE_TV": "Live TV",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_DESKTOP": "Desktop",
"SETTINGS.CARD_PLAYER": "Player",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.LINK_BLOG": "Blog",
"SETTINGS.NAV_EPG": "EPG",
"SETTINGS.NAV_GROUP_APP": "App",
"SETTINGS.NAV_REMOTE": "Remote",
"SETTINGS.PLAYER_ARTPLAYER": "ArtPlayer",
"SETTINGS.PLAYER_VLC": "VLC",
@@ -490,6 +498,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -607,6 +616,8 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_EXPERIMENTAL": "Experimental",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -741,6 +752,10 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_GUIDE": "Guide",
"SETTINGS.CARD_SOURCES": "Sources",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -882,6 +897,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1005,12 +1021,16 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_DESKTOP": "Desktop",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
"SETTINGS.EPG_VIEW_MODE_TIMELINE": "Timeline",
"SETTINGS.LINK_BLOG": "Blog",
"SETTINGS.NAV_EPG": "EPG",
"SETTINGS.NAV_GROUP_APP": "App",
"SETTINGS.NAV_REMOTE": "Remote",
"SETTINGS.PLAYER_ARTPLAYER": "ArtPlayer",
"SETTINGS.PLAYER_VLC": "VLC",
@@ -1120,6 +1140,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1236,6 +1257,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1367,15 +1389,20 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_DESKTOP": "Desktop",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.COMMUNITY": "Project & community",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
"SETTINGS.LINK_BLOG": "Blog",
"SETTINGS.NAV_EPG": "EPG",
"SETTINGS.NAV_GROUP_APP": "App",
"SETTINGS.NAV_TMDB": "Metadata",
"SETTINGS.PLAYER_ARTPLAYER": "ArtPlayer",
"SETTINGS.PLAYER_VLC": "VLC",
"SETTINGS.RESTART_LATER": "Later",
"SETTINGS.TMDB": "Metadata (TMDB)",
"SETTINGS.TMDB_CREDITS": "Metadata",
"SOURCE_CLEANUP.CLEANUP_WARNING": "Source deleted; some follow-up cleanup failed",
@@ -1501,6 +1528,8 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_RESTART": "Restart",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1623,6 +1652,9 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_CACHE": "Cache",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.CHIP_EXPERIMENTAL": "Experimental",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1724,6 +1756,7 @@
"PORTALS.MULTI_SOURCE.FILTER_HD": "HD+",
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.NAV_EPG": "EPG",
"SETTINGS.PLAYER_ARTPLAYER": "ArtPlayer",
"SETTINGS.PLAYER_VLC": "VLC",
@@ -1784,6 +1817,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1891,6 +1925,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -1992,6 +2027,7 @@
"SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY": "Nightly",
"SETTINGS.APP_UPDATE_CHANNEL_STABLE": "Stable",
"SETTINGS.BROWSE_EPG_FILE": "Choose a local XMLTV file…",
"SETTINGS.CARD_XTREAM": "Xtream",
"SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES": "Examples:",
"SETTINGS.EPG_SOURCE_FORMATS_HINT": "Remote XMLTV links (http/https) and files on this computer are both supported, as plain .xml or gzip-compressed .xml.gz. Local files need an absolute path or a file:// URL.",
"SETTINGS.EPG_SOURCE_LIST_TITLE": "Source list",
@@ -237,7 +237,7 @@ async function openRemoteControlSettings(page: Page): Promise<void> {
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
'[data-test-id="remote-control-enabled"] [role="switch"]'
);
if (!(await toggle.isChecked())) {
@@ -327,7 +327,7 @@ async function openSettingsTmdb(page: Page): Promise<void> {
await section.waitFor({ state: 'visible', timeout: 15_000 });
const toggle = section.locator(
'[data-test-id="tmdb-enabled"] input[type="checkbox"]'
'[data-test-id="tmdb-enabled"] [role="switch"]'
);
if (!(await toggle.isChecked())) {