mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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.
|
||||
@@ -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.
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
import 'zone.js';
|
||||
@@ -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 +0,0 @@
|
||||
import 'jest-preset-angular/setup-jest';
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-25
@@ -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);
|
||||
}
|
||||
}
|
||||
-59
@@ -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>
|
||||
-79
@@ -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;
|
||||
}
|
||||
}
|
||||
-7
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user