From 1f036d2a8e78ed4cad88d9958491c965f79640b8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 8 Oct 2026 09:26:09 +0200 Subject: [PATCH] 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 --- .changes/settings-cards-and-grouped-nav.md | 11 + .../src/electron-test-fixtures.ts | 23 +- apps/electron-backend-e2e/src/epg.e2e.ts | 15 +- .../src/picture-in-picture.e2e.ts | 7 +- .../src/settings-connectivity-guard.e2e.ts | 9 +- apps/electron-backend-e2e/src/settings.e2e.ts | 47 +- .../src/xtream-epg.e2e.ts | 11 +- apps/web-e2e/src/e2e-helpers.ts | 15 + apps/web-e2e/src/m3u-movie-details.e2e.ts | 16 +- apps/web-e2e/src/mobile-layout.e2e.ts | 2 +- apps/web-e2e/src/settings.e2e.ts | 84 +- .../src/stalker-season-markers.fixture.ts | 6 +- .../src/xtream-series-playback.fixture.ts | 12 +- apps/web-e2e/src/xtream.e2e.ts | 2 +- apps/web/src/_settings-theme.scss | 388 ++--- .../settings-about-section.component.html | 770 +++++----- .../settings-about-section.component.scss | 262 ++++ .../settings-about-section.component.ts | 49 +- .../settings-backup-section.component.html | 230 ++- .../settings-backup-section.component.scss | 50 + .../settings-backup-section.component.ts | 14 +- .../settings/settings-change-tracking.spec.ts | 64 + .../app/settings/settings-change-tracking.ts | 96 ++ .../settings-dashboard-section.component.html | 398 ++--- .../settings-dashboard-section.component.ts | 4 +- .../settings-epg-section.component.html | 516 ++++--- .../settings-epg-section.component.scss | 169 +++ .../settings-epg-section.component.spec.ts | 105 ++ .../settings-epg-section.component.ts | 44 +- .../src/app/settings/settings-form.facade.ts | 57 +- .../settings-general-section.component.html | 545 +++---- .../settings-general-section.component.ts | 4 +- .../src/app/settings/settings-options.spec.ts | 18 +- apps/web/src/app/settings/settings-options.ts | 1 + .../settings-parental-section.component.html | 270 ++-- ...ngs-playback-section.auto-failover.spec.ts | 1 + .../settings-playback-section.component.html | 1300 ++++++++++------- ...ettings-playback-section.component.spec.ts | 21 +- .../settings-playback-section.component.ts | 10 +- ...ings-remote-control-section.component.html | 267 ++-- ...ings-remote-control-section.component.scss | 45 + ...ttings-remote-control-section.component.ts | 6 +- .../settings-reset-section.component.html | 90 -- .../settings-reset-section.component.ts | 37 - .../settings/settings-search-registry.spec.ts | 13 +- .../settings-search-results.component.html | 22 +- .../settings-search-results.component.scss | 23 +- .../settings-tmdb-section.component.html | 319 ++-- .../settings-tmdb-section.component.spec.ts | 4 +- .../settings-tmdb-section.component.ts | 4 +- .../settings/settings.component.form.spec.ts | 152 +- .../src/app/settings/settings.component.html | 61 +- .../src/app/settings/settings.component.scss | 1072 +++++--------- .../settings.component.search.spec.ts | 4 +- .../app/settings/settings.component.spec.ts | 2 +- .../src/app/settings/settings.component.ts | 105 +- apps/web/src/app/settings/settings.models.ts | 2 + apps/web/src/assets/i18n/ar.json | 34 +- apps/web/src/assets/i18n/ary.json | 34 +- apps/web/src/assets/i18n/by.json | 34 +- apps/web/src/assets/i18n/de.json | 34 +- apps/web/src/assets/i18n/el.json | 34 +- apps/web/src/assets/i18n/en.json | 34 +- apps/web/src/assets/i18n/es.json | 34 +- apps/web/src/assets/i18n/fr.json | 34 +- apps/web/src/assets/i18n/hu.json | 34 +- apps/web/src/assets/i18n/it.json | 34 +- apps/web/src/assets/i18n/ja.json | 34 +- apps/web/src/assets/i18n/ko.json | 34 +- apps/web/src/assets/i18n/nl.json | 34 +- apps/web/src/assets/i18n/pl.json | 34 +- apps/web/src/assets/i18n/pt.json | 34 +- apps/web/src/assets/i18n/ru.json | 34 +- apps/web/src/assets/i18n/tr.json | 34 +- apps/web/src/assets/i18n/zh.json | 34 +- apps/web/src/assets/i18n/zhtw.json | 34 +- docs/architecture/iptvnator-ui-guidelines.md | 31 + ...pace-settings-context-panel.component.scss | 167 ++- ...e-settings-context-panel.component.spec.ts | 133 +- ...kspace-settings-context-panel.component.ts | 219 ++- .../util/src/lib/settings-context.service.ts | 23 + .../settings-search-entries-playback.ts | 137 +- .../settings-search-entries.ts | 30 +- .../settings-search-sections.ts | 54 +- .../settings-search/settings-search.types.ts | 5 + tools/i18n/identical-en-baseline.json | 36 + .../capture-navigation-setup-actions.ts | 4 +- 87 files changed, 5745 insertions(+), 3614 deletions(-) create mode 100644 .changes/settings-cards-and-grouped-nav.md create mode 100644 apps/web/src/app/settings/settings-about-section.component.scss create mode 100644 apps/web/src/app/settings/settings-backup-section.component.scss create mode 100644 apps/web/src/app/settings/settings-change-tracking.spec.ts create mode 100644 apps/web/src/app/settings/settings-change-tracking.ts create mode 100644 apps/web/src/app/settings/settings-epg-section.component.scss create mode 100644 apps/web/src/app/settings/settings-epg-section.component.spec.ts create mode 100644 apps/web/src/app/settings/settings-remote-control-section.component.scss delete mode 100644 apps/web/src/app/settings/settings-reset-section.component.html delete mode 100644 apps/web/src/app/settings/settings-reset-section.component.ts diff --git a/.changes/settings-cards-and-grouped-nav.md b/.changes/settings-cards-and-grouped-nav.md new file mode 100644 index 000000000..f90cd6056 --- /dev/null +++ b/.changes/settings-cards-and-grouped-nav.md @@ -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. diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 01295d7c6..7d2c5d85f 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 { + if ((await toggle.isChecked()) !== checked) { + await toggle.click(); + } + await expect(toggle).toBeChecked({ checked }); +} + export async function enableRemoteControl( page: Page, port: number ): Promise { 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)); } diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index a1453e3bb..c7d946c43 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -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' }) diff --git a/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts b/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts index 4b8d83469..b8650a6f4 100644 --- a/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts +++ b/apps/electron-backend-e2e/src/picture-in-picture.e2e.ts @@ -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. diff --git a/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts b/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts index 4082eab85..aa8efc606 100644 --- a/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts +++ b/apps/electron-backend-e2e/src/settings-connectivity-guard.e2e.ts @@ -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 diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 8784a7d09..a2d14bd82 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -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); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index f1741be35..200229445 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -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( diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index fce681780..e3e924604 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -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 { + if ((await toggle.isChecked()) !== checked) { + await toggle.click(); + } + await expect(toggle).toBeChecked({ checked }); +} + export async function pressTab( page: Page, browserName: string, diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index b28e80cae..af4010d44 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -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 { 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); } } diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index e58f9cb9e..f3485b5a9 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -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 ); diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index e440d0cd9..19f1ec828 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -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(); diff --git a/apps/web-e2e/src/stalker-season-markers.fixture.ts b/apps/web-e2e/src/stalker-season-markers.fixture.ts index 3c9e6e94c..f6be83973 100644 --- a/apps/web-e2e/src/stalker-season-markers.fixture.ts +++ b/apps/web-e2e/src/stalker-season-markers.fixture.ts @@ -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(); diff --git a/apps/web-e2e/src/xtream-series-playback.fixture.ts b/apps/web-e2e/src/xtream-series-playback.fixture.ts index 6ad490ef1..206d83271 100644 --- a/apps/web-e2e/src/xtream-series-playback.fixture.ts +++ b/apps/web-e2e/src/xtream-series-playback.fixture.ts @@ -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(); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 0b7029188..815ddbd15 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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(); diff --git a/apps/web/src/_settings-theme.scss b/apps/web/src/_settings-theme.scss index 8a843b60e..7ee7150c6 100644 --- a/apps/web/src/_settings-theme.scss +++ b/apps/web/src/_settings-theme.scss @@ -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); - } -} diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index 0d6f87c9f..22201ffad 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -1,437 +1,427 @@
-
- info -
-
-

{{ 'SETTINGS.ABOUT' | translate }}

-

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

-
+

{{ 'SETTINGS.ABOUT' | translate }}

+

{{ 'SETTINGS.ABOUT_SUBTITLE' | translate }}

-
-
-

{{ 'SETTINGS.VERSION' | translate }}

-

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

-
-
- - {{ version() }} - @if (buildCommitShort(); as commit) { - ({{ commit }}) - } - - {{ updateMessage() }} -
-
- - @if (isDesktop() && appUpdateStatus(); as updateStatus) { -
-
-

{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}

-

{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}

-
-
-
- @if (appliedChannelLabelKey(); as channelLabelKey) { - - {{ channelLabelKey | translate }} - - } - - {{ - appUpdateStatusLabelKey() - | translate - : { - version: - updateStatus.latestVersion || - updateStatus.currentVersion, - } - }} - - @if (updateStatus.error) { - {{ updateStatus.error }} - } - @if (updateStatus.progress; as progress) { - - } - @if (hasPendingChannelChange()) { - - {{ - 'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT' - | translate - : { - current: - (channelLabelKey( - verdictChannel() - ) | translate), - next: - (channelLabelKey( - draftUpdateChannel() - ) | translate), - } - }} - - } @else if (hasRetainedOtherChannelVerdict()) { - - {{ - 'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT' - | translate - : { - current: - (channelLabelKey( - verdictChannel() - ) | translate), - next: - (channelLabelKey( - updateStatus.channel - ) | translate), - } - }} - + +
+
+
+ +
+
+ IPTVnator + @if (updateStatusChip(); as chip) { + {{ chip.labelKey | translate }} + } +
+ + {{ version() }} + @if (buildCommitShort(); as commit) { + ({{ commit }}) + } + + @if (!appUpdateStatus() && updateMessage()) { + {{ + updateMessage() + }} }
- @if (canSelectUpdateChannel()) { -
- - {{ - 'SETTINGS.APP_UPDATE_CHANNEL' | translate - }} - + @if (canOpenAppUpdateReleaseNotes()) { + } - @if (isNightlyBuildOnStableChannel()) { - + save {{ - 'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT' + 'SETTINGS.APP_UPDATE_SAVE_AND_CHECK' | translate + : { + channel: + (channelLabelKey( + draftUpdateChannel() + ) | translate), + } }} - + + } @else { + }
} -
- @if (canSaveAndCheckForAppUpdate()) { - - } @else { - - } - @if (canDownloadAppUpdate()) { - - } - @if (canOpenAppUpdateReleaseNotes()) { - - } - @if (canInstallAppUpdate()) { - - } - @if (canOpenManualAppUpdate()) { - - } -
-
- } - @if (isDesktop()) { -
- {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate - }} - {{ - 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' | translate - }} -
- } + @if (isDesktop() && appUpdateStatus(); as updateStatus) { +
+
+
+ @if (appliedChannelLabelKey(); as channelLabelKey) { + + {{ channelLabelKey | translate }} + + } + + {{ + appUpdateStatusLabelKey() + | translate + : { + version: + updateStatus.latestVersion || + updateStatus.currentVersion, + } + }} + + @if (updateStatus.error) { + {{ updateStatus.error }} + } + @if (updateStatus.progress; as progress) { + + } + @if (hasPendingChannelChange()) { + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_PENDING_HINT' + | translate + : { + current: + (channelLabelKey( + verdictChannel() + ) | translate), + next: + (channelLabelKey( + draftUpdateChannel() + ) | translate), + } + }} + + } @else if (hasRetainedOtherChannelVerdict()) { + + {{ + 'SETTINGS.APP_UPDATE_CHANNEL_RETAINED_HINT' + | translate + : { + current: + (channelLabelKey( + verdictChannel() + ) | translate), + next: + (channelLabelKey( + updateStatus.channel + ) | translate), + } + }} + + } +
+
+
+ @if (canDownloadAppUpdate()) { + + } + @if (canInstallAppUpdate()) { + + } + @if (canOpenManualAppUpdate()) { + + } +
+
-
-
-

{{ 'SETTINGS.TMDB_CREDITS' | translate }}

-

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

+ @if (canSelectUpdateChannel()) { +
+
+

+ {{ 'SETTINGS.APP_UPDATE_CHANNEL' | translate }} +

+

+ {{ + 'SETTINGS.APP_UPDATE_CHANNEL_DESCRIPTION' + | translate + }} +

+
+
+ + + @for ( + option of updateChannelOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ + @if (draftUpdateChannel() === 'nightly') { +
+
+ warning +
+ {{ + 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY_WARNING' + | translate + }} +
+ +
+
+
+
+ } + @if (isNightlyBuildOnStableChannel()) { +
+
+ info +
+ {{ + 'SETTINGS.APP_UPDATE_NIGHTLY_ON_STABLE_HINT' + | translate + }} +
+
+
+ } + } + } +
+
+ +
+
+

{{ 'SETTINGS.COMMUNITY' | translate }}

+
+ -
+
- - -
-
-
-
- favorite -
-
-

{{ 'SETTINGS.SUPPORT_TITLE' | translate }}

-

{{ 'SETTINGS.SUPPORT_DESCRIPTION' | translate }}

-
+
+ +
+ +
+ TMDB + {{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}
diff --git a/apps/web/src/app/settings/settings-about-section.component.scss b/apps/web/src/app/settings/settings-about-section.component.scss new file mode 100644 index 000000000..1c5bd5cf6 --- /dev/null +++ b/apps/web/src/app/settings/settings-about-section.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 028522a27..511206c62 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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(); readonly openManualAppUpdate = output(); readonly openAppUpdateReleaseNotes = output(); + /** The nightly warning offers the backup page before switching. */ + readonly openBackup = output(); 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( + () => { + 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; diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html index 4c9a3ee07..34d6aef9b 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.html +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -1,73 +1,173 @@
-
- backup -
-
-

{{ 'SETTINGS.BACKUP' | translate }}

-

{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}

-
+

{{ 'SETTINGS.NAV_BACKUP' | translate }}

+

{{ 'SETTINGS.BACKUP_DESCRIPTION' | translate }}

-
-
-

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

-

- {{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }} -

+
+
+

{{ 'SETTINGS.BACKUP' | translate }}

-
- - -
-
+
+
+
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

+

+ {{ 'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION' | translate }} +

+
+
+ + +
+
- @if (isPwa()) { -
- {{ 'SETTINGS.EPG_NOTE' | translate }} -  {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} + @if (isPwa()) { +
+
+ info +
+ {{ 'SETTINGS.EPG_NOTE' | translate }} + {{ 'SETTINGS.EPG_NOTE_URL_TEXT' | translate }} +
+
+
+ }
- } +
+ + +
+
+

{{ 'SETTINGS.RESET' | translate }}

+
+

+ {{ 'SETTINGS.RESET_DESCRIPTION' | translate }} +

+
+
+
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

+

{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}

+
+ + {{ + sourceIcons.m3u + }} + {{ + 'HOME.PLAYLIST_TYPES.M3U' | translate + }} + {{ + playlistDeleteSummary().m3u + }} + + + {{ + sourceIcons.xtream + }} + {{ + 'HOME.PLAYLIST_TYPES.XTREAM' | translate + }} + {{ + playlistDeleteSummary().xtream + }} + + + {{ + sourceIcons.stalker + }} + {{ + 'HOME.PLAYLIST_TYPES.STALKER' | translate + }} + {{ + playlistDeleteSummary().stalker + }} + +
+ @if (removeAllProgressLabel(); as progressLabel) { +

+ {{ progressLabel }} +

+ } +
+
+ +
+
+
+
diff --git a/apps/web/src/app/settings/settings-backup-section.component.scss b/apps/web/src/app/settings/settings-backup-section.component.scss new file mode 100644 index 000000000..f7e88bbe4 --- /dev/null +++ b/apps/web/src/app/settings/settings-backup-section.component.scss @@ -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); +} diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts index 0af8aefba..2a25ff1f6 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.ts +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -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(); + readonly removeAllProgressLabel = input(null); readonly importData = output(); readonly exportData = output(); + readonly removeAll = output(); } diff --git a/apps/web/src/app/settings/settings-change-tracking.spec.ts b/apps/web/src/app/settings/settings-change-tracking.spec.ts new file mode 100644 index 000000000..9eb2c0d16 --- /dev/null +++ b/apps/web/src/app/settings/settings-change-tracking.spec.ts @@ -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\./); + } + }); +}); diff --git a/apps/web/src/app/settings/settings-change-tracking.ts b/apps/web/src/app/settings/settings-change-tracking.ts new file mode 100644 index 000000000..be36549e3 --- /dev/null +++ b/apps/web/src/app/settings/settings-change-tracking.ts @@ -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> = { + 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> = { + 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 { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) + ); +} diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index cd5ee2e0e..2f64e8b99 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -1,212 +1,236 @@
-
- dashboard -
-
-

{{ 'SETTINGS.DASHBOARD' | translate }}

-

{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}

-
+

{{ 'SETTINGS.DASHBOARD' | translate }}

+

{{ 'SETTINGS.DASHBOARD_SUBTITLE' | translate }}

-
-
-

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

-

- {{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }} -

+
+
+
+
+

+ {{ 'SETTINGS.SHOW_DASHBOARD' | translate }} +

+

+ {{ 'SETTINGS.SHOW_DASHBOARD_DESCRIPTION' | translate }} +

+
+
+ +
+
-
- -
-
+
-
-
-
-

{{ 'SETTINGS.DASHBOARD_HERO' | translate }}

-

- {{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }} -

-
-
- -
+
+
+

{{ 'SETTINGS.CARD_RAILS' | translate }}

+
+
+
+

+ {{ 'SETTINGS.DASHBOARD_HERO' | translate }} +

+

+ {{ 'SETTINGS.DASHBOARD_HERO_DESCRIPTION' | translate }} +

+
+
+ +
+
-
-
-

{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_CONTINUE_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

- {{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }} -

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

- {{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }} -

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_SOURCES_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

- {{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }} -

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION' - | translate - }} -

+
+
+

+ {{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

- {{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }} -

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION' - | translate - }} -

+
+
+ +

+ {{ + 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION' + | translate + }} +

+
+
+ +
-
- -
-
-
-
-

- {{ - 'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS' - | translate - }} -

-

- {{ - 'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION' - | translate - }} -

-
-
- +
+
+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS' + | translate + }} +

+

+ {{ + 'SETTINGS.DASHBOARD_RAIL_TMDB_RECOMMENDATIONS_DESCRIPTION' + | translate + }} +

+
+
+ +
-
+
diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.ts b/apps/web/src/app/settings/settings-dashboard-section.component.ts index c39bdbca5..173b5fa89 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.ts +++ b/apps/web/src/app/settings/settings-dashboard-section.component.ts @@ -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, ], diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index 523e92004..9b73ca1c9 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -1,238 +1,330 @@
-
- list_alt -
-
-

{{ 'SETTINGS.EPG_SOURCES' | translate }}

-

{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}

-
+

{{ 'SETTINGS.NAV_EPG' | translate }}

+

{{ 'SETTINGS.EPG_SUBTITLE' | translate }}

-
-
-

{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}

-

{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}

+
+
+

{{ 'SETTINGS.CARD_GUIDE' | translate }}

-
-
- @for (option of epgViewModeOptions(); track option.value) { - - } + @for (option of epgViewModeOptions(); track option.value) { + + } +
+
+
+ +
+
+

+ {{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }} +

+

{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}

+
+
+
+ + + min + +
+ @if (offsetControl?.invalid) { + + } +
-
+ -
-
-

{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}

-

{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}

-
-
- - {{ - 'SETTINGS.EPG_OFFSET_LABEL' | translate - }} - - {{ - 'SETTINGS.EPG_OFFSET_ERROR' | translate - }} - -
-
- -
-
-

{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}

-

{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}

-

- {{ - 'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate +

+
+

{{ 'SETTINGS.CARD_SOURCES' | translate }}

+ @if (epgUrl().length > 0) { + {{ + epgUrl().length }} - https://example.org/guide.xml.gz - /home/you/epg/guide.xml - C:\epg\guide.xml.gz - file:///…/guide.xml -

-
-
- - @if (epgUrl().length === 0) { -
+ + +
+ } +
+

+ {{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }} +

+
+
+ + @if (epgUrl().length === 0) { +
-

- {{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }} -

-

- {{ 'SETTINGS.EPG_EMPTY_HINT' | translate }} -

-
- } @else { -
- @for (_ of epgUrl().controls; track _; let i = $index) { -
- - {{ - 'SETTINGS.EPG_URL_PLACEHOLDER' - | translate - }} - - {{ - 'SETTINGS.EPG_URL_ERROR' | translate - }} - - @if (epgField.value) { - - } - @if (canBrowseFiles()) { + event_busy +

+ {{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }} +

+

+ {{ 'SETTINGS.EPG_EMPTY_HINT' | translate }} +

+ +
+ } @else { +
+ @for ( + _ of epgUrl().controls; + track _; + let i = $index + ) { +
+ + + {{ + 'SETTINGS.EPG_URL_ERROR' | translate + }} + + @if (epgField.value) { + + } + @if (canBrowseFiles()) { + + } + - } - - -
+
+ } +
+ } +
+

+ {{ + 'SETTINGS.EPG_SOURCE_FORMATS_EXAMPLES' | translate + }} + https://example.org/guide.xml.gz + /home/you/epg/guide.xml + C:\epg\guide.xml.gz + file:///…/guide.xml +

+
+
+
+ +
+
+

{{ 'SETTINGS.CARD_XTREAM_PLAYLISTS' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }} +

+

{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}

+
+
+ +
+
+
+
+ +
+
+

{{ 'SETTINGS.DATA' | translate }}

+
+
+
+
+

{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}

+

{{ 'SETTINGS.CLEAR_EPG_DATA_DESCRIPTION' | translate }}

+
+
+
- } - + +
+
-
- -
-
-

{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}

-

{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}

-
-
- -
-
- -
-
- - - -
-
+ diff --git a/apps/web/src/app/settings/settings-epg-section.component.scss b/apps/web/src/app/settings/settings-epg-section.component.scss new file mode 100644 index 000000000..0fd3a8e2d --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/settings/settings-epg-section.component.spec.ts b/apps/web/src/app/settings/settings-epg-section.component.spec.ts new file mode 100644 index 000000000..518c7fef3 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg-section.component.spec.ts @@ -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>([]), + epgViewMode: new FormControl('timeline'), + epgOffsetMinutes: new FormControl(offset, [ + Validators.required, + Validators.min(-720), + Validators.max(720), + ]), + preferUploadedEpgOverXtream: new FormControl(false), + }); +} + +describe('SettingsEpgSectionComponent offset stepper', () => { + let fixture: ComponentFixture; + + 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' + ); + }); +}); diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index 8152b24da..d4ec29556 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -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(); @@ -59,4 +64,35 @@ export class SettingsEpgSectionComponent { readonly refreshAllEpg = output(); readonly clearEpgData = output(); readonly selectEpgViewMode = output(); + + /** 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; diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index e1c9d39a1..b7fc60785 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -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([]); + + /** Controls whose saved change waits for a restart (cleared on Later). */ + readonly restartPendingControls = signal([]); + + 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 diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index 012a8f449..9cef5dc06 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -1,276 +1,311 @@
-
- settings -
-
-

{{ 'SETTINGS.GENERAL' | translate }}

-

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
+

{{ 'SETTINGS.GENERAL' | translate }}

+

{{ 'SETTINGS.GENERAL_SUBTITLE' | translate }}

-
-
-

{{ 'SETTINGS.LANGUAGE' | translate }}

-

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

+
+
+

{{ 'SETTINGS.CARD_APPEARANCE' | translate }}

-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - language of languageEnum() | keyvalue; - track language - ) { - {{ - 'LANGUAGES.' + language.key | translate - }} - } - - -
-
- -
-
-

{{ 'SETTINGS.THEME' | translate }}

-

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

-
-
-
- @for (themeOption of themeOptions(); track themeOption.value) { - - } -
-
-
- -
-
-

{{ 'SETTINGS.COVER_SIZE' | translate }}

-

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

-
-
-
- @for (option of coverSizeOptions(); track option.value) { - - } -
-
-
- -
-
-

- {{ 'SETTINGS.COVER_TITLES' | translate }} -

-

- {{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }} -

-
-
- -
-
- -
-
-

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

-

- {{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }} -

-
-
- - {{ - 'SETTINGS.STARTUP_BEHAVIOR' | translate - }} - - @for ( - option of startupBehaviorOptions(); - track option.value - ) { - - {{ option.labelKey | translate }} - - } - - -
-
- - @if (supportsStartupWindowMode()) { -
-
-

{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}

-

- {{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }} -

+ @for ( + language of languageEnum() | keyvalue; + track language + ) { + {{ + 'LANGUAGES.' + language.key | translate + }} + } + + +
-
- - {{ - 'SETTINGS.STARTUP_WINDOW_MODE' | translate - }} - +
+

{{ 'SETTINGS.THEME' | translate }}

+

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

+
+
+
@for ( - option of startupWindowModeOptions(); - track option.value + themeOption of themeOptions(); + track themeOption.value ) { - - {{ option.labelKey | translate }} - + {{ themeOption.icon }} + {{ themeOption.labelKey | translate }} + } - - +
+
-
- } -
-
-

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

-

- {{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }} -

-
-
- -
-
+
+
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

+

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

+
+
+
+ @for (option of coverSizeOptions(); track option.value) { + + } +
+
+
-
-
-

{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}

-

- {{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }} -

-
-
- -
-
-
-@if (supportsPortalConnectivityGuard()) { -
-
-
- hub -
-
-

{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}

-
-
-
-
-

- {{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }} -

-

- {{ - 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION' - | translate - }} -

- @if (form().get('portalConnectivityGuard')?.value === false) { -

- {{ - 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED' - | translate - }} +

+
+

+ {{ 'SETTINGS.COVER_TITLES' | translate }} +

+

+ {{ 'SETTINGS.COVER_TITLES_DESCRIPTION' | translate }}

- } -
-
- +
+
+ +
-} + +
+
+

{{ 'SETTINGS.CARD_STARTUP' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }} +

+

+ {{ 'SETTINGS.STARTUP_BEHAVIOR_DESCRIPTION' | translate }} +

+
+
+ + + @for ( + option of startupBehaviorOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ + @if (supportsStartupWindowMode()) { +
+
+

+ {{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }} + {{ + 'SETTINGS.CHIP_RESTART' | translate + }} +

+

+ {{ + 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' + | translate + }} +

+
+
+ + + @for ( + option of startupWindowModeOptions(); + track option.value + ) { + + {{ option.labelKey | translate }} + + } + + +
+
+ } +
+
+ +
+
+

{{ 'SETTINGS.CARD_CHANNELS' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }} +

+

+ {{ + 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+
+
+ + @if (supportsPortalConnectivityGuard()) { +
+
+

{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }} +

+

+ {{ + 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION' + | translate + }} +

+ @if ( + form().get('portalConnectivityGuard')?.value === + false + ) { +

+ {{ + 'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED' + | translate + }} +

+ } +
+
+ +
+
+
+
+ } + diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index b44971bf0..7bb7b4f33 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -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, ], diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index 381cc47cc..6b919a4f3 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -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(); + }); }); diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 3b03e57aa..ff0c085d2 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -177,5 +177,6 @@ export function buildSettingsSectionNavItems({ label: section.navLabelKey, icon: section.icon, visible: meetsSettingsRequirements(section.requires, capabilities), + ...(section.group ? { group: section.group } : {}), })); } diff --git a/apps/web/src/app/settings/settings-parental-section.component.html b/apps/web/src/app/settings/settings-parental-section.component.html index 00264f3ce..148289fae 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.html +++ b/apps/web/src/app/settings/settings-parental-section.component.html @@ -1,145 +1,151 @@
-
- family_restroom -
-
-

{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}

-

{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}

-
+

{{ 'SETTINGS.PARENTAL_LOCK.TITLE' | translate }}

+

{{ 'SETTINGS.PARENTAL_LOCK.DESCRIPTION' | translate }}

-
-
-

{{ 'SETTINGS.PARENTAL_LOCK.ENABLE' | translate }}

-

{{ 'SETTINGS.PARENTAL_LOCK.ENABLE_DESCRIPTION' | translate }}

-
-
- -
-
- - @if (enabled()) { -
-
-

{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}

-

- {{ - (unlocked() - ? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED' - : 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED' - ) | translate - }} -

-
-
- @if (unlocked()) { - - } @else { - - } -
-
- -
-
-

{{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }}

-

- {{ - 'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate - }} - - @for (option of relockOptions(); track option) { - - {{ - relockLabelKey(option) - | translate: { minutes: option } - }} - + > +
+
+ + @if (enabled()) { +
+
+

{{ 'SETTINGS.PARENTAL_LOCK.STATUS' | translate }}

+

+ {{ + (unlocked() + ? 'SETTINGS.PARENTAL_LOCK.STATUS_UNLOCKED' + : 'SETTINGS.PARENTAL_LOCK.STATUS_LOCKED' + ) | translate + }} +

+
+
+ @if (unlocked()) { + + } @else { + } - - -
-
+
+
-
-
-

{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}

-

- {{ - 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION' - | translate - }} -

-
-
- -
-
+
+
+

+ {{ 'SETTINGS.PARENTAL_LOCK.RELOCK' | translate }} +

+

+ {{ + 'SETTINGS.PARENTAL_LOCK.RELOCK_DESCRIPTION' + | translate + }} +

+
+
+ + + @for (option of relockOptions(); track option) { + + {{ + relockLabelKey(option) + | translate: { minutes: option } + }} + + } + + +
+
-
-
-

{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}

-

- {{ - 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION' - | translate - }} -

-
-
- } +
+
+

{{ 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN' | translate }}

+

+ {{ + 'SETTINGS.PARENTAL_LOCK.CHANGE_PIN_DESCRIPTION' + | translate + }} +

+
+
+ +
+
-
-
-

{{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}

+
+
+

{{ 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK' | translate }}

+

+ {{ + 'SETTINGS.PARENTAL_LOCK.HOW_TO_LOCK_DESCRIPTION' + | translate + }} +

+
+
+ }
+ + +
+ {{ 'SETTINGS.PARENTAL_LOCK.DISCLAIMER' | translate }}
diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts index e4faba996..f6d43afba 100644 --- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -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), diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 1ee3d3b39..f8d6c7d54 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -1,611 +1,775 @@
-
- play_circle -
-
-

{{ 'SETTINGS.PLAYBACK' | translate }}

-

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

-
+

{{ 'SETTINGS.PLAYBACK' | translate }}

+

{{ 'SETTINGS.PLAYBACK_SUBTITLE' | translate }}

-
-
-

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

-

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

+
+
+

{{ 'SETTINGS.CARD_PLAYER' | translate }}

-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for (player of players(); track player.id) { - {{ player.labelKey | translate }} - - } - - -
-
- -
-
-

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

-

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

-
-
- - {{ - 'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate - }} - - @for ( - streamFormat of streamFormatEnum() | keyvalue; - track streamFormat - ) { - {{ streamFormat.value }} - - } - - -
-
- - @if (isWebPlayerSelected()) { -
-
-

- {{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }} -

-

- {{ - 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION' - | translate - }} -

-
-
- -
-
- -
-
-

- {{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }} -

-

- {{ 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION' | translate }} -

-
-
- -
-
- -
-
-

- {{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }} -

-

- {{ 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION' | translate }} -

-
-
- -
-
- } - - -
-
-

- {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }} -

-

- {{ - 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' - | translate - }} -

-
-
- -
-
- - @if (supportsUpNextCard()) { -
-
-

- {{ 'SETTINGS.PLAYER_UP_NEXT_CARD' | translate }} -

-

- {{ 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION' | translate }} -

-
-
- -
-
- } - - @if (supportsFullscreenChannelPanel()) { -
-
-

- {{ 'SETTINGS.FULLSCREEN_CHANNEL_PANEL' | translate }} -

-

- {{ - 'SETTINGS.FULLSCREEN_CHANNEL_PANEL_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - - @if (supportsVodMultiSource() && isWebPlayerSelected()) { -
-
-

- {{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }} -

-

- {{ 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION' | translate }} -

-
-
- -
-
- } - - @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) { -
-
-

- {{ - 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' - | translate - }} -

-

- {{ - 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - - @if (isDesktop()) { -
-
-

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

-

- {{ - 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' - | translate - }} -

-
-
- -
-
- - @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) { -
+
+
-

- {{ 'SETTINGS.EMBEDDED_MPV_FRAME_COPY' | translate }} -

-

- {{ - 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION' - | translate - }} - @if ( - form().value.embeddedMpvFrameCopy !== - frameCopyActive() - ) { - - {{ - 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_RESTART' - | translate - }} - +

+ {{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }} + @if (isEmbeddedMpvSelected()) { + {{ + 'SETTINGS.CHIP_EXPERIMENTAL' | translate + }} } -

-

-
- -
-
- } - - @if (form().value.player === 'embedded-mpv') { -
-
-

- {{ 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate }}

-

- {{ - 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION' - | translate - }} -

+

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

- {{ - 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' | translate - }} - - {{ - 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_HINT' - | translate - }} - @if ( - form().get('embeddedMpvExtraOptions')?.errors; - as errors - ) { - - @if (errors['forbiddenKeys']) { - {{ - 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS' - | translate - : { - keys: errors[ - 'forbiddenKeys' - ].join(', '), - } - }} - } @else if (errors['invalidLines']) { - {{ - 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES' - | translate - : { - lines: errors[ - 'invalidLines' - ].join(', '), - } - }} - } - - } + + @for (player of players(); track player.id) { + {{ player.labelKey | translate }} + + } +
+ + @if (isDesktop() && isEmbeddedMpvSelected()) { +
+
+ info +
+ {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_TITLE' | translate + }} + {{ + 'SETTINGS.EMBEDDED_MPV_NOTICE_DESCRIPTION' + | translate + }} +
+
+
+ } + + @if (isWebPlayerSelected()) { +
+
+

+ {{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }} +

+

+ {{ + 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ +
+
+

+ {{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }} +

+

+ {{ + 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) { +
+
+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK' + | translate + }} +

+

+ {{ + 'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (isDesktop()) { + @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) { +
+
+

+ {{ 'SETTINGS.EMBEDDED_MPV_FRAME_COPY' | translate }} + @if ( + form().value.embeddedMpvFrameCopy !== + frameCopyActive() + ) { + {{ + 'SETTINGS.CHIP_RESTART' | translate + }} + } +

+

+ {{ + 'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (isEmbeddedMpvSelected()) { +
+
+

+ {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' + | translate + }} +

+

+ {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS' + | translate + }} + + {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_HINT' + | translate + }} + @if ( + form().get('embeddedMpvExtraOptions') + ?.errors; + as errors + ) { + + @if (errors['forbiddenKeys']) { + {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_FORBIDDEN_KEYS' + | translate + : { + keys: errors[ + 'forbiddenKeys' + ].join(', '), + } + }} + } @else if (errors['invalidLines']) { + {{ + 'SETTINGS.EMBEDDED_MPV_EXTRA_OPTIONS_INVALID_LINES' + | translate + : { + lines: errors[ + 'invalidLines' + ].join(', '), + } + }} + } + + } + +
+
+ +
+
+

+ {{ + 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT' + | translate + }} +

+

+ {{ + 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + } + + @if ( + supportsExternalPlayerPathSettings() && + form().value.player === 'mpv' + ) { +
+
+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }} +

+

+ {{ 'SETTINGS.MPV_COMPATIBLE_PLAYER_TIP' | translate }} +

+
+
+ + {{ + 'SETTINGS.MPV_PLAYER_PATH' | translate + }} + + +
+
+ +
+
+

+ {{ 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL' | translate }} +

+

+ {{ + 'SETTINGS.MPV_PLAYER_ARGUMENTS_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.MPV_PLAYER_ARGUMENTS' | translate + }} + + {{ + 'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' + | translate + }} + +
+
+ +
+
+

+ {{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }} +

+

+ {{ + 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if ( + supportsExternalPlayerPathSettings() && + form().value.player === 'vlc' + ) { +
+
+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

+

+ {{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_PATH' | translate + }} + + +
+
+ +
+
+

+ {{ 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL' | translate }} +

+

+ {{ + 'SETTINGS.VLC_PLAYER_ARGUMENTS_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.VLC_PLAYER_ARGUMENTS' | translate + }} + + {{ + 'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' + | translate + }} + +
+
+ +
+
+

+ {{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }} +

+

+ {{ + 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } +
+
+ +
+
+

{{ 'SETTINGS.CARD_WATCHING' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.SHOW_CAPTIONS' | translate }} +

+

+ {{ 'SETTINGS.SHOW_CAPTIONS_DESCRIPTION' | translate }} +

+
+
+ +
+
+ +

- {{ 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT' | translate }} + {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }}

{{ - 'SETTINGS.EMBEDDED_MPV_AUTO_RECONNECT_DESCRIPTION' + 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' | translate }}

- +
- } -
-
-

{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}

-

- {{ 'SETTINGS.RECORDING_FOLDER_DESCRIPTION' | translate }} -

-
-
- - {{ - 'SETTINGS.RECORDING_FOLDER' | translate - }} - -
+ } + + @if (supportsUpNextCard()) { +
+
+

+ {{ 'SETTINGS.PLAYER_UP_NEXT_CARD' | translate }} +

+

+ {{ + 'SETTINGS.PLAYER_UP_NEXT_CARD_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + @if (supportsFullscreenChannelPanel()) { +
+
+

+ {{ 'SETTINGS.FULLSCREEN_CHANNEL_PANEL' | translate }} +

+

+ {{ + 'SETTINGS.FULLSCREEN_CHANNEL_PANEL_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + + + @if (supportsVodMultiSource() && isWebPlayerSelected()) { +
+
+

+ {{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }} +

+

+ {{ 'SETTINGS.VOD_AUTO_FAILOVER_DESCRIPTION' | translate }} +

+
+
+ +
+
+ } +
+
+ +
+
+

{{ 'SETTINGS.CARD_XTREAM' | translate }}

+
+
+
+
+

+ {{ 'SETTINGS.STREAM_FORMAT' | translate }} +

+

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

+
+
+ - folder_open - - + + @for ( + streamFormat of streamFormatEnum() | keyvalue; + track streamFormat + ) { + {{ streamFormat.value }} + + } + + +
- } +
- @if ( - supportsExternalPlayerPathSettings() && form().value.player === 'mpv' - ) { -
-
-

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

-

- {{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }} -

-

- {{ 'SETTINGS.MPV_COMPATIBLE_PLAYER_TIP' | translate }} -

+ @if (isDesktop()) { +
+
+

{{ 'SETTINGS.CARD_DESKTOP' | translate }}

-
- - {{ - 'SETTINGS.MPV_PLAYER_PATH' | translate - }} - - -
-
+
+
+
+

+ {{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }} +

+

+ {{ + 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION' + | translate + }} +

+
+
+ +
+
-
-
-

{{ 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL' | translate }}

-

- {{ - 'SETTINGS.MPV_PLAYER_ARGUMENTS_DESCRIPTION' | translate - }} -

+
+
+

{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}

+

+ {{ 'SETTINGS.RECORDING_FOLDER_DESCRIPTION' | translate }} +

+
+
+ + {{ + 'SETTINGS.RECORDING_FOLDER' | translate + }} + + + +
+
-
- - {{ - 'SETTINGS.MPV_PLAYER_ARGUMENTS' | translate - }} - - {{ - 'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' | translate - }} - -
-
- -
-
-

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

-

- {{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }} -

-
-
- -
-
- } - - @if ( - supportsExternalPlayerPathSettings() && form().value.player === 'vlc' - ) { -
-
-

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

-

- {{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }} -

-
-
- - {{ - 'SETTINGS.VLC_PLAYER_PATH' | translate - }} - - -
-
- -
-
-

{{ 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL' | translate }}

-

- {{ - 'SETTINGS.VLC_PLAYER_ARGUMENTS_DESCRIPTION' | translate - }} -

-
-
- - {{ - 'SETTINGS.VLC_PLAYER_ARGUMENTS' | translate - }} - - {{ - 'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' | translate - }} - -
-
- -
-
-

{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}

-

- {{ 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' | translate }} -

-
-
- -
-
+ } diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 00b826dc5..63d561134 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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( - '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' + ).querySelector( + '[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( - '[data-test-id="web-player-shared-controls-toggle"] input[type="checkbox"]' + ).querySelector( + '[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), diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5a3eee85d..5fd306029 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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; + } } diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html index bdead5a0d..a98ab4745 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.html +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -1,134 +1,153 @@
-
- settings_remote -
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

-
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

-
-
-

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

-

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

-
-
- -
-
- - @if (form().value.remoteControl === true) { -
-
-

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

-

- {{ 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' | translate }} -

-
-
- - {{ - 'SETTINGS.PORT_RANGE_LABEL' | translate - }} - - @if ( - form().get('remoteControlPort')?.hasError('required') - ) { - {{ - 'SETTINGS.PORT_REQUIRED' | translate - }} - } - @if (form().get('remoteControlPort')?.hasError('min')) { - {{ - 'SETTINGS.PORT_MIN' | translate - }} - } - @if (form().get('remoteControlPort')?.hasError('max')) { - {{ - 'SETTINGS.PORT_MAX' | translate - }} - } - @if (form().get('remoteControlPort')?.hasError('pattern')) { - {{ - 'SETTINGS.PORT_PATTERN' | translate - }} - } - -
-
- - @if (localIpAddresses().length > 0) { -
+
+
+
-

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

-

- {{ - 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' - | translate - }} -

+

+ {{ 'SETTINGS.REMOTE_CONTROL' | translate }} +

+

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

-
- @for (ip of localIpAddresses(); track ip) { -
- - @if (visibleQrCodeIp() === ip) { -
- +
+ +
+
+ + @if (form().value.remoteControl === true) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

+

+ {{ + 'SETTINGS.REMOTE_CONTROL_PORT_DESCRIPTION' + | translate + }} +

+
+
+ + {{ + 'SETTINGS.PORT_RANGE_LABEL' | translate + }} + + @if ( + form() + .get('remoteControlPort') + ?.hasError('required') + ) { + {{ + 'SETTINGS.PORT_REQUIRED' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('min')) { + {{ + 'SETTINGS.PORT_MIN' | translate + }} + } + @if (form().get('remoteControlPort')?.hasError('max')) { + {{ + 'SETTINGS.PORT_MAX' | translate + }} + } + @if ( + form() + .get('remoteControlPort') + ?.hasError('pattern') + ) { + {{ + 'SETTINGS.PORT_PATTERN' | translate + }} + } + +
+
+ + @if (localIpAddresses().length > 0) { +
+
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

+

+ {{ + 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' + | translate + }} +

+
+
+ @for (ip of localIpAddresses(); track ip) { +
+ + @if (visibleQrCodeIp() === ip) { +
+ +
+ }
}
- } -
-
- } - } +
+ } + } +
+
diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.scss b/apps/web/src/app/settings/settings-remote-control-section.component.scss new file mode 100644 index 000000000..19340f381 --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control-section.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 7d667f46a..3b69c0636 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -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(); diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html deleted file mode 100644 index 8e14f7077..000000000 --- a/apps/web/src/app/settings/settings-reset-section.component.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
-
- delete_sweep -
-
-

{{ 'SETTINGS.RESET' | translate }}

-

{{ 'SETTINGS.RESET_DESCRIPTION' | translate }}

-
-
- -
-
-
-

{{ 'SETTINGS.REMOVE_ALL' | translate }}

-

{{ 'SETTINGS.REMOVE_ALL_DESCRIPTION' | translate }}

-
- -
-
- - {{ - sourceIcons.m3u - }} - {{ - 'HOME.PLAYLIST_TYPES.M3U' | translate - }} - {{ - playlistDeleteSummary().m3u - }} - - - {{ - sourceIcons.xtream - }} - {{ - 'HOME.PLAYLIST_TYPES.XTREAM' | translate - }} - {{ - playlistDeleteSummary().xtream - }} - - - {{ - sourceIcons.stalker - }} - {{ - 'HOME.PLAYLIST_TYPES.STALKER' | translate - }} - {{ - playlistDeleteSummary().stalker - }} - -
- @if (removeAllProgressLabel(); as progressLabel) { -

- {{ progressLabel }} -

- } -
-
diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts deleted file mode 100644 index 8396ee5c4..000000000 --- a/apps/web/src/app/settings/settings-reset-section.component.ts +++ /dev/null @@ -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(); - readonly removeAllProgressLabel = input(null); - - readonly removeAll = output(); -} diff --git a/apps/web/src/app/settings/settings-search-registry.spec.ts b/apps/web/src/app/settings/settings-search-registry.spec.ts index f6afbf8fa..ab65e5fca 100644 --- a/apps/web/src/app/settings/settings-search-registry.spec.ts +++ b/apps/web/src/app/settings/settings-search-registry.spec.ts @@ -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(/]*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( - /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/ )?.[1] ?? null, descriptionKey: meta.match( - /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_.]+)'/ )?.[1] ?? null, }); }); diff --git a/apps/web/src/app/settings/settings-search-results.component.html b/apps/web/src/app/settings/settings-search-results.component.html index d1290af20..c462ccad3 100644 --- a/apps/web/src/app/settings/settings-search-results.component.html +++ b/apps/web/src/app/settings/settings-search-results.component.html @@ -4,21 +4,19 @@ aria-labelledby="settings-search-results-title" >
-
-

- {{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }} -

-

- {{ - 'SETTINGS.SEARCH_RESULTS_COUNT' - | translate: { count: results().length, query: query() } - }} -

-
+

+ {{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }} +

+

+ {{ + 'SETTINGS.SEARCH_RESULTS_COUNT' + | translate: { count: results().length, query: query() } + }} +

@if (results().length > 0) { -
+
+
+ + @if (m3uVodDetailsControl; as m3uVodDetails) { +
+
+

+ {{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }} +

+

+ {{ + 'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ } + } +
+ + +
+
+

{{ 'SETTINGS.CARD_CACHE' | translate }}

+
+
+
+
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

+

+ {{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }} +

+

+ @if (cacheError()) { + + {{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }} + + } @else if (cacheStats(); as stats) { + {{ + 'SETTINGS.TMDB_CACHE_SIZE' + | translate + : { + entries: stats.entries, + size: formatBytes(stats.bytes), + } + }} + }

- - {{ - 'SETTINGS.TMDB_API_KEY' | translate - }} - - -
- - @if (keyTestState() === 'success') { - - check_circle - {{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }} - - } @else if (keyTestState() === 'error') { - - error - {{ 'SETTINGS.TMDB_TEST_FAILED' | translate }} - + @if (isClearing()) { + + } @else { + delete_sweep } -
+ {{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }} +
- } - - @if (form().value.tmdb?.enabled === true) { - @if (m3uVodDetailsControl; as m3uVodDetails) { -
-
-

- {{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }} -

-

- {{ - 'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION' - | translate - }} -

-
-
- -
-
- } - } - -
-
-

{{ 'SETTINGS.TMDB_CACHE' | translate }}

-

- {{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }} -

-

- @if (cacheError()) { - - {{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }} - - } @else if (cacheStats(); as stats) { - {{ - 'SETTINGS.TMDB_CACHE_SIZE' - | translate - : { - entries: stats.entries, - size: formatBytes(stats.bytes), - } - }} - } -

-
-
- -
+
-
-
- TMDB -

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

-
-
+
+ TMDB + {{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}
diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts index 4ed0ccbcb..48725435b 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.spec.ts @@ -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( diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 0959308c7..428893053 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -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, ], diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index bf9b9b1d2..9086c3442 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -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 diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 19ab3fd33..5823c6e0a 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -12,6 +12,30 @@ {{ 'SETTINGS.TITLE' | translate }} + + @if (form.restartPendingControls().length > 0) { +
+ restart_alt + {{ + 'SETTINGS.RESTART_NOTICE' + | translate: { settings: restartPendingLabels() } + }} + +
+ } +
- } - @case ('reset') { - } @@ -142,6 +161,7 @@ (openAppUpdateReleaseNotes)=" appUpdate.openReleaseNotes() " + (openBackup)="openSection('backup')" /> } @default { @@ -168,16 +188,19 @@ }
- + @if (!settingsForm.pristine) {
-
- edit - {{ 'SETTINGS.UNSAVED_CHANGES' | translate }} -
+ + + {{ unsavedMessageKey() | translate: { count: changeCount() } }} +
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 96b94a1d4..f9b88dab5 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -1,3 +1,8 @@ +// Settings page: one content column of titled cards. Every row is a label +// column plus one right-aligned control column, so selects, switches and +// segmented controls line up down the page. Tokens and the Material token +// overrides (compact switch, dense select) live in `_settings-theme.scss`. + .settings-page-host { display: block; } @@ -5,19 +10,22 @@ .settings-container { height: auto; overflow: visible; - padding: 18px 20px calc(36px + var(--settings-safe-bottom)); + // Rows stack by the pane's width, not the viewport's: the sidebar keeps + // the page narrow on small desktop windows. This block is not the + // scroll owner, so inline-size containment costs it nothing. + container-type: inline-size; } .settings-layout { + position: relative; display: flex; flex-direction: column; - gap: 16px; + gap: 20px; align-items: stretch; - max-width: 920px; + box-sizing: border-box; + max-width: 880px; margin: 0 auto; - padding-bottom: calc( - var(--settings-action-bar-space) + var(--settings-safe-bottom) - ); + padding: 28px 40px calc(48px + var(--settings-safe-bottom)); } .settings-content { @@ -27,101 +35,178 @@ gap: 16px; } -// Flat section — no panel chrome. The v0.22 mockup treats Settings as a -// single scroll column with each section anchored by a large title + -// bottom-border separator. The previous card frame (border-radius 22px, -// filled bg, box-shadow, active-state ring + icon-circle) made every -// section look like a feature card, which fought the inline form rhythm. +// ─── Page header ─────────────────────────────────────────────────────────── .settings-group { - position: relative; - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - overflow: visible; - padding: 0; - margin-top: 24px; - scroll-margin-top: 24px; - transition: none; - - &:first-of-type { - margin-top: 0; - } + display: flex; + flex-direction: column; + gap: 28px; } .settings-group__header { + h2 { + margin: 0; + font-size: 26px; + font-weight: 600; + letter-spacing: -0.3px; + line-height: 1.2; + color: var(--app-heading-color); + } + + p { + margin: 6px 0 0; + color: var(--app-body-color); + font-size: 13.5px; + line-height: 1.45; + } +} + +// ─── Sections and cards ──────────────────────────────────────────────────── +.settings-section { + display: block; +} + +.settings-section__header { display: flex; - align-items: flex-start; - gap: 14px; - padding: 4px 0 18px; - margin: 0 0 8px; - border-bottom: 1px solid var(--settings-item-border); + align-items: center; + gap: 10px; + margin: 0 2px 10px; + min-height: 32px; - // Icon-circle removed per UX-audit recommendation (the section title - // is the anchor, not a card). Hidden rather than ripped out of every - // section template so the existing markup stays untouched. - .settings-group__header-icon { - display: none; - } - - .settings-group__header-text { - h3 { - margin: 0; - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.015em; - line-height: 1.2; - color: var(--app-heading-color); - } - - p { - margin: 6px 0 0; - color: var(--app-body-color); - font-size: 0.92rem; - line-height: 1.45; - } + h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--app-heading-color); } } -.settings-group__footer { - padding: 12px 18px 16px; - border-top: 1px solid var(--settings-item-border); +.settings-section__count { + padding: 1px 7px; + border-radius: 999px; + background: var(--settings-chip-bg); + color: var(--settings-chip-fg); + font-size: 11px; + font-variant-numeric: tabular-nums; } -// Row layout — flat list aligned to the section title (no inset). The -// previous `margin: 0 18px` came from the card chrome; now that each -// section is a flat block, rows sit at the same gutter as the title. +.settings-section__actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.settings-section__desc { + margin: -4px 2px 10px; + color: var(--settings-muted); + font-size: 12.5px; + line-height: 1.5; + max-width: 72ch; +} + +.settings-card { + background: var(--settings-card-bg); + border: 1px solid var(--settings-card-border); + border-radius: 12px; +} + +.settings-card--danger { + border-color: var(--settings-item-danger-border); +} + +// ─── Rows ────────────────────────────────────────────────────────────────── .setting-item { display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(220px, 340px); - gap: 14px; - margin: 0; - padding: 18px 0; + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: center; + min-height: 60px; + padding: 14px 16px; + position: relative; +} + +.setting-item + .setting-item, +.setting-item__sub + .setting-item, +.setting-item + .setting-item__sub + .setting-item { border-top: 1px solid var(--settings-item-border); - background: transparent; +} - &:first-of-type { - border-top: 0; - padding-top: 8px; - } +// Meta stacked over a full-width control (textareas, lists). Flex children +// do not grow from `justify-content`, so the field itself takes the row. +.setting-item--full-control { + grid-template-columns: 1fr; + gap: 12px; - &:last-of-type { - margin-bottom: 0; + .setting-item__control { + justify-content: stretch; + + > .mat-mdc-form-field { + width: 100%; + } } } -.settings-group .setting-item:last-child { - margin-bottom: 0; +.setting-item--list { + display: block; + padding: 0; } -// A row opened from settings search or the command palette. It receives -// programmatic focus (tabindex -1). Keyboard users keep a focus ring after -// the temporary highlight fades; pointer users rely on the highlight alone, -// as with any mouse-driven focus. The wash fills the row's own padding box -// and two offset shadows widen it past the flush-left row edges without -// shifting layout or painting over the separators above and below. -.setting-item[data-setting-id] { - scroll-margin: 96px 0 calc(96px + var(--settings-action-bar-space)); +// A callout under the row it explains (player runtime note, nightly warning). +.setting-item__sub { + padding: 0 16px 14px; +} + +.setting-item__meta { + min-width: 0; + + h4 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin: 0; + font-size: 13.5px; + font-weight: 500; + 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; + text-wrap: pretty; + } + + a { + color: var(--app-selection-color); + } +} + +.setting-item__control { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + min-width: 0; +} + +.setting-item__control--stack { + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.setting-item__toggle { + justify-content: flex-end; +} + +// Row revealed from settings search or the command palette. +.setting-item[data-setting-id], +.settings-card[data-setting-id], +.settings-foot[data-setting-id] { + scroll-margin: 96px 0 120px; &:focus { outline: none; @@ -130,39 +215,27 @@ &:focus-visible { outline: 2px solid color-mix(in srgb, var(--app-selection-color) 60%, transparent); - outline-offset: 6px; - border-radius: 4px; + outline-offset: 2px; + border-radius: 12px; } } .setting-item--revealed { - --settings-reveal-wash: color-mix( - in srgb, - var(--app-selection-color) 12%, - transparent - ); - - background: var(--settings-reveal-wash); - box-shadow: - -12px 0 0 var(--settings-reveal-wash), - 12px 0 0 var(--settings-reveal-wash); animation: settings-reveal-fade 2.4s ease-out forwards; } @keyframes settings-reveal-fade { 0%, 60% { - background: var(--settings-reveal-wash); - box-shadow: - -12px 0 0 var(--settings-reveal-wash), - 12px 0 0 var(--settings-reveal-wash); + background: color-mix( + in srgb, + var(--app-selection-color) 10%, + transparent + ); } 100% { background: transparent; - box-shadow: - -12px 0 0 transparent, - 12px 0 0 transparent; } } @@ -172,264 +245,155 @@ } } -.setting-item__meta { - h4 { - margin: 0; - font-size: 0.95rem; - font-weight: 600; - letter-spacing: -0.01em; +// ─── Controls ────────────────────────────────────────────────────────────── +// Compact select without a floating label: the row label names it. +.settings-select.mat-mdc-form-field { + width: 220px; + + .mat-mdc-text-field-wrapper { + background: var(--settings-control-bg); } - p { - margin: 3px 0 0; - color: var(--settings-muted); - font-size: 0.84rem; - line-height: 1.45; + .mat-mdc-select-value, + .mat-mdc-input-element { + font-size: 13px; } } -.setting-item__control { - display: flex; - justify-content: flex-end; +.settings-text-field.mat-mdc-form-field { + width: min(100%, 340px); +} + +.settings-chip { + display: inline-flex; align-items: center; - gap: 12px; - min-height: 40px; - - mat-form-field, - .w-full { - width: min(100%, 340px); - } + padding: 1px 7px; + border-radius: 999px; + background: var(--settings-chip-bg); + color: var(--settings-chip-fg); + font-size: 10.5px; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; } -.setting-item__toggle { - justify-content: flex-end; +.settings-chip--success { + background: color-mix(in srgb, #4ade80 14%, transparent); + color: var(--app-provider-stalker); } -.setting-item--full-control { - grid-template-columns: 1fr; - gap: 12px; +.settings-chip--accent { + background: var(--app-selection-surface); + color: var(--app-selection-color); } -.setting-item--full-control .setting-item__control { - justify-content: stretch; +.settings-chip--warn { + background: var(--app-expiry-warn-surface); + color: var(--app-expiry-warn-text); } -.app-update-panel__control { - align-items: stretch; - justify-content: space-between; - flex-wrap: wrap; -} - -.app-update-status { - min-width: min(100%, 260px); +.settings-callout { display: flex; - flex: 1 1 280px; - flex-direction: column; - gap: 6px; + gap: 10px; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--settings-callout-info-border); + background: var(--settings-callout-info-bg); color: var(--settings-muted); - font-size: 0.84rem; - line-height: 1.45; + font-size: 12.5px; + line-height: 1.5; - strong { - color: var(--app-body-color); - font-size: 0.9rem; + > mat-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + font-size: 16px; + margin-top: 1px; + color: var(--app-selection-color); + } + + b { + display: block; + margin-bottom: 2px; + color: var(--app-heading-color); font-weight: 600; } - progress { - width: min(100%, 320px); - height: 8px; - accent-color: var(--app-selection-color); + a { + color: var(--app-selection-color); } } -.app-update-actions { +.settings-callout--warn { + border-color: var(--settings-callout-warn-border); + background: var(--settings-callout-warn-bg); + + > mat-icon { + color: var(--settings-callout-warn-icon); + } +} + +.settings-callout__actions { display: flex; - flex: 0 1 auto; - flex-wrap: wrap; - justify-content: flex-end; gap: 8px; - - button { - app-region: no-drag; - } + margin-top: 10px; } -.setting-item--external-player-arguments { - grid-template-columns: 1fr; - gap: 10px; - - .setting-item__control { - justify-content: stretch; - align-items: stretch; - } - - mat-form-field { - width: 100%; - } - - textarea { - min-height: 86px; - resize: vertical; - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, - monospace; - font-size: 0.84rem; - line-height: 1.45; - } -} - -// Compact segmented control — replaces the previous 3 buttons each ~78px -// tall. Per the UX audit, "Light/Dark/System" was reading as the most -// important setting on the page because of its sheer footprint. Now the -// row reads at the same weight as the other compact rows (Language, -// Startup behavior, etc.), restoring the visual hierarchy. +// Segmented control (theme, cover size, guide layout): a pill. .theme-switcher { - width: min(100%, 360px); display: inline-flex; padding: 3px; - gap: 2px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-low) 80%, - transparent - ); - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent); - border-radius: 10px; + border: 1px solid var(--settings-control-border); + border-radius: 999px; + background: var(--settings-control-bg); } .theme-switcher__option { - flex: 1 1 0; display: inline-flex; align-items: center; justify-content: center; gap: 6px; - min-height: 30px; - padding: 4px 12px; + height: 28px; + padding: 0 12px; border: 0; - border-radius: 7px; + border-radius: 999px; background: transparent; - color: var(--mat-sys-on-surface-variant); + color: var(--app-body-color); font: inherit; - font-size: 0.82rem; + font-size: 12.5px; font-weight: 500; - letter-spacing: 0; cursor: pointer; transition: - background 0.16s ease, - color 0.16s ease, - box-shadow 0.16s ease; + background 0.15s ease, + color 0.15s ease; mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - line-height: 16px; + width: 14px; + height: 14px; + font-size: 14px; } &:hover { - background: color-mix( - in srgb, - var(--mat-sys-surface-container-highest) 70%, - transparent - ); - color: var(--mat-sys-on-surface); + color: var(--app-heading-color); } &:focus-visible { - outline: none; - box-shadow: 0 0 0 2px - color-mix(in srgb, var(--app-selection-color) 50%, transparent); + outline: 2px solid var(--app-selection-color); + outline-offset: 1px; } } .theme-switcher__option--selected { - background: var(--app-widget-bg, var(--mat-sys-surface)); + background: var(--app-selection-surface); color: var(--app-selection-color); - font-weight: 600; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.18), - inset 0 0 0 1px - color-mix(in srgb, var(--app-selection-border) 36%, transparent); -} - -.epg-sources { - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -} - -.epg-sources__formats-examples { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 8px; - - code { - padding: 1px 6px; - border-radius: 6px; - background: var(--app-hover-overlay); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.75rem; - white-space: nowrap; - } -} - -.epg-sources-empty { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 8px; - padding: 24px 16px 16px; - color: var(--mat-sys-on-surface-variant); - - .epg-sources-empty__icon { - font-size: 40px; - width: 40px; - height: 40px; - opacity: 0.55; - margin-bottom: 4px; - } - - .epg-sources-empty__title { - margin: 0; - font-size: 0.95rem; - font-weight: 600; - color: var(--mat-sys-on-surface); - } - - .epg-sources-empty__hint { - margin: 0; - font-size: 0.825rem; - line-height: 1.5; - max-width: 360px; - opacity: 0.75; - } -} - -.epg-source-row { - display: flex; - align-items: center; - gap: 6px; - - .w-full { - width: 100%; - } - - app-epg-source-status { - flex-shrink: 0; - } } +// ─── Buttons in rows ─────────────────────────────────────────────────────── .inline-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; - gap: 6px; + gap: 8px; } .button-spinner { @@ -438,361 +402,76 @@ vertical-align: middle; } -.inline-actions__button { - min-width: 118px; -} - -.inline-actions__button--busy { - background: color-mix( - in srgb, - var(--app-selection-surface) 74%, - transparent - ); - color: var(--app-selection-color); - box-shadow: - inset 0 0 0 1px - color-mix(in srgb, var(--app-selection-border) 48%, transparent), - 0 8px 18px -18px var(--settings-group-active-glow); -} - .inline-actions__label { display: inline-flex; align-items: center; } -.remote-control-urls { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; +.inline-actions__button--busy { + background: var(--app-selection-surface); } -.remote-control-url-item { - width: min(100%, 340px); -} - -.url-row { +// TMDB attribution / disclaimers under the last card (About, Metadata, +// Parental lock). +.settings-foot { display: flex; align-items: center; - justify-content: flex-end; - gap: 4px; -} - -.remote-control-url { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace; - color: inherit; - text-decoration: none; - font-size: 0.84rem; - - &:hover { - text-decoration: underline; - } -} - -.qr-code-container { - display: flex; - justify-content: flex-end; - padding: 8px 0; -} - -.version-block { - flex-direction: column; - align-items: flex-end; - - span { - font-weight: 700; - } - - small { - margin-top: 4px; - color: var(--mat-sys-on-surface-variant); - text-align: right; - } -} - -.link-cards { - width: 100%; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; - box-sizing: border-box; + color: var(--settings-muted); + font-size: 11.5px; + line-height: 1.6; + + img { + height: 14px; + } } -.link-card { - box-sizing: border-box; - display: grid; - grid-template-columns: 40px minmax(0, 1fr) 20px; +// ─── Restart notice ──────────────────────────────────────────────────────── +.settings-restart-bar { + position: sticky; + top: 0; + z-index: 5; + display: flex; align-items: center; gap: 12px; - padding: 12px 14px; - border: 1px solid - color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent); - border-radius: 16px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-low) 88%, - transparent - ); - color: var(--mat-sys-on-surface); - text-decoration: none; - transition: - border-color 0.18s ease, - background 0.18s ease, - transform 0.18s ease, - box-shadow 0.18s ease; - - &:hover { - transform: translateY(-1px); - border-color: color-mix( - in srgb, - var(--app-selection-border) 54%, - var(--mat-sys-outline-variant) - ); - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 92%, - transparent - ); - box-shadow: 0 10px 20px -22px rgba(15, 23, 42, 0.5); - - .link-card__arrow { - opacity: 1; - transform: translate(2px, -2px); - } - } - - &: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); - } -} - -.link-card__glyph { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border-radius: 12px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 82%, - transparent - ); - color: var(--mat-sys-on-surface-variant); - - svg { - width: 20px; - height: 20px; - } + padding: 8px 12px 8px 16px; + border: 1px solid var(--settings-card-border); + border-radius: 10px; + background: var(--settings-restart-bar-bg); + color: var(--app-body-color); + font-size: 12.5px; mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } -} - -.link-card--github .link-card__glyph { - background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent); - color: var(--mat-sys-on-surface); -} - -.link-card--blog .link-card__glyph { - background: color-mix(in srgb, #ff7849 18%, transparent); - color: #ff8a5b; -} - -.link-card--telegram .link-card__glyph { - background: color-mix(in srgb, #229ed9 22%, transparent); - color: #38b0e3; -} - -.link-card__text { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - - strong { - font-size: 0.92rem; - font-weight: 600; - letter-spacing: -0.01em; + flex-shrink: 0; + width: 16px; + height: 16px; + font-size: 16px; + color: var(--app-selection-color); } span { - color: var(--settings-muted); - font-size: 0.78rem; - line-height: 1.35; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - } -} - -.link-card__arrow { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--settings-muted); - opacity: 0.6; - transition: - opacity 0.18s ease, - transform 0.18s ease; -} - -.settings-note { - margin: 12px 18px 18px; - padding: 10px 12px; - border-radius: 14px; - font-size: 0.84rem; - background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); - border: 1px solid - color-mix(in srgb, var(--mat-sys-primary) 30%, transparent); - line-height: 1.45; - - a { - color: inherit; - font-weight: 700; - } -} - -.danger-zone { - margin-top: 8px; - margin-bottom: 18px; - padding: 14px 16px; - background: var(--settings-item-danger-bg); - border: 1px solid var(--settings-item-danger-border); - border-radius: 16px; - grid-template-columns: 1fr; - gap: 14px; -} - -.danger-zone__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - - .setting-item__meta { flex: 1; min-width: 0; } } -.danger-zone__summary { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 0; -} - -.danger-zone__summary-pill { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 34px; - padding: 4px 6px 4px 12px; - border-radius: 12px; - background: color-mix( - in srgb, - var(--mat-sys-surface-container-high) 78%, - transparent - ); - border: 1px solid - color-mix(in srgb, var(--settings-item-danger-border) 60%, transparent); - font-size: 0.82rem; - line-height: 1.2; - color: var(--mat-sys-on-surface); - - .danger-zone__summary-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--mat-sys-on-surface-variant); - flex-shrink: 0; - } - - .danger-zone__summary-label { - font-weight: 500; - letter-spacing: 0.01em; - } - - .danger-zone__summary-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 26px; - height: 22px; - padding: 0 8px; - border-radius: 999px; - background: color-mix( - in srgb, - var(--mat-sys-on-surface) 10%, - transparent - ); - color: var(--mat-sys-on-surface); - font-size: 0.78rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - margin-left: 2px; - } -} - -.danger-zone__progress { - margin: 0; - color: var(--mat-sys-on-surface-variant); - font-size: 0.84rem; -} - -.danger-zone__button { - min-width: 138px; - flex-shrink: 0; - align-self: flex-start; -} - -.danger-zone__button--busy { - background: color-mix( - in srgb, - var(--settings-item-danger-border) 28%, - transparent - ); -} - -// Unsaved-changes bar — only present while the form is dirty, so unlike the -// previous always-visible footer Save button it doubles as the "you edited -// something on another section page" reminder. Sticky rather than fixed so -// it stays inside the settings column and never covers the shell rail. +// ─── Save bar ────────────────────────────────────────────────────────────── +// Only present while the form is dirty. It floats over the content column +// (sticky, so it never covers the shell rail) and carries the change count. .unsaved-bar { position: sticky; - bottom: calc( - var(--settings-action-bar-offset) + var(--settings-safe-bottom) - ); - margin-left: 0; - margin-right: 0; - // Stretched to the full layout width, so its own inline padding would be - // added on top and push the settings page into a horizontal scroll. - box-sizing: border-box; + bottom: calc(16px + var(--settings-safe-bottom)); z-index: 100; display: flex; - justify-content: space-between; align-items: center; gap: 12px; - flex-wrap: wrap; - padding: 12px 18px; - border-top: 1px solid var(--settings-action-bar-border); - border-left: 0; - border-right: 0; - border-bottom: 0; - border-radius: 0; + width: min(100%, 720px); + margin: 0 auto; + padding: 10px 10px 10px 16px; + box-sizing: border-box; + border: 1px solid var(--settings-action-bar-border); + border-radius: 12px; background: var(--settings-action-bar-bg); - backdrop-filter: blur(12px); box-shadow: var(--settings-action-bar-shadow); animation: unsaved-bar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); @@ -804,7 +483,7 @@ @keyframes unsaved-bar-enter { from { opacity: 0; - transform: translateY(8px); + transform: translateY(12px); } to { @@ -813,18 +492,19 @@ } } -.unsaved-bar__message { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--settings-muted); - font-size: 0.88rem; +.unsaved-bar__dot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--app-selection-color); +} - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } +.unsaved-bar__message { + flex: 1; + min-width: 0; + color: var(--app-heading-color); + font-size: 13px; } .unsaved-bar__actions { @@ -832,116 +512,85 @@ align-items: center; gap: 8px; margin-left: auto; - flex-wrap: wrap; } .settings-save-button.mat-mdc-button-base { - // Use the unified blue primary (--app-selection-color) instead of the - // old hand-rolled #3b82f6 gradient that didn't match the rest of the - // app's CTAs after the color unification commit. - background: var(--app-selection-color); - color: var(--app-selection-on-color, #fff); + font-weight: 600; +} + +.settings-kbd { + margin-left: 8px; + padding: 1px 5px; + border-radius: 4px; + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; font-weight: 500; - padding: 0 20px; - border-radius: 10px; - box-shadow: 0 4px 14px - color-mix(in srgb, var(--app-selection-color) 28%, transparent); - transition: - transform 0.2s ease, - box-shadow 0.2s ease, - opacity 0.2s ease; - - mat-icon { - margin-right: 6px; - } - - &:hover:not([disabled]) { - transform: translateY(-1px); - box-shadow: 0 6px 18px - color-mix(in srgb, var(--app-selection-color) 38%, transparent); - } - - &:active:not([disabled]) { - transform: translateY(0); - } - - &[disabled] { - background: color-mix( - in srgb, - var(--mat-sys-on-surface) 12%, - transparent - ); - color: color-mix(in srgb, var(--mat-sys-on-surface) 38%, transparent); - box-shadow: none; - } + opacity: 0.8; } -@media (max-width: 1100px) { +// ─── Narrow panes ────────────────────────────────────────────────────────── +// Below 600px of content width a row cannot hold its label beside a 220px +// control, so the control drops under the label. +@container (max-width: 600px) { .settings-layout { - max-width: 100%; - padding: 0; - } -} - -@media (max-width: 860px) { - .settings-container { - height: auto; - padding: 12px 12px calc(18px + var(--settings-safe-bottom)); + padding: 20px 16px calc(24px + var(--settings-safe-bottom)); } - .settings-page-header { - padding: 12px 14px; - border-radius: 16px; + .settings-group__header h2 { + font-size: 22px; } .setting-item { grid-template-columns: 1fr; gap: 12px; - margin: 0 14px; - padding: 12px 0; } - .setting-item__control { + .setting-item__control, + .setting-item__toggle { justify-content: flex-start; - - mat-form-field, - .w-full, - .epg-sources, - .remote-control-url-item { - width: 100%; - } } - .theme-switcher { - width: 100%; - grid-template-columns: 1fr; - } - - .remote-control-urls, - .version-block { + .setting-item__control--stack { align-items: flex-start; } - .link-cards { - grid-template-columns: 1fr; + .settings-select.mat-mdc-form-field, + .settings-text-field.mat-mdc-form-field { + width: 100%; } - .support-btn { - flex: 1 1 100%; + // Translated option labels (ru, de) do not fit a one-line pill on a + // phone: the segments take the row and wrap when they must. + .theme-switcher { + width: 100%; + flex-wrap: wrap; + border-radius: 14px; } - .url-row { - justify-content: flex-start; + .theme-switcher__option { + flex: 1 1 auto; + min-width: 0; + } + + .settings-section__header { flex-wrap: wrap; } - .unsaved-bar { - flex-direction: column; - align-items: stretch; - border-radius: 16px; + .settings-section__actions { + flex-basis: 100%; + margin-left: 0; } + .unsaved-bar { + flex-wrap: wrap; + bottom: calc(8px + var(--settings-safe-bottom)); + } + + // Long translations ("Enregistrer les modifications") do not fit two + // buttons side by side on a phone: stack them, Save first. .unsaved-bar__actions { + flex-basis: 100%; flex-direction: column-reverse; align-items: stretch; margin-left: 0; @@ -950,4 +599,9 @@ width: 100%; } } + + // A keyboard-only affordance has no place on a phone. + .settings-kbd { + display: none; + } } diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts index 540761a1d..eca7adb0e 100644 --- a/apps/web/src/app/settings/settings.component.search.spec.ts +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -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); diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index e03d4a8d7..97c359453 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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 = () => diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index f43b3eb30..fdeba525d 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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(); + 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 { const folder = await this.embeddedMpv.selectRecordingFolder(); diff --git a/apps/web/src/app/settings/settings.models.ts b/apps/web/src/app/settings/settings.models.ts index 5296493f9..62a88e66e 100644 --- a/apps/web/src/app/settings/settings.models.ts +++ b/apps/web/src/app/settings/settings.models.ts @@ -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 { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index c599ae9ac..b55949740 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3e317715f..9d85b034c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 948e7077a..d8597fe85 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 89c6f78e2..22ef4c239 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index fcbd90d74..c230988b4 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 3ad44ab4f..cdea9ba31 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 9c9f450e8..baf500b2e 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4db7f7642..ef1ecbc1e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 136c5d2ac..6cf233883 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e2504cc16..304a40ff6 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 739da65f4..09794490b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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 のデータで映画やシリーズの詳細情報を充実させます", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 92232644d..8a0a3b67a 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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의 데이터로 영화 및 시리즈 상세 정보를 보강합니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index d52090069..69330f6a1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4e55beeb9..40aec779f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 541599150..7ee69dd8c 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0114559bd..5922be9bb 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 97ba7300f..a79f257ec 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b2492e5c3..838498134 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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 的数据丰富电影和剧集详情", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9bffe2cb3..13ff5f671 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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 的資料豐富電影與影集的詳細資訊", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a9f26aa52..fcdb3c9bb 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index 24b1d1c6f..85044b901 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -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; } } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts index a92669eb5..b6cb12cfb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -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(); + }); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index d7cacdedd..05b70ad06 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -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: `

{{ 'SETTINGS.TITLE' | translate }}

- + + `, }) export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); + private readonly host = inject>(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(() => { + 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() + ); + } } diff --git a/libs/workspace/shell/util/src/lib/settings-context.service.ts b/libs/workspace/shell/util/src/lib/settings-context.service.ts index ece0d1ca2..4566c2bf2 100644 --- a/libs/workspace/shell/util/src/lib/settings-context.service.ts +++ b/libs/workspace/shell/util/src/lib/settings-context.service.ts @@ -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> | null>( null ); + /** Sections holding staged (unsaved) edits; the panel marks them. */ + readonly dirtySections = signal>(new Set()); + /** Installed app version, shown beside the About entry. */ + readonly version = signal(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): 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); } } diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts index 4c623cee0..d38adff5e 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts @@ -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'], + }, ]; diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts index 5ddc901b9..0776d219a 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts @@ -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', }, diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts index 73117442e..0e7830871 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts @@ -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' }, ]; diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts index c4e76e6f0..92cd5fbd3 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts @@ -20,6 +20,9 @@ export type SettingsSearchCapabilities = Readonly< Record >; +/** 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; } /** diff --git a/tools/i18n/identical-en-baseline.json b/tools/i18n/identical-en-baseline.json index 224a25605..be5891794 100644 --- a/tools/i18n/identical-en-baseline.json +++ b/tools/i18n/identical-en-baseline.json @@ -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", diff --git a/tools/release/capture-navigation-setup-actions.ts b/tools/release/capture-navigation-setup-actions.ts index 3689f0f49..89e2d5c10 100644 --- a/tools/release/capture-navigation-setup-actions.ts +++ b/tools/release/capture-navigation-setup-actions.ts @@ -237,7 +237,7 @@ async function openRemoteControlSettings(page: Page): Promise { 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 { 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())) {