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/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts index 6782598e9..4db1cecd8 100644 --- a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -150,7 +150,12 @@ test.describe('Theme tokens', () => { ).toBeGreaterThanOrEqual(3); // A floated label inherits the field's text size and renders - // at 75% of it; it must stay legible. + // at 75% of it; it must stay legible. The settings selects + // have no floating label any more (the row title names + // them): the recording folder field on Playback keeps one, + // and focusing it floats the label. + await openSettingsSection(page, 'playback'); + await page.locator('#recordingFolder').focus(); const floated = page .locator('mat-form-field .mdc-floating-label--float-above') .first(); 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..53e68ec3a 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -1,4 +1,10 @@ -import { DestroyRef, inject, Injectable } from '@angular/core'; +import { + DestroyRef, + inject, + Injectable, + signal, + untracked, +} 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 +22,11 @@ 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 { SettingsLaunchValuesService } from './settings-launch-values.service'; import { applyEpgUrlsToFormArray, createEpgUrlControl, @@ -36,6 +47,7 @@ export class SettingsFormFacade { private readonly destroyRef = inject(DestroyRef); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly formBuilder = inject(FormBuilder); + private readonly launchValues = inject(SettingsLaunchValuesService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsService = inject(SettingsService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -50,6 +62,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 value differs from what the running app uses. */ + 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 +112,23 @@ export class SettingsFormFacade { this.epgUrl.clear(); this.setEpgUrls(currentSettings.epgUrl); } + this.takeSavedSnapshot(); + // The app launched with the stored values; recorded once per app run, + // not per settings visit, so a later visit compares against launch. + this.launchValues.captureOnce( + (control) => this.form.get(control)?.value + ); + this.refreshRestartPending(); + } + + /** + * What the running app actually uses for a restart control when that + * differs from the stored value (a frame-copy opt-in the engine could + * not honour). Re-evaluates the pending notice. + */ + setRunningValue(control: string, value: unknown): void { + this.launchValues.set(control, value); + this.refreshRestartPending(); } bindDashboardControlsEnabledState(): void { @@ -164,7 +211,6 @@ export class SettingsFormFacade { this.form, this.settingsStore.getSettings() ); - let cleanupError: EpgSourceReconciliationError | undefined; try { await this.settingsStore.updateSettings(settings, { @@ -176,7 +222,10 @@ export class SettingsFormFacade { // committed values, but retain the dirty form for cleanup retry. cleanupError = error; } - if (!cleanupError) onSaved(); + if (!cleanupError) { + onSaved(); + this.refreshRestartPending(); + } if (window.electron) { window.electron.updateSettings(settings); @@ -189,9 +238,19 @@ export class SettingsFormFacade { if (cleanupError) throw cleanupError; } + /** Later: the reminder stays away until one of those settings changes. */ + dismissRestartNotice(): void { + const saved = (this.savedSnapshot ?? {}) as Record; + for (const control of this.restartPendingControls()) { + this.launchValues.dismiss(control, saved[control]); + } + 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 +260,43 @@ export class SettingsFormFacade { ); } + /** + * Restart controls whose SAVED value the running app does not use yet. + * Compares the last saved snapshot, never the draft form: a staged but + * unsaved edit must not raise or clear the reminder. Called from an + * effect (the engine probe), so the current list is read untracked and + * only a changed list is written: a fresh array on every run would + * re-trigger that effect for ever. + */ + private refreshRestartPending(): void { + const running = this.launchValues.get(); + const saved = (this.savedSnapshot ?? {}) as Record; + const pending = Object.keys(SETTINGS_RESTART_CONTROLS).filter( + (control) => + control in running && + saved[control] !== running[control] && + !this.launchValues.isDismissed(control, saved[control]) + ); + const current = untracked(this.restartPendingControls); + if ( + current.length !== pending.length || + current.some((control, index) => control !== pending[index]) + ) { + this.restartPendingControls.set(pending); + } + } + + 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-launch-values.service.ts b/apps/web/src/app/settings/settings-launch-values.service.ts new file mode 100644 index 000000000..e21ba9a01 --- /dev/null +++ b/apps/web/src/app/settings/settings-launch-values.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { SETTINGS_RESTART_CONTROLS } from './settings-change-tracking'; + +/** + * Values of the restart-only settings the running app started with. Root + * provided on purpose: the settings page (and its form facade) is recreated + * every time it opens, but what the app launched with does not change until + * it restarts, so the restart notice must compare against one app-lifetime + * record. + */ +@Injectable({ providedIn: 'root' }) +export class SettingsLaunchValuesService { + private values: Record | null = null; + /** Saved values whose restart reminder the user dismissed with Later. */ + private dismissed: Record = {}; + + /** Records the stored values once, on the first settings open. */ + captureOnce(read: (control: string) => unknown): void { + if (this.values) return; + const values: Record = {}; + for (const control of Object.keys(SETTINGS_RESTART_CONTROLS)) { + values[control] = read(control); + } + this.values = values; + } + + /** + * What the running app actually uses when that differs from the stored + * value (a frame-copy opt-in the engine could not honour). + */ + set(control: string, value: unknown): void { + if (!(control in SETTINGS_RESTART_CONTROLS)) return; + this.values = { ...(this.values ?? {}), [control]: value }; + } + + get(): Readonly> { + return this.values ?? {}; + } + + /** Later: this saved value needs no further reminder this app run. */ + dismiss(control: string, savedValue: unknown): void { + this.dismissed = { ...this.dismissed, [control]: savedValue }; + } + + /** A dismissed reminder returns only once the saved value changes. */ + isDismissed(control: string, savedValue: unknown): boolean { + return ( + control in this.dismissed && this.dismissed[control] === savedValue + ); + } +} 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..20c2d3120 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,244 @@ 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(); + + // Saving the launch value back withdraws the notice: nothing + // waits for a restart any more. + component.settingsForm.get('startupWindowMode')?.setValue('normal'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.form.restartPendingControls()).toEqual([]); + expect(notice()).toBeNull(); + + component.settingsForm + .get('startupWindowMode') + ?.setValue('fullscreen'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(notice()).not.toBeNull(); + + ( + notice()?.querySelector( + '[data-test-id="settings-restart-later"]' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + + expect(notice()).toBeNull(); + + // Later holds: an unrelated save does not bring the reminder back. + component.settingsForm.get('theme')?.setValue(Theme.LightTheme); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(notice()).toBeNull(); + + // A different restart value is news again. + component.settingsForm + .get('startupWindowMode') + ?.setValue('maximized'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(notice()).not.toBeNull(); + }); + + it('keeps the launch values across settings visits', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + settingsStore.updateSettings.mockImplementation((settings) => { + settingsStore._setSettings(settings); + return Promise.resolve(undefined); + }); + component.settingsForm + .get('startupWindowMode') + ?.setValue('maximized'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([ + 'startupWindowMode', + ]); + + // Leave settings and come back: the page and its form are new, + // but the app still runs with the launch value. + fixture.destroy(); + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([ + 'startupWindowMode', + ]); + + // Saving the launch value back withdraws the notice here too. + component.settingsForm.get('startupWindowMode')?.setValue('normal'); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + expect(component.form.restartPendingControls()).toEqual([]); + }); + + it('compares the frame-copy opt-in against the engine the app runs', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + // Stored true, but the engine could not honour it at launch. + component.form.setRunningValue('embeddedMpvFrameCopy', false); + component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(true); + component.settingsForm.markAsDirty(); + component.onSubmit(); + await fixture.whenStable(); + + expect(component.form.restartPendingControls()).toEqual([ + 'embeddedMpvFrameCopy', + ]); + + // A staged, unsaved edit back to the launch value changes nothing: + // the saved setting still needs the restart. + component.settingsForm.get('embeddedMpvFrameCopy')?.setValue(false); + component.form.setRunningValue('embeddedMpvFrameCopy', false); + expect(component.form.restartPendingControls()).toEqual([ + 'embeddedMpvFrameCopy', + ]); + + // The engine catches up (a later probe reports frame copy): the + // notice goes without another save. + component.form.setRunningValue('embeddedMpvFrameCopy', true); + expect(component.form.restartPendingControls()).toEqual([]); + }); + 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..a8b695543 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,63 @@ 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); + }); + // The frame-copy opt-in may not be what the engine runs (it was not + // available at launch): the restart notice compares against that. + effect(() => { + if (this.embeddedMpv.support()) { + this.form.setRunningValue( + 'embeddedMpvFrameCopy', + this.embeddedMpv.frameCopyActive() + ); + } + }); + 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 +374,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 ce5bdf13f..d15e84249 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -756,6 +756,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/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 265a14a6d..674840f43 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -85,7 +85,6 @@ picker), so each marks itself on the form's `events` through - [x] `apps/web/src/app/settings/settings-general-section.component.ts` - [x] `apps/web/src/app/settings/settings-playback-section.component.ts` - [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts` -- [x] `apps/web/src/app/settings/settings-reset-section.component.ts` - [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts` - [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` 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())) {