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/stalker/:id/downloads/:downloadId`. Focused download details hide
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):
@@ -8,6 +8,7 @@ import {
expect,
launchElectronApp,
openSettings,
openSettingsSection,
openSources,
openWorkspaceSection,
resetMockServers,
@@ -69,6 +70,7 @@ test.describe('Electron playlist backup round-trip', () => {
}, exportPath);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const backupSection = app.mainWindow.locator('#backup');
await backupSection
.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
// renderer opens a browser file chooser for it.
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'backup');
const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser');
await backupSection
@@ -123,6 +123,9 @@ async function selectDownloadCoverSize(
): Promise<void> {
await openSettings(page);
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
.poll(() =>
page.evaluate(
@@ -130,7 +133,6 @@ async function selectDownloadCoverSize(
)
)
.toBe(size);
await saveSettings(page);
await openDownloadsPage(page);
}
@@ -983,10 +983,24 @@ export async function openSettings(page: Page): Promise<void> {
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(
page: Page,
port: number
): Promise<void> {
await openSettingsSection(page, 'remote-control');
const remoteControlCheckbox = page.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
);
@@ -1011,9 +1025,10 @@ export async function saveSettings(page: Page): Promise<void> {
// deterministic post-save state below.
await saveButton.click({ noWaitAfter: true });
// `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the
// settings write resolves, which disables the button. Awaiting that is a
// stronger, race-free confirmation that the save actually committed.
await expect(saveButton).toBeDisabled();
// settings write resolves, which hides the whole unsaved-changes bar.
// Awaiting that is a stronger, race-free confirmation that the save
// actually committed.
await expect(saveButton).toBeHidden();
// Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to
// the main process before callers may relaunch the app to assert persistence.
await page.waitForTimeout(300);
+9
View File
@@ -9,6 +9,8 @@ import {
launchElectronApp,
openWorkspaceSection,
openSettings,
openSettingsSection,
saveSettings,
test,
} from './electron-test-fixtures';
@@ -74,6 +76,7 @@ test.describe('Electron EPG', () => {
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
@@ -254,6 +257,7 @@ test.describe('Electron EPG', () => {
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
@@ -272,6 +276,11 @@ test.describe('Electron EPG', () => {
})
.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');
const channelItem = channelItemByTitle(
@@ -10,6 +10,7 @@ import {
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
openSettingsSection,
saveSettings,
test,
waitForM3uCatalog,
@@ -47,6 +48,7 @@ test.describe('Electron Remote Control', () => {
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await selectSettingsOption(
app.mainWindow,
'select-video-player',
@@ -16,6 +16,7 @@ import {
m3uFixturePath,
openGlobalRecent,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
@@ -49,6 +50,7 @@ test.describe('Electron Settings', () => {
try {
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-check')).toBeVisible();
@@ -75,6 +77,7 @@ test.describe('Electron Settings', () => {
await installExternalPlayerLaunchCapture(app);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await expect(
app.mainWindow.getByTestId(
'external-player-double-click-setting'
@@ -117,6 +120,7 @@ test.describe('Electron Settings', () => {
});
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await expect(doubleClickSetting).toBeVisible();
await doubleClickCheckbox.check();
await saveSettings(app.mainWindow);
@@ -229,6 +233,7 @@ test.describe('Electron Settings', () => {
await firstLaunch.mainWindow
.locator('[data-test-id="DARK_THEME"]')
.click();
await openSettingsSection(firstLaunch.mainWindow, 'playback');
await selectSettingsOption(
firstLaunch.mainWindow,
'select-video-player',
@@ -250,6 +255,7 @@ test.describe('Electron Settings', () => {
)
.uncheck();
await enableRemoteControl(firstLaunch.mainWindow, 8877);
await openSettingsSection(firstLaunch.mainWindow, 'epg');
await firstLaunch.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
@@ -273,6 +279,7 @@ test.describe('Electron Settings', () => {
await expect(
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
).toHaveAttribute('aria-checked', 'true');
await openSettingsSection(secondLaunch.mainWindow, 'playback');
await expect(
secondLaunch.mainWindow.getByTestId('select-video-player')
).toContainText(/HTML5/i);
@@ -289,6 +296,7 @@ test.describe('Electron Settings', () => {
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
)
).not.toBeChecked();
await openSettingsSection(secondLaunch.mainWindow, 'remote-control');
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
@@ -297,6 +305,7 @@ test.describe('Electron Settings', () => {
await expect(
secondLaunch.mainWindow.locator('#remoteControlPort')
).toHaveValue('8877');
await openSettingsSection(secondLaunch.mainWindow, 'epg');
await expect(
secondLaunch.mainWindow.locator('.epg-source-row input').first()
).toHaveValue(epgServer.resourceUrl);
@@ -313,6 +322,7 @@ test.describe('Electron Settings', () => {
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await selectSettingsOption(
app.mainWindow,
'select-video-player',
@@ -478,6 +488,7 @@ test.describe('Electron Settings', () => {
try {
await openSettings(firstLaunch.mainWindow);
await openSettingsSection(firstLaunch.mainWindow, 'epg');
const listToggle = firstLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]'
);
@@ -493,6 +504,7 @@ test.describe('Electron Settings', () => {
try {
await openSettings(secondLaunch.mainWindow);
await openSettingsSection(secondLaunch.mainWindow, 'epg');
await expect(
secondLaunch.mainWindow.locator(
'[data-test-id="epg-view-mode-list"]'
@@ -508,6 +520,7 @@ test.describe('Electron Settings', () => {
try {
await openSettings(firstLaunch.mainWindow);
await openSettingsSection(firstLaunch.mainWindow, 'dashboard');
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
@@ -9,6 +9,7 @@ import {
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
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
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('[data-test-id="epg-view-mode-list"]')
.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> {
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(
page.getByRole('button', { name: 'Back', exact: true })
).toBeVisible();
await page
.locator('[data-test-id="settings-section-playback"]')
.click();
await page.waitForURL(/\/workspace\/settings\/playback$/);
}
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' });
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> {
@@ -131,7 +138,7 @@ test('@web @m3u @playback temporarily switches to the recommended player', async
await openSettings(page);
await page.reload();
await expect(page).toHaveURL(/\/workspace\/settings$/);
await expect(page).toHaveURL(/\/workspace\/settings\/playback$/);
const persistedPlayerSelect = page.locator(
'[data-test-id="select-video-player"]'
);
+143 -2
View File
@@ -4,16 +4,25 @@ import { expect, test } from './fixtures';
async function openSettings(page: Page) {
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-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) {
const saveButton = page.locator('[data-test-id="save-settings"]');
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', () => {
@@ -30,6 +39,7 @@ test.describe('Settings', () => {
test('@settings @web Change video player', async ({ page }) => {
await openSettings(page);
await openSettingsSection(page, 'playback');
const playerSelect = page.locator('[data-test-id="select-video-player"]');
@@ -42,6 +52,7 @@ test.describe('Settings', () => {
await saveSettings(page);
await page.reload();
await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(playerSelect).toContainText(
/HTML5/i
@@ -52,6 +63,7 @@ test.describe('Settings', () => {
page,
}) => {
await openSettings(page);
await openSettingsSection(page, 'playback');
const setting = page.locator(
'[data-test-id="web-player-shared-controls-setting"]'
@@ -64,6 +76,7 @@ test.describe('Settings', () => {
await saveSettings(page);
await page.reload();
await openSettings(page);
await openSettingsSection(page, 'playback');
await expect(checkbox).toBeChecked();
});
@@ -93,6 +106,134 @@ test.describe('Settings', () => {
).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 }) => {
await openSettings(page);
const languageSelect = page.locator('[data-test-id="select-language"]');
-10
View File
@@ -26,7 +26,6 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
@@ -35,10 +34,6 @@ import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.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 { PwaService } from './services/pwa.service';
import { shouldEnableServiceWorker } from './services/runtime-config';
@@ -147,11 +142,6 @@ export const appConfig: ApplicationConfig = {
useExisting: ExternalPlaybackService,
},
...providePortalPlaybackPositions(),
...providePortalNavigationActions(),
{
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
{
+22 -4
View File
@@ -2,6 +2,7 @@ import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
@@ -141,10 +142,27 @@ export const routes: Routes = [
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(
(c) => c.SettingsComponent
),
children: [
{
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
class="settings-group"
[class.settings-group--active]="activeSection() === 'about'"
id="about"
>
<header class="settings-group__header">
@@ -16,7 +16,6 @@ function configureComponent(
fixture: ComponentFixture<SettingsAboutSectionComponent>,
status: ElectronBridgeAppUpdateStatus
) {
fixture.componentRef.setInput('activeSection', 'about');
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('version', '0.22.0');
fixture.componentRef.setInput('updateMessage', '');
@@ -145,7 +144,6 @@ describe('SettingsAboutSectionComponent version display', () => {
}).compileComponents();
fixture = TestBed.createComponent(SettingsAboutSectionComponent);
fixture.componentRef.setInput('activeSection', 'about');
fixture.componentRef.setInput('version', '0.23.0');
});
@@ -24,7 +24,6 @@ import {
],
})
export class SettingsAboutSectionComponent {
readonly activeSection = input.required<string>();
readonly isDesktop = input(false);
readonly version = input<string | undefined>();
readonly buildCommit = input<string | undefined>();
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'backup'"
id="backup"
>
<header class="settings-group__header">
@@ -17,7 +17,6 @@ import { TranslateModule } from '@ngx-translate/core';
styles: [':host { display: contents; }'],
})
export class SettingsBackupSectionComponent {
readonly activeSection = input.required<string>();
readonly isPwa = input(false);
readonly isRemovingAllPlaylists = input(false);
readonly isExportingData = input(false);
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'dashboard'"
id="dashboard"
[formGroup]="form()"
>
@@ -20,5 +20,4 @@ import { TranslateModule } from '@ngx-translate/core';
})
export class SettingsDashboardSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
}
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'epg'"
id="epg"
[formGroup]="form()"
>
@@ -32,7 +32,6 @@ import { EpgViewModeOption } from './settings.models';
})
export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly epgUrl = input.required<FormArray>();
readonly isClearingEpgData = input(false);
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
@@ -99,15 +99,18 @@ export class SettingsFormFacade {
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 {
if (this.form.value.coverSize === coverSize) {
return;
}
this.patchAndMarkDirty({ coverSize }, 'coverSize');
this.settingsStore.updateSettings({ coverSize }).catch(() => {
this.settingsSnackbar.storageFailure('save');
});
}
selectEpgViewMode(epgViewMode: EpgViewMode): void {
@@ -116,9 +119,6 @@ export class SettingsFormFacade {
}
this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode');
this.settingsStore.updateSettings({ epgViewMode }).catch(() => {
this.settingsSnackbar.storageFailure('save');
});
}
setRecordingFolder(recordingFolder: string): void {
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'general'"
id="general"
[formGroup]="form()"
>
@@ -30,7 +30,6 @@ import {
})
export class SettingsGeneralSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly languageEnum = input.required<typeof Language>();
readonly themeOptions = input.required<ThemeOption[]>();
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
@@ -1,81 +1,41 @@
import { readFileSync, readdirSync } from 'fs';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { buildSettingsSectionNavItems } from './settings-options';
/**
* Every nav-item id must match the `id="..."` attribute on its
* corresponding section component template, otherwise clicking the nav
* link silently no-ops (the scroll directive runs `document.getElementById`
* and falls through when no element is found).
* Every nav-item id must match a `@case ('...')` label in the settings page
* template (or the `@default` general section), otherwise clicking the nav
* link routes to `/workspace/settings/:id` and renders the fallback general
* page instead of the intended section.
*
* This bug bit the Remote section once already — its nav id was set to
* the Nx library name (`@iptvnator/ui/remote-control`) instead of the
* template's `id="remote-control"`. The guard below catches future
* rename/copy-paste regressions before they ship.
* The id coupling bit the Remote section once already in the scroll-anchor
* era — its nav id was set to the Nx library name
* (`@iptvnator/ui/remote-control`) instead of `remote-control`. The guard
* below catches future rename/copy-paste regressions before they ship.
*/
describe('buildSettingsSectionNavItems', () => {
// Anchor on the Nx workspace root (Jest runs from there); avoids
// depending on __dirname which isn't defined under the project's
// ESM Jest preset.
const settingsDir = resolve(
process.cwd(),
'apps/web/src/app/settings'
);
const settingsDir = resolve(process.cwd(), 'apps/web/src/app/settings');
function collectSectionTemplateIds(): Set<string> {
const ids = new 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[] {
/** Section ids the page template can render: @case labels + @default. */
function collectRenderableSectionIds(): Set<string> {
const html = readFileSync(
resolve(settingsDir, 'settings.component.html'),
'utf-8'
);
const idsByComponent = new Map<string, string>();
for (const fileName of readdirSync(settingsDir)) {
const componentMatch =
/^settings-(.+)-section\.component\.html$/.exec(fileName);
if (!componentMatch) {
continue;
}
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]
);
}
const ids = new Set<string>(
Array.from(
html.matchAll(/@case \('([a-z-]+)'\)/g),
(match) => match[1]
)
);
if (/@default\s*\{\s*<app-settings-general-section/.test(html)) {
ids.add('general');
}
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));
return ids;
}
it('exposes feature-specific items only when their runtime capabilities are supported', () => {
@@ -100,28 +60,34 @@ describe('buildSettingsSectionNavItems', () => {
).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(
buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
}).map((item) => item.id)
);
const templateIds = collectSectionTemplateIds();
const renderableIds = collectRenderableSectionIds();
// Every nav id must exist as a section root id.
const orphans = [...navIds].filter((id) => !templateIds.has(id));
// A nav id without a template case silently falls back to the
// general page.
const orphans = [...navIds].filter((id) => !renderableIds.has(id));
expect(orphans).toEqual([]);
// And every section template id must be reachable from the nav
// (catches the opposite drift — a new section added without a nav
// entry would never get scrolled to).
const unreachable = [...templateIds].filter((id) => !navIds.has(id));
// And every template case must be reachable from the nav (catches
// the opposite drift — a new section page added without a nav entry
// could never be opened).
const unreachable = [...renderableIds].filter((id) => !navIds.has(id));
expect(unreachable).toEqual([]);
});
it('keeps Dashboard below EPG in both the settings rail and content order', () => {
const expectedOrder = [
it('keeps the settings rail in the expected order', () => {
expect(
buildSettingsSectionNavItems({
supportsEpg: true,
supportsRemoteControl: true,
}).map((item) => item.id)
).toEqual([
'general',
'playback',
'epg',
@@ -131,14 +97,6 @@ describe('buildSettingsSectionNavItems', () => {
'backup',
'reset',
'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();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', []);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'playback'"
id="playback"
[formGroup]="form()"
>
@@ -51,7 +51,6 @@ describe('SettingsPlaybackSectionComponent', () => {
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('form', createForm());
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', [
{
id: VideoPlayer.VideoJs,
@@ -45,7 +45,6 @@ export class SettingsPlaybackSectionComponent {
].join('\n');
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'remote-control'"
id="remote-control"
[formGroup]="form()"
>
@@ -28,7 +28,6 @@ import { QRCodeComponent } from 'angularx-qrcode';
})
export class SettingsRemoteControlSectionComponent {
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly localIpAddresses = input.required<string[]>();
readonly visibleQrCodeIp = input<string | null>(null);
@@ -1,6 +1,5 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'reset'"
id="reset"
>
<header class="settings-group__header">
@@ -18,7 +18,6 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
styles: [':host { display: contents; }'],
})
export class SettingsResetSectionComponent {
readonly activeSection = input.required<string>();
readonly isRemovingAllPlaylists = input(false);
readonly canRemoveAllPlaylists = input(false);
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
class="settings-group"
[class.settings-group--active]="activeSection() === 'tmdb'"
id="tmdb"
[formGroup]="form()"
>
@@ -27,14 +27,24 @@ describe('SettingsTmdbSectionComponent', () => {
const clearButton = () =>
queryByTestId('tmdb-clear-cache') as HTMLButtonElement;
/** The stats effect only fires once the TMDB section is the active one */
const activate = async (section = 'tmdb') => {
fixture.componentRef.setInput('activeSection', section);
fixture.detectChanges();
/** Constructor kicks off the stats read; let it settle and render. */
const settle = async () => {
await fixture.whenStable();
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 () => {
getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 });
clear = jest.fn().mockResolvedValue(42);
@@ -73,21 +83,18 @@ describe('SettingsTmdbSectionComponent', () => {
fixture = TestBed.createComponent(SettingsTmdbSectionComponent);
fixture.componentRef.setInput('form', createForm());
fixture.componentRef.setInput('activeSection', 'general');
fixture.detectChanges();
});
it('defers the full-table scan until the section is opened', async () => {
expect(getStats).not.toHaveBeenCalled();
await activate();
it('sizes the cache as soon as the section page opens', async () => {
await settle();
expect(getStats).toHaveBeenCalledTimes(1);
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42');
});
it('clears the cache and re-reads the size', async () => {
await activate();
await settle();
getStats.mockResolvedValue({ entries: 0, bytes: 0 });
clearButton().click();
@@ -101,7 +108,7 @@ describe('SettingsTmdbSectionComponent', () => {
});
it('surfaces a failed clear instead of claiming an empty cache', async () => {
await activate();
await settle();
clear.mockResolvedValue(null);
clearButton().click();
@@ -116,9 +123,9 @@ describe('SettingsTmdbSectionComponent', () => {
});
it('surfaces a failed size read the same way', async () => {
getStats.mockReset();
getStats.mockResolvedValue(null);
await activate();
await reopenSection();
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain(
CACHE_ERROR_LABEL
@@ -126,17 +133,18 @@ describe('SettingsTmdbSectionComponent', () => {
});
it('retries the size read next time the section is opened', async () => {
getStats.mockReset();
getStats.mockResolvedValueOnce(null);
await activate();
getStats.mockResolvedValue({ entries: 42, bytes: 2048 });
await reopenSection();
expect(queryByTestId('tmdb-cache-size')?.textContent).toContain(
CACHE_ERROR_LABEL
);
await activate('general');
await activate();
// Otherwise a single transient failure sticks until the destructive
// 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(queryByTestId('tmdb-cache-size')?.textContent).toContain('42');
});
@@ -1,10 +1,8 @@
import {
Component,
effect,
inject,
input,
signal,
untracked,
ViewEncapsulation,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
@@ -84,7 +82,6 @@ export class SettingsTmdbSectionComponent {
private readonly tmdbCache = inject(TmdbCacheService);
readonly form = input.required<FormGroup>();
readonly activeSection = input.required<string>();
readonly keyTestState = signal<TmdbKeyTestState>('idle');
readonly cacheStats = signal<TmdbCacheStats | null>(null);
@@ -92,21 +89,13 @@ export class SettingsTmdbSectionComponent {
readonly isClearing = signal(false);
constructor() {
// Sizing the cache is a full table scan, so it waits until the
// user is actually looking at this section.
effect(() => {
if (this.activeSection() !== 'tmdb') {
return;
}
untracked(() => {
// 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();
}
});
});
// Sizing the cache is a full table scan, but this component only
// exists while its section page is open, so loading on construction
// preserves the old "wait until the user is actually looking"
// behaviour. A failed read leaves the signals in their "unknown"
// state and reopening the section recreates the component, which
// retries naturally.
void this.refreshCacheStats();
}
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,
MatSnackBarStub,
MockSettingsStore,
setSettingsSection,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
@@ -111,7 +112,7 @@ describe('SettingsComponent form', () => {
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 = (
fixture.nativeElement as HTMLElement
).querySelector(
@@ -122,12 +123,15 @@ describe('SettingsComponent form', () => {
fixture.detectChanges();
expect(component.settingsForm.value.coverSize).toBe('large');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
coverSize: 'large',
});
expect(component.settingsForm.dirty).toBe(true);
// 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 = (
fixture.nativeElement as HTMLElement
).querySelector(
@@ -138,14 +142,16 @@ describe('SettingsComponent form', () => {
fixture.detectChanges();
expect(component.settingsForm.value.epgViewMode).toBe('list');
expect(settingsStore.updateSettings).toHaveBeenCalledWith({
epgViewMode: 'list',
});
expect(component.settingsForm.dirty).toBe(true);
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
});
describe('Dashboard controls', () => {
it('renders dashboard controls with the expected defaults', () => {
setSettingsSection('dashboard');
fixture.detectChanges();
const nativeElement = fixture.nativeElement as HTMLElement;
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', () => {
it('shows the save confirmation snackbar at the bottom center with the settings offset class', () => {
jest.spyOn(translate, 'instant').mockReturnValue('Settings saved');
+133 -132
View File
@@ -1,13 +1,4 @@
@if (isDialog) {
<h2 mat-dialog-title>{{ 'SETTINGS.GENERAL' | translate }}</h2>
}
<div
class="settings-container"
data-test-id="settings-container"
[class.dialog-mode]="isDialog"
appSettingsSectionScroll
>
<div class="settings-container" data-test-id="settings-container">
<form
[formGroup]="settingsForm"
(ngSubmit)="onSubmit()"
@@ -16,137 +7,147 @@
>
<!-- Page-level "Settings" h1 intentionally removed: the left rail
already labels the page "Settings" (sg-nav-title), and each
section below provides its own h3 + subtitle. Stacking another
h1 in the right pane reproduced the audit's "vertical ladder
of identical labels" complaint. -->
@if (!isDialog) {
<!-- 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>
}
section below provides its own h3 + subtitle. -->
<span class="visually-hidden" data-test-id="settings-page-header">
{{ 'SETTINGS.TITLE' | translate }}
</span>
<div class="settings-content">
<app-settings-general-section
[form]="settingsForm"
[activeSection]="activeSection()"
[languageEnum]="languageEnum"
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
<app-settings-playback-section
[form]="settingsForm"
[activeSection]="activeSection()"
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@if (supportsEpg) {
<app-settings-epg-section
[form]="settingsForm"
[activeSection]="activeSection()"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="epg.refresh($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
(clearEpgData)="epg.clear()"
(selectEpgViewMode)="form.selectEpgViewMode($event)"
/>
<!-- Only the routed section renders. The parent keeps the shared
form alive across section switches, so edits staged on one
page survive a visit to another. -->
@switch (activeSection()) {
@case ('playback') {
<app-settings-playback-section
[form]="settingsForm"
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="
supportsManagedExternalPlayers
"
[supportsExternalPlayerPathSettings]="
supportsExternalPlayerPathSettings
"
[supportsVodMultiSource]="supportsVodMultiSource"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
}
@case ('epg') {
<app-settings-epg-section
[form]="settingsForm"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="epg.refresh($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
(clearEpgData)="epg.clear()"
(selectEpgViewMode)="form.selectEpgViewMode($event)"
/>
}
@case ('dashboard') {
<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
[form]="settingsForm"
[activeSection]="activeSection()"
/>
@if (supportsRemoteControl) {
<app-settings-remote-control-section
[form]="settingsForm"
[activeSection]="activeSection()"
[localIpAddresses]="remoteControl.localIpAddresses()"
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
(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) {
<!-- Discord-style unsaved-changes bar: appears the moment any edit
makes the form dirty — on whichever section page — and vanishes
once saved or discarded. It replaces the always-visible footer
Save button of the single-scroll layout. -->
@if (!settingsForm.pristine) {
<footer class="unsaved-bar" data-test-id="settings-unsaved-bar">
<div class="unsaved-bar__message">
<mat-icon>edit</mat-icon>
<span>{{ 'SETTINGS.UNSAVED_CHANGES' | translate }}</span>
</div>
<div class="unsaved-bar__actions">
<button
mat-button
type="button"
(click)="backToHome()"
data-test-id="back-to-home"
(click)="discardChanges()"
data-test-id="discard-settings"
>
<mat-icon>close</mat-icon>
{{ 'CLOSE' | translate }}
{{ 'SETTINGS.DISCARD_CHANGES' | translate }}
</button>
}
<button
mat-flat-button
type="submit"
class="settings-save-button"
[disabled]="settingsForm.pristine || !settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
<button
mat-flat-button
type="submit"
class="settings-save-button"
[disabled]="!settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</div>
</footer>
</div>
}
</form>
</div>
@@ -8,13 +8,6 @@
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 {
display: flex;
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 {
min-width: 0;
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 {
display: flex;
align-items: flex-start;
@@ -738,13 +691,11 @@
);
}
// Sticky-footer Save bar — full content width so it never overlaps form
// rows. The previous chip-style bar (border-radius:18px, margin-left:auto)
// floated in the bottom-right corner and clipped fields underneath; the
// UX audit called this out as the worst of three available patterns.
// Now the bar acts as a proper footer: spans the column, separator line
// on top, sits flush with the bottom edge while still sticking on scroll.
.action-bar {
// Unsaved-changes bar — only present while the form is dirty, so unlike the
// previous always-visible footer Save button it doubles as the "you edited
// something on another section page" reminder. Sticky rather than fixed so
// it stays inside the settings column and never covers the shell rail.
.unsaved-bar {
position: sticky;
bottom: calc(
var(--settings-action-bar-offset) + var(--settings-safe-bottom)
@@ -756,7 +707,7 @@
box-sizing: border-box;
z-index: 100;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
@@ -769,17 +720,45 @@
background: var(--settings-action-bar-bg);
backdrop-filter: blur(12px);
box-shadow: var(--settings-action-bar-shadow);
animation: unsaved-bar-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.settings-container.dialog-mode .action-bar {
position: static;
margin-top: 12px;
margin-bottom: 2px;
box-shadow: none;
border-radius: 16px;
padding: 12px;
border-top: 1px solid var(--settings-action-bar-border);
justify-content: space-between;
@keyframes unsaved-bar-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
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 {
@@ -882,13 +861,15 @@
flex-wrap: wrap;
}
.action-bar {
position: static;
flex-direction: column-reverse;
.unsaved-bar {
flex-direction: column;
align-items: stretch;
border-radius: 16px;
box-shadow: none;
width: 100%;
}
.unsaved-bar__actions {
flex-direction: column-reverse;
align-items: stretch;
margin-left: 0;
button {
@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
@@ -15,15 +17,17 @@ import {
createEpgBridgeStub,
createPlaylistMeta,
DEFAULT_SETTINGS,
MockRouter,
setSettingsSection,
stubSettingsSideEffects,
} from './test-stubs/settings-test-harness.stub';
/**
* Page-shell behaviour: chrome, the facade lifecycle and the runtime
* capabilities that decide which sections and players are offered. Form
* editing and saving live in `settings.component.form.spec.ts`, section
* scrolling in `settings-section-scroll.directive.spec.ts`, and the
* per-section behaviour in the matching `*.facade.spec.ts` files.
* Page-shell behaviour: chrome, the `:section` page routing, the facade
* lifecycle and the runtime capabilities that decide which sections and
* players are offered. Form editing and saving live in
* `settings.component.form.spec.ts`, and the per-section behaviour in the
* matching `*.facade.spec.ts` files.
*/
describe('SettingsComponent', () => {
let component: SettingsComponent;
@@ -102,7 +106,7 @@ describe('SettingsComponent', () => {
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;
expect(
@@ -111,32 +115,117 @@ describe('SettingsComponent', () => {
expect(nativeElement.querySelector('.settings-intro')).toBeNull();
});
it('should not render the page header in dialog mode', () => {
fixture.destroy();
describe('Section pages', () => {
it('renders only the section named by the route param', () => {
const nativeElement = fixture.nativeElement as HTMLElement;
const dialogFixture = TestBed.createComponent(SettingsComponent);
const dialogComponent = dialogFixture.componentInstance;
expect(
nativeElement.querySelector('app-settings-general-section')
).not.toBeNull();
expect(
nativeElement.querySelector('app-settings-playback-section')
).toBeNull();
stubSettingsSideEffects(dialogComponent);
dialogComponent.isDialog = true;
dialogFixture.detectChanges();
setSettingsSection('playback');
fixture.detectChanges();
const nativeElement = dialogFixture.nativeElement as HTMLElement;
expect(
nativeElement.querySelector('[data-test-id="settings-page-header"]')
).toBeNull();
expect(
nativeElement.querySelector('h2[mat-dialog-title]')
).not.toBeNull();
expect(
nativeElement.querySelector('app-settings-general-section')
).toBeNull();
expect(
nativeElement.querySelector('app-settings-playback-section')
).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', () => {
jest.spyOn(router, 'navigateByUrl');
component.backToHome();
expect(router.navigateByUrl).toHaveBeenCalledWith('/');
describe('Leaving with unsaved changes', () => {
const answerDialogWith = (
choice: 'save' | 'discard' | undefined
): 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', () => {
setSettingsSection('reset');
fixture.detectChanges();
const deleteButton = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.danger-zone__button'
@@ -1,6 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsComponent } from './settings.component';
@@ -18,14 +19,6 @@ const ERROR_SNACKBAR_CONFIG = expect.objectContaining({
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
* 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 epgBridge: Partial<EpgRuntimeBridgeService>;
const originalElectron = window.electron;
const componentMatDialog = (): MatDialog =>
(component as unknown as SettingsComponentPrivateTestApi).matDialog;
beforeEach(waitForAsync(() => {
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(
new Error('storage unavailable')
);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
component.isDialog = true;
component.settingsForm.markAsDirty();
component.onSubmit();
await fixture.whenStable();
@@ -92,37 +82,47 @@ describe('SettingsComponent storage failures', () => {
'CLOSE',
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);
const closeAll = jest.spyOn(componentMatDialog(), 'closeAll');
jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation();
component.isDialog = true;
component.settingsForm.markAsDirty();
component.onSubmit();
// save() resolves a tick after the store write: the callback and
// the Electron mirror run first, so the close lands on the next
// turn of the microtask queue.
// the Electron mirror run first, so the pristine flip lands on the
// next turn of the microtask queue.
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(
new Error('storage unavailable')
);
component.settingsForm.markAsDirty();
(TestBed.inject(MatDialog).open as jest.Mock).mockReturnValue({
afterClosed: () => of('save'),
});
component.form.selectCoverSize('large');
await fixture.whenStable();
// Allowing the navigation here would silently drop the edits the
// dialog just promised to save.
await expect(component.confirmLeaveWithUnsavedChanges()).resolves.toBe(
false
);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
ERROR_SNACKBAR_CONFIG
);
expect(component.settingsForm.dirty).toBe(true);
});
});
+145 -57
View File
@@ -1,27 +1,25 @@
import { CommonModule } from '@angular/common';
import {
Component,
computed,
effect,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
ViewEncapsulation,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogModule,
} from '@angular/material/dialog';
import { MatDialog } from '@angular/material/dialog';
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 { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { VodSourceDiscoveryService } from '@iptvnator/portal/shared/data-access';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { firstValueFrom, map } from 'rxjs';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
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 { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
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 { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
import { SettingsSnackbarService } from './settings-snackbar.service';
export const SETTINGS_DEFAULT_SECTION = 'general';
/**
* 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
* page-level wiring: environment capabilities, section navigation, and the
* save/close flow that spans several facades.
* page-level wiring: environment capabilities, the `:section` route param that
* 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({
templateUrl: './settings.component.html',
@@ -65,12 +74,10 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
},
encapsulation: ViewEncapsulation.None,
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
ReactiveFormsModule,
TranslateModule,
MatDialogModule,
SettingsAboutSectionComponent,
SettingsBackupSectionComponent,
SettingsDashboardSectionComponent,
@@ -79,7 +86,6 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
SettingsPlaybackSectionComponent,
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
SettingsSectionScrollDirective,
SettingsTmdbSectionComponent,
],
providers: [
@@ -93,7 +99,9 @@ import { SettingsSnackbarService } from './settings-snackbar.service';
SettingsSnackbarService,
],
})
export class SettingsComponent implements OnInit, OnDestroy {
export class SettingsComponent
implements OnInit, OnDestroy, SettingsLeaveConfirmation
{
readonly appUpdate = inject(SettingsAppUpdateFacade);
readonly backup = inject(SettingsBackupFacade);
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
@@ -107,14 +115,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly vodSourceDiscovery = inject(VodSourceDiscoveryService);
private readonly matDialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
MAT_DIALOG_DATA,
{ optional: true }
);
@Input() isDialog = this.dialogData?.isDialog ?? false;
private readonly hostElement = inject(ElementRef<HTMLElement>);
/** List with available languages as enum */
readonly languageEnum = Language;
@@ -134,8 +138,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly activeSection = this.settingsCtx.activeSection;
/** Settings form object */
readonly settingsForm = this.form.form;
@@ -164,6 +166,49 @@ export class SettingsComponent implements OnInit, OnDestroy {
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
* storage (indexed db)
@@ -178,9 +223,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.appUpdate.init();
void this.remoteControl.fetchLocalIpAddresses();
if (!this.isDialog) {
this.settingsCtx.setSections(this.sectionNav);
}
this.settingsCtx.setSections(this.sectionNav);
}
ngOnDestroy(): void {
@@ -202,26 +245,78 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
this.form
.save(() => this.applyChangedSettings())
.then(() => {
if (this.isDialog) {
this.matDialog.closeAll();
}
})
.catch(() => {
// The store already applied the change in memory, so without
// this the save looks successful until the next restart. The
// dialog stays open 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');
});
void this.persistSettings();
}
/**
* 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
* write that did not land would silently lose the edits the dialog just
* promised to keep.
*/
async confirmLeaveWithUnsavedChanges(): Promise<boolean> {
if (this.settingsForm.pristine) {
return true;
}
const choice = await firstValueFrom(
this.matDialog
.open<
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> {
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
* task blocks the main thread.
@@ -13,11 +13,6 @@ export interface SettingsSection {
visible: boolean;
}
export interface ObservedSettingsSection {
id: string;
element: HTMLElement;
}
export interface ThemeOption {
value: Theme;
icon: string;
@@ -16,7 +16,12 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
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 {
EpgRuntimeBridgeService,
@@ -47,7 +52,7 @@ import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule, MockProvider } from 'ng-mocks';
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 { SettingsStorageFailure } from '@iptvnator/services';
import { SettingsStore } from '../../services/settings-store.service';
@@ -121,11 +126,36 @@ export class MatSnackBarStub {
}
export class MockRouter {
navigate = jest.fn().mockResolvedValue(true);
navigateByUrl(url: string): string {
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 {
private _settings = signal(DEFAULT_SETTINGS);
@@ -273,6 +303,7 @@ export function settingsTestProviders(
{ provide: MatSnackBar, useClass: MatSnackBarStub },
{ provide: DataService, useClass: ElectronServiceStub },
{ provide: Router, useClass: MockRouter },
{ provide: ActivatedRoute, useClass: MockActivatedRoute },
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: [] },
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "اختر خيارًا",
"VERSION": "الإصدار",
"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": "العودة للرئيسية",
"NEW_VERSION_AVAILABLE": "إصدار جديد متاح",
"LATEST_VERSION": "أنت تستخدم الإصدار الأحدث",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "اختار خيار",
"VERSION": "الإصدار",
"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": "رجوع",
"NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر",
"LATEST_VERSION": "راك تستعمل أحدث إصدار",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Выберыце са спісу",
"VERSION": "Версія праграмы",
"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": "Назад",
"NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы",
"LATEST_VERSION": "Вы выкарыстоўваеце апошнюю версію праграмы",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Option auswählen",
"VERSION": "Version",
"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",
"NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar",
"LATEST_VERSION": "Sie nutzen die neueste Version der Anwendung",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Επιλέξτε μια επιλογή",
"VERSION": "Έκδοση",
"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": "Πίσω",
"NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση",
"LATEST_VERSION": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Select an option",
"VERSION": "Version",
"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",
"NEW_VERSION_AVAILABLE": "There is a new version available",
"LATEST_VERSION": "You are using the latest version",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecciona una opción",
"VERSION": "Versión",
"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",
"NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible",
"LATEST_VERSION": "Estás usando la versión reciente",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Choisir une option",
"VERSION": "Version",
"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",
"NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible",
"LATEST_VERSION": "Vous utilisez la dernière version",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Válasszon egy lehetőséget",
"VERSION": "Verzió",
"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",
"NEW_VERSION_AVAILABLE": "Új verzió érhető el",
"LATEST_VERSION": "A legújabb verziót használja",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Seleziona una opzione",
"VERSION": "Versione",
"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",
"NEW_VERSION_AVAILABLE": "È disponibile una nuova versione",
"LATEST_VERSION": "Questa versione è aggiornata",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "オプションを選択",
"VERSION": "バージョン",
"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": "戻る",
"NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です",
"LATEST_VERSION": "最新バージョンを使用中です",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "옵션을 선택하세요",
"VERSION": "버전",
"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": "홈으로",
"NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다",
"LATEST_VERSION": "최신 버전을 사용하고 있습니다",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecteer een optie",
"VERSION": "Versie",
"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",
"NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar",
"LATEST_VERSION": "Je gebruikt de nieuwste versie",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Wybierz opcję",
"VERSION": "Wersja",
"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óć",
"NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja",
"LATEST_VERSION": "Używasz najnowszej wersji",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Selecione uma opção",
"VERSION": "Versão",
"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",
"NEW_VERSION_AVAILABLE": "Há uma nova versão disponível",
"LATEST_VERSION": "Você está usando a versão mais recente",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Выберите из списка",
"VERSION": "Версия приложения",
"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": "Назад",
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
"LATEST_VERSION": "Вы используете последнюю версию приложения",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "Bir seçenek belirleyin",
"VERSION": "Sürüm",
"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",
"NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut",
"LATEST_VERSION": "En son sürümü kullanıyorsunuz",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "选择一个选项",
"VERSION": "版本",
"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": "回到主页",
"NEW_VERSION_AVAILABLE": "有新版本可用 ",
"LATEST_VERSION": "您使用的是最新版本",
+8
View File
@@ -296,6 +296,14 @@
"VIDEO_PLAYER_PLACEHOLDER": "選擇一個選項",
"VERSION": "版本",
"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": "返回主頁",
"NEW_VERSION_AVAILABLE": "有新版本可用",
"LATEST_VERSION": "您正在使用最新版本",
+3 -2
View File
@@ -51,7 +51,8 @@ Current workspace routes:
7. `/workspace/global-recent`
8. `/workspace/search`
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/...`
12. `/workspace/stalker/:id/...`
@@ -130,7 +131,7 @@ The shell decides which secondary panel to show from the current route:
1. `WorkspaceContextPanelComponent`
3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
1. `WorkspaceContextPanelComponent`
4. `/workspace/settings`
4. `/workspace/settings/:section`
1. `WorkspaceSettingsContextPanelComponent`
5. Downloads sections
1. `WorkspaceCollectionContextPanelComponent`
@@ -125,6 +125,7 @@
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
@@ -135,6 +136,7 @@
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
} @else {
<app-epg-timeline
@@ -153,6 +155,7 @@
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[emptyReason]="liveEpgEmptyReason()"
(programActivated)="
onTimelineProgramActivated($event)
"
@@ -163,6 +166,7 @@
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(openEpgSettings)="openEpgSettings()"
/>
}
</div>
@@ -196,6 +196,7 @@ describe('VideoPlayerComponent', () => {
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const epgUrlSetting = signal<string[]>([]);
const originalElectron = window.electron;
const overlayRef = {
@@ -397,6 +398,7 @@ describe('VideoPlayerComponent', () => {
showCaptions,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
epgUrl: epgUrlSetting,
},
},
{
@@ -1174,6 +1176,45 @@ describe('VideoPlayerComponent', () => {
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 {
@@ -37,6 +37,7 @@ import {
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
EpgTimelineEmptyReason,
getTodayEpgDateKey,
MultiEpgContainerComponent,
shiftEpgDateKey,
@@ -51,6 +52,7 @@ import {
selectActive,
selectActiveEpgProgram,
selectActivePlaybackUrl,
selectActivePlaylist,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
@@ -358,6 +360,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
? 'EPG.ARCHIVE_PLAYBACK'
: '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(
() => this.activeEpgProgramOrNull() !== null
);
@@ -705,6 +735,11 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
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 {
if (event.type === 'live') {
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-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-playback-positions';
export * from './lib/series-quick-start';
export * from './lib/portal-navigation-actions';
export * from './lib/portal-shell-actions';
export * from './lib/portal-collection-context';
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 { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
WorkspaceShellContextDrawerService,
SettingsContextService
SettingsContextService,
} from '@iptvnator/workspace/shell/util';
@Component({
selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, TranslateModule],
imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule],
styleUrls: ['./workspace-settings-context-panel.component.scss'],
template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="settings-panel-body">
<div class="nav-list settings-sections-list">
@for (section of ctx.sections(); track section.id) {
<button
type="button"
<!-- replaceUrl keeps a single settings entry in the
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.active]="ctx.activeSection() === section.id"
routerLinkActive="active"
[routerLink]="['/workspace/settings', section.id]"
[replaceUrl]="true"
[attr.data-test-id]="'settings-section-' + section.id"
(click)="onSectionClicked(section.id)"
(click)="onSectionClicked()"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
</button>
</a>
}
</div>
</div>
@@ -44,15 +50,15 @@ import {
export class WorkspaceSettingsContextPanelComponent {
readonly ctx = inject(SettingsContextService);
private readonly location = inject(Location);
// Root-provided; optional keeps standalone unit tests light. Section clicks
// scroll the settings page without navigating, so the phone drawer's
// NavigationEnd auto-close never fires for them.
// Root-provided; optional keeps standalone unit tests light. Section
// links are real navigations now, so the phone drawer's NavigationEnd
// 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, {
optional: true,
});
onSectionClicked(sectionId: string) {
this.ctx.navigateToSection(sectionId);
onSectionClicked() {
this.contextDrawer?.close();
}
@@ -6,32 +6,22 @@ export interface SettingsNavItem {
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' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
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 {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}