Merge remote-tracking branch 'origin/master' into agent/stalker-smart-discovery

# Conflicts:
#	apps/web/src/app/app.config.ts
This commit is contained in:
4gray committed 2026-08-09 10:07:22 +02:00
commit 87bb0aea31
95 files changed
+2198 -1820

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.
+2 -2
View File
@@ -18,9 +18,9 @@ This file provides guidance to coding agents working in this repository.
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Keep `nx` and every official `@nx/*` package on the same exact version; run
`pnpm run deps:nx:validate` after dependency updates.
- Vite `7.3.5`, resolved through Angular's build tooling, is patched with
- Vite `7.3.6`, resolved through Angular's build tooling, is patched with
bounded transform prefilters and the upstream precise matchers in
`patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
after related dependency updates.
- A directory holding files consumed by other projects must be an Nx project.
+3 -3
View File
@@ -76,9 +76,9 @@ pnpm nx show projects
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
- Keep `nx` and every official `@nx/*` package on the same exact version; run
`pnpm run deps:nx:validate` after dependency updates.
- Vite `7.3.5`, resolved through Angular's build tooling, is patched with
- Vite `7.3.6`, resolved through Angular's build tooling, is patched with
bounded transform prefilters and the upstream precise matchers in
`patches/vite@7.3.5.patch`. Keep the patch until supported Angular tooling
`patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling
resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`
after related dependency updates.
- A directory holding files consumed by other projects must be an Nx project.
@@ -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 { STALKER_PLAYLIST_CONNECTION_EDITOR } from '@iptvnator/playlist/shared/ui';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
@@ -36,10 +35,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';
@@ -149,11 +144,6 @@ export const appConfig: ApplicationConfig = {
useExisting: ExternalPlaybackService,
},
...providePortalPlaybackPositions(),
...providePortalNavigationActions(),
{
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
{
provide: STALKER_PLAYLIST_CONNECTION_EDITOR,
useExisting: AppStalkerPlaylistConnectionEditorService,
+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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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
@@ -298,6 +298,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 -3
View File
@@ -52,7 +52,7 @@ resulting PR runs the full CI pipeline.
## Vite Dev-Server Patch
Angular's development builder currently resolves Vite `7.3.5`. That release's
Angular's development builder currently resolves Vite `7.3.6`. That release's
asset and worker transform prefilters can catastrophically backtrack on a large
generated chunk containing unrelated `new URL...` expressions while searching
for a valid `new URL(..., import.meta.url)` construct. The Electron development
@@ -60,12 +60,12 @@ server can then fail a lazy chunk request with `Maximum call stack size
exceeded`, even though static builds succeed because they do not pass emitted
chunks through Vite's request-time plugin container.
`patches/vite@7.3.5.patch` backports Vite's upstream precise-matcher fix from
`patches/vite@7.3.6.patch` backports Vite's upstream precise-matcher fix from
[vitejs/vite#21800](https://github.com/vitejs/vite/pull/21800). Bounded
prefilters keep the request-time scan linear while allowing comment-bearing
asset and worker expressions to reach the precise matcher after Vite strips
comments. Keep the patch while the supported Angular toolchain resolves Vite
`7.3.5`; remove it only after the resolved Vite contains the upstream fix. Run
`7.3.6`; remove it only after the resolved Vite contains the upstream fix. Run
the regression check after any related manifest or lockfile update:
```bash
+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);
}
}
+24 -24
View File
@@ -81,16 +81,16 @@
"private": true,
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"dependencies": {
"@angular/animations": "21.2.17",
"@angular/animations": "21.2.19",
"@angular/cdk": "21.2.14",
"@angular/common": "21.2.17",
"@angular/compiler": "21.2.17",
"@angular/core": "21.2.17",
"@angular/forms": "21.2.17",
"@angular/common": "21.2.19",
"@angular/compiler": "21.2.19",
"@angular/core": "21.2.19",
"@angular/forms": "21.2.19",
"@angular/material": "21.2.14",
"@angular/platform-browser": "21.2.17",
"@angular/platform-browser-dynamic": "21.2.17",
"@angular/router": "21.2.17",
"@angular/platform-browser": "21.2.19",
"@angular/platform-browser-dynamic": "21.2.19",
"@angular/router": "21.2.19",
"@ngrx/effects": "21.1.1",
"@ngrx/entity": "21.1.1",
"@ngrx/router-store": "21.1.1",
@@ -125,25 +125,25 @@
"zone.js": "~0.16.2"
},
"devDependencies": {
"@angular-devkit/core": "21.2.17",
"@angular-devkit/schematics": "21.2.17",
"@angular-devkit/core": "21.2.19",
"@angular-devkit/schematics": "21.2.19",
"@angular-eslint/builder": "21.3.1",
"@angular-eslint/eslint-plugin": "21.3.1",
"@angular-eslint/eslint-plugin-template": "21.3.1",
"@angular-eslint/schematics": "21.3.1",
"@angular-eslint/template-parser": "21.3.1",
"@angular/build": "21.2.17",
"@angular/cli": "21.2.17",
"@angular/common": "21.2.17",
"@angular/compiler": "21.2.17",
"@angular/compiler-cli": "21.2.17",
"@angular/core": "21.2.17",
"@angular/forms": "21.2.17",
"@angular/language-service": "21.2.17",
"@angular/platform-browser": "21.2.17",
"@angular/platform-browser-dynamic": "21.2.17",
"@angular/router": "21.2.17",
"@angular/service-worker": "21.2.17",
"@angular/build": "21.2.19",
"@angular/cli": "21.2.19",
"@angular/common": "21.2.19",
"@angular/compiler": "21.2.19",
"@angular/compiler-cli": "21.2.19",
"@angular/core": "21.2.19",
"@angular/forms": "21.2.19",
"@angular/language-service": "21.2.19",
"@angular/platform-browser": "21.2.19",
"@angular/platform-browser-dynamic": "21.2.19",
"@angular/router": "21.2.19",
"@angular/service-worker": "21.2.19",
"@astrojs/mdx": "4.3.13",
"@astrojs/sitemap": "3.7.3",
"@astrojs/tailwind": "6.0.2",
@@ -169,7 +169,7 @@
"@nx/web": "22.7.2",
"@nx/workspace": "22.7.2",
"@playwright/test": "^1.62.0",
"@schematics/angular": "21.2.9",
"@schematics/angular": "21.2.19",
"@swc-node/register": "1.12.1",
"@swc/core": "1.15.46",
"@swc/helpers": "0.5.23",
@@ -252,7 +252,7 @@
},
"patchedDependencies": {
"nx-electron@22.0.0": "patches/nx-electron@22.0.0.patch",
"vite@7.3.5": "patches/vite@7.3.5.patch"
"vite@7.3.6": "patches/vite@7.3.6.patch"
}
}
}
@@ -1,5 +1,4 @@
diff --git a/dist/node/chunks/config.js b/dist/node/chunks/config.js
index 9a45043275249a25101fd7991ffcfc316478840e..5435b17de9ca65b64a8abacb609b75587226b106 100644
--- a/dist/node/chunks/config.js
+++ b/dist/node/chunks/config.js
@@ -27667,7 +27667,8 @@ async function getWorkerType(raw, clean, i$1) {
+835 -835
View File
File diff suppressed because it is too large. Load diff
@@ -27,7 +27,7 @@ function extractRegExp(name) {
}
test('pins the Vite version carrying the local transform-filter backport', () => {
assert.equal(vitePackage.version, '7.3.5');
assert.equal(vitePackage.version, '7.3.6');
});
test('uses bounded Vite prefilters with precise handler matchers', () => {