feat(settings): per-section pages with unsaved-changes bar (#1384)

* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

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

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

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

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-08-09 09:34:03 +02:00
1 parent c95f826739
commit 1a6af75761
89 files changed
+1331 -952

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: settings
---
Settings sections are now separate pages with shareable URLs and a floating Save/Discard bar that appears only when you have unsaved changes; leaving settings with pending edits asks whether to save, discard, or keep editing. The EPG panel's "Open EPG settings" button now jumps straight to the EPG settings page.
+1 -1
View File
@@ -514,7 +514,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
`/workspace/xtreams/:id/downloads/:downloadId` and `/workspace/xtreams/:id/downloads/:downloadId` and
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
the workspace context panel. the workspace context panel.
- Settings: `/workspace/settings` (`/settings` redirects there) - Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation
**Service Architecture** (Factory Pattern): **Service Architecture** (Factory Pattern):
@@ -8,6 +8,7 @@ import {
expect, expect,
launchElectronApp, launchElectronApp,
openSettings, openSettings,
openSettingsSection,
openSources, openSources,
openWorkspaceSection, openWorkspaceSection,
resetMockServers, resetMockServers,
@@ -69,6 +70,7 @@ test.describe('Electron playlist backup round-trip', () => {
}, exportPath); }, exportPath);
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const backupSection = app.mainWindow.locator('#backup'); const backupSection = app.mainWindow.locator('#backup');
await backupSection await backupSection
.getByRole('button', { name: 'Export', exact: true }) .getByRole('button', { name: 'Export', exact: true })
@@ -113,6 +115,7 @@ test.describe('Electron playlist backup round-trip', () => {
// Import the exported file back through the settings flow; the // Import the exported file back through the settings flow; the
// renderer opens a browser file chooser for it. // renderer opens a browser file chooser for it.
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const fileChooserPromise = const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser'); app.mainWindow.waitForEvent('filechooser');
await backupSection await backupSection
@@ -123,6 +123,9 @@ async function selectDownloadCoverSize(
): Promise<void> { ): Promise<void> {
await openSettings(page); await openSettings(page);
await page.getByTestId(`cover-size-${size}`).click(); await page.getByTestId(`cover-size-${size}`).click();
// Cover size is staged like every other setting now — it reaches the
// store (and the document dataset) only once the save lands.
await saveSettings(page);
await expect await expect
.poll(() => .poll(() =>
page.evaluate( page.evaluate(
@@ -130,7 +133,6 @@ async function selectDownloadCoverSize(
) )
) )
.toBe(size); .toBe(size);
await saveSettings(page);
await openDownloadsPage(page); await openDownloadsPage(page);
} }
@@ -983,10 +983,24 @@ export async function openSettings(page: Page): Promise<void> {
await expect(page.getByTestId('settings-container')).toBeVisible(); await expect(page.getByTestId('settings-container')).toBeVisible();
} }
/**
* Settings render one section page at a time (`/workspace/settings/:section`),
* so a control can only be interacted with after its section page is open.
*/
export async function openSettingsSection(
page: Page,
sectionId: string
): Promise<void> {
await page.getByTestId(`settings-section-${sectionId}`).click();
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
}
export async function enableRemoteControl( export async function enableRemoteControl(
page: Page, page: Page,
port: number port: number
): Promise<void> { ): Promise<void> {
await openSettingsSection(page, 'remote-control');
const remoteControlCheckbox = page.locator( const remoteControlCheckbox = page.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
); );
@@ -1011,9 +1025,10 @@ export async function saveSettings(page: Page): Promise<void> {
// deterministic post-save state below. // deterministic post-save state below.
await saveButton.click({ noWaitAfter: true }); await saveButton.click({ noWaitAfter: true });
// `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the // `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the
// settings write resolves, which disables the button. Awaiting that is a // settings write resolves, which hides the whole unsaved-changes bar.
// stronger, race-free confirmation that the save actually committed. // Awaiting that is a stronger, race-free confirmation that the save
await expect(saveButton).toBeDisabled(); // actually committed.
await expect(saveButton).toBeHidden();
// Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to // Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to
// the main process before callers may relaunch the app to assert persistence. // the main process before callers may relaunch the app to assert persistence.
await page.waitForTimeout(300); await page.waitForTimeout(300);
+9
View File
@@ -9,6 +9,8 @@ import {
launchElectronApp, launchElectronApp,
openWorkspaceSection, openWorkspaceSection,
openSettings, openSettings,
openSettingsSection,
saveSettings,
test, test,
} from './electron-test-fixtures'; } from './electron-test-fixtures';
@@ -74,6 +76,7 @@ test.describe('Electron EPG', () => {
try { try {
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow await app.mainWindow
.getByRole('button', { name: 'Add EPG source' }) .getByRole('button', { name: 'Add EPG source' })
.click(); .click();
@@ -254,6 +257,7 @@ test.describe('Electron EPG', () => {
); );
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow await app.mainWindow
.getByRole('button', { name: 'Add EPG source' }) .getByRole('button', { name: 'Add EPG source' })
.click(); .click();
@@ -272,6 +276,11 @@ test.describe('Electron EPG', () => {
}) })
.toBeGreaterThan(0); .toBeGreaterThan(0);
// The added source is still only staged in the form — leaving
// settings without saving would surface the unsaved-changes
// dialog and block the navigation below.
await saveSettings(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'All channels'); await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle( const channelItem = channelItemByTitle(
@@ -10,6 +10,7 @@ import {
importM3uPlaylistFromNativeDialog, importM3uPlaylistFromNativeDialog,
launchElectronApp, launchElectronApp,
openSettings, openSettings,
openSettingsSection,
saveSettings, saveSettings,
test, test,
waitForM3uCatalog, waitForM3uCatalog,
@@ -47,6 +48,7 @@ test.describe('Electron Remote Control', () => {
try { try {
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await selectSettingsOption( await selectSettingsOption(
app.mainWindow, app.mainWindow,
'select-video-player', 'select-video-player',
@@ -16,6 +16,7 @@ import {
m3uFixturePath, m3uFixturePath,
openGlobalRecent, openGlobalRecent,
openSettings, openSettings,
openSettingsSection,
openWorkspaceSection, openWorkspaceSection,
resetMockServers, resetMockServers,
restartElectronApp, restartElectronApp,
@@ -49,6 +50,7 @@ test.describe('Electron Settings', () => {
try { try {
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'about');
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible(); await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible(); await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
@@ -75,6 +77,7 @@ test.describe('Electron Settings', () => {
await installExternalPlayerLaunchCapture(app); await installExternalPlayerLaunchCapture(app);
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await expect( await expect(
app.mainWindow.getByTestId( app.mainWindow.getByTestId(
'external-player-double-click-setting' 'external-player-double-click-setting'
@@ -117,6 +120,7 @@ test.describe('Electron Settings', () => {
}); });
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await expect(doubleClickSetting).toBeVisible(); await expect(doubleClickSetting).toBeVisible();
await doubleClickCheckbox.check(); await doubleClickCheckbox.check();
await saveSettings(app.mainWindow); await saveSettings(app.mainWindow);
@@ -229,6 +233,7 @@ test.describe('Electron Settings', () => {
await firstLaunch.mainWindow await firstLaunch.mainWindow
.locator('[data-test-id="DARK_THEME"]') .locator('[data-test-id="DARK_THEME"]')
.click(); .click();
await openSettingsSection(firstLaunch.mainWindow, 'playback');
await selectSettingsOption( await selectSettingsOption(
firstLaunch.mainWindow, firstLaunch.mainWindow,
'select-video-player', 'select-video-player',
@@ -250,6 +255,7 @@ test.describe('Electron Settings', () => {
) )
.uncheck(); .uncheck();
await enableRemoteControl(firstLaunch.mainWindow, 8877); await enableRemoteControl(firstLaunch.mainWindow, 8877);
await openSettingsSection(firstLaunch.mainWindow, 'epg');
await firstLaunch.mainWindow await firstLaunch.mainWindow
.getByRole('button', { name: 'Add EPG source' }) .getByRole('button', { name: 'Add EPG source' })
.click(); .click();
@@ -273,6 +279,7 @@ test.describe('Electron Settings', () => {
await expect( await expect(
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]') secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
).toHaveAttribute('aria-checked', 'true'); ).toHaveAttribute('aria-checked', 'true');
await openSettingsSection(secondLaunch.mainWindow, 'playback');
await expect( await expect(
secondLaunch.mainWindow.getByTestId('select-video-player') secondLaunch.mainWindow.getByTestId('select-video-player')
).toContainText(/HTML5/i); ).toContainText(/HTML5/i);
@@ -289,6 +296,7 @@ test.describe('Electron Settings', () => {
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]' 'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
) )
).not.toBeChecked(); ).not.toBeChecked();
await openSettingsSection(secondLaunch.mainWindow, 'remote-control');
await expect( await expect(
secondLaunch.mainWindow.locator( secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
@@ -297,6 +305,7 @@ test.describe('Electron Settings', () => {
await expect( await expect(
secondLaunch.mainWindow.locator('#remoteControlPort') secondLaunch.mainWindow.locator('#remoteControlPort')
).toHaveValue('8877'); ).toHaveValue('8877');
await openSettingsSection(secondLaunch.mainWindow, 'epg');
await expect( await expect(
secondLaunch.mainWindow.locator('.epg-source-row input').first() secondLaunch.mainWindow.locator('.epg-source-row input').first()
).toHaveValue(epgServer.resourceUrl); ).toHaveValue(epgServer.resourceUrl);
@@ -313,6 +322,7 @@ test.describe('Electron Settings', () => {
try { try {
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await selectSettingsOption( await selectSettingsOption(
app.mainWindow, app.mainWindow,
'select-video-player', 'select-video-player',
@@ -478,6 +488,7 @@ test.describe('Electron Settings', () => {
try { try {
await openSettings(firstLaunch.mainWindow); await openSettings(firstLaunch.mainWindow);
await openSettingsSection(firstLaunch.mainWindow, 'epg');
const listToggle = firstLaunch.mainWindow.locator( const listToggle = firstLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]' '[data-test-id="epg-view-mode-list"]'
); );
@@ -493,6 +504,7 @@ test.describe('Electron Settings', () => {
try { try {
await openSettings(secondLaunch.mainWindow); await openSettings(secondLaunch.mainWindow);
await openSettingsSection(secondLaunch.mainWindow, 'epg');
await expect( await expect(
secondLaunch.mainWindow.locator( secondLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]' '[data-test-id="epg-view-mode-list"]'
@@ -508,6 +520,7 @@ test.describe('Electron Settings', () => {
try { try {
await openSettings(firstLaunch.mainWindow); await openSettings(firstLaunch.mainWindow);
await openSettingsSection(firstLaunch.mainWindow, 'dashboard');
await firstLaunch.mainWindow await firstLaunch.mainWindow
.locator( .locator(
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]' 'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
@@ -9,6 +9,7 @@ import {
goToDashboard, goToDashboard,
launchElectronApp, launchElectronApp,
openSettings, openSettings,
openSettingsSection,
openWorkspaceSection, openWorkspaceSection,
resetMockServers, resetMockServers,
saveSettings, saveSettings,
@@ -302,6 +303,7 @@ test('@epg @xtream @electron renders the vertical list view when the setting is
// Opt into the list view first (from the fresh workspace) so the portal // Opt into the list view first (from the fresh workspace) so the portal
// → Live TV → channel flow afterwards mirrors the timeline test exactly. // → Live TV → channel flow afterwards mirrors the timeline test exactly.
await openSettings(app.mainWindow); await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow await app.mainWindow
.locator('[data-test-id="epg-view-mode-list"]') .locator('[data-test-id="epg-view-mode-list"]')
.click(); .click();
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

@@ -64,10 +64,16 @@ async function servePlaybackFixtures(page: Page): Promise<void> {
async function openSettings(page: Page): Promise<void> { async function openSettings(page: Page): Promise<void> {
await page.getByRole('link', { name: 'Open settings' }).click(); await page.getByRole('link', { name: 'Open settings' }).click();
await page.waitForURL(/\/workspace\/settings$/); // The bare settings URL redirects to the default section page; the
// player select lives on the playback section page.
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect( await expect(
page.getByRole('button', { name: 'Back', exact: true }) page.getByRole('button', { name: 'Back', exact: true })
).toBeVisible(); ).toBeVisible();
await page
.locator('[data-test-id="settings-section-playback"]')
.click();
await page.waitForURL(/\/workspace\/settings\/playback$/);
} }
async function selectAndSaveHtml5(page: Page): Promise<void> { async function selectAndSaveHtml5(page: Page): Promise<void> {
@@ -79,7 +85,8 @@ async function selectAndSaveHtml5(page: Page): Promise<void> {
const saveButton = page.getByRole('button', { name: 'Save changes' }); const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click(); await saveButton.click();
await expect(saveButton).toBeDisabled(); // A successful save removes the unsaved-changes bar with its button.
await expect(saveButton).toBeHidden();
} }
async function importFatalHlsPlaylist(page: Page): Promise<void> { async function importFatalHlsPlaylist(page: Page): Promise<void> {
@@ -131,7 +138,7 @@ test('@web @m3u @playback temporarily switches to the recommended player', async
await openSettings(page); await openSettings(page);
await page.reload(); await page.reload();
await expect(page).toHaveURL(/\/workspace\/settings$/); await expect(page).toHaveURL(/\/workspace\/settings\/playback$/);
const persistedPlayerSelect = page.locator( const persistedPlayerSelect = page.locator(
'[data-test-id="select-video-player"]' '[data-test-id="select-video-player"]'
); );
+143 -2
View File
@@ -4,16 +4,25 @@ import { expect, test } from './fixtures';
async function openSettings(page: Page) { async function openSettings(page: Page) {
await page.locator('a[href$="/workspace/settings"]').click(); await page.locator('a[href$="/workspace/settings"]').click();
await page.waitForURL(/\/workspace\/settings$/); // The bare settings URL redirects to the default section page.
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect(page.locator('.settings-container')).toBeVisible(); await expect(page.locator('.settings-container')).toBeVisible();
await expect(page.locator('.settings-back-button')).toBeVisible(); await expect(page.locator('.settings-back-button')).toBeVisible();
} }
/** Settings render one section page at a time — open it via the rail. */
async function openSettingsSection(page: Page, sectionId: string) {
await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click();
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
}
async function saveSettings(page: Page) { async function saveSettings(page: Page) {
const saveButton = page.locator('[data-test-id="save-settings"]'); const saveButton = page.locator('[data-test-id="save-settings"]');
await saveButton.click(); await saveButton.click();
await expect(saveButton).toBeDisabled(); // A successful save marks the form pristine, which removes the whole
// unsaved-changes bar together with the save button.
await expect(saveButton).toBeHidden();
} }
test.describe('Settings', () => { test.describe('Settings', () => {
@@ -30,6 +39,7 @@ test.describe('Settings', () => {
test('@settings @web Change video player', async ({ page }) => { test('@settings @web Change video player', async ({ page }) => {
await openSettings(page); await openSettings(page);
await openSettingsSection(page, 'playback');
const playerSelect = page.locator('[data-test-id="select-video-player"]'); const playerSelect = page.locator('[data-test-id="select-video-player"]');
@@ -42,6 +52,7 @@ test.describe('Settings', () => {
await saveSettings(page); await saveSettings(page);
await page.reload(); await page.reload();
await openSettings(page); await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(playerSelect).toContainText( await expect(playerSelect).toContainText(
/HTML5/i /HTML5/i
@@ -52,6 +63,7 @@ test.describe('Settings', () => {
page, page,
}) => { }) => {
await openSettings(page); await openSettings(page);
await openSettingsSection(page, 'playback');
const setting = page.locator( const setting = page.locator(
'[data-test-id="web-player-shared-controls-setting"]' '[data-test-id="web-player-shared-controls-setting"]'
@@ -64,6 +76,7 @@ test.describe('Settings', () => {
await saveSettings(page); await saveSettings(page);
await page.reload(); await page.reload();
await openSettings(page); await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(checkbox).toBeChecked(); await expect(checkbox).toBeChecked();
}); });
@@ -93,6 +106,134 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true'); ).toHaveAttribute('aria-checked', 'true');
}); });
test('@settings @web Deep links open one section page and unknown sections redirect', async ({
page,
}) => {
await page.goto('/workspace/settings/playback');
// Only the routed section renders — the playback controls are
// there, the general ones are not.
await expect(
page.locator('[data-test-id="select-video-player"]')
).toBeVisible();
await expect(
page.locator('[data-test-id="select-language"]')
).toHaveCount(0);
// A stale or mistyped section URL is rewritten to the default page.
await page.goto('/workspace/settings/nonsense');
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect(
page.locator('[data-test-id="select-language"]')
).toBeVisible();
});
test('@settings @web Unsaved bar survives section switches and discard reverts', async ({
page,
}) => {
await openSettings(page);
const unsavedBar = page.locator(
'[data-test-id="settings-unsaved-bar"]'
);
await expect(unsavedBar).toBeHidden();
const themeGroup = page.locator(
'[data-test-id="select-theme"][role="radiogroup"]'
);
await themeGroup
.getByRole('radio', { name: 'Dark', exact: true })
.click();
await expect(unsavedBar).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 page.locator('[data-test-id="discard-settings"]').click();
await expect(unsavedBar).toBeHidden();
await openSettingsSection(page, 'general');
await expect(
themeGroup.getByRole('radio', { name: 'System', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Leaving with unsaved edits asks for confirmation', async ({
page,
}) => {
await openSettings(page);
const themeGroup = page.locator(
'[data-test-id="select-theme"][role="radiogroup"]'
);
await themeGroup
.getByRole('radio', { name: 'Dark', exact: true })
.click();
// Trying to leave the settings area surfaces the dialog.
await page
.getByRole('navigation')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Keep editing: navigation is cancelled, the edit survives.
await dialog.locator('[data-test-id="unsaved-dialog-stay"]').click();
await expect(dialog).toBeHidden();
await expect(page).toHaveURL(/\/workspace\/settings\/general$/);
await expect(
page.locator('[data-test-id="settings-unsaved-bar"]')
).toBeVisible();
// Leave without saving: the staged edit is discarded.
await page
.getByRole('navigation')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
.getByRole('dialog')
.locator('[data-test-id="unsaved-dialog-discard"]')
.click();
await page.waitForURL(/\/workspace\/dashboard$/);
await openSettings(page);
await expect(
themeGroup.getByRole('radio', { name: 'System', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Save-and-leave persists the staged edit', async ({
page,
}) => {
await openSettings(page);
const themeGroup = page.locator(
'[data-test-id="select-theme"][role="radiogroup"]'
);
await themeGroup
.getByRole('radio', { name: 'Dark', exact: true })
.click();
await page
.getByRole('navigation')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await page
.getByRole('dialog')
.locator('[data-test-id="unsaved-dialog-save"]')
.click();
await page.waitForURL(/\/workspace\/dashboard$/);
await page.reload();
await openSettings(page);
await expect(
themeGroup.getByRole('radio', { name: 'Dark', exact: true })
).toHaveAttribute('aria-checked', 'true');
});
test('@settings @web Change app language', async ({ page }) => { test('@settings @web Change app language', async ({ page }) => {
await openSettings(page); await openSettings(page);
const languageSelect = page.locator('[data-test-id="select-language"]'); const languageSelect = page.locator('[data-test-id="select-language"]');
-10
View File
@@ -26,7 +26,6 @@ import {
PORTAL_EXTERNAL_PLAYBACK, PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER, PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util'; } from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services'; import { DataService } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces'; import { dbConfig } from '@iptvnator/shared/interfaces';
@@ -35,10 +34,6 @@ import { routes } from './app.routes';
import { ElectronService } from './services/electron.service'; import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service'; import { ExternalPlaybackService } from './services/external-playback.service';
import { PlayerService } from './services/player.service'; import { PlayerService } from './services/player.service';
import {
AppPortalNavigationActionsService,
providePortalNavigationActions,
} from './services/portal-navigation-actions.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service'; import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service'; import { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config'; import { shouldEnableServiceWorker } from './services/runtime-config';
@@ -147,11 +142,6 @@ export const appConfig: ApplicationConfig = {
useExisting: ExternalPlaybackService, useExisting: ExternalPlaybackService,
}, },
...providePortalPlaybackPositions(), ...providePortalPlaybackPositions(),
...providePortalNavigationActions(),
{
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
...provideWorkspaceShellActions(), ...provideWorkspaceShellActions(),
...provideXtreamDataSource(), ...provideXtreamDataSource(),
{ {
+22 -4
View File
@@ -2,6 +2,7 @@ import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router'; import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings(); const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
@@ -141,10 +142,27 @@ export const routes: Routes = [
}, },
{ {
path: 'settings', path: 'settings',
loadComponent: () => children: [
import('./settings/settings.component').then( {
(c) => c.SettingsComponent path: '',
), pathMatch: 'full',
redirectTo: 'general',
},
{
// One routed component for every section: the same
// instance survives :section param changes (default
// route reuse), so the settings form — and its dirty
// state — persists while the user moves between
// section pages. The guard only intercepts leaving
// the settings area with unsaved edits.
path: ':section',
canDeactivate: [settingsUnsavedChangesGuard],
loadComponent: () =>
import('./settings/settings.component').then(
(c) => c.SettingsComponent
),
},
],
}, },
], ],
}, },
@@ -1,64 +0,0 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
import {
PORTAL_NAVIGATION_ACTIONS,
PortalNavigationActions,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
import { SettingsComponent } from '../settings/settings.component';
@Injectable({ providedIn: 'root' })
export class AppPortalNavigationActionsService
implements PortalNavigationActions
{
private readonly dialog = inject(MatDialog);
private readonly xtreamStore = inject(XtreamStore);
openAccountInfo(): void {
const data = {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
};
void import('@iptvnator/portal/xtream/feature').then(
({ AccountInfoComponent }) => {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
}
);
}
openPlaylistInfo(playlist: Playlist | null | undefined): void {
this.dialog.open(PlaylistInfoComponent, {
data: playlist ?? null,
});
}
openSettings(): void {
this.dialog.open(SettingsComponent, {
width: '1200px',
maxWidth: '96vw',
maxHeight: '92vh',
data: {
isDialog: true,
},
});
}
}
export function providePortalNavigationActions(): Provider[] {
return [
AppPortalNavigationActionsService,
{
provide: PORTAL_NAVIGATION_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
];
}
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'about'"
id="about" id="about"
> >
<header class="settings-group__header"> <header class="settings-group__header">
@@ -16,7 +16,6 @@ function configureComponent(
fixture: ComponentFixture<SettingsAboutSectionComponent>, fixture: ComponentFixture<SettingsAboutSectionComponent>,
status: ElectronBridgeAppUpdateStatus status: ElectronBridgeAppUpdateStatus
) { ) {
fixture.componentRef.setInput('activeSection', 'about');
fixture.componentRef.setInput('isDesktop', true); fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('version', '0.22.0'); fixture.componentRef.setInput('version', '0.22.0');
fixture.componentRef.setInput('updateMessage', ''); fixture.componentRef.setInput('updateMessage', '');
@@ -145,7 +144,6 @@ describe('SettingsAboutSectionComponent version display', () => {
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(SettingsAboutSectionComponent); fixture = TestBed.createComponent(SettingsAboutSectionComponent);
fixture.componentRef.setInput('activeSection', 'about');
fixture.componentRef.setInput('version', '0.23.0'); fixture.componentRef.setInput('version', '0.23.0');
}); });
@@ -24,7 +24,6 @@ import {
], ],
}) })
export class SettingsAboutSectionComponent { export class SettingsAboutSectionComponent {
readonly activeSection = input.required<string>();
readonly isDesktop = input(false); readonly isDesktop = input(false);
readonly version = input<string | undefined>(); readonly version = input<string | undefined>();
readonly buildCommit = input<string | undefined>(); readonly buildCommit = input<string | undefined>();
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'backup'"
id="backup" id="backup"
> >
<header class="settings-group__header"> <header class="settings-group__header">
@@ -17,7 +17,6 @@ import { TranslateModule } from '@ngx-translate/core';
styles: [':host { display: contents; }'], styles: [':host { display: contents; }'],
}) })
export class SettingsBackupSectionComponent { export class SettingsBackupSectionComponent {
readonly activeSection = input.required<string>();
readonly isPwa = input(false); readonly isPwa = input(false);
readonly isRemovingAllPlaylists = input(false); readonly isRemovingAllPlaylists = input(false);
readonly isExportingData = input(false); readonly isExportingData = input(false);
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'dashboard'"
id="dashboard" id="dashboard"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -20,5 +20,4 @@ import { TranslateModule } from '@ngx-translate/core';
}) })
export class SettingsDashboardSectionComponent { export class SettingsDashboardSectionComponent {
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
} }
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'epg'"
id="epg" id="epg"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -32,7 +32,6 @@ import { EpgViewModeOption } from './settings.models';
}) })
export class SettingsEpgSectionComponent { export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly epgUrl = input.required<FormArray>(); readonly epgUrl = input.required<FormArray>();
readonly isClearingEpgData = input(false); readonly isClearingEpgData = input(false);
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>(); readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
@@ -99,15 +99,18 @@ export class SettingsFormFacade {
this.settingsService.changeTheme(theme); this.settingsService.changeTheme(theme);
} }
/**
* Staged like every other control — the write happens on Save. These two
* used to persist eagerly, which made Discard a lie: `hydrateFromStore()`
* would faithfully reload the just-persisted edit and the unsaved bar
* disappeared without anything being reverted.
*/
selectCoverSize(coverSize: CoverSize): void { selectCoverSize(coverSize: CoverSize): void {
if (this.form.value.coverSize === coverSize) { if (this.form.value.coverSize === coverSize) {
return; return;
} }
this.patchAndMarkDirty({ coverSize }, 'coverSize'); this.patchAndMarkDirty({ coverSize }, 'coverSize');
this.settingsStore.updateSettings({ coverSize }).catch(() => {
this.settingsSnackbar.storageFailure('save');
});
} }
selectEpgViewMode(epgViewMode: EpgViewMode): void { selectEpgViewMode(epgViewMode: EpgViewMode): void {
@@ -116,9 +119,6 @@ export class SettingsFormFacade {
} }
this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode');
this.settingsStore.updateSettings({ epgViewMode }).catch(() => {
this.settingsSnackbar.storageFailure('save');
});
} }
setRecordingFolder(recordingFolder: string): void { setRecordingFolder(recordingFolder: string): void {
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'general'"
id="general" id="general"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -30,7 +30,6 @@ import {
}) })
export class SettingsGeneralSectionComponent { export class SettingsGeneralSectionComponent {
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly languageEnum = input.required<typeof Language>(); readonly languageEnum = input.required<typeof Language>();
readonly themeOptions = input.required<ThemeOption[]>(); readonly themeOptions = input.required<ThemeOption[]>();
readonly coverSizeOptions = input.required<CoverSizeOption[]>(); readonly coverSizeOptions = input.required<CoverSizeOption[]>();
@@ -1,81 +1,41 @@
import { readFileSync, readdirSync } from 'fs'; import { readFileSync } from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { buildSettingsSectionNavItems } from './settings-options'; import { buildSettingsSectionNavItems } from './settings-options';
/** /**
* Every nav-item id must match the `id="..."` attribute on its * Every nav-item id must match a `@case ('...')` label in the settings page
* corresponding section component template, otherwise clicking the nav * template (or the `@default` general section), otherwise clicking the nav
* link silently no-ops (the scroll directive runs `document.getElementById` * link routes to `/workspace/settings/:id` and renders the fallback general
* and falls through when no element is found). * page instead of the intended section.
* *
* This bug bit the Remote section once already — its nav id was set to * The id coupling bit the Remote section once already in the scroll-anchor
* the Nx library name (`@iptvnator/ui/remote-control`) instead of the * era — its nav id was set to the Nx library name
* template's `id="remote-control"`. The guard below catches future * (`@iptvnator/ui/remote-control`) instead of `remote-control`. The guard
* rename/copy-paste regressions before they ship. * below catches future rename/copy-paste regressions before they ship.
*/ */
describe('buildSettingsSectionNavItems', () => { describe('buildSettingsSectionNavItems', () => {
// Anchor on the Nx workspace root (Jest runs from there); avoids // Anchor on the Nx workspace root (Jest runs from there); avoids
// depending on __dirname which isn't defined under the project's // depending on __dirname which isn't defined under the project's
// ESM Jest preset. // ESM Jest preset.
const settingsDir = resolve( const settingsDir = resolve(process.cwd(), 'apps/web/src/app/settings');
process.cwd(),
'apps/web/src/app/settings'
);
function collectSectionTemplateIds(): Set<string> { /** Section ids the page template can render: @case labels + @default. */
const ids = new Set<string>(); function collectRenderableSectionIds(): Set<string> {
for (const fileName of readdirSync(settingsDir)) {
if (!/^settings-.+-section\.component\.html$/.test(fileName)) {
continue;
}
const html = readFileSync(
resolve(settingsDir, fileName),
'utf-8'
);
// Match the FIRST `id="…"` on the <section> root only —
// descendant elements (form controls, anchors) also use id=
// attributes and would pollute the set.
const rootMatch = /<section[^>]*\sid="([^"]+)"/i.exec(html);
if (rootMatch) {
ids.add(rootMatch[1]);
}
}
return ids;
}
function collectSettingsComponentSectionOrder(): string[] {
const html = readFileSync( const html = readFileSync(
resolve(settingsDir, 'settings.component.html'), resolve(settingsDir, 'settings.component.html'),
'utf-8' 'utf-8'
); );
const idsByComponent = new Map<string, string>(); const ids = new Set<string>(
Array.from(
for (const fileName of readdirSync(settingsDir)) { html.matchAll(/@case \('([a-z-]+)'\)/g),
const componentMatch = (match) => match[1]
/^settings-(.+)-section\.component\.html$/.exec(fileName); )
if (!componentMatch) { );
continue; if (/@default\s*\{\s*<app-settings-general-section/.test(html)) {
} ids.add('general');
const sectionHtml = readFileSync(
resolve(settingsDir, fileName),
'utf-8'
);
const rootMatch = /<section[^>]*\sid="([^"]+)"/i.exec(
sectionHtml
);
if (rootMatch) {
idsByComponent.set(
`app-settings-${componentMatch[1]}-section`,
rootMatch[1]
);
}
} }
return ids;
return Array.from(
html.matchAll(/<app-settings-[a-z-]+-section\b/g),
(match) => idsByComponent.get(match[0].slice(1))
).filter((id): id is string => Boolean(id));
} }
it('exposes feature-specific items only when their runtime capabilities are supported', () => { it('exposes feature-specific items only when their runtime capabilities are supported', () => {
@@ -100,28 +60,34 @@ describe('buildSettingsSectionNavItems', () => {
).toBe(false); ).toBe(false);
}); });
it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => { it('every nav id renders a section page in the settings template', () => {
const navIds = new Set( const navIds = new Set(
buildSettingsSectionNavItems({ buildSettingsSectionNavItems({
supportsEpg: true, supportsEpg: true,
supportsRemoteControl: true, supportsRemoteControl: true,
}).map((item) => item.id) }).map((item) => item.id)
); );
const templateIds = collectSectionTemplateIds(); const renderableIds = collectRenderableSectionIds();
// Every nav id must exist as a section root id. // A nav id without a template case silently falls back to the
const orphans = [...navIds].filter((id) => !templateIds.has(id)); // general page.
const orphans = [...navIds].filter((id) => !renderableIds.has(id));
expect(orphans).toEqual([]); expect(orphans).toEqual([]);
// And every section template id must be reachable from the nav // And every template case must be reachable from the nav (catches
// (catches the opposite drift — a new section added without a nav // the opposite drift — a new section page added without a nav entry
// entry would never get scrolled to). // could never be opened).
const unreachable = [...templateIds].filter((id) => !navIds.has(id)); const unreachable = [...renderableIds].filter((id) => !navIds.has(id));
expect(unreachable).toEqual([]); expect(unreachable).toEqual([]);
}); });
it('keeps Dashboard below EPG in both the settings rail and content order', () => { it('keeps the settings rail in the expected order', () => {
const expectedOrder = [ expect(
buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
}).map((item) => item.id)
).toEqual([
'general', 'general',
'playback', 'playback',
'epg', 'epg',
@@ -131,14 +97,6 @@ describe('buildSettingsSectionNavItems', () => {
'backup', 'backup',
'reset', 'reset',
'about', 'about',
]; ]);
expect(
buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
}).map((item) => item.id)
).toEqual(expectedOrder);
expect(collectSettingsComponentSectionOrder()).toEqual(expectedOrder);
}); });
}); });
@@ -38,7 +38,6 @@ describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => {
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', []); fixture.componentRef.setInput('players', []);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat); fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
}); });
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'playback'"
id="playback" id="playback"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -51,7 +51,6 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent); fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('form', createForm()); fixture.componentRef.setInput('form', createForm());
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', [ fixture.componentRef.setInput('players', [
{ {
id: VideoPlayer.VideoJs, id: VideoPlayer.VideoJs,
@@ -45,7 +45,6 @@ export class SettingsPlaybackSectionComponent {
].join('\n'); ].join('\n');
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly players = input.required<SettingsPlayerOption[]>(); readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>(); readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false); readonly isDesktop = input(false);
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'remote-control'"
id="remote-control" id="remote-control"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -28,7 +28,6 @@ import { QRCodeComponent } from 'angularx-qrcode';
}) })
export class SettingsRemoteControlSectionComponent { export class SettingsRemoteControlSectionComponent {
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly localIpAddresses = input.required<string[]>(); readonly localIpAddresses = input.required<string[]>();
readonly visibleQrCodeIp = input<string | null>(null); readonly visibleQrCodeIp = input<string | null>(null);
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'reset'"
id="reset" id="reset"
> >
<header class="settings-group__header"> <header class="settings-group__header">
@@ -18,7 +18,6 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
styles: [':host { display: contents; }'], styles: [':host { display: contents; }'],
}) })
export class SettingsResetSectionComponent { export class SettingsResetSectionComponent {
readonly activeSection = input.required<string>();
readonly isRemovingAllPlaylists = input(false); readonly isRemovingAllPlaylists = input(false);
readonly canRemoveAllPlaylists = input(false); readonly canRemoveAllPlaylists = input(false);
readonly playlistDeleteSummary = readonly playlistDeleteSummary =
@@ -1,107 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { SettingsComponent } from './settings.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
import {
configureSettingsComponentTestBed,
createElectronStub,
createEpgBridgeStub,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
interface SettingsSectionScrollDirectiveTestApi {
getScrollRoot(): HTMLElement | null;
}
/**
* The directive only makes sense against a real settings page, so this is an
* integration spec: it renders the component and drives the section
* navigation the workspace shell would trigger.
*/
describe('SettingsSectionScrollDirective', () => {
let fixture: ComponentFixture<SettingsComponent>;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub();
configureSettingsComponentTestBed(epgBridge);
}));
beforeEach(() => {
window.electron = createElectronStub();
});
afterEach(() => {
window.electron = originalElectron;
});
it('should scroll the selected navigation target within the workspace viewport', async () => {
jest.useFakeTimers();
try {
fixture = TestBed.createComponent(SettingsComponent);
const component = fixture.componentInstance;
const settingsContext = TestBed.inject(SettingsContextService);
const originalGetElementById =
document.getElementById.bind(document);
const scrollTo = jest.fn();
const scrollRoot = {
scrollTop: 96,
clientHeight: 885,
scrollHeight: 2469,
getBoundingClientRect: () =>
({
top: 56,
}) as DOMRect,
scrollTo,
} as unknown as HTMLElement;
stubSettingsSideEffects(component);
fixture.detectChanges();
const scrollDirective = fixture.debugElement
.query(By.directive(SettingsSectionScrollDirective))
.injector.get(SettingsSectionScrollDirective);
jest.spyOn(
scrollDirective as unknown as SettingsSectionScrollDirectiveTestApi,
'getScrollRoot'
).mockReturnValue(scrollRoot);
const getElementByIdSpy = jest
.spyOn(document, 'getElementById')
.mockImplementation((id: string) => {
if (id === 'about') {
return {
getBoundingClientRect: () =>
({
top: 2050,
height: 159,
}) as DOMRect,
} as HTMLElement;
}
return originalGetElementById(id);
});
await fixture.whenStable();
fixture.detectChanges();
settingsContext.navigateToSection('about');
fixture.detectChanges();
expect(getElementByIdSpy).toHaveBeenCalledWith('about');
expect(scrollTo).toHaveBeenCalledWith({
behavior: 'smooth',
top: 1488,
});
expect(settingsContext.pendingScrollTarget()).toBe('about');
jest.advanceTimersByTime(600);
expect(settingsContext.pendingScrollTarget()).toBeNull();
} finally {
jest.useRealTimers();
}
});
});
@@ -1,241 +0,0 @@
import {
AfterViewInit,
Directive,
effect,
ElementRef,
inject,
Injector,
OnDestroy,
} from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { ObservedSettingsSection } from './settings.models';
@Directive({
selector: '[appSettingsSectionScroll]',
})
export class SettingsSectionScrollDirective
implements AfterViewInit, OnDestroy
{
private static readonly SECTION_SCROLL_TOP_GUTTER = 112;
private static readonly SECTION_SCROLL_BOTTOM_GUTTER = 124;
private static readonly PENDING_SCROLL_CLEAR_DELAY_MS = 600;
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
private readonly settingsCtx = inject(SettingsContextService);
private sectionObserver?: IntersectionObserver;
private pendingScrollClearTimer: ReturnType<
typeof window.setTimeout
> | null = null;
private pendingScrollClearRoot: HTMLElement | null = null;
private pendingScrollEndListener: (() => void) | null = null;
constructor() {
effect(
() => {
const sectionId = this.settingsCtx.pendingScrollTarget();
if (!sectionId || typeof document === 'undefined') {
return;
}
const scrollRoot = this.scrollToSection(sectionId);
this.schedulePendingScrollTargetClear(scrollRoot);
},
{ injector: this.injector }
);
// The previous active-section change handler ran a 260ms box-shadow
// animation (inset 1px ring + soft glow) on whichever section
// became active during scroll. With the new flat layout (no card
// chrome, no static active ring) that pulse drew a brief 1px
// border around each block as the user scrolled past — what the
// user reported as "short border kind of highlight around blocks".
// The rail's left active state already announces the current
// section, so the inline pulse is redundant. Removed entirely.
}
ngAfterViewInit(): void {
requestAnimationFrame(() => this.setupSectionObserver());
}
ngOnDestroy(): void {
this.cancelPendingScrollTargetClear();
this.sectionObserver?.disconnect();
}
private setupSectionObserver(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const scrollRoot = this.getScrollRoot();
const contentSections = Array.from(
this.elementRef.nativeElement.querySelectorAll(
'.settings-group[id]'
)
) as HTMLElement[];
const sections: ObservedSettingsSection[] = contentSections.map(
(section) => ({
id: section.id,
element: section,
})
);
if (sections.length === 0) {
return;
}
this.sectionObserver?.disconnect();
this.sectionObserver = new IntersectionObserver(
() => {
if (this.settingsCtx.pendingScrollTarget()) {
return;
}
const activeSection = this.resolveActiveSection(sections);
if (activeSection) {
this.settingsCtx.setActiveSection(activeSection);
}
},
{
root: scrollRoot,
threshold: [0.12, 0.24, 0.4, 0.6],
rootMargin: '-18% 0px -52% 0px',
}
);
sections.forEach((section) =>
this.sectionObserver?.observe(section.element)
);
const initialSection = this.resolveActiveSection(sections);
if (initialSection) {
this.settingsCtx.setActiveSection(initialSection);
}
}
private resolveActiveSection(
sections: ObservedSettingsSection[]
): string | null {
const scrollRoot = this.getScrollRoot();
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
const sectionAtActivationLine = sections.find((section) => {
const rect = section.element.getBoundingClientRect();
return rect.top <= activationLine && rect.bottom >= activationLine;
});
if (sectionAtActivationLine) {
return sectionAtActivationLine.id;
}
const nearestSection = sections
.map((section) => ({
id: section.id,
distance: Math.abs(
section.element.getBoundingClientRect().top - activationLine
),
}))
.sort((a, b) => a.distance - b.distance)[0];
return nearestSection?.id ?? null;
}
private getScrollRoot(): HTMLElement | null {
return this.elementRef.nativeElement.closest(
'main.workspace-content'
) as HTMLElement | null;
}
private schedulePendingScrollTargetClear(
scrollRoot: HTMLElement | null
): void {
const clearPendingScrollTarget = () => {
this.cancelPendingScrollTargetClear();
this.settingsCtx.clearPendingScrollTarget();
};
this.cancelPendingScrollTargetClear();
this.pendingScrollClearTimer = window.setTimeout(
clearPendingScrollTarget,
SettingsSectionScrollDirective.PENDING_SCROLL_CLEAR_DELAY_MS
);
this.pendingScrollClearRoot = scrollRoot;
this.pendingScrollEndListener = clearPendingScrollTarget;
scrollRoot?.addEventListener?.('scrollend', clearPendingScrollTarget, {
once: true,
});
}
private cancelPendingScrollTargetClear(): void {
if (this.pendingScrollClearTimer) {
clearTimeout(this.pendingScrollClearTimer);
this.pendingScrollClearTimer = null;
}
if (this.pendingScrollClearRoot && this.pendingScrollEndListener) {
this.pendingScrollClearRoot.removeEventListener?.(
'scrollend',
this.pendingScrollEndListener
);
}
this.pendingScrollClearRoot = null;
this.pendingScrollEndListener = null;
}
private scrollToSection(sectionId: string): HTMLElement | null {
const sectionElement = document.getElementById(sectionId);
if (!sectionElement) {
return null;
}
const scrollRoot = this.getScrollRoot();
if (!scrollRoot) {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
return null;
}
const rootRect = scrollRoot.getBoundingClientRect();
const sectionRect = sectionElement.getBoundingClientRect();
const sectionTop =
scrollRoot.scrollTop + (sectionRect.top - rootRect.top);
const sectionBottom = sectionTop + sectionRect.height;
const visibleTop =
scrollRoot.scrollTop +
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
const visibleBottom =
scrollRoot.scrollTop +
scrollRoot.clientHeight -
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
let nextScrollTop = scrollRoot.scrollTop;
if (sectionTop < visibleTop) {
nextScrollTop =
sectionTop -
SettingsSectionScrollDirective.SECTION_SCROLL_TOP_GUTTER;
} else if (sectionBottom > visibleBottom) {
nextScrollTop =
sectionBottom -
scrollRoot.clientHeight +
SettingsSectionScrollDirective.SECTION_SCROLL_BOTTOM_GUTTER;
}
const maxScrollTop = Math.max(
0,
scrollRoot.scrollHeight - scrollRoot.clientHeight
);
scrollRoot.scrollTo({
top: Math.min(Math.max(nextScrollTop, 0), maxScrollTop),
behavior: 'smooth',
});
return scrollRoot;
}
}
@@ -1,6 +1,5 @@
<section <section
class="settings-group" class="settings-group"
[class.settings-group--active]="activeSection() === 'tmdb'"
id="tmdb" id="tmdb"
[formGroup]="form()" [formGroup]="form()"
> >
@@ -27,14 +27,24 @@ describe('SettingsTmdbSectionComponent', () => {
const clearButton = () => const clearButton = () =>
queryByTestId('tmdb-clear-cache') as HTMLButtonElement; queryByTestId('tmdb-clear-cache') as HTMLButtonElement;
/** The stats effect only fires once the TMDB section is the active one */ /** Constructor kicks off the stats read; let it settle and render. */
const activate = async (section = 'tmdb') => { const settle = async () => {
fixture.componentRef.setInput('activeSection', section);
fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
}; };
/**
* The section component only exists while its settings page is open, so
* "reopening the section" means recreating the component.
*/
const reopenSection = async () => {
fixture.destroy();
fixture = TestBed.createComponent(SettingsTmdbSectionComponent);
fixture.componentRef.setInput('form', createForm());
fixture.detectChanges();
await settle();
};
beforeEach(async () => { beforeEach(async () => {
getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 }); getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 });
clear = jest.fn().mockResolvedValue(42); clear = jest.fn().mockResolvedValue(42);
@@ -73,21 +83,18 @@ describe('SettingsTmdbSectionComponent', () => {
fixture = TestBed.createComponent(SettingsTmdbSectionComponent); fixture = TestBed.createComponent(SettingsTmdbSectionComponent);
fixture.componentRef.setInput('form', createForm()); fixture.componentRef.setInput('form', createForm());
fixture.componentRef.setInput('activeSection', 'general');
fixture.detectChanges(); fixture.detectChanges();
}); });
it('defers the full-table scan until the section is opened', async () => { it('sizes the cache as soon as the section page opens', async () => {
expect(getStats).not.toHaveBeenCalled(); await settle();
await activate();
expect(getStats).toHaveBeenCalledTimes(1); expect(getStats).toHaveBeenCalledTimes(1);
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42');
}); });
it('clears the cache and re-reads the size', async () => { it('clears the cache and re-reads the size', async () => {
await activate(); await settle();
getStats.mockResolvedValue({ entries: 0, bytes: 0 }); getStats.mockResolvedValue({ entries: 0, bytes: 0 });
clearButton().click(); clearButton().click();
@@ -101,7 +108,7 @@ describe('SettingsTmdbSectionComponent', () => {
}); });
it('surfaces a failed clear instead of claiming an empty cache', async () => { it('surfaces a failed clear instead of claiming an empty cache', async () => {
await activate(); await settle();
clear.mockResolvedValue(null); clear.mockResolvedValue(null);
clearButton().click(); clearButton().click();
@@ -116,9 +123,9 @@ describe('SettingsTmdbSectionComponent', () => {
}); });
it('surfaces a failed size read the same way', async () => { it('surfaces a failed size read the same way', async () => {
getStats.mockReset();
getStats.mockResolvedValue(null); getStats.mockResolvedValue(null);
await reopenSection();
await activate();
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( expect(queryByTestId('tmdb-cache-size')?.textContent).toContain(
CACHE_ERROR_LABEL CACHE_ERROR_LABEL
@@ -126,17 +133,18 @@ describe('SettingsTmdbSectionComponent', () => {
}); });
it('retries the size read next time the section is opened', async () => { it('retries the size read next time the section is opened', async () => {
getStats.mockReset();
getStats.mockResolvedValueOnce(null); getStats.mockResolvedValueOnce(null);
await activate(); getStats.mockResolvedValue({ entries: 42, bytes: 2048 });
await reopenSection();
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( expect(queryByTestId('tmdb-cache-size')?.textContent).toContain(
CACHE_ERROR_LABEL CACHE_ERROR_LABEL
); );
await activate('general'); // Otherwise a single transient failure sticks until the destructive
await activate(); // Clear button shifts it — reopening the page must retry instead
await reopenSection();
// Otherwise a single transient failure sticks for the life of the
// page, and only the destructive Clear button can shift it
expect(getStats).toHaveBeenCalledTimes(2); expect(getStats).toHaveBeenCalledTimes(2);
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42');
}); });
@@ -1,10 +1,8 @@
import { import {
Component, Component,
effect,
inject, inject,
input, input,
signal, signal,
untracked,
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, ReactiveFormsModule } from '@angular/forms';
@@ -84,7 +82,6 @@ export class SettingsTmdbSectionComponent {
private readonly tmdbCache = inject(TmdbCacheService); private readonly tmdbCache = inject(TmdbCacheService);
readonly form = input.required<FormGroup>(); readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly keyTestState = signal<TmdbKeyTestState>('idle'); readonly keyTestState = signal<TmdbKeyTestState>('idle');
readonly cacheStats = signal<TmdbCacheStats | null>(null); readonly cacheStats = signal<TmdbCacheStats | null>(null);
@@ -92,21 +89,13 @@ export class SettingsTmdbSectionComponent {
readonly isClearing = signal(false); readonly isClearing = signal(false);
constructor() { constructor() {
// Sizing the cache is a full table scan, so it waits until the // Sizing the cache is a full table scan, but this component only
// user is actually looking at this section. // exists while its section page is open, so loading on construction
effect(() => { // preserves the old "wait until the user is actually looking"
if (this.activeSection() !== 'tmdb') { // behaviour. A failed read leaves the signals in their "unknown"
return; // state and reopening the section recreates the component, which
} // retries naturally.
untracked(() => { void this.refreshCacheStats();
// A failed read leaves both signals in their "unknown"
// state, so reopening the section retries rather than
// showing the error until the page is rebuilt.
if (this.cacheStats() === null) {
void this.refreshCacheStats();
}
});
});
} }
get enteredApiKey(): string { get enteredApiKey(): string {
@@ -0,0 +1,86 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
/** What the user picked; closing via backdrop/Esc yields `undefined` = stay. */
export type SettingsUnsavedChangesChoice = 'save' | 'discard';
export interface SettingsUnsavedChangesDialogData {
/** Save-and-leave is offered only while the form can actually be saved. */
canSave: boolean;
}
/**
* Asked once, when the user is about to leave the settings area with staged
* edits. Section switches never show this — the shared form survives them.
* "Keep editing" carries the initial focus so Enter is always the safe
* choice.
*/
@Component({
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
styles: [
`
.unsaved-dialog__invalid-hint {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 0;
color: var(--mat-sys-error);
font-size: 0.86rem;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
`,
],
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<p>{{ 'SETTINGS.UNSAVED_DIALOG_MESSAGE' | translate }}</p>
@if (!data.canSave) {
<p class="unsaved-dialog__invalid-hint">
<mat-icon>error_outline</mat-icon>
<span>{{
'SETTINGS.UNSAVED_DIALOG_INVALID_HINT' | translate
}}</span>
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
</button>
<button
mat-button
mat-dialog-close
cdkFocusInitial
data-test-id="unsaved-dialog-stay"
>
{{ 'SETTINGS.UNSAVED_DIALOG_STAY' | translate }}
</button>
<button
mat-flat-button
color="primary"
[disabled]="!data.canSave"
[mat-dialog-close]="'save'"
data-test-id="unsaved-dialog-save"
>
{{ 'SETTINGS.UNSAVED_DIALOG_SAVE' | translate }}
</button>
</mat-dialog-actions>
`,
})
export class SettingsUnsavedChangesDialogComponent {
readonly data = inject<SettingsUnsavedChangesDialogData>(MAT_DIALOG_DATA);
}
@@ -0,0 +1,58 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import {
SettingsLeaveConfirmation,
settingsUnsavedChangesGuard,
} from './settings-unsaved-changes.guard';
describe('settingsUnsavedChangesGuard', () => {
const runGuard = (
nextUrl: string,
component: SettingsLeaveConfirmation
): Promise<boolean> | boolean =>
settingsUnsavedChangesGuard(
component,
{} as ActivatedRouteSnapshot,
{ url: '/workspace/settings/general' } as RouterStateSnapshot,
{ url: nextUrl } as RouterStateSnapshot
) as Promise<boolean> | boolean;
const componentSpy = (answer: boolean): SettingsLeaveConfirmation => ({
confirmLeaveWithUnsavedChanges: jest.fn().mockResolvedValue(answer),
});
it.each([
'/workspace/settings/playback',
'/workspace/settings/general?focus=theme',
'/workspace/settings',
])('lets section-internal navigation to %s pass silently', (url) => {
const component = componentSpy(false);
expect(runGuard(url, component)).toBe(true);
expect(component.confirmLeaveWithUnsavedChanges).not.toHaveBeenCalled();
});
it('asks the component when actually leaving the settings area', async () => {
const component = componentSpy(false);
await expect(runGuard('/workspace/dashboard', component)).resolves.toBe(
false
);
expect(component.confirmLeaveWithUnsavedChanges).toHaveBeenCalledTimes(
1
);
});
it('propagates an allowed leave', async () => {
await expect(
runGuard('/workspace/playlists/abc/all', componentSpy(true))
).resolves.toBe(true);
});
it('does not treat a lookalike prefix as inside the settings area', async () => {
const component = componentSpy(true);
await runGuard('/workspace/settingsish', component);
expect(component.confirmLeaveWithUnsavedChanges).toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { CanDeactivateFn } from '@angular/router';
/**
* Structural contract instead of importing the (lazy-loaded) settings
* component into the eager route config.
*/
export interface SettingsLeaveConfirmation {
confirmLeaveWithUnsavedChanges(): Promise<boolean> | boolean;
}
function isInsideSettingsArea(url: string): boolean {
const path = url.split('?')[0];
return (
path === '/workspace/settings' ||
path.startsWith('/workspace/settings/')
);
}
/**
* Guards only the EXIT from the settings area. Section switches
* (`/settings/general` → `/settings/playback`) re-run deactivation checks
* because the route's `:section` param changes, but they share the one
* settings form and can never lose staged edits — asking there would be
* pure nagging, so they pass unconditionally.
*/
export const settingsUnsavedChangesGuard: CanDeactivateFn<
SettingsLeaveConfirmation
> = (component, _currentRoute, _currentState, nextState) => {
if (isInsideSettingsArea(nextState.url)) {
return true;
}
return component.confirmLeaveWithUnsavedChanges();
};
@@ -18,6 +18,7 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
MatSnackBarStub, MatSnackBarStub,
MockSettingsStore, MockSettingsStore,
setSettingsSection,
stubSettingsSideEffects, stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub'; } from './test-stubs/settings-test-harness.stub';
@@ -111,7 +112,7 @@ describe('SettingsComponent form', () => {
expect(component.settingsForm.dirty).toBeTruthy(); expect(component.settingsForm.dirty).toBeTruthy();
}); });
it('updates cover size through the general section output', () => { it('stages cover size without writing to the store until Save', () => {
const largeCoverButton = ( const largeCoverButton = (
fixture.nativeElement as HTMLElement fixture.nativeElement as HTMLElement
).querySelector( ).querySelector(
@@ -122,12 +123,15 @@ describe('SettingsComponent form', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.settingsForm.value.coverSize).toBe('large'); expect(component.settingsForm.value.coverSize).toBe('large');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({ expect(component.settingsForm.dirty).toBe(true);
coverSize: 'large', // An eager write here would make Discard unable to revert it
}); expect(settingsStore.updateSettings).not.toHaveBeenCalled();
}); });
it('updates the EPG view mode through the epg section output', () => { it('stages the EPG view mode without writing to the store until Save', () => {
setSettingsSection('epg');
fixture.detectChanges();
const listButton = ( const listButton = (
fixture.nativeElement as HTMLElement fixture.nativeElement as HTMLElement
).querySelector( ).querySelector(
@@ -138,14 +142,16 @@ describe('SettingsComponent form', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(component.settingsForm.value.epgViewMode).toBe('list'); expect(component.settingsForm.value.epgViewMode).toBe('list');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({ expect(component.settingsForm.dirty).toBe(true);
epgViewMode: 'list', expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
}); });
}); });
describe('Dashboard controls', () => { describe('Dashboard controls', () => {
it('renders dashboard controls with the expected defaults', () => { it('renders dashboard controls with the expected defaults', () => {
setSettingsSection('dashboard');
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement; const nativeElement = fixture.nativeElement as HTMLElement;
expect( expect(
@@ -208,6 +214,78 @@ describe('SettingsComponent form', () => {
}); });
}); });
describe('Unsaved-changes bar', () => {
const unsavedBar = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="settings-unsaved-bar"]'
);
it('stays hidden while the form is pristine', () => {
expect(unsavedBar()).toBeNull();
});
it('appears once an edit lands and survives a section switch', () => {
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
fixture.detectChanges();
expect(unsavedBar()).not.toBeNull();
// The form lives on the page component, not the section pages —
// moving to another section must not swallow the pending edit.
setSettingsSection('about');
fixture.detectChanges();
expect(unsavedBar()).not.toBeNull();
expect(component.settingsForm.dirty).toBe(true);
});
it('hides again after a successful save', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
fixture.detectChanges();
component.onSubmit();
await fixture.whenStable();
fixture.detectChanges();
expect(component.settingsForm.pristine).toBe(true);
expect(unsavedBar()).toBeNull();
});
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
const largeCoverButton = (
fixture.nativeElement as HTMLElement
).querySelector(
'[data-test-id="cover-size-large"]'
) as HTMLButtonElement;
largeCoverButton.click();
fixture.detectChanges();
expect(component.settingsForm.value.coverSize).toBe('large');
component.discardChanges();
expect(component.settingsForm.value.coverSize).toBe('medium');
expect(component.settingsForm.pristine).toBe(true);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
it('discard reverts to the stored values and hides the bar', () => {
const storedTheme = component.settingsForm.value.theme;
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
fixture.detectChanges();
component.discardChanges();
fixture.detectChanges();
expect(component.settingsForm.value.theme).toBe(storedTheme);
expect(component.settingsForm.pristine).toBe(true);
expect(unsavedBar()).toBeNull();
});
});
describe('Saving', () => { describe('Saving', () => {
it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => { it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => {
jest.spyOn(translate, 'instant').mockReturnValue('Settings saved'); jest.spyOn(translate, 'instant').mockReturnValue('Settings saved');
+133 -132
View File
@@ -1,13 +1,4 @@
@if (isDialog) { <div class="settings-container" data-test-id="settings-container">
<h2 mat-dialog-title>{{ 'SETTINGS.GENERAL' | translate }}</h2>
}
<div
class="settings-container"
data-test-id="settings-container"
[class.dialog-mode]="isDialog"
appSettingsSectionScroll
>
<form <form
[formGroup]="settingsForm" [formGroup]="settingsForm"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
@@ -16,137 +7,147 @@
> >
<!-- Page-level "Settings" h1 intentionally removed: the left rail <!-- Page-level "Settings" h1 intentionally removed: the left rail
already labels the page "Settings" (sg-nav-title), and each already labels the page "Settings" (sg-nav-title), and each
section below provides its own h3 + subtitle. Stacking another section below provides its own h3 + subtitle. -->
h1 in the right pane reproduced the audit's "vertical ladder <span class="visually-hidden" data-test-id="settings-page-header">
of identical labels" complaint. --> {{ 'SETTINGS.TITLE' | translate }}
@if (!isDialog) { </span>
<!-- Reserved hook so the page test selector still resolves,
even though there's no visible chrome here. -->
<span
class="visually-hidden"
data-test-id="settings-page-header"
>
{{ 'SETTINGS.TITLE' | translate }}
</span>
}
<div class="settings-content"> <div class="settings-content">
<app-settings-general-section <!-- Only the routed section renders. The parent keeps the shared
[form]="settingsForm" form alive across section switches, so edits staged on one
[activeSection]="activeSection()" page survive a visit to another. -->
[languageEnum]="languageEnum" @switch (activeSection()) {
[themeOptions]="themeOptions" @case ('playback') {
[coverSizeOptions]="coverSizeOptions" <app-settings-playback-section
[startupBehaviorOptions]="startupBehaviorOptions" [form]="settingsForm"
(selectTheme)="form.selectTheme($event)" [players]="players()"
(selectCoverSize)="form.selectCoverSize($event)" [streamFormatEnum]="streamFormatEnum"
/> [isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="
<app-settings-playback-section supportsManagedExternalPlayers
[form]="settingsForm" "
[activeSection]="activeSection()" [supportsExternalPlayerPathSettings]="
[players]="players()" supportsExternalPlayerPathSettings
[streamFormatEnum]="streamFormatEnum" "
[isDesktop]="isDesktop" [supportsVodMultiSource]="supportsVodMultiSource"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers" [frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings" [frameCopyActive]="embeddedMpv.frameCopyActive()"
[supportsVodMultiSource]="supportsVodMultiSource" (selectRecordingFolder)="selectRecordingFolder()"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()" />
[frameCopyActive]="embeddedMpv.frameCopyActive()" }
(selectRecordingFolder)="selectRecordingFolder()" @case ('epg') {
/> <app-settings-epg-section
[form]="settingsForm"
@if (supportsEpg) { [epgUrl]="form.epgUrl"
<app-settings-epg-section [isClearingEpgData]="epg.isClearing()"
[form]="settingsForm" [epgViewModeOptions]="epgViewModeOptions"
[activeSection]="activeSection()" (refreshEpg)="epg.refresh($event)"
[epgUrl]="form.epgUrl" (removeEpgSource)="form.removeEpgSource($event)"
[isClearingEpgData]="epg.isClearing()" (addEpgSource)="form.addEpgSource()"
[epgViewModeOptions]="epgViewModeOptions" (refreshAllEpg)="epg.refreshAll()"
(refreshEpg)="epg.refresh($event)" (clearEpgData)="epg.clear()"
(removeEpgSource)="form.removeEpgSource($event)" (selectEpgViewMode)="form.selectEpgViewMode($event)"
(addEpgSource)="form.addEpgSource()" />
(refreshAllEpg)="epg.refreshAll()" }
(clearEpgData)="epg.clear()" @case ('dashboard') {
(selectEpgViewMode)="form.selectEpgViewMode($event)" <app-settings-dashboard-section [form]="settingsForm" />
/> }
@case ('remote-control') {
<app-settings-remote-control-section
[form]="settingsForm"
[localIpAddresses]="remoteControl.localIpAddresses()"
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
(toggleQrCode)="remoteControl.toggleQrCode($event)"
/>
}
@case ('tmdb') {
<app-settings-tmdb-section [form]="settingsForm" />
}
@case ('backup') {
<app-settings-backup-section
[isPwa]="isPwa"
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[isExportingData]="backup.isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
}
@case ('reset') {
<app-settings-reset-section
[isRemovingAllPlaylists]="
playlistReset.isRemovingAllPlaylists()
"
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
[playlistDeleteSummary]="playlistReset.deleteSummary()"
[removeAllProgressLabel]="
playlistReset.removeAllProgressLabel()
"
(removeAll)="removeAll()"
/>
}
@case ('about') {
<app-settings-about-section
[isDesktop]="isDesktop"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="appUpdate.updateMessage()"
[appUpdateStatus]="appUpdate.status()"
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
(installAppUpdate)="appUpdate.installAppUpdate()"
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
(openAppUpdateReleaseNotes)="
appUpdate.openReleaseNotes()
"
/>
}
@default {
<app-settings-general-section
[form]="settingsForm"
[languageEnum]="languageEnum"
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
}
} }
</div>
<app-settings-dashboard-section <!-- Discord-style unsaved-changes bar: appears the moment any edit
[form]="settingsForm" makes the form dirty — on whichever section page — and vanishes
[activeSection]="activeSection()" once saved or discarded. It replaces the always-visible footer
/> Save button of the single-scroll layout. -->
@if (!settingsForm.pristine) {
@if (supportsRemoteControl) { <footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
<app-settings-remote-control-section <div class="unsaved-bar__message">
[form]="settingsForm" <mat-icon>edit</mat-icon>
[activeSection]="activeSection()" <span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
[localIpAddresses]="remoteControl.localIpAddresses()" </div>
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()" <div class="unsaved-bar__actions">
(toggleQrCode)="remoteControl.toggleQrCode($event)"
/>
}
<app-settings-tmdb-section
[form]="settingsForm"
[activeSection]="activeSection()"
/>
<app-settings-backup-section
[activeSection]="activeSection()"
[isPwa]="isPwa"
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
[isExportingData]="backup.isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
<app-settings-reset-section
[activeSection]="activeSection()"
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
[playlistDeleteSummary]="playlistReset.deleteSummary()"
[removeAllProgressLabel]="playlistReset.removeAllProgressLabel()"
(removeAll)="removeAll()"
/>
<app-settings-about-section
[activeSection]="activeSection()"
[isDesktop]="isDesktop"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="appUpdate.updateMessage()"
[appUpdateStatus]="appUpdate.status()"
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
(installAppUpdate)="appUpdate.installAppUpdate()"
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
(openAppUpdateReleaseNotes)="appUpdate.openReleaseNotes()"
/>
<footer class="action-bar" data-test-id="settings-action-bar">
@if (isDialog) {
<button <button
mat-button mat-button
type="button" type="button"
(click)="backToHome()" (click)="discardChanges()"
data-test-id="back-to-home" data-test-id="discard-settings"
> >
<mat-icon>close</mat-icon> {{ 'SETTINGS.DISCARD_CHANGES' | translate }}
{{ 'CLOSE' | translate }}
</button> </button>
} <button
<button mat-flat-button
mat-flat-button type="submit"
type="submit" class="settings-save-button"
class="settings-save-button" [disabled]="!settingsForm.valid"
[disabled]="settingsForm.pristine || !settingsForm.valid" data-test-id="save-settings"
data-test-id="save-settings" >
> <mat-icon>save</mat-icon>
<mat-icon>save</mat-icon> {{ 'SETTINGS.SAVE_CHANGES' | translate }}
{{ 'SETTINGS.SAVE_CHANGES' | translate }} </button>
</button> </div>
</footer> </footer>
</div> }
</form> </form>
</div> </div>
@@ -8,13 +8,6 @@
padding: 18px 20px calc(36px + var(--settings-safe-bottom)); padding: 18px 20px calc(36px + var(--settings-safe-bottom));
} }
.settings-container.dialog-mode {
height: auto;
max-height: none;
overflow: visible;
padding: 12px 8px calc(24px + var(--settings-safe-bottom));
}
.settings-layout { .settings-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -27,36 +20,6 @@
); );
} }
.settings-container.dialog-mode .settings-layout {
padding-bottom: 0;
}
.settings-page-header {
padding: 14px 18px 8px;
background: transparent;
border: 0;
box-shadow: none;
}
.settings-page-header__text {
max-width: 56ch;
h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
line-height: 1.08;
letter-spacing: -0.03em;
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--settings-muted);
font-size: 0.86rem;
line-height: 1.45;
}
}
.settings-content { .settings-content {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -86,16 +49,6 @@
} }
} }
// The active section no longer gets a ring/shadow — the rail's active
// state on the left already announces "you are here". Title still
// colours to the accent so users scanning the right pane don't lose the
// anchor.
.settings-group--active {
.settings-group__header-text h3 {
color: var(--app-selection-color);
}
}
.settings-group__header { .settings-group__header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -738,13 +691,11 @@
); );
} }
// Sticky-footer Save bar — full content width so it never overlaps form // Unsaved-changes bar — only present while the form is dirty, so unlike the
// rows. The previous chip-style bar (border-radius:18px, margin-left:auto) // previous always-visible footer Save button it doubles as the "you edited
// floated in the bottom-right corner and clipped fields underneath; the // something on another section page" reminder. Sticky rather than fixed so
// UX audit called this out as the worst of three available patterns. // it stays inside the settings column and never covers the shell rail.
// Now the bar acts as a proper footer: spans the column, separator line .unsaved-bar {
// on top, sits flush with the bottom edge while still sticking on scroll.
.action-bar {
position: sticky; position: sticky;
bottom: calc( bottom: calc(
var(--settings-action-bar-offset) + var(--settings-safe-bottom) var(--settings-action-bar-offset) + var(--settings-safe-bottom)
@@ -756,7 +707,7 @@
box-sizing: border-box; box-sizing: border-box;
z-index: 100; z-index: 100;
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
@@ -769,17 +720,45 @@
background: var(--settings-action-bar-bg); background: var(--settings-action-bar-bg);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
box-shadow: var(--settings-action-bar-shadow); box-shadow: var(--settings-action-bar-shadow);
animation: unsaved-bar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
@media (prefers-reduced-motion: reduce) {
animation: none;
}
} }
.settings-container.dialog-mode .action-bar { @keyframes unsaved-bar-enter {
position: static; from {
margin-top: 12px; opacity: 0;
margin-bottom: 2px; transform: translateY(8px);
box-shadow: none; }
border-radius: 16px;
padding: 12px; to {
border-top: 1px solid var(--settings-action-bar-border); opacity: 1;
justify-content: space-between; transform: translateY(0);
}
}
.unsaved-bar__message {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--settings-muted);
font-size: 0.88rem;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.unsaved-bar__actions {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
} }
.settings-save-button.mat-mdc-button-base { .settings-save-button.mat-mdc-button-base {
@@ -882,13 +861,15 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.action-bar { .unsaved-bar {
position: static; flex-direction: column;
flex-direction: column-reverse;
align-items: stretch; align-items: stretch;
border-radius: 16px; border-radius: 16px;
box-shadow: none; }
width: 100%;
.unsaved-bar__actions {
flex-direction: column-reverse;
align-items: stretch;
margin-left: 0; margin-left: 0;
button { button {
@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { import {
@@ -15,15 +17,17 @@ import {
createEpgBridgeStub, createEpgBridgeStub,
createPlaylistMeta, createPlaylistMeta,
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
MockRouter,
setSettingsSection,
stubSettingsSideEffects, stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub'; } from './test-stubs/settings-test-harness.stub';
/** /**
* Page-shell behaviour: chrome, the facade lifecycle and the runtime * Page-shell behaviour: chrome, the `:section` page routing, the facade
* capabilities that decide which sections and players are offered. Form * lifecycle and the runtime capabilities that decide which sections and
* editing and saving live in `settings.component.form.spec.ts`, section * players are offered. Form editing and saving live in
* scrolling in `settings-section-scroll.directive.spec.ts`, and the * `settings.component.form.spec.ts`, and the per-section behaviour in the
* per-section behaviour in the matching `*.facade.spec.ts` files. * matching `*.facade.spec.ts` files.
*/ */
describe('SettingsComponent', () => { describe('SettingsComponent', () => {
let component: SettingsComponent; let component: SettingsComponent;
@@ -102,7 +106,7 @@ describe('SettingsComponent', () => {
expect(appUpdateDispose).toHaveBeenCalledTimes(1); expect(appUpdateDispose).toHaveBeenCalledTimes(1);
}); });
it('should render a compact page header outside dialog mode', () => { it('should render the hidden page header hook', () => {
const nativeElement = fixture.nativeElement as HTMLElement; const nativeElement = fixture.nativeElement as HTMLElement;
expect( expect(
@@ -111,32 +115,117 @@ describe('SettingsComponent', () => {
expect(nativeElement.querySelector('.settings-intro')).toBeNull(); expect(nativeElement.querySelector('.settings-intro')).toBeNull();
}); });
it('should not render the page header in dialog mode', () => { describe('Section pages', () => {
fixture.destroy(); it('renders only the section named by the route param', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
const dialogFixture = TestBed.createComponent(SettingsComponent); expect(
const dialogComponent = dialogFixture.componentInstance; nativeElement.querySelector('app-settings-general-section')
).not.toBeNull();
expect(
nativeElement.querySelector('app-settings-playback-section')
).toBeNull();
stubSettingsSideEffects(dialogComponent); setSettingsSection('playback');
dialogComponent.isDialog = true; fixture.detectChanges();
dialogFixture.detectChanges();
const nativeElement = dialogFixture.nativeElement as HTMLElement; expect(
expect( nativeElement.querySelector('app-settings-general-section')
nativeElement.querySelector('[data-test-id="settings-page-header"]') ).toBeNull();
).toBeNull(); expect(
expect( nativeElement.querySelector('app-settings-playback-section')
nativeElement.querySelector('h2[mat-dialog-title]') ).not.toBeNull();
).not.toBeNull(); });
it('falls back to the general page and rewrites unknown section URLs', () => {
const navigate = (router as unknown as MockRouter).navigate;
setSettingsSection('nonsense');
fixture.detectChanges();
expect(component.activeSection()).toBe('general');
expect(navigate).toHaveBeenCalledWith(
['/workspace/settings', 'general'],
{ replaceUrl: true }
);
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'app-settings-general-section'
)
).not.toBeNull();
});
}); });
it('should navigate back to home page', () => { describe('Leaving with unsaved changes', () => {
jest.spyOn(router, 'navigateByUrl'); const answerDialogWith = (
component.backToHome(); choice: 'save' | 'discard' | undefined
expect(router.navigateByUrl).toHaveBeenCalledWith('/'); ): jest.Mock => {
const open = TestBed.inject(MatDialog).open as jest.Mock;
open.mockReturnValue({ afterClosed: () => of(choice) });
return open;
};
it('lets a pristine form leave without asking', async () => {
const open = answerDialogWith(undefined);
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(open).not.toHaveBeenCalled();
});
it('stays when the dialog is dismissed', async () => {
component.settingsForm.markAsDirty();
answerDialogWith(undefined);
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(false);
expect(component.settingsForm.dirty).toBe(true);
});
it('discard-and-leave reverts the staged edits', async () => {
component.settingsForm.get('theme')?.setValue('DARK_THEME');
component.settingsForm.markAsDirty();
answerDialogWith('discard');
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(component.settingsForm.pristine).toBe(true);
});
it('save-and-leave persists before allowing the navigation', async () => {
component.settingsForm.markAsDirty();
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
answerDialogWith('save');
await expect(
component.confirmLeaveWithUnsavedChanges()
).resolves.toBe(true);
expect(component.settingsForm.pristine).toBe(true);
});
it('offers save-and-leave only while the form is valid', async () => {
component.settingsForm.markAsDirty();
component.settingsForm.setErrors({ invalid: true });
const open = answerDialogWith(undefined);
await component.confirmLeaveWithUnsavedChanges();
expect(open).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
data: { canSave: false },
})
);
});
}); });
it('enables the global wipe action only once a playlist exists', () => { it('enables the global wipe action only once a playlist exists', () => {
setSettingsSection('reset');
fixture.detectChanges();
const deleteButton = () => const deleteButton = () =>
(fixture.nativeElement as HTMLElement).querySelector( (fixture.nativeElement as HTMLElement).querySelector(
'.danger-zone__button' '.danger-zone__button'
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '../services/settings-store.service'; import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component'; import { SettingsComponent } from './settings.component';
@@ -18,14 +19,6 @@ const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
panelClass: ['settings-snackbar', 'settings-snackbar--error'], panelClass: ['settings-snackbar', 'settings-snackbar--error'],
}); });
/**
* Assertions target the MatDialog the component actually calls: spying on the
* one returned by `TestBed.inject(MatDialog)` does not observe it.
*/
interface SettingsComponentPrivateTestApi {
matDialog: MatDialog;
}
/** /**
* Settings live in the renderer's IndexedDB, and both halves of the round trip * Settings live in the renderer's IndexedDB, and both halves of the round trip
* can fail while the UI keeps looking healthy: a failed read shows defaults as * can fail while the UI keeps looking healthy: a failed read shows defaults as
@@ -39,8 +32,6 @@ describe('SettingsComponent storage failures', () => {
let snackBar: MatSnackBarStub; let snackBar: MatSnackBarStub;
let epgBridge: Partial<EpgRuntimeBridgeService>; let epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron; const originalElectron = window.electron;
const componentMatDialog = (): MatDialog =>
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
epgBridge = createEpgBridgeStub(); epgBridge = createEpgBridgeStub();
@@ -77,12 +68,11 @@ describe('SettingsComponent storage failures', () => {
); );
}); });
it('warns and keeps the dialog open when the settings write fails', async () => { it('warns and keeps the form dirty when the settings write fails', async () => {
settingsStore.updateSettings.mockRejectedValue( settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable') new Error('storage unavailable')
); );
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); component.settingsForm.markAsDirty();
component.isDialog = true;
component.onSubmit(); component.onSubmit();
await fixture.whenStable(); await fixture.whenStable();
@@ -92,37 +82,47 @@ describe('SettingsComponent storage failures', () => {
'CLOSE', 'CLOSE',
ERROR_SNACKBAR_CONFIG ERROR_SNACKBAR_CONFIG
); );
expect(closeAll).not.toHaveBeenCalled(); // The unsaved-changes bar keys off the dirty state, so the failed
// write must not mark the form pristine — that is what keeps the
// retry path on screen.
expect(component.settingsForm.dirty).toBe(true);
}); });
it('closes the dialog only after the settings write succeeded', async () => { it('marks the form pristine only after the settings write succeeded', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined); settingsStore.updateSettings.mockResolvedValue(undefined);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
component.isDialog = true; component.settingsForm.markAsDirty();
component.onSubmit(); component.onSubmit();
// save() resolves a tick after the store write: the callback and // save() resolves a tick after the store write: the callback and
// the Electron mirror run first, so the close lands on the next // the Electron mirror run first, so the pristine flip lands on the
// turn of the microtask queue. // next turn of the microtask queue.
await fixture.whenStable(); await fixture.whenStable();
await fixture.whenStable(); await fixture.whenStable();
expect(closeAll).toHaveBeenCalledTimes(1); expect(component.settingsForm.pristine).toBe(true);
}); });
it('warns when a section write fails without leaving an unhandled rejection', async () => { it('keeps the user in settings when save-and-leave cannot persist', async () => {
settingsStore.updateSettings.mockRejectedValue( settingsStore.updateSettings.mockRejectedValue(
new Error('storage unavailable') new Error('storage unavailable')
); );
component.settingsForm.markAsDirty();
(TestBed.inject(MatDialog).open as jest.Mock).mockReturnValue({
afterClosed: () => of('save'),
});
component.form.selectCoverSize('large'); // Allowing the navigation here would silently drop the edits the
await fixture.whenStable(); // dialog just promised to save.
await expect(component.confirmLeaveWithUnsavedChanges()).resolves.toBe(
false
);
expect(snackBar.open).toHaveBeenCalledWith( expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED', 'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE', 'CLOSE',
ERROR_SNACKBAR_CONFIG ERROR_SNACKBAR_CONFIG
); );
expect(component.settingsForm.dirty).toBe(true);
}); });
}); });
+145 -57
View File
@@ -1,27 +1,25 @@
import { CommonModule } from '@angular/common';
import { import {
Component, Component,
computed, computed,
effect,
ElementRef,
inject, inject,
Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
ViewEncapsulation, ViewEncapsulation,
} from '@angular/core'; } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import { MatDialog } from '@angular/material/dialog';
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access'; import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces'; import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { firstValueFrom, map } from 'rxjs';
import { BUILD_COMMIT } from '../../environments/build-commit'; import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component'; import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade'; import { SettingsAppUpdateFacade } from './settings-app-update.facade';
@@ -45,17 +43,28 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co
import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
import {
SettingsUnsavedChangesChoice,
SettingsUnsavedChangesDialogComponent,
} from './settings-unsaved-changes-dialog.component';
import { SettingsLeaveConfirmation } from './settings-unsaved-changes.guard';
import { SettingsBackupFacade } from './settings-backup.facade'; import { SettingsBackupFacade } from './settings-backup.facade';
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade'; import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
import { SettingsSnackbarService } from './settings-snackbar.service'; import { SettingsSnackbarService } from './settings-snackbar.service';
export const SETTINGS_DEFAULT_SECTION = 'general';
/** /**
* Thin coordinator for the settings page. The behaviour of each section lives * Thin coordinator for the settings page. The behaviour of each section lives
* in a dedicated facade the template binds to directly; what stays here is the * in a dedicated facade the template binds to directly; what stays here is the
* page-level wiring: environment capabilities, section navigation, and the * page-level wiring: environment capabilities, the `:section` route param that
* save/close flow that spans several facades. * selects which section page renders, and the save/discard flow that spans
* several facades.
*
* Routed as `/workspace/settings/:section`. Navigating between sections only
* changes the param — the component instance (and with it the form and its
* dirty state) survives until the user leaves settings entirely.
*/ */
@Component({ @Component({
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
@@ -65,12 +74,10 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
}, },
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
imports: [ imports: [
CommonModule,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
ReactiveFormsModule, ReactiveFormsModule,
TranslateModule, TranslateModule,
MatDialogModule,
SettingsAboutSectionComponent, SettingsAboutSectionComponent,
SettingsBackupSectionComponent, SettingsBackupSectionComponent,
SettingsDashboardSectionComponent, SettingsDashboardSectionComponent,
@@ -79,7 +86,6 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
SettingsPlaybackSectionComponent, SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent, SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent, SettingsResetSectionComponent,
SettingsSectionScrollDirective,
SettingsTmdbSectionComponent, SettingsTmdbSectionComponent,
], ],
providers: [ providers: [
@@ -93,7 +99,9 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
SettingsSnackbarService, SettingsSnackbarService,
], ],
}) })
export class SettingsComponent implements OnInit, OnDestroy { export class SettingsComponent
implements OnInit, OnDestroy, SettingsLeaveConfirmation
{
readonly appUpdate = inject(SettingsAppUpdateFacade); readonly appUpdate = inject(SettingsAppUpdateFacade);
readonly backup = inject(SettingsBackupFacade); readonly backup = inject(SettingsBackupFacade);
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade); readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
@@ -107,14 +115,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService); private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService); private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly matDialog = inject(MatDialog); private readonly matDialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly dialogData = inject<{ isDialog: boolean } | null>( private readonly hostElement = inject(ElementRef<HTMLElement>);
MAT_DIALOG_DATA,
{ optional: true }
);
@Input() isDialog = this.dialogData?.isDialog ?? false;
/** List with available languages as enum */ /** List with available languages as enum */
readonly languageEnum = Language; readonly languageEnum = Language;
@@ -134,8 +138,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable; readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl; readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly activeSection = this.settingsCtx.activeSection;
/** Settings form object */ /** Settings form object */
readonly settingsForm = this.form.form; readonly settingsForm = this.form.form;
@@ -164,6 +166,49 @@ export class SettingsComponent implements OnInit, OnDestroy {
return this.sectionNavItems.filter((section) => section.visible); return this.sectionNavItems.filter((section) => section.visible);
} }
private readonly sectionParam = toSignal(
this.route.paramMap.pipe(map((params) => params.get('section'))),
{ initialValue: null }
);
/**
* Section page currently rendered. Unknown or capability-gated params
* fall back to the default section while the redirect effect below
* rewrites the URL to match.
*/
readonly activeSection = computed(() => {
const section = this.sectionParam();
return section && this.isNavigableSection(section)
? section
: SETTINGS_DEFAULT_SECTION;
});
constructor() {
// A stale or hand-typed URL (`/settings/epg` in a runtime without
// EPG support, `/settings/nonsense`) must not leave the address bar
// lying about what is on screen.
effect(() => {
const section = this.sectionParam();
if (section && !this.isNavigableSection(section)) {
void this.router.navigate(
['/workspace/settings', SETTINGS_DEFAULT_SECTION],
{ replaceUrl: true }
);
}
});
// Section pages share one scroll container (`main.workspace-content`);
// without this, opening a long section, scrolling, and switching to a
// short one strands the viewport past the new page's content. Instant
// on purpose — this is navigation, not an animated transition.
effect(() => {
this.activeSection();
this.hostElement.nativeElement
.closest('main.workspace-content')
?.scrollTo({ top: 0 });
});
}
/** /**
* Reads the config object from the browsers * Reads the config object from the browsers
* storage (indexed db) * storage (indexed db)
@@ -178,9 +223,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.appUpdate.init(); this.appUpdate.init();
void this.remoteControl.fetchLocalIpAddresses(); void this.remoteControl.fetchLocalIpAddresses();
if (!this.isDialog) { this.settingsCtx.setSections(this.sectionNav);
this.settingsCtx.setSections(this.sectionNav);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -202,26 +245,78 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store * the indexed db store
*/ */
onSubmit(): void { onSubmit(): void {
this.form void this.persistSettings();
.save(() => this.applyChangedSettings()) }
.then(() => {
if (this.isDialog) { /**
this.matDialog.closeAll(); * Exit gate for `settingsUnsavedChangesGuard`: silently allows leaving
} * while the form is pristine, otherwise lets the user save, discard, or
}) * stay. A failed save keeps the user in settings — navigating away on a
.catch(() => { * write that did not land would silently lose the edits the dialog just
// The store already applied the change in memory, so without * promised to keep.
// this the save looks successful until the next restart. The */
// dialog stays open so it can be retried. async confirmLeaveWithUnsavedChanges(): Promise<boolean> {
// if (this.settingsForm.pristine) {
// The Electron-side pushes in SettingsFormFacade.save() stay in return true;
// the success branch on purpose: main keeps its own copy of the }
// player paths and remote-control state, and applying half the
// form while telling the user nothing was saved is worse than const choice = await firstValueFrom(
// applying none of it. Once settings live in the main process this.matDialog
// (issue #1273) this split disappears. .open<
this.settingsSnackbar.storageFailure('save'); SettingsUnsavedChangesDialogComponent,
}); { canSave: boolean },
SettingsUnsavedChangesChoice
>(SettingsUnsavedChangesDialogComponent, {
width: '440px',
data: { canSave: this.settingsForm.valid },
})
.afterClosed()
);
if (choice === 'save') {
return this.persistSettings();
}
if (choice === 'discard') {
// Also reverts the live theme preview — leaving must not keep a
// theme the store never saved.
this.discardChanges();
return true;
}
return false;
}
/** @returns whether the write actually landed */
private async persistSettings(): Promise<boolean> {
try {
await this.form.save(() => this.applyChangedSettings());
return true;
} catch {
// The store already applied the change in memory, so without
// this the save looks successful until the next restart. The
// unsaved-changes bar stays visible so it can be retried.
//
// The Electron-side pushes in SettingsFormFacade.save() stay in
// the success branch on purpose: main keeps its own copy of the
// player paths and remote-control state, and applying half the
// form while telling the user nothing was saved is worse than
// applying none of it. Once settings live in the main process
// (issue #1273) this split disappears.
this.settingsSnackbar.storageFailure('save');
return false;
}
}
/**
* Throws away every staged form edit and returns to the persisted
* settings. `applySavedSettings` also reverts the live theme preview
* (`selectTheme` applies immediately) and marks the form pristine, which
* hides the unsaved-changes bar.
*/
discardChanges(): void {
this.form.hydrateFromStore();
this.form.applySavedSettings();
} }
/** /**
@@ -235,17 +330,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
); );
} }
/**
* Navigates back to the applications homepage
*/
backToHome(): void {
if (this.isDialog) {
this.matDialog.closeAll();
} else {
this.router.navigateByUrl('/');
}
}
async exportData(): Promise<void> { async exportData(): Promise<void> {
await this.backup.exportData(() => this.waitForUiFeedbackFrame()); await this.backup.exportData(() => this.waitForUiFeedbackFrame());
} }
@@ -260,6 +344,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
); );
} }
private isNavigableSection(sectionId: string): boolean {
return this.sectionNav.some((section) => section.id === sectionId);
}
/** /**
* Lets the browser paint the pending busy state before a long running * Lets the browser paint the pending busy state before a long running
* task blocks the main thread. * task blocks the main thread.
@@ -13,11 +13,6 @@ export interface SettingsSection {
visible: boolean; visible: boolean;
} }
export interface ObservedSettingsSection {
id: string;
element: HTMLElement;
}
export interface ThemeOption { export interface ThemeOption {
value: Theme; value: Theme;
icon: string; icon: string;
@@ -16,7 +16,12 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Router } from '@angular/router'; import {
ActivatedRoute,
convertToParamMap,
ParamMap,
Router,
} from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { import {
EpgRuntimeBridgeService, EpgRuntimeBridgeService,
@@ -47,7 +52,7 @@ import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { MockModule, MockProvider } from 'ng-mocks'; import { MockModule, MockProvider } from 'ng-mocks';
import { NgxIndexedDBService } from 'ngx-indexed-db'; import { NgxIndexedDBService } from 'ngx-indexed-db';
import { from, of } from 'rxjs'; import { BehaviorSubject, from, of } from 'rxjs';
import { ElectronServiceStub } from '../../services/electron.service.stub'; import { ElectronServiceStub } from '../../services/electron.service.stub';
import { SettingsStorageFailure } from '@iptvnator/services'; import { SettingsStorageFailure } from '@iptvnator/services';
import { SettingsStore } from '../../services/settings-store.service'; import { SettingsStore } from '../../services/settings-store.service';
@@ -121,11 +126,36 @@ export class MatSnackBarStub {
} }
export class MockRouter { export class MockRouter {
navigate = jest.fn().mockResolvedValue(true);
navigateByUrl(url: string): string { navigateByUrl(url: string): string {
return url; return url;
} }
} }
/**
* Stands in for the `:section` route param the settings page renders from.
* Specs switch section pages with `setSettingsSection` below.
*/
export class MockActivatedRoute {
private readonly params = new BehaviorSubject<ParamMap>(
convertToParamMap({ section: 'general' })
);
readonly paramMap = this.params.asObservable();
setSection(section: string): void {
this.params.next(convertToParamMap({ section }));
}
}
/** Routes the rendered settings page to the given section page. */
export function setSettingsSection(section: string): void {
(
TestBed.inject(ActivatedRoute) as unknown as MockActivatedRoute
).setSection(section);
}
export class MockSettingsStore { export class MockSettingsStore {
private _settings = signal(DEFAULT_SETTINGS); private _settings = signal(DEFAULT_SETTINGS);
@@ -273,6 +303,7 @@ export function settingsTestProviders(
{ provide: MatSnackBar, useClass: MatSnackBarStub }, { provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub }, { provide: DataService, useClass: ElectronServiceStub },
{ provide: Router, useClass: MockRouter }, { provide: Router, useClass: MockRouter },
{ provide: ActivatedRoute, useClass: MockActivatedRoute },
provideMockStore({ provideMockStore({
selectors: [ selectors: [
{ selector: selectAllPlaylistsMeta, value: [] }, { selector: selectAllPlaylistsMeta, value: [] },
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا", "VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا",
"VERSION": "الإصدار", "VERSION": "الإصدار",
"SAVE_CHANGES": "حفظ التغييرات", "SAVE_CHANGES": "حفظ التغييرات",
"UNSAVED_CHANGES": "لديك تغييرات غير محفوظة",
"DISCARD_CHANGES": "تجاهل",
"UNSAVED_DIALOG_TITLE": "تغييرات غير محفوظة",
"UNSAVED_DIALOG_MESSAGE": "لديك تغييرات غير محفوظة في الإعدادات. هل تريد حفظها قبل المغادرة؟",
"UNSAVED_DIALOG_SAVE": "احفظ وغادر",
"UNSAVED_DIALOG_DISCARD": "غادر دون حفظ",
"UNSAVED_DIALOG_STAY": "متابعة التحرير",
"UNSAVED_DIALOG_INVALID_HINT": "بعض الحقول غير صالحة، لذلك لا يمكن حفظ التغييرات بعد.",
"BACK_TO_HOME": "العودة للرئيسية", "BACK_TO_HOME": "العودة للرئيسية",
"NEW_VERSION_AVAILABLE": "إصدار جديد متاح", "NEW_VERSION_AVAILABLE": "إصدار جديد متاح",
"LATEST_VERSION": "أنت تستخدم الإصدار الأحدث", "LATEST_VERSION": "أنت تستخدم الإصدار الأحدث",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "اختار خيار", "VIDEO_PLAYER_PLACEHOLDER": "اختار خيار",
"VERSION": "الإصدار", "VERSION": "الإصدار",
"SAVE_CHANGES": "حفظ التغييرات", "SAVE_CHANGES": "حفظ التغييرات",
"UNSAVED_CHANGES": "عندك تغييرات ماتسجلوش",
"DISCARD_CHANGES": "تجاهل",
"UNSAVED_DIALOG_TITLE": "تغييرات ماتسجلاتش",
"UNSAVED_DIALOG_MESSAGE": "عندك تغييرات ماتسجلاتش فالإعدادات. بغيتي تسجلهم قبل ما تخرج؟",
"UNSAVED_DIALOG_SAVE": "سجّل و خرج",
"UNSAVED_DIALOG_DISCARD": "خرج بلا ما تسجل",
"UNSAVED_DIALOG_STAY": "كمّل التعديل",
"UNSAVED_DIALOG_INVALID_HINT": "شي خانات ماشي صحيحين، علاش مايمكنش تسجل التغييرات دابا.",
"BACK_TO_HOME": "رجوع", "BACK_TO_HOME": "رجوع",
"NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر", "NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر",
"LATEST_VERSION": "راك تستعمل أحدث إصدار", "LATEST_VERSION": "راك تستعمل أحدث إصدار",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Выберыце са спісу", "VIDEO_PLAYER_PLACEHOLDER": "Выберыце са спісу",
"VERSION": "Версія праграмы", "VERSION": "Версія праграмы",
"SAVE_CHANGES": "Захаваць", "SAVE_CHANGES": "Захаваць",
"UNSAVED_CHANGES": "У вас ёсць незахаваныя змены",
"DISCARD_CHANGES": "Адмяніць змены",
"UNSAVED_DIALOG_TITLE": "Незахаваныя змены",
"UNSAVED_DIALOG_MESSAGE": "У вас ёсць незахаваныя змены налад. Захаваць іх перад выхадам?",
"UNSAVED_DIALOG_SAVE": "Захаваць і выйсці",
"UNSAVED_DIALOG_DISCARD": "Выйсці без захавання",
"UNSAVED_DIALOG_STAY": "Працягнуць рэдагаванне",
"UNSAVED_DIALOG_INVALID_HINT": "Некаторыя палі запоўнены няправільна, таму змены пакуль нельга захаваць.",
"BACK_TO_HOME": "Назад", "BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы", "NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы",
"LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы", "LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Option auswählen", "VIDEO_PLAYER_PLACEHOLDER": "Option auswählen",
"VERSION": "Version", "VERSION": "Version",
"SAVE_CHANGES": "Änderungen speichern", "SAVE_CHANGES": "Änderungen speichern",
"UNSAVED_CHANGES": "Du hast ungespeicherte Änderungen",
"DISCARD_CHANGES": "Verwerfen",
"UNSAVED_DIALOG_TITLE": "Ungespeicherte Änderungen",
"UNSAVED_DIALOG_MESSAGE": "Du hast ungespeicherte Änderungen an den Einstellungen. Möchtest du sie vor dem Verlassen speichern?",
"UNSAVED_DIALOG_SAVE": "Speichern und verlassen",
"UNSAVED_DIALOG_DISCARD": "Verlassen ohne zu speichern",
"UNSAVED_DIALOG_STAY": "Weiter bearbeiten",
"UNSAVED_DIALOG_INVALID_HINT": "Einige Felder sind ungültig, daher können die Änderungen noch nicht gespeichert werden.",
"BACK_TO_HOME": "Zurück", "BACK_TO_HOME": "Zurück",
"NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar", "NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar",
"LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung", "LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή", "VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή",
"VERSION": "Έκδοση", "VERSION": "Έκδοση",
"SAVE_CHANGES": "Αποθήκευση αλλαγών", "SAVE_CHANGES": "Αποθήκευση αλλαγών",
"UNSAVED_CHANGES": "Έχετε μη αποθηκευμένες αλλαγές",
"DISCARD_CHANGES": "Απόρριψη",
"UNSAVED_DIALOG_TITLE": "Μη αποθηκευμένες αλλαγές",
"UNSAVED_DIALOG_MESSAGE": "Έχετε μη αποθηκευμένες αλλαγές στις ρυθμίσεις. Θέλετε να τις αποθηκεύσετε πριν φύγετε;",
"UNSAVED_DIALOG_SAVE": "Αποθήκευση και έξοδος",
"UNSAVED_DIALOG_DISCARD": "Έξοδος χωρίς αποθήκευση",
"UNSAVED_DIALOG_STAY": "Συνέχεια επεξεργασίας",
"UNSAVED_DIALOG_INVALID_HINT": "Ορισμένα πεδία δεν είναι έγκυρα, επομένως οι αλλαγές δεν μπορούν να αποθηκευτούν ακόμη.",
"BACK_TO_HOME": "Πίσω", "BACK_TO_HOME": "Πίσω",
"NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση", "NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση",
"LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση", "LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Select an option", "VIDEO_PLAYER_PLACEHOLDER": "Select an option",
"VERSION": "Version", "VERSION": "Version",
"SAVE_CHANGES": "Save changes", "SAVE_CHANGES": "Save changes",
"UNSAVED_CHANGES": "You have unsaved changes",
"DISCARD_CHANGES": "Discard",
"UNSAVED_DIALOG_TITLE": "Unsaved changes",
"UNSAVED_DIALOG_MESSAGE": "You have unsaved settings changes. Do you want to save them before leaving?",
"UNSAVED_DIALOG_SAVE": "Save and leave",
"UNSAVED_DIALOG_DISCARD": "Leave without saving",
"UNSAVED_DIALOG_STAY": "Keep editing",
"UNSAVED_DIALOG_INVALID_HINT": "Some fields are invalid, so the changes cannot be saved yet.",
"BACK_TO_HOME": "Back", "BACK_TO_HOME": "Back",
"NEW_VERSION_AVAILABLE": "There is a new version available", "NEW_VERSION_AVAILABLE": "There is a new version available",
"LATEST_VERSION": "You are using the latest version", "LATEST_VERSION": "You are using the latest version",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción", "VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción",
"VERSION": "Versión", "VERSION": "Versión",
"SAVE_CHANGES": "Guardar cambios", "SAVE_CHANGES": "Guardar cambios",
"UNSAVED_CHANGES": "Tienes cambios sin guardar",
"DISCARD_CHANGES": "Descartar",
"UNSAVED_DIALOG_TITLE": "Cambios sin guardar",
"UNSAVED_DIALOG_MESSAGE": "Tienes cambios sin guardar en los ajustes. ¿Quieres guardarlos antes de salir?",
"UNSAVED_DIALOG_SAVE": "Guardar y salir",
"UNSAVED_DIALOG_DISCARD": "Salir sin guardar",
"UNSAVED_DIALOG_STAY": "Seguir editando",
"UNSAVED_DIALOG_INVALID_HINT": "Algunos campos no son válidos, por lo que los cambios aún no se pueden guardar.",
"BACK_TO_HOME": "Volver al inicio", "BACK_TO_HOME": "Volver al inicio",
"NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible", "NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible",
"LATEST_VERSION": "Estás usando la versión reciente", "LATEST_VERSION": "Estás usando la versión reciente",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Choisir une option", "VIDEO_PLAYER_PLACEHOLDER": "Choisir une option",
"VERSION": "Version", "VERSION": "Version",
"SAVE_CHANGES": "Enregistrer les modifications", "SAVE_CHANGES": "Enregistrer les modifications",
"UNSAVED_CHANGES": "Vous avez des modifications non enregistrées",
"DISCARD_CHANGES": "Annuler les modifications",
"UNSAVED_DIALOG_TITLE": "Modifications non enregistrées",
"UNSAVED_DIALOG_MESSAGE": "Vous avez des modifications non enregistrées dans les paramètres. Voulez-vous les enregistrer avant de quitter ?",
"UNSAVED_DIALOG_SAVE": "Enregistrer et quitter",
"UNSAVED_DIALOG_DISCARD": "Quitter sans enregistrer",
"UNSAVED_DIALOG_STAY": "Continuer la modification",
"UNSAVED_DIALOG_INVALID_HINT": "Certains champs sont invalides, les modifications ne peuvent donc pas encore être enregistrées.",
"BACK_TO_HOME": "Retourner à l'accueil", "BACK_TO_HOME": "Retourner à l'accueil",
"NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible", "NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible",
"LATEST_VERSION": "Vous utilisez la dernière version", "LATEST_VERSION": "Vous utilisez la dernière version",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Válasszon egy lehetőséget", "VIDEO_PLAYER_PLACEHOLDER": "Válasszon egy lehetőséget",
"VERSION": "Verzió", "VERSION": "Verzió",
"SAVE_CHANGES": "Változtatások mentése", "SAVE_CHANGES": "Változtatások mentése",
"UNSAVED_CHANGES": "Nem mentett módosításaid vannak",
"DISCARD_CHANGES": "Elvetés",
"UNSAVED_DIALOG_TITLE": "Nem mentett módosítások",
"UNSAVED_DIALOG_MESSAGE": "Nem mentett módosításaid vannak a beállításokban. Szeretnéd menteni őket, mielőtt kilépsz?",
"UNSAVED_DIALOG_SAVE": "Mentés és kilépés",
"UNSAVED_DIALOG_DISCARD": "Kilépés mentés nélkül",
"UNSAVED_DIALOG_STAY": "Szerkesztés folytatása",
"UNSAVED_DIALOG_INVALID_HINT": "Néhány mező érvénytelen, ezért a módosítások még nem menthetők.",
"BACK_TO_HOME": "Vissza", "BACK_TO_HOME": "Vissza",
"NEW_VERSION_AVAILABLE": "Új verzió érhető el", "NEW_VERSION_AVAILABLE": "Új verzió érhető el",
"LATEST_VERSION": "A legújabb verziót használja", "LATEST_VERSION": "A legújabb verziót használja",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione", "VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione",
"VERSION": "Versione", "VERSION": "Versione",
"SAVE_CHANGES": "Salva modifiche", "SAVE_CHANGES": "Salva modifiche",
"UNSAVED_CHANGES": "Hai modifiche non salvate",
"DISCARD_CHANGES": "Annulla modifiche",
"UNSAVED_DIALOG_TITLE": "Modifiche non salvate",
"UNSAVED_DIALOG_MESSAGE": "Hai modifiche alle impostazioni non salvate. Vuoi salvarle prima di uscire?",
"UNSAVED_DIALOG_SAVE": "Salva ed esci",
"UNSAVED_DIALOG_DISCARD": "Esci senza salvare",
"UNSAVED_DIALOG_STAY": "Continua a modificare",
"UNSAVED_DIALOG_INVALID_HINT": "Alcuni campi non sono validi, quindi le modifiche non possono ancora essere salvate.",
"BACK_TO_HOME": "Indietro", "BACK_TO_HOME": "Indietro",
"NEW_VERSION_AVAILABLE": "È disponibile una nuova versione", "NEW_VERSION_AVAILABLE": "È disponibile una nuova versione",
"LATEST_VERSION": "Questa versione è aggiornata", "LATEST_VERSION": "Questa versione è aggiornata",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "オプションを選択", "VIDEO_PLAYER_PLACEHOLDER": "オプションを選択",
"VERSION": "バージョン", "VERSION": "バージョン",
"SAVE_CHANGES": "変更を保存", "SAVE_CHANGES": "変更を保存",
"UNSAVED_CHANGES": "保存されていない変更があります",
"DISCARD_CHANGES": "破棄",
"UNSAVED_DIALOG_TITLE": "未保存の変更",
"UNSAVED_DIALOG_MESSAGE": "設定に未保存の変更があります。移動する前に保存しますか?",
"UNSAVED_DIALOG_SAVE": "保存して移動",
"UNSAVED_DIALOG_DISCARD": "保存せずに移動",
"UNSAVED_DIALOG_STAY": "編集を続ける",
"UNSAVED_DIALOG_INVALID_HINT": "一部の項目が無効なため、変更をまだ保存できません。",
"BACK_TO_HOME": "戻る", "BACK_TO_HOME": "戻る",
"NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です", "NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です",
"LATEST_VERSION": "最新バージョンを使用中です", "LATEST_VERSION": "最新バージョンを使用中です",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "옵션을 선택하세요", "VIDEO_PLAYER_PLACEHOLDER": "옵션을 선택하세요",
"VERSION": "버전", "VERSION": "버전",
"SAVE_CHANGES": "변경 사항 저장", "SAVE_CHANGES": "변경 사항 저장",
"UNSAVED_CHANGES": "저장되지 않은 변경 사항이 있습니다",
"DISCARD_CHANGES": "취소",
"UNSAVED_DIALOG_TITLE": "저장되지 않은 변경 사항",
"UNSAVED_DIALOG_MESSAGE": "설정에 저장되지 않은 변경 사항이 있습니다. 나가기 전에 저장하시겠습니까?",
"UNSAVED_DIALOG_SAVE": "저장하고 나가기",
"UNSAVED_DIALOG_DISCARD": "저장하지 않고 나가기",
"UNSAVED_DIALOG_STAY": "계속 편집",
"UNSAVED_DIALOG_INVALID_HINT": "일부 항목이 유효하지 않아 변경 사항을 아직 저장할 수 없습니다.",
"BACK_TO_HOME": "홈으로", "BACK_TO_HOME": "홈으로",
"NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다", "NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다",
"LATEST_VERSION": "최신 버전을 사용하고 있습니다", "LATEST_VERSION": "최신 버전을 사용하고 있습니다",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecteer een optie", "VIDEO_PLAYER_PLACEHOLDER": "Selecteer een optie",
"VERSION": "Versie", "VERSION": "Versie",
"SAVE_CHANGES": "Wijzigingen opslaan", "SAVE_CHANGES": "Wijzigingen opslaan",
"UNSAVED_CHANGES": "Je hebt niet-opgeslagen wijzigingen",
"DISCARD_CHANGES": "Verwerpen",
"UNSAVED_DIALOG_TITLE": "Niet-opgeslagen wijzigingen",
"UNSAVED_DIALOG_MESSAGE": "Je hebt niet-opgeslagen wijzigingen in de instellingen. Wil je ze opslaan voordat je vertrekt?",
"UNSAVED_DIALOG_SAVE": "Opslaan en verlaten",
"UNSAVED_DIALOG_DISCARD": "Verlaten zonder opslaan",
"UNSAVED_DIALOG_STAY": "Verder bewerken",
"UNSAVED_DIALOG_INVALID_HINT": "Sommige velden zijn ongeldig, dus de wijzigingen kunnen nog niet worden opgeslagen.",
"BACK_TO_HOME": "Terug", "BACK_TO_HOME": "Terug",
"NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar", "NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar",
"LATEST_VERSION": "Je gebruikt de nieuwste versie", "LATEST_VERSION": "Je gebruikt de nieuwste versie",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Wybierz opcję", "VIDEO_PLAYER_PLACEHOLDER": "Wybierz opcję",
"VERSION": "Wersja", "VERSION": "Wersja",
"SAVE_CHANGES": "Zapisz zmiany", "SAVE_CHANGES": "Zapisz zmiany",
"UNSAVED_CHANGES": "Masz niezapisane zmiany",
"DISCARD_CHANGES": "Odrzuć",
"UNSAVED_DIALOG_TITLE": "Niezapisane zmiany",
"UNSAVED_DIALOG_MESSAGE": "Masz niezapisane zmiany ustawień. Czy chcesz je zapisać przed wyjściem?",
"UNSAVED_DIALOG_SAVE": "Zapisz i wyjdź",
"UNSAVED_DIALOG_DISCARD": "Wyjdź bez zapisywania",
"UNSAVED_DIALOG_STAY": "Kontynuuj edycję",
"UNSAVED_DIALOG_INVALID_HINT": "Niektóre pola są nieprawidłowe, więc zmian nie można jeszcze zapisać.",
"BACK_TO_HOME": "Wróć", "BACK_TO_HOME": "Wróć",
"NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja", "NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja",
"LATEST_VERSION": "Używasz najnowszej wersji", "LATEST_VERSION": "Używasz najnowszej wersji",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecione uma opção", "VIDEO_PLAYER_PLACEHOLDER": "Selecione uma opção",
"VERSION": "Versão", "VERSION": "Versão",
"SAVE_CHANGES": "Salvar alterações", "SAVE_CHANGES": "Salvar alterações",
"UNSAVED_CHANGES": "Você tem alterações não salvas",
"DISCARD_CHANGES": "Descartar",
"UNSAVED_DIALOG_TITLE": "Alterações não salvas",
"UNSAVED_DIALOG_MESSAGE": "Você tem alterações não salvas nas configurações. Deseja salvá-las antes de sair?",
"UNSAVED_DIALOG_SAVE": "Salvar e sair",
"UNSAVED_DIALOG_DISCARD": "Sair sem salvar",
"UNSAVED_DIALOG_STAY": "Continuar editando",
"UNSAVED_DIALOG_INVALID_HINT": "Alguns campos são inválidos, então as alterações ainda não podem ser salvas.",
"BACK_TO_HOME": "Voltar", "BACK_TO_HOME": "Voltar",
"NEW_VERSION_AVAILABLE": "Há uma nova versão disponível", "NEW_VERSION_AVAILABLE": "Há uma nova versão disponível",
"LATEST_VERSION": "Você está usando a versão mais recente", "LATEST_VERSION": "Você está usando a versão mais recente",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка", "VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка",
"VERSION": "Версия приложения", "VERSION": "Версия приложения",
"SAVE_CHANGES": "Сохранить", "SAVE_CHANGES": "Сохранить",
"UNSAVED_CHANGES": "У вас есть несохранённые изменения",
"DISCARD_CHANGES": "Отменить изменения",
"UNSAVED_DIALOG_TITLE": "Несохранённые изменения",
"UNSAVED_DIALOG_MESSAGE": "У вас есть несохранённые изменения настроек. Сохранить их перед выходом?",
"UNSAVED_DIALOG_SAVE": "Сохранить и выйти",
"UNSAVED_DIALOG_DISCARD": "Выйти без сохранения",
"UNSAVED_DIALOG_STAY": "Продолжить редактирование",
"UNSAVED_DIALOG_INVALID_HINT": "Некоторые поля заполнены неверно, поэтому изменения пока нельзя сохранить.",
"BACK_TO_HOME": "Назад", "BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения", "NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
"LATEST_VERSION": "Вы используете последнюю версию приложения", "LATEST_VERSION": "Вы используете последнюю версию приложения",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Bir seçenek belirleyin", "VIDEO_PLAYER_PLACEHOLDER": "Bir seçenek belirleyin",
"VERSION": "Sürüm", "VERSION": "Sürüm",
"SAVE_CHANGES": "Değişiklikleri kaydet", "SAVE_CHANGES": "Değişiklikleri kaydet",
"UNSAVED_CHANGES": "Kaydedilmemiş değişiklikleriniz var",
"DISCARD_CHANGES": "Vazgeç",
"UNSAVED_DIALOG_TITLE": "Kaydedilmemiş değişiklikler",
"UNSAVED_DIALOG_MESSAGE": "Ayarlarda kaydedilmemiş değişiklikleriniz var. Çıkmadan önce kaydetmek ister misiniz?",
"UNSAVED_DIALOG_SAVE": "Kaydet ve çık",
"UNSAVED_DIALOG_DISCARD": "Kaydetmeden çık",
"UNSAVED_DIALOG_STAY": "Düzenlemeye devam et",
"UNSAVED_DIALOG_INVALID_HINT": "Bazı alanlar geçersiz olduğundan değişiklikler henüz kaydedilemiyor.",
"BACK_TO_HOME": "Ana sayfaya dön", "BACK_TO_HOME": "Ana sayfaya dön",
"NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut", "NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut",
"LATEST_VERSION": "En son sürümü kullanıyorsunuz", "LATEST_VERSION": "En son sürümü kullanıyorsunuz",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "选择一个选项", "VIDEO_PLAYER_PLACEHOLDER": "选择一个选项",
"VERSION": "版本", "VERSION": "版本",
"SAVE_CHANGES": "保存更改", "SAVE_CHANGES": "保存更改",
"UNSAVED_CHANGES": "您有未保存的更改",
"DISCARD_CHANGES": "放弃更改",
"UNSAVED_DIALOG_TITLE": "未保存的更改",
"UNSAVED_DIALOG_MESSAGE": "设置中有未保存的更改。要在离开前保存吗?",
"UNSAVED_DIALOG_SAVE": "保存并离开",
"UNSAVED_DIALOG_DISCARD": "不保存直接离开",
"UNSAVED_DIALOG_STAY": "继续编辑",
"UNSAVED_DIALOG_INVALID_HINT": "部分字段无效,因此暂时无法保存更改。",
"BACK_TO_HOME": "回到主页", "BACK_TO_HOME": "回到主页",
"NEW_VERSION_AVAILABLE": "有新版本可用 ", "NEW_VERSION_AVAILABLE": "有新版本可用 ",
"LATEST_VERSION": "您使用的是最新版本", "LATEST_VERSION": "您使用的是最新版本",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "選擇一個選項", "VIDEO_PLAYER_PLACEHOLDER": "選擇一個選項",
"VERSION": "版本", "VERSION": "版本",
"SAVE_CHANGES": "儲存變更", "SAVE_CHANGES": "儲存變更",
"UNSAVED_CHANGES": "您有未儲存的變更",
"DISCARD_CHANGES": "捨棄變更",
"UNSAVED_DIALOG_TITLE": "未儲存的變更",
"UNSAVED_DIALOG_MESSAGE": "設定中有未儲存的變更。要在離開前儲存嗎?",
"UNSAVED_DIALOG_SAVE": "儲存並離開",
"UNSAVED_DIALOG_DISCARD": "不儲存直接離開",
"UNSAVED_DIALOG_STAY": "繼續編輯",
"UNSAVED_DIALOG_INVALID_HINT": "部分欄位無效,因此暫時無法儲存變更。",
"BACK_TO_HOME": "返回主頁", "BACK_TO_HOME": "返回主頁",
"NEW_VERSION_AVAILABLE": "有新版本可用", "NEW_VERSION_AVAILABLE": "有新版本可用",
"LATEST_VERSION": "您正在使用最新版本", "LATEST_VERSION": "您正在使用最新版本",
+3 -2
View File
@@ -51,7 +51,8 @@ Current workspace routes:
7. `/workspace/global-recent` 7. `/workspace/global-recent`
8. `/workspace/search` 8. `/workspace/search`
9. `/workspace/downloads` 9. `/workspace/downloads`
10. `/workspace/settings` 10. `/workspace/settings/:section` (`/workspace/settings` redirects to
`general`; the settings context panel links each section page)
11. `/workspace/xtreams/:id/...` 11. `/workspace/xtreams/:id/...`
12. `/workspace/stalker/:id/...` 12. `/workspace/stalker/:id/...`
@@ -130,7 +131,7 @@ The shell decides which secondary panel to show from the current route:
1. `WorkspaceContextPanelComponent` 1. `WorkspaceContextPanelComponent`
3. Stalker category sections (`itv`, `radio`, `vod`, `series`) 3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
1. `WorkspaceContextPanelComponent` 1. `WorkspaceContextPanelComponent`
4. `/workspace/settings` 4. `/workspace/settings/:section`
1. `WorkspaceSettingsContextPanelComponent` 1. `WorkspaceSettingsContextPanelComponent`
5. Downloads sections 5. Downloads sections
1. `WorkspaceCollectionContextPanelComponent` 1. `WorkspaceCollectionContextPanelComponent`
@@ -125,6 +125,7 @@
" "
[summary]="liveEpgPanelSummary()" [summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()" [summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)=" (programActivated)="
onTimelineProgramActivated($event) onTimelineProgramActivated($event)
" "
@@ -135,6 +136,7 @@
(collapsedChange)=" (collapsedChange)="
onLiveEpgPanelCollapsedChange($event) onLiveEpgPanelCollapsedChange($event)
" "
(openEpgSettings)="openEpgSettings()"
/> />
} @else { } @else {
<app-epg-timeline <app-epg-timeline
@@ -153,6 +155,7 @@
" "
[summary]="liveEpgPanelSummary()" [summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()" [summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)=" (programActivated)="
onTimelineProgramActivated($event) onTimelineProgramActivated($event)
" "
@@ -163,6 +166,7 @@
(collapsedChange)=" (collapsedChange)="
onLiveEpgPanelCollapsedChange($event) onLiveEpgPanelCollapsedChange($event)
" "
(openEpgSettings)="openEpgSettings()"
/> />
} }
</div> </div>
@@ -196,6 +196,7 @@ describe('VideoPlayerComponent', () => {
const showCaptions = signal(false); const showCaptions = signal(false);
const stripCountryPrefix = signal(false); const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline'); const epgViewMode = signal<'timeline' | 'list'>('timeline');
const epgUrlSetting = signal<string[]>([]);
const originalElectron = window.electron; const originalElectron = window.electron;
const overlayRef = { const overlayRef = {
@@ -397,6 +398,7 @@ describe('VideoPlayerComponent', () => {
showCaptions, showCaptions,
stripCountryPrefix, stripCountryPrefix,
resolvedEpgViewMode: epgViewMode, resolvedEpgViewMode: epgViewMode,
epgUrl: epgUrlSetting,
}, },
}, },
{ {
@@ -1174,6 +1176,45 @@ describe('VideoPlayerComponent', () => {
EpgActions.resetActiveEpgProgram() EpgActions.resetActiveEpgProgram()
); );
}); });
describe('EPG needs-setup empty state', () => {
beforeEach(() => {
// Earlier tests leave programmes in the shared subject; the
// component keeps a live subscription, so this reset reaches it.
epgUrlSetting.set([]);
epgPrograms$.next([]);
});
afterEach(() => {
epgUrlSetting.set([]);
epgPrograms$.next([]);
});
it('claims needs-setup only while no EPG source exists anywhere', () => {
expect(component.liveEpgEmptyReason()).toBe('m3u-needs-setup');
epgUrlSetting.set(['https://example.org/guide.xml']);
expect(component.liveEpgEmptyReason()).toBe('none');
});
it('never overrides a channel that actually has programmes', () => {
// Uploaded XMLTV files produce programmes without any
// configured source URL — the ribbon must win over the hint.
epgPrograms$.next([buildProgram('Morning Bulletin')]);
expect(component.liveEpgEmptyReason()).toBe('none');
});
it('deep links the empty-state button to the EPG settings page', () => {
component.openEpgSettings();
expect(routerMock.navigate).toHaveBeenCalledWith([
'/workspace/settings',
'epg',
]);
});
});
}); });
function buildProgram(title: string): EpgProgram { function buildProgram(title: string): EpgProgram {
@@ -37,6 +37,7 @@ import {
EpgListViewComponent, EpgListViewComponent,
EpgProgramActivationEvent, EpgProgramActivationEvent,
EpgTimelineComponent, EpgTimelineComponent,
EpgTimelineEmptyReason,
getTodayEpgDateKey, getTodayEpgDateKey,
MultiEpgContainerComponent, MultiEpgContainerComponent,
shiftEpgDateKey, shiftEpgDateKey,
@@ -51,6 +52,7 @@ import {
selectActive, selectActive,
selectActiveEpgProgram, selectActiveEpgProgram,
selectActivePlaybackUrl, selectActivePlaybackUrl,
selectActivePlaylist,
selectChannels, selectChannels,
selectChannelsLoading, selectChannelsLoading,
selectCurrentEpgProgram, selectCurrentEpgProgram,
@@ -358,6 +360,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
? 'EPG.ARCHIVE_PLAYBACK' ? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM' : 'EPG.CURRENT_PROGRAM'
); );
private readonly activePlaylistForEpg =
this.store.selectSignal(selectActivePlaylist);
/**
* Without a single configured XMLTV source the whole playlist has no EPG,
* so the panel's empty state should point at the EPG settings page
* instead of implying that just this channel is unmapped. Only claims
* "needs setup" when nothing contradicts it: no programmes for the
* channel (uploaded XMLTV files produce programmes without any URL), no
* global source in settings (legacy values may be a plain string), and no
* playlist-scoped `url-tvg` source either.
*/
readonly liveEpgEmptyReason = computed<EpgTimelineEmptyReason>(() => {
if (this.epgPrograms().length > 0) {
return 'none';
}
const globalSources = this.settingsStore.epgUrl?.() ?? [];
const hasGlobalSources = Array.isArray(globalSources)
? globalSources.some((url) => Boolean(url?.trim?.()))
: Boolean(globalSources);
const hasPlaylistSources = (
this.activePlaylistForEpg()?.epgUrls ?? []
).some((url) => Boolean(url?.trim?.()));
return hasGlobalSources || hasPlaylistSources
? 'none'
: 'm3u-needs-setup';
});
readonly showReturnToLive = computed( readonly showReturnToLive = computed(
() => this.activeEpgProgramOrNull() !== null () => this.activeEpgProgramOrNull() !== null
); );
@@ -705,6 +735,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.store.dispatch(EpgActions.returnToLivePlayback()); this.store.dispatch(EpgActions.returnToLivePlayback());
} }
/** Deep link from the EPG panel's "needs setup" empty state */
openEpgSettings(): void {
void this.router.navigate(['/workspace/settings', 'epg']);
}
onTimelineProgramActivated(event: EpgProgramActivationEvent): void { onTimelineProgramActivated(event: EpgProgramActivationEvent): void {
if (event.type === 'live') { if (event.type === 'live') {
this.returnToLivePlayback(); this.returnToLivePlayback();
-1
View File
@@ -1,3 +1,2 @@
export * from './lib/playlist-player-actions';
export * from './lib/playlist-context.facade'; export * from './lib/playlist-context.facade';
export * from './lib/playlist-file-import.service'; export * from './lib/playlist-file-import.service';
@@ -1,8 +0,0 @@
import { InjectionToken } from '@angular/core';
export interface PlaylistPlayerActions {
openSettings(): void;
}
export const PLAYLIST_PLAYER_ACTIONS =
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
-1
View File
@@ -8,7 +8,6 @@ export * from './lib/portal-external-playback';
export * from './lib/portal-player'; export * from './lib/portal-player';
export * from './lib/portal-playback-positions'; export * from './lib/portal-playback-positions';
export * from './lib/series-quick-start'; export * from './lib/series-quick-start';
export * from './lib/portal-navigation-actions';
export * from './lib/portal-shell-actions'; export * from './lib/portal-shell-actions';
export * from './lib/portal-collection-context'; export * from './lib/portal-collection-context';
export * from './lib/portal-collection-context.service'; export * from './lib/portal-collection-context.service';
@@ -1,11 +0,0 @@
import { InjectionToken } from '@angular/core';
import { Playlist } from '@iptvnator/shared/interfaces';
export interface PortalNavigationActions {
openAccountInfo(): void;
openPlaylistInfo(playlist: Playlist | null | undefined): void;
openSettings(): void;
}
export const PORTAL_NAVIGATION_ACTIONS =
new InjectionToken<PortalNavigationActions>('PORTAL_NAVIGATION_ACTIONS');
@@ -1,31 +1,37 @@
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { import {
WorkspaceShellContextDrawerService, WorkspaceShellContextDrawerService,
SettingsContextService SettingsContextService,
} from '@iptvnator/workspace/shell/util'; } from '@iptvnator/workspace/shell/util';
@Component({ @Component({
selector: 'app-workspace-settings-context-panel', selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, TranslateModule], imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule],
styleUrls: ['./workspace-settings-context-panel.component.scss'], styleUrls: ['./workspace-settings-context-panel.component.scss'],
template: ` template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2> <h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="settings-panel-body"> <div class="settings-panel-body">
<div class="nav-list settings-sections-list"> <div class="nav-list settings-sections-list">
@for (section of ctx.sections(); track section.id) { @for (section of ctx.sections(); track section.id) {
<button <!-- replaceUrl keeps a single settings entry in the
type="button" browser history: switching sections must not turn
"Back" (footer or browser) into a walk through every
visited section page before finally leaving. -->
<a
class="nav-item settings-section-item" class="nav-item settings-section-item"
[class.active]="ctx.activeSection() === section.id" routerLinkActive="active"
[routerLink]="['/workspace/settings', section.id]"
[replaceUrl]="true"
[attr.data-test-id]="'settings-section-' + section.id" [attr.data-test-id]="'settings-section-' + section.id"
(click)="onSectionClicked(section.id)" (click)="onSectionClicked()"
> >
<mat-icon>{{ section.icon }}</mat-icon> <mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span> <span>{{ section.label | translate }}</span>
</button> </a>
} }
</div> </div>
</div> </div>
@@ -44,15 +50,15 @@ import {
export class WorkspaceSettingsContextPanelComponent { export class WorkspaceSettingsContextPanelComponent {
readonly ctx = inject(SettingsContextService); readonly ctx = inject(SettingsContextService);
private readonly location = inject(Location); private readonly location = inject(Location);
// Root-provided; optional keeps standalone unit tests light. Section clicks // Root-provided; optional keeps standalone unit tests light. Section
// scroll the settings page without navigating, so the phone drawer's // links are real navigations now, so the phone drawer's NavigationEnd
// NavigationEnd auto-close never fires for them. // auto-close fires too — the explicit close just makes the drawer react
// immediately instead of waiting for the navigation to settle.
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, { private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
optional: true, optional: true,
}); });
onSectionClicked(sectionId: string) { onSectionClicked() {
this.ctx.navigateToSection(sectionId);
this.contextDrawer?.close(); this.contextDrawer?.close();
} }
@@ -6,32 +6,22 @@ export interface SettingsNavItem {
icon: string; icon: string;
} }
/**
* Bridge between the routed settings page and the workspace context panel:
* the page publishes which section pages exist for the current runtime, the
* panel renders them as router links to `/workspace/settings/:section`.
* Active-state highlighting comes from the router (`routerLinkActive`), not
* from this service.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsContextService { export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]); readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void { setSections(items: SettingsNavItem[]): void {
this.sections.set(items); this.sections.set(items);
} }
setActiveSection(id: string): void {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void { reset(): void {
this.sections.set([]); this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
} }
} }