Merge origin/master into claude/server-storage-pwa

Resolves tools/performance/journey-baselines.json: master's #1929
(zoneless renderer) lowered renderer.initialBytes to 1,278,278; the
merged tree measures 1,285,523 locally, so the branch's raise is now
7,245 bytes (0.6%), with its note updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 21:39:22 +02:00
commit f4d3d66164
34 files changed
+455 -564

No files matched your search

@@ -0,0 +1,8 @@
---
type: perf
area: performance
---
The app does less background work and starts with less code: screens now
update only when something they show changes, instead of after every timer,
network reply or click anywhere in the app.
+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: ui
---
Buttons that delete or discard data now show as a red tint with red text
instead of a solid red block, in both themes. The option lists of the Settings
dropdowns use the same compact, bordered panel as the app's menus.
+3
View File
@@ -348,9 +348,12 @@ jobs:
--only launch/main.modulesRegisteredBeforeWindow
--only launch/renderer.layoutShiftScore
--only launch/renderer.layoutShiftScoreSettled
--only launch/renderer.cdTicksToFirstCard
--only launch/renderer.cdTicksIdle30s
--only open-source/main.mockHttpRequestsToSettled
--only open-source/renderer.ipcCallsToFirstPage
--only open-source/renderer.layoutShiftScore
--only open-source/renderer.cdTicksToFirstPage
--only playback/renderer.httpRequestsToPlaying
--only playback/renderer.layoutShiftScore
--only search/renderer.ipcCallsPerSearch
@@ -331,6 +331,7 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
) as {
WORKSPACE: { SHELL: { RAIL_LIVE: string } };
EPG: {
PREVIOUS_DAY: string;
PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string };
TIMELINE: { WATCH_FROM_START: string };
};
@@ -365,6 +366,15 @@ test('@epg @xtream @electron stacks the programme dialog actions on a phone', as
.first()
.click();
const guide = app.mainWindow.locator('app-epg-list-view');
await expect(guide.locator('[data-when="now"]').first()).toBeVisible();
// The mock's 30-minute slots run relative to now, so in the first half
// hour after UTC midnight today holds only the programme on air: step
// back a day for a finished one.
if ((await guide.locator('[data-when="past"]').count()) === 0) {
await guide
.getByRole('button', { name: fr.EPG.PREVIOUS_DAY })
.click();
}
await expect(guide.locator('[data-when="past"]').first()).toBeVisible();
// The window cannot shrink below its desktop minimum, so emulate a
@@ -0,0 +1,21 @@
import { expect, Locator, Page } from '@playwright/test';
/**
* Wait until keyboard focus has entered the open `mat-menu`, and return it.
*
* Material moves focus to the first item after the next render, which the
* zoneless scheduler runs on a later task than the keydown that opened the
* menu. The panel is visible before that, so a navigation key sent as soon
* as it appears still reaches the trigger and the menu never sees it.
*/
export async function waitForMenuFocus(page: Page): Promise<Locator> {
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await expect
.poll(
() => menu.evaluate((el) => el.contains(document.activeElement)),
{ message: 'focus inside the open menu' }
)
.toBe(true);
return menu;
}
@@ -10,7 +10,6 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>;
defaultConfiguration?: string;
dependsOn?: unknown;
executor?: unknown;
options?: Record<string, unknown>;
@@ -163,12 +162,7 @@ test('only the web performance build installs the tick counter the journeys read
for (const [name, configuration] of Object.entries(
webProject.targets['build'].configurations ?? {}
)) {
if (
name === 'electron-performance' ||
name === 'electron-performance-zoneless'
) {
continue;
}
if (name === 'electron-performance') continue;
assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []),
/environment\.performance/,
@@ -186,12 +180,7 @@ test('only development web builds provide the NgRx store devtools', () => {
replace: 'apps/web/src/environments/store-devtools.providers.ts',
with: 'apps/web/src/environments/store-devtools.providers.dev.ts',
};
// electron-e2e-zoneless is electron-e2e plus the zoneless swap.
const developmentConfigurations = new Set([
'development',
'electron-e2e',
'electron-e2e-zoneless',
]);
const developmentConfigurations = new Set(['development', 'electron-e2e']);
assert.match(
readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'),
@@ -230,42 +219,23 @@ test('only development web builds provide the NgRx store devtools', () => {
}
});
// Plan item C6 measures zoneless change detection behind a build-time flag:
// each *-zoneless configuration is its base configuration plus one swap of
// the change-detection providers, and nothing else selects that swap.
test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => {
const configurations = webProject.targets['build'].configurations ?? {};
const zonelessReplacement = {
replace: 'apps/web/src/environments/change-detection.providers.ts',
with: 'apps/web/src/environments/change-detection.providers.zoneless.ts',
};
// Plan item C6: the renderer runs zoneless. A zone.js polyfill or a zone
// provider would bring back a tick after every macrotask and make the
// change-detection tick counters racy again.
test('the web app bootstraps zoneless without the zone.js polyfill', () => {
const build = webProject.targets['build'];
const configurations = Object.entries(build.configurations ?? {});
for (const base of ['electron-performance', 'electron-e2e']) {
const baseConfiguration = configurations[base];
const zoneless = configurations[`${base}-zoneless`];
assert.ok(zoneless, `web:build must define ${base}-zoneless`);
assert.ok(baseConfiguration, `web:build must define ${base}`);
const { fileReplacements: baseReplacements, ...baseRest } =
baseConfiguration;
const { fileReplacements, ...rest } = zoneless;
assert.deepEqual(rest, baseRest, base);
assert.deepEqual(fileReplacements, [
...((baseReplacements as unknown[] | undefined) ?? []),
zonelessReplacement,
]);
assert.equal(build.options?.['polyfills'], undefined);
for (const [name, configuration] of configurations) {
assert.equal(configuration['polyfills'], undefined, name);
}
for (const [name, configuration] of Object.entries(configurations)) {
if (name.endsWith('-zoneless')) continue;
assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []),
/change-detection\.providers/,
name
);
}
assert.equal(
webProject.targets['build'].defaultConfiguration,
'production'
const appConfig = readFileSync(
join(workspaceRoot, 'apps/web/src/app/app.config.ts'),
'utf8'
);
assert.match(appConfig, /provideZonelessChangeDetection\(\)/);
assert.doesNotMatch(appConfig, /provideZoneChangeDetection/);
});
test('the resolved web build cache output is the renderer directory', () => {
@@ -30,6 +30,7 @@ import {
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
import {
routePlayableStreams,
startAndConfirmPlayback,
@@ -572,8 +573,7 @@ test.describe('Electron Playlist Switcher', () => {
await expect(search).toBeFocused();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Shift+F10');
const rowMenu = page.locator('.mat-mdc-menu-panel');
await expect(rowMenu).toBeVisible();
const rowMenu = await waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(
rowMenu.getByRole('menuitem', { name: /Remove/ })
@@ -9,6 +9,7 @@ import {
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
// ---------------------------------------------------------------------------
// A source row opens from the keyboard. Each row has its own Tab stop: the
@@ -217,6 +218,7 @@ test.describe('Electron Sources keyboard', () => {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
@@ -47,16 +47,27 @@ async function outlineContrast(
}, fillSelector);
}
/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */
async function resolveColor(page: Page, value: string): Promise<string> {
return page.evaluate((css) => {
const probe = document.createElement('div');
probe.style.color = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, value);
/**
* Resolves a CSS color (including var() and color-mix()) the way the given
* property serializes it, so it compares with a computed `color` or
* `backgroundColor`.
*/
async function resolveColor(
page: Page,
value: string,
property: 'color' | 'backgroundColor' = 'color'
): Promise<string> {
return page.evaluate(
({ css, prop }) => {
const probe = document.createElement('div');
probe.style[prop] = css;
document.body.appendChild(probe);
const color = getComputedStyle(probe)[prop];
probe.remove();
return color;
},
{ css: value, prop: property }
);
}
async function systemVariable(page: Page, name: string): Promise<string> {
@@ -200,7 +211,7 @@ test.describe('Theme tokens', () => {
}
});
test('@theme @electron destructive actions name the action and use the error color', async ({
test('@theme @electron destructive actions name the action and use the danger tint', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
@@ -211,18 +222,35 @@ test.describe('Theme tokens', () => {
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
const error = await resolveColor(page, 'var(--mat-sys-error)');
const dangerText = await resolveColor(
page,
'var(--app-danger-text)'
);
const dangerSurface = await resolveColor(
page,
'var(--app-danger-surface)',
'backgroundColor'
);
const trigger = page.getByRole('button', {
name: 'Clear EPG data',
exact: true,
});
// The `warn` color input was a no-op with M3: the trigger
// rendered in the primary color.
// rendered in the primary color. The kit draws a destructive
// button as a red tint, never a solid error fill, and the
// outlined trigger carries the same tint as a filled one.
await expect
.poll(() =>
trigger.evaluate((el) => getComputedStyle(el).color)
)
.toBe(error);
.toBe(dangerText);
await expect
.poll(() =>
trigger.evaluate(
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(dangerSurface);
await trigger.click();
const dialog = page.locator('mat-dialog-container');
@@ -239,7 +267,12 @@ test.describe('Theme tokens', () => {
(el) => getComputedStyle(el).backgroundColor
)
)
.toBe(error);
.toBe(dangerSurface);
await expect
.poll(() =>
confirm.evaluate((el) => getComputedStyle(el).color)
)
.toBe(dangerText);
await dialog
.getByRole('button', { name: 'Cancel', exact: true })
-43
View File
@@ -19,7 +19,6 @@
"base": "dist/apps/web",
"browser": ""
},
"polyfills": ["zone.js"],
"tsConfig": "apps/web/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"serviceWorker": "ngsw-config.json",
@@ -149,31 +148,6 @@
}
]
},
"electron-performance-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false,
"removeSpecialComments": true
},
"fonts": true
},
"outputHashing": "all",
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.performance.ts"
},
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
},
"electron-e2e": {
"baseHref": "./",
"serviceWorker": false,
@@ -186,23 +160,6 @@
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
}
]
},
"electron-e2e-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/store-devtools.providers.ts",
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
},
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
}
},
"defaultConfiguration": "production"
+7 -3
View File
@@ -8,7 +8,12 @@ import {
FullscreenOverlayContainer,
OverlayContainer,
} from '@angular/cdk/overlay';
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
import {
ApplicationConfig,
inject,
importProvidersFrom,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects';
@@ -30,7 +35,6 @@ import {
DataService,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { changeDetectionProviders } from '../environments/change-detection.providers';
import { storeDevtoolsProviders } from '../environments/store-devtools.providers';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
@@ -103,7 +107,7 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
...changeDetectionProviders,
provideZonelessChangeDetection(),
provideRouter(routes, withComponentInputBinding()),
// No provideAnimations(): nothing declares an Angular animation
// trigger, and Material 22 animates with CSS, reading
@@ -1,4 +1,4 @@
import { inject, Injectable, NgZone, OnDestroy } from '@angular/core';
import { inject, Injectable, OnDestroy } from '@angular/core';
import { AppUpdateInstallService } from '../services/app-update-install.service';
import { SettingsForm } from './settings-form.utils';
@@ -39,7 +39,6 @@ export interface SettingsUnloadGuardHost {
*/
@Injectable()
export class SettingsUnloadGuardService implements OnDestroy {
private readonly zone = inject(NgZone);
private readonly installService = inject(AppUpdateInstallService);
private host: SettingsUnloadGuardHost | null = null;
private unsubscribeCloseRequests: (() => void) | null = null;
@@ -78,7 +77,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
this.unsubscribeCloseRequests =
window.electron?.onWindowCloseRequested?.((requestId) => {
this.latestCloseRequestId = requestId;
this.zone.run(() => void this.handleCloseRequest('close'));
void this.handleCloseRequest('close');
}) ?? null;
// Whatever surface installs an app update — the settings About
@@ -141,9 +140,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
this.host = null;
}
private readonly beforeUnloadHandler = (
event: BeforeUnloadEvent
): void => {
private readonly beforeUnloadHandler = (event: BeforeUnloadEvent): void => {
if (!this.host?.form.dirty) {
return;
}
@@ -160,7 +157,7 @@ export class SettingsUnloadGuardService implements OnDestroy {
// handler entirely — so this can only be a reload: ask, then
// re-trigger it.
setTimeout(() => {
this.zone.run(() => void this.handleCloseRequest('reload'));
void this.handleCloseRequest('reload');
});
}
};
@@ -9,11 +9,11 @@ import { ApplicationRef } from '@angular/core';
*
* Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode
* `window.ng` global, which the optimized build does not publish. Every
* tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless
* scheduler or an explicit `ApplicationRef.tick()`, goes through the
* internal `ApplicationRef._tick`, which is also where Angular emits the
* profiler's `ChangeDetectionStart`. Counting its calls therefore matches
* the profiler's count and stays comparable across the zoneless migration.
* tick, whether scheduled by the zoneless scheduler, an `afterNextRender`
* hook or an explicit `ApplicationRef.tick()`, goes through the internal
* `ApplicationRef._tick`, which is also where Angular emits the profiler's
* `ChangeDetectionStart`. Counting its calls therefore matches the
* profiler's count; it did so under zone.js too, before the zoneless flip.
* Contract: docs/architecture/performance-journeys.md.
*/
export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
@@ -1,12 +0,0 @@
import {
EnvironmentProviders,
provideZoneChangeDetection,
} from '@angular/core';
// Change detection for every build: zone.js schedules the ticks. The
// *-zoneless build configurations replace this file with
// change-detection.providers.zoneless.ts while plan item C6 measures
// zoneless change detection; see docs/architecture/zoneless-migration.md.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZoneChangeDetection({ eventCoalescing: true }),
];
@@ -1,11 +0,0 @@
import {
EnvironmentProviders,
provideZonelessChangeDetection,
} from '@angular/core';
// Swapped in for change-detection.providers.ts by the *-zoneless build
// configurations only. zone.js stays in the polyfills until the flip, so
// Angular logs NG0914 in these builds; nothing patches through it.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZonelessChangeDetection(),
];
@@ -1,8 +1,8 @@
import { EnvironmentProviders } from '@angular/core';
import { provideStoreDevtools } from '@ngrx/store-devtools';
// Swapped in for store-devtools.providers.ts by the development,
// electron-e2e and electron-e2e-zoneless build configurations only.
// Swapped in for store-devtools.providers.ts by the development and
// electron-e2e build configurations only.
export const storeDevtoolsProviders: EnvironmentProviders[] = [
provideStoreDevtools({ maxAge: 25 }),
];
+88 -22
View File
@@ -3,7 +3,8 @@
@use '@angular/material' as mat;
@use 'styles';
@include mat.elevation-classes();
// No `mat.elevation-classes()`: nothing in the app uses the `.mat-elevation-z*`
// utilities, and the 25 rules cost 4.4 KB of the initial stylesheet.
@include mat.app-background();
// ─── Typography ─────────────────────────────────────────────────────────────
@@ -407,39 +408,104 @@ html {
font-size: 13.5px;
}
// A select stays inside a form; its option panel follows the kit menu:
// a bordered 10px panel with 6px padding, 36px rows at 13px and the
// chosen row on the list-selection tokens. The overrides sit on the
// panel, which renders in the overlay container inside the theme
// context, so the `--app-*` values resolve per theme. The selector is
// doubled because Material sets the panel's padding and attached
// corners from `div.mat-mdc-select-panel` and
// `.mat-mdc-select-panel-above div.mat-mdc-select-panel`, injected after
// the global stylesheet.
div.mat-mdc-select-panel.mat-mdc-select-panel {
@include mat.select-overrides(
(
panel-background-color: var(--app-widget-bg),
container-elevation-shadow: 0 18px 40px rgba(0, 0, 0, 0.35),
)
);
@include mat.option-overrides(
(
label-text-size: 13px,
label-text-color: var(--app-heading-color),
selected-state-label-text-color: var(--app-list-selected-fg),
selected-state-layer-color: var(--app-list-selected-bg),
hover-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 8%, transparent),
focus-state-layer-color:
color-mix(in srgb, var(--app-on-surface) 10%, transparent),
)
);
padding: 6px;
border: 1px solid var(--app-line-strong);
border-radius: 10px;
.mat-mdc-option {
min-height: 36px;
padding: 0 10px;
border-radius: 6px;
}
}
// ─── Destructive actions ───────────────────────────────────────────────
// Material only emits `.mat-warn` button colors for M2 themes, so the
// `warn` color input is a no-op with this M3 theme. Buttons that remove or
// discard user data opt in with this class instead; the error tokens are
// declared per theme context above, so light and dark each get their own
// error/on-error pair.
// discard user data opt in with this class instead. The kit draws them as
// a red tint (`--app-danger-*`, declared per theme context above), never
// a solid error fill: hover changes the fill only, the ripple is off and
// a disabled button is the same button at 40% opacity.
.app-destructive-button {
@include mat.button-overrides(
(
filled-container-color: var(--mat-sys-error),
filled-label-text-color: var(--mat-sys-on-error),
filled-state-layer-color: var(--mat-sys-on-error),
filled-ripple-color:
color-mix(in srgb, var(--mat-sys-on-error) 12%, transparent),
text-label-text-color: var(--mat-sys-error),
text-state-layer-color: var(--mat-sys-error),
text-ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
outlined-label-text-color: var(--mat-sys-error),
outlined-outline-color: var(--mat-sys-error),
outlined-state-layer-color: var(--mat-sys-error),
outlined-ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
filled-container-color: var(--app-danger-surface),
filled-disabled-container-color: var(--app-danger-surface),
filled-label-text-color: var(--app-danger-text),
filled-disabled-label-text-color: var(--app-danger-text),
filled-state-layer-color: var(--app-danger-text),
filled-hover-state-layer-opacity: 0.08,
filled-focus-state-layer-opacity: 0.08,
filled-pressed-state-layer-opacity: 0.12,
filled-ripple-color: transparent,
text-label-text-color: var(--app-danger-text),
text-disabled-label-text-color: var(--app-danger-text),
text-state-layer-color: var(--app-danger-text),
text-hover-state-layer-opacity: 0.08,
text-focus-state-layer-opacity: 0.08,
text-pressed-state-layer-opacity: 0.12,
text-ripple-color: transparent,
outlined-label-text-color: var(--app-danger-text),
outlined-disabled-label-text-color: var(--app-danger-text),
outlined-outline-color: transparent,
outlined-disabled-outline-color: transparent,
outlined-state-layer-color: var(--app-danger-text),
outlined-hover-state-layer-opacity: 0.08,
outlined-focus-state-layer-opacity: 0.08,
outlined-pressed-state-layer-opacity: 0.12,
outlined-ripple-color: transparent,
)
);
@include mat.icon-button-overrides(
(
icon-color: var(--mat-sys-error),
state-layer-color: var(--mat-sys-error),
ripple-color:
color-mix(in srgb, var(--mat-sys-error) 12%, transparent),
icon-color: var(--app-danger-text),
disabled-icon-color: var(--app-danger-text),
state-layer-color: var(--app-danger-text),
hover-state-layer-opacity: 0.08,
focus-state-layer-opacity: 0.08,
pressed-state-layer-opacity: 0.12,
ripple-color: transparent,
)
);
// The kit has no outlined buttons: an outlined destructive button
// carries the same tint as a filled one.
&.mat-mdc-outlined-button {
background-color: var(--app-danger-surface);
}
&[disabled],
&.mat-mdc-button-disabled {
opacity: 0.4;
}
}
.mat-mdc-form-field-subscript-wrapper {
-1
View File
@@ -1 +0,0 @@
import 'zone.js';
-52
View File
@@ -1,52 +0,0 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js'; // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/
-1
View File
@@ -1 +0,0 @@
import 'jest-preset-angular/setup-jest';
+12 -1
View File
@@ -1,6 +1,7 @@
@use '@angular/material' as mat;
@use '../../../libs/ui/components/src/lib/resizable/resizable.scss';
@use '../../../libs/ui/styles/focus-ring';
@use '../../../libs/ui/styles/app-controls' as controls;
@use './settings-theme';
@use './cover-size';
@@ -238,10 +239,20 @@ body.dark-theme {
overflow-x: hidden !important;
}
.cdk-overlay-backdrop {
// Modal overlays blur the page behind their backdrop. The CDK gives a dialog
// the dark backdrop class by default; menus, selects and popovers open over
// `cdk-overlay-transparent-backdrop`, which sits at opacity 0 and stays a
// plain layer without a filter of its own.
.cdk-overlay-dark-backdrop {
backdrop-filter: blur(4px);
}
// UI-kit menu panel: `<mat-menu class="app-menu">`. The panel renders in the
// overlay container, outside every component view, so the rule is global.
.mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
}
// Command palette dialog — the palette-shell renders its own background, border,
// radius and shadow, so strip MDC's dialog surface chrome to avoid a darker
// halo peeking out behind the rounded corners.
+72 -5
View File
@@ -22,6 +22,54 @@ Use it when changing existing views or introducing new list-based UI in the work
5. Scroll ownership must be explicit.
Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.
## Angular Material's Role
Decision of 2026-10-12. The repository has no ADR folder; decisions live in
the subsystem's document.
**Decision.**
- Angular Material stays as the behaviour and accessibility layer: dialog,
menu, select (inside forms only), form field and input, snack-bar, tooltip,
slide-toggle, checkbox, progress and icon.
- The look comes from the IPTVnator design kit, applied through
`mat.*-overrides()` on the `--app-*` tokens in one place: the shared
partials under `libs/ui/styles/` (the control mixins live in
`_app-controls.scss`), not per-component `::ng-deep`.
- A kit component that Material lacks is built on CDK primitives (overlay,
a11y key managers), as the playlist switcher popover is.
- A plain action button may become a native `<button>` with the kit mixins.
- No wholesale replacement of Material. Re-evaluate Angular's headless
`@angular/aria` (developer preview as of v21; not installed) for menu,
listbox and combobox once it is stable.
**Why** (production build, `renderer.initialBytes` 1,597,763 bytes):
- Material's JavaScript is about 203 KB (12.7% of the initial bytes) and the
CDK's about 126 KB (7.9%). Any replacement keeps the CDK, and the CDK is
where the behaviour lives.
- In the global CSS the `--mat-*` tokens are 96 KB raw but about 11 KB
gzipped, and Material's own rules are 9 KB. 23.6 KB of the tokens belong to
17 components the app never imports, because `mat.all-component-themes`
emits every component.
- The startup cost came from a few eager imports (the form-field defaults
token in `app.config.ts`, the startup, update and EPG-progress components,
the confirm-dialog service), not from Material itself. Those are gone (see
Material off the startup path below): `renderer.initialBytes` went from
1,597,763 to 1,345,807 with Material still in place.
- Rewriting dialog focus trapping, menu keyboard handling, select listboxes
and snack-bar live regions would cost months and accessibility risk for no
visible gain.
**Consequences.**
- Material upgrades can change behaviour. Seen in v22: the menu panel has no
max-height; a submenu is offset by the parent's first direct
`mat-menu-item`, so a trigger that follows `role="group"` rows is wrapped
in `<div role="none">`; overlay panes are `position: static` inside the
bounding box.
- Keep E2E coverage on menus and dialogs.
## Canonical References
- Channel row:
@@ -78,8 +126,9 @@ in `apps/web/src/m3-theme.scss`):
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag,
destructive menu items and `.app-destructive-button`;
`--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
@@ -243,6 +292,15 @@ selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `<mat-menu class="app-menu">` gets the kit panel from one global rule in
`styles.scss` (`menu-panel` in `libs/ui/styles/_app-controls.scss`): a
bordered 10px panel with 6px padding, 36px rows at 13px with 16px icons, the
checked row on the list-selection tokens, a destructive row marked
`app-menu-item--danger` after a divider, and a shortcut hint in a trailing
`kbd.app-menu-item__kbd`. The panel renders in the overlay container, outside
every component view, so a component never copies the mixin into its own
stylesheet.
A `mat-menu` that picks one value, such as a sort order or a rating
threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
`@iptvnator/ui/components`:
@@ -1097,14 +1155,17 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
- **Destructive actions.** Material only emits `warn` button colors for M2
themes, so the `color` input is a no-op here. A button that removes or
discards user data uses the global `.app-destructive-button` class from
`m3-theme.scss` (error/on-error tokens per theme, for filled, text,
outlined and icon buttons), as the unsaved-changes dialog's Discard does.
`m3-theme.scss`, as the unsaved-changes dialog's Discard does. It is the
kit's red tint on the `--app-danger-*` tokens per theme (tinted fill and
red text for filled and outlined buttons, red text for text and icon
buttons), never a solid error fill; hover changes the fill only, and a
disabled button is the same button at 40% opacity.
Confirmations go through `DialogService.openConfirmDialog` with a
translated verb as the required `confirmLabel` ("Remove playlist",
"Clear") and `tone: 'destructive'` when the action loses data; the dismiss
defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself
is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both
alike. `theme-tokens.e2e.ts` checks the label and the danger tint in both
themes.
- **One control per action.** A page offers one control for a destructive
action. Clearing a favorites or recently viewed tab goes through
@@ -1123,6 +1184,12 @@ provide the defaults, because the token import there would load Material's
form field and `@angular/forms` on startup. `pnpm run
ui:form-field-defaults:validate` (in CI) reports a missing provider.
A `mat-select` belongs inside a form. Its option panel is styled once in
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
not a select: it is a secondary button with a chevron that opens an
`app-menu`.
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
vocabulary, so a field reads the same wherever it appears:
+12 -8
View File
@@ -64,12 +64,14 @@ in both dependency sections and all official Nx packages on one exact version.
Use `.nvmrc` for development/CI; supported LTS Node ranges are
`^22.22.3 || ^24.15.0`. Docker uses the supported Node 24 line.
The Angular 22 migration explicitly preserves the old default change detection
with `ChangeDetectionStrategy.Eager`. Existing OnPush components and
`provideZoneChangeDetection` remain in their existing modes.
Existing eager components under Angular ESLint carry a line-scoped explanation
for the newly recommended OnPush rule; new components keep that rule enabled.
Adopting OnPush in those components requires a separate behavior review.
The Angular 22 migration explicitly preserved the old default change detection
with `ChangeDetectionStrategy.Eager`. Plan item C6 since converted every component
the renderer uses to OnPush and switched it to
`provideZonelessChangeDetection()` without the `zone.js` polyfill; see the
[zoneless migration](zoneless-migration.md). `apps/remote-control-web` and its
remote-control component still use zone.js and `ChangeDetectionStrategy.Eager`,
with a line-scoped explanation for the OnPush lint rule; new components keep
that rule enabled.
TypeScript 6 migration settings preserve compiler defaults and per-project output roots;
`ignoreDeprecations: "6.0"` temporarily supports legacy Node test configs and
`baseUrl`, rather than changing the whole workspace to browser resolution.
@@ -105,8 +107,10 @@ imports `zone.js/testing` for existing `fakeAsync` helpers. This preserves the
Angular 21 scheduler used by jest-preset-angular 15: its `setupZoneTestEnv` loaded
Zone but did not provide zone-based change detection on Angular 20+. Preset 17
does provide it, which disables automatic fixture rendering and changes
`whenStable()` behavior. Production applications continue to use their existing
`provideZoneChangeDetection`; browser and Electron E2E validate that runtime.
`whenStable()` behavior. The production renderer (`apps/web`) runs
`provideZonelessChangeDetection()` like the tests; `apps/remote-control-web`
keeps `provideZoneChangeDetection`. Browser and Electron E2E validate both
runtimes.
## Vite Dev-Server Patch
+24 -6
View File
@@ -250,8 +250,9 @@ swaps `apps/web/src/environments/environment.ts` for
and calls `installChangeDetectionTickCounter()` from
`change-detection-tick-counter.ts` while `main.js` is evaluated, before
Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`,
the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`,
the zoneless scheduler, `afterNextRender` idle buckets and the public
the method every tick runs through: the zoneless scheduler (and, before the
zoneless flip, the zone scheduler's `onMicrotaskEmpty`), `afterNextRender`
idle buckets and the public
`ApplicationRef.tick()` all call it, and it is where Angular emits the
profiler's `ChangeDetectionStart`. The count therefore equals the profiler's
tick count and stays comparable across the zoneless migration. The running
@@ -296,6 +297,16 @@ on another differ by one tick without any different work. Treat a one-tick
difference in J1 as that race, and confirm on the CI runner that the counter
is deterministic before it becomes a baseline.
Since the zoneless flip (plan item C6) the renderer has no zone.js: a tick
runs only when a signal, a `markForCheck`, an Angular event or an
`afterNextRender` hook schedules one, so the race above is gone. The first
local measurement after the flip (macOS, 2026-10-13, five iterations each)
read `renderer.cdTicksToFirstCard` 5, `renderer.cdTicksIdle30s` 3 and
`renderer.cdTicksToFirstPage` 9 in every iteration, `renderer.cdTicksToPlaying`
7 or 8 and J4 `renderer.cdTicksToResults` 11 or 12; the DOM mutation counts
were identical across iterations. The history of the migration and the
before/after tables are in [zoneless migration](zoneless-migration.md).
#### Main-process counters
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
@@ -1150,8 +1161,8 @@ runtime flag: `if (!AppConfig.production)` keeps the imported module in
`main.js` because the optimizer does not fold the property read. The NgRx
store devtools therefore come from
`apps/web/src/environments/store-devtools.providers.ts`, an empty list in
every build, which only the `development`, `electron-e2e` and
`electron-e2e-zoneless` configurations replace with
every build, which only the `development` and `electron-e2e` configurations
replace with
`store-devtools.providers.dev.ts`; a build-config test in
`performance-build-config.spec.ts` keeps it that way. Removing the static
import lowered the counter by 12,565 bytes.
@@ -1431,8 +1442,15 @@ Not enforced, with the reason:
- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the
week, J3 1 or 2 until #1817 and 0 since): a long task is a task over
50 ms, so the count follows runner speed, not work.
- Every `cdTicks` counter: the zoneless migration (plan item C6) changes
them.
- J3 `renderer.cdTicksToPlaying` (7 or 8) and J4
`renderer.cdTicksToResults` (10 or 11): not identical even zoneless; the
player's and the search debounce's events land in one or two ticks. J1
`renderer.cdTicksIdle30s` (3) and J2 `renderer.cdTicksToFirstPage` (9) are
enforced exactly since the zoneless flip (#1929), identical in all 15
iterations of its three dispatched runs. J1 `renderer.cdTicksToFirstCard`
read 5 in those 15 too, but after #1931 moved Angular Material off the
startup path one iteration in six read 6: the lazy chunk resolves on either
side of a render. It is enforced at 5 with `slack: 1`.
- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of
recent commits.
- J3 `renderer.ipcCallsToPlaying` (4 or 5) and
+52 -44
View File
@@ -40,11 +40,11 @@ must be ticked here.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
1. [x] Keep `@ngrx/store-devtools` out of production bundles (#1810). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
3. [x] Per-project PRs (#1818 to #1823), in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
@@ -54,7 +54,7 @@ must be ticked here.
`fileReplacements` flag, off by default; the three implemented journeys
(J1-J3; J4 is still planned) and the Electron E2E suite run with it on
(see [Zoneless flag](#zoneless-flag)).
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
5. [x] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
@@ -183,9 +183,10 @@ the field a signal (or a `computed`), or writes it through one.
## Explicit zone and change-detector calls
They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through). Remove them in the flip PR, not
before: with zone.js on they still matter.
They kept working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through) and were removed in the flip PR,
because with zone.js on they still mattered. The embedded MPV position poll's
spec case that asserted `runOutsideAngular` went with it.
- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
`zone.run` calls around the window-close dialog (IPC
@@ -194,10 +195,10 @@ before: with zone.js on they still matter.
`runOutsideAngular(() => setInterval(...))` for the position poll;
`embedded-mpv-session-controller.position.spec.ts` asserts the call and
changes with it.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
writes.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
- [x] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
one `ngZone.run` around a signal update.
- `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts`
(4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and
@@ -233,46 +234,31 @@ their events arrive over IPC.
## Build, tests and runtime details
- [ ] `provideServiceWorker(..., { registrationStrategy:
- `provideServiceWorker(..., { registrationStrategy:
'registerWhenStable:30000' })`: under zoneless "stable" means no pending
tasks. The 30 s bound still registers the worker; check the PWA build in
the flag PR.
- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
tasks; the 30 s bound registers the worker even if a task stays pending.
- [x] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
the zoneless scheduler also calls, so the counters stay comparable.
- [ ] Specs that need zone.js: `fakeAsync` in
- Specs that need zone.js: `fakeAsync` in
`playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`,
`waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten;
removing zone.js from the build polyfills does not affect them.
- [ ] Unreferenced leftovers to delete in the flip PR:
`waitForAsync` in 13 files. They keep `zone.js/testing` (and the `zone.js`
package) until rewritten; the build polyfills do not affect them.
`apps/remote-control-web` still bootstraps with zone.js.
- [x] Unreferenced leftovers deleted in the flip PR:
`apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`,
`apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses
them).
## Zoneless flag
`app.config.ts` takes its change-detection providers from
`apps/web/src/environments/change-detection.providers.ts`
(`provideZoneChangeDetection({ eventCoalescing: true })`). The
`electron-performance-zoneless` and `electron-e2e-zoneless` web
configurations are their base configuration plus one `fileReplacements`
swap to `change-detection.providers.zoneless.ts`
(`provideZonelessChangeDetection()`); a test in
`performance-build-config.spec.ts` pins that and refuses the swap in any
other configuration. zone.js stays in the polyfills, so these builds log
NG0914 in dev mode and nothing schedules through the zone. The Electron app
loads the renderer from `dist/apps/web`, so rebuilding only the web app
switches an existing Electron build:
```bash
pnpm nx run electron-backend:build-performance # or build-e2e
pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless
cd apps/electron-backend-e2e
../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts
../../node_modules/.bin/playwright test --grep-invert packaged
```
Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e`
afterwards: their build dependencies restore the zone.js renderer.
Between #1824 and the flip, `app.config.ts` took its change-detection
providers from `environments/change-detection.providers.ts`, and two
`*-zoneless` web configurations swapped in `provideZonelessChangeDetection()`
so the journeys and the Electron E2E suite could run zoneless before anything
shipped it. The flip PR removed the flag: `app.config.ts` provides
`provideZonelessChangeDetection()` directly, `apps/web` builds without the
`zone.js` polyfill, and `performance-build-config.spec.ts` fails if either
comes back.
First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on
the flag branch; the same integration build measured with the flag off and
@@ -305,8 +291,11 @@ passed zoneless: external-player launch states and the MPV/VLC DASH
fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`,
`remote-control`, `picture-in-picture` and `stream-info`. Their state reaches
the renderer over IPC into signals and never ran in the zone. Embedded MPV
playback itself is covered only by the packaged frame-copy E2E, which these
runs left out; run it on a packaged zoneless build before the flip.
playback itself is covered only by the packaged frame-copy E2E: it passed on a
packaged zoneless Linux x64 build from throwaway draft #1832 (see the comment
on #1824), with every packaging job and 8 of 9 cross-platform E2E shards green
(the ninth failed `dashboard-hero-legibility`, which failed the same day on
zone.js PRs too).
## Measuring a PR
@@ -314,6 +303,25 @@ Build `electron-performance` and run the journeys as described in
[performance journeys](performance-journeys.md), then paste
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
after. While zone.js is on, removing Eager does not change the number of
ticks, only the work per tick; expect the counters to stay put until the flag
PR and the template work (DOM mutations, profile time) to drop.
after. Until the flip, while zone.js was on, removing Eager did not change
the number of ticks, only the work per tick. Since the flip (#1929)
`renderer.cdTicksToFirstCard` (5, with one tick of slack for the lazy
Material chunk since #1931), `renderer.cdTicksIdle30s` (3) and
`renderer.cdTicksToFirstPage` (9) are ratchet baselines: a change that adds a
tick to launch, to the idle dashboard or to opening a source fails the
journeys job.
Canonical runner (`Performance ratchet` dispatched on #1929, three runs, five
iterations each) against `master` the same day with zone.js:
| Counter | `master` (zone.js) | #1929 (zoneless) |
| --- | --- | --- |
| `renderer.cdTicksToFirstCard` | 31 (2026-10-04) | 5 |
| `renderer.cdTicksIdle30s` | 1 to 4 | 3 |
| `renderer.cdTicksToFirstPage` | 22 | 9 |
| `renderer.cdTicksToPlaying` | 15 to 17 | 7 or 8 |
| `renderer.initialBytes` | 1,596,201 | 1,545,931 |
| J3 `clickToPlayingMs` p50 | 151.6, 159.2, 185.7 | 128.0, 178.6, 189.5 |
J3 wall clock is in the same range, so the local rise measured on the flag
PR was machine load.
@@ -357,10 +357,3 @@ app-source-tile {
display: none;
}
}
// UI-kit menu panel. The panel renders in the overlay container, so the
// rule is global; it ships with the components that use `class="app-menu"`
// rather than in the app's initial stylesheet.
::ng-deep .mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
}
@@ -1,4 +1,3 @@
import { NgZone } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
EmbeddedMpvSession,
@@ -132,27 +131,6 @@ describe('EmbeddedMpvSessionController position drift poll', () => {
expect(electron.setEmbeddedMpvBounds).not.toHaveBeenCalled();
});
it('registers the poll outside the Angular zone', async () => {
// With zone change detection, a zone-registered interval would run
// app-wide change detection every 500 ms for the whole stream.
const zone = TestBed.inject(NgZone);
const runOutsideAngular = jest.spyOn(zone, 'runOutsideAngular');
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
expect(runOutsideAngular).toHaveBeenCalled();
teardown();
});
it('skips drift measurement for the frame-copy engine', async () => {
// Frame-copy paints into a DOM canvas that moves with the layout, so
// a position-only shift needs no re-sync (size changes still arrive
@@ -162,9 +140,7 @@ describe('EmbeddedMpvSessionController position drift poll', () => {
platform: 'linux',
engine: 'frame-copy',
});
electron.attachEmbeddedMpvFrameView = jest
.fn()
.mockResolvedValue(true);
electron.attachEmbeddedMpvFrameView = jest.fn().mockResolvedValue(true);
electron.detachEmbeddedMpvFrameView = jest.fn();
const rect = { left: 10, top: 20, width: 640, height: 360 };
@@ -1,7 +1,6 @@
import {
DestroyRef,
Injectable,
NgZone,
computed,
effect,
inject,
@@ -80,7 +79,6 @@ export class EmbeddedMpvSessionController {
);
private readonly destroyRef = inject(DestroyRef);
private readonly zone = inject(NgZone);
private readonly translate = inject(TranslateService);
private readonly unsubscribeSessionUpdate?: () => void;
@@ -192,28 +190,22 @@ export class EmbeddedMpvSessionController {
// on drift, so the idle cost is one getBoundingClientRect per tick
// with no IPC.
//
// The interval runs outside Angular's zone: with zone change
// detection a zone-registered timer would run app-wide change
// detection every tick for the whole stream. It never re-enters the
// zone — the drift path is rAF → setEmbeddedMpvBounds IPC and
// touches no Angular state. Frame-copy paints into a DOM canvas
// The poll touches no Angular state, so with zoneless change
// detection it schedules no render: the drift path is rAF →
// setEmbeddedMpvBounds IPC. Frame-copy paints into a DOM canvas
// that moves with the layout, so only native-view can go stale on a
// position-only shift; the poll skips the measurement there.
const positionPoll = this.zone.runOutsideAngular(() =>
window.setInterval(() => {
if (!activeSessionId || !lastSyncedBounds) {
return;
}
if (untracked(() => this.isFrameCopyEngine())) {
return;
}
if (
boundsDiffer(this.boundsProvider(host), lastSyncedBounds)
) {
scheduleBoundsSync();
}
}, POSITION_POLL_INTERVAL_MS)
);
const positionPoll = window.setInterval(() => {
if (!activeSessionId || !lastSyncedBounds) {
return;
}
if (untracked(() => this.isFrameCopyEngine())) {
return;
}
if (boundsDiffer(this.boundsProvider(host), lastSyncedBounds)) {
scheduleBoundsSync();
}
}, POSITION_POLL_INTERVAL_MS);
// Page zoom and monitor DPI rescale the CSS→native-pixel mapping the
// backend applies to these bounds. Moving the window to a display
@@ -1,11 +1,4 @@
import {
Injectable,
NgZone,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
@@ -91,7 +84,6 @@ export class DashboardDataService {
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly favoritesAutoRefreshEnabled = signal(false);
@@ -372,13 +364,11 @@ export class DashboardDataService {
if (generation !== this.playbackPositionsLoadGeneration) {
return;
}
this.ngZone.run(() => {
this.playbackPositionMaps.set(maps);
// A playlist whose load failed counts as loaded: its titles
// show without progress rather than holding the rail back.
this.playbackPositionPlaylistIds.set(playlistIds);
this.playbackPositionsReloads.update((count) => count + 1);
});
this.playbackPositionMaps.set(maps);
// A playlist whose load failed counts as loaded: its titles
// show without progress rather than holding the rail back.
this.playbackPositionPlaylistIds.set(playlistIds);
this.playbackPositionsReloads.update((count) => count + 1);
}
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() => {
@@ -440,11 +430,9 @@ export class DashboardDataService {
return;
}
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
}
@@ -490,9 +478,7 @@ export class DashboardDataService {
if (!this.hasPortalActivityStorage) {
try {
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
this.ngZone.run(() =>
this.xtreamGlobalRecentItems.set(recentItems)
);
this.xtreamGlobalRecentItems.set(recentItems);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload PWA recent items',
@@ -510,13 +496,13 @@ export class DashboardDataService {
const normalized = recentItems.map((item) =>
mapDbRecentToItem(item)
);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
this.xtreamGlobalRecentItems.set(normalized);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global recent items',
err
);
this.ngZone.run(() => this.xtreamGlobalRecentItems.set([]));
this.xtreamGlobalRecentItems.set([]);
} finally {
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
@@ -531,9 +517,7 @@ export class DashboardDataService {
}
const xtreamReload = this.reloadXtreamGlobalFavorites().finally(() => {
this.ngZone.run(() =>
this.xtreamGlobalFavoritesLoadedState.set(true)
);
this.xtreamGlobalFavoritesLoadedState.set(true);
this.finishInitialGlobalFavoritesLoadIfReady();
});
const m3uReload = this.reloadPlaylistBackedGlobalFavorites();
@@ -586,37 +570,31 @@ export class DashboardDataService {
}
if (!this.hasPortalActivityStorage) {
this.ngZone.run(() => {
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
this.xtreamRecentlyAddedItemsState.set([]);
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
return;
}
try {
const items = await this.getXtreamRecentlyAddedItems(limit);
this.ngZone.run(() =>
this.xtreamRecentlyAddedItemsState.set(items)
);
this.xtreamRecentlyAddedItemsState.set(items);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload Xtream recently added items',
err
);
this.ngZone.run(() => this.xtreamRecentlyAddedItemsState.set([]));
this.xtreamRecentlyAddedItemsState.set([]);
} finally {
this.ngZone.run(() => {
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
});
this.xtreamRecentlyAddedLoadingState.set(false);
this.xtreamRecentlyAddedLoadedState.set(true);
}
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!this.hasPortalActivityStorage) {
const favorites = await this.loadPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.xtreamGlobalFavorites.set(favorites);
return;
}
@@ -625,13 +603,13 @@ export class DashboardDataService {
const normalized = favorites.map((item) =>
mapDbFavoriteToItem(item)
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
this.xtreamGlobalFavorites.set(normalized);
} catch (err) {
console.warn(
'[DashboardData] Failed to reload global favorites',
err
);
this.ngZone.run(() => this.xtreamGlobalFavorites.set([]));
this.xtreamGlobalFavorites.set([]);
}
}
@@ -730,9 +708,7 @@ export class DashboardDataService {
return;
}
this.ngZone.run(() =>
this.playlistBackedGlobalFavoritesLoadedState.set(true)
);
this.playlistBackedGlobalFavoritesLoadedState.set(true);
this.finishInitialGlobalFavoritesLoadIfReady();
}
@@ -762,17 +738,15 @@ export class DashboardDataService {
// Drop entries for M3U playlists no longer in the eligible set
// (removed playlists or playlists whose favorites were cleared).
this.ngZone.run(() => {
this.m3uPlaylistFavoritesMap.update((prev) => {
let next: Map<string, DashboardFavoriteItem[]> | null = null;
for (const id of prev.keys()) {
if (!validIds.has(id)) {
next ??= new Map(prev);
next.delete(id);
}
this.m3uPlaylistFavoritesMap.update((prev) => {
let next: Map<string, DashboardFavoriteItem[]> | null = null;
for (const id of prev.keys()) {
if (!validIds.has(id)) {
next ??= new Map(prev);
next.delete(id);
}
return next ?? prev;
});
}
return next ?? prev;
});
for (const id of Array.from(this.m3uFavoritesCache.keys())) {
if (!validIds.has(id)) {
@@ -801,12 +775,10 @@ export class DashboardDataService {
items = [];
}
this.ngZone.run(() => {
this.m3uPlaylistFavoritesMap.update((prev) => {
const next = new Map(prev);
next.set(playlist._id, items);
return next;
});
this.m3uPlaylistFavoritesMap.update((prev) => {
const next = new Map(prev);
next.set(playlist._id, items);
return next;
});
})
);
@@ -821,10 +793,8 @@ export class DashboardDataService {
return;
}
this.ngZone.run(() => {
this.globalFavoritesLoadedState.set(true);
this.globalFavoritesLoadingState.set(false);
});
this.globalFavoritesLoadedState.set(true);
this.globalFavoritesLoadingState.set(false);
}
private finishInitialGlobalRecentLoadIfReady(): void {
@@ -832,10 +802,8 @@ export class DashboardDataService {
return;
}
this.ngZone.run(() => {
this.globalRecentLoadedState.set(true);
this.globalRecentLoadingState.set(false);
});
this.globalRecentLoadedState.set(true);
this.globalRecentLoadingState.set(false);
}
isTypeInKind(
@@ -1,7 +1,7 @@
import { Injector } from '@angular/core';
import { SourceHealthService } from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { Injectable, NgZone, inject, signal } from '@angular/core';
import { Injectable, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { normalizeStoredStalkerAccountInfo } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService, PortalStatusService } from '@iptvnator/services';
@@ -26,7 +26,6 @@ import type { SourceExpiryFacts } from './dashboard-source-expiry.util';
export class DashboardSourceExpiryService {
private readonly injector = inject(Injector);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly ngZone = inject(NgZone);
private readonly playlistsService = inject(PlaylistsService);
private readonly portalStatusService = inject(PortalStatusService);
@@ -182,8 +181,6 @@ export class DashboardSourceExpiryService {
if (round !== this.refreshRound) {
return;
}
this.ngZone.run(() => {
this.factsState.update(update);
});
this.factsState.update(update);
}
}
@@ -1,59 +0,0 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
[placeholder]="
'WORKSPACE.GLOBAL_FAVORITES.SEARCH_PLACEHOLDER' | translate
"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
</mat-form-field>
</div>
<div class="nav-list app-scrollbar" cdkDropList (cdkDropListDropped)="onDrop($event)">
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (searchTerm()) {
<p class="empty-title">
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
</p>
<p class="empty-hint">
{{
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
| translate
}}
</p>
} @else {
<p class="empty-title">
{{
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
}}
</p>
<p class="empty-hint">
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!searchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -1,79 +0,0 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
:host ::ng-deep .search-bar div {
border-radius: 20px;
}
:host ::ng-deep .search-bar div[matformfieldlineripple],
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
display: none;
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 1.05rem;
font-weight: 500;
letter-spacing: -0.01em;
margin: 0 0 8px;
}
.empty-hint {
font-size: 0.85rem;
opacity: 0.65;
margin: 0;
max-width: 260px;
}
}
@@ -123,10 +123,3 @@
.sort-failing-first__check--off {
visibility: hidden;
}
// UI-kit menu panel. The panel renders in the overlay container, so the
// rule is global; it ships with the components that use `class="app-menu"`
// rather than in the app's initial stylesheet.
::ng-deep .mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
}
+2 -2
View File
@@ -3,13 +3,13 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 1338342,
"value": 1285523,
"unit": "bytes",
"slack": 4096,
"updatedAt": "2026-10-14",
"evidencePr": 1903,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes",
"note": "Raised by 9,093 bytes (0.7%) over #1938's 1,329,249 for self-hosted server storage: what must run before the first route stays on the startup path (the /v1/instance probe and remembered server, the connection status, the access token bound to its instance, the playlist storage backend with its per-playlist write serialization). The storage code itself (user-data sync, migration, sign-in, Settings > Server) loads by dynamic import in server mode only."
"note": "Raised by 7,245 bytes (0.6%) over #1929's 1,278,278 for self-hosted server storage: what must run before the first route stays on the startup path (the /v1/instance probe and remembered server, the connection status, the access token bound to its instance, the playlist storage backend with its per-playlist write serialization). The storage code itself (user-data sync, migration, sign-in, Settings > Server) loads by dynamic import in server mode only."
},
"renderer.ipcCallsToFirstCard": {
"value": 15,