mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
perf(web): take Angular Material off the startup path (#1931)
Takes Angular Material off the renderer's startup path: renderer.initialBytes 1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with master, where #1928 lands the form-field defaults per component, the branch measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler keeps each module in one chunk, so one eager import of any export carries the module's whole code; each item removes the last eager importer of one Material module. - EPG progress and update panels: eager host + `@defer (when visible)` view with an `@error` notice; the startup spinner is static markup copied from mat-spinner. - LazyMatDialog: the confirm dialog service, player service, parental lock prompt and shell actions resolve MatDialog on first use. - The theme emits tokens for the 21 imported components only, through a `$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs (CI) keeps it in step with the imports in both directions. - No provideAnimations(): Material 22 animates with CSS and reads prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts proves dialogs, selects, menus, snack-bars and tooltips animate in and out and stop under reduced motion. Its renderer had deferred every DOM removal to the end of the tick, so the catalog list now tracks the grid offset from scroll events and the player settings panel decides "focus was inside" in a microtask, both with specs that detach the view first. - The snack-bar stays, and with it button, the icon-button chunk and the CDK overlay (about 190 KB): root services inject it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec4b9286a7
commit
e6ef8cdd54
53 files changed
+2068
-543
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app loads about 12% less code before it shows its first screen: dialog,
|
||||
tooltip and progress-bar code, the animation engine and the theme colours of
|
||||
unused components now load only when something needs them, so every launch
|
||||
has less to download and parse.
|
||||
@@ -436,6 +436,11 @@ jobs:
|
||||
- name: Validate form-field defaults
|
||||
run: pnpm run ui:form-field-defaults:validate
|
||||
|
||||
# m3-theme.scss themes only the Material components the app
|
||||
# imports; a new import without a theme entry renders unthemed.
|
||||
- name: Validate the Material theme scope
|
||||
run: pnpm run styles:material-theme-scope:validate
|
||||
|
||||
# Nx rejects a non-parallel task with continuous dependencies, and
|
||||
# the Playwright plugin infers serve dependencies only when CI is
|
||||
# unset, so this checks both the local and the CI inference.
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromRenderer,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// app.config.ts does not provide the Angular animations module: nothing
|
||||
// declares an animation trigger, and Material 22 animates its overlays with
|
||||
// CSS, reading prefers-reduced-motion itself. These checks keep the overlays'
|
||||
// enter and exit motion, and the close paths that wait for it, working
|
||||
// without the module, and off under reduced motion.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MotionEvent {
|
||||
type: string;
|
||||
name: string;
|
||||
/** The class attribute of the element the event fired on. */
|
||||
target: string;
|
||||
}
|
||||
|
||||
type MotionWindow = Window & { __motionEvents?: MotionEvent[] };
|
||||
|
||||
/** Records every CSS animation and transition event of the document. */
|
||||
async function recordMotion(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const events: MotionEvent[] = [];
|
||||
(window as MotionWindow).__motionEvents = events;
|
||||
for (const type of [
|
||||
'animationstart',
|
||||
'animationend',
|
||||
'transitionrun',
|
||||
'transitionend',
|
||||
]) {
|
||||
document.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
const target = event.target;
|
||||
events.push({
|
||||
type,
|
||||
name:
|
||||
(event as AnimationEvent).animationName ??
|
||||
(event as TransitionEvent).propertyName,
|
||||
target:
|
||||
target instanceof Element
|
||||
? (target.getAttribute('class') ?? '')
|
||||
: '',
|
||||
});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function motionEvents(page: Page): Promise<MotionEvent[]> {
|
||||
return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for an event of that type and name, on an element wearing
|
||||
* `targetClass` when given, among the events recorded from `since` on.
|
||||
*/
|
||||
async function expectMotion(
|
||||
page: Page,
|
||||
type: string,
|
||||
name: string,
|
||||
{ targetClass, since = 0 }: { targetClass?: string; since?: number } = {}
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await motionEvents(page))
|
||||
.slice(since)
|
||||
.some(
|
||||
(event) =>
|
||||
event.type === type &&
|
||||
event.name === name &&
|
||||
(targetClass === undefined ||
|
||||
event.target.split(/\s+/).includes(targetClass))
|
||||
),
|
||||
`${type} ${name}${targetClass ? ` on .${targetClass}` : ''}`
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function recordedCount(page: Page): Promise<number> {
|
||||
return (await motionEvents(page)).length;
|
||||
}
|
||||
|
||||
/** Opens the Clear EPG data confirmation from the EPG settings section. */
|
||||
async function openClearEpgDialog(page: Page) {
|
||||
await openSettingsSection(page, 'epg');
|
||||
await page
|
||||
.getByRole('button', { name: 'Clear EPG data', exact: true })
|
||||
.click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test.describe('Material motion without the animations module', () => {
|
||||
test('@theme @electron overlays animate in and out and close after their exit motion', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
await addXtreamPortal(page);
|
||||
await openSources(page);
|
||||
await recordMotion(page);
|
||||
|
||||
// Tooltip: keyframes on show and on hide.
|
||||
const settingsLink = page.locator('a[href$="/workspace/settings"]');
|
||||
await settingsLink.hover();
|
||||
// Scoped to the one tooltip: the rail link hovered on the way
|
||||
// to Sources may still be fading out.
|
||||
const tooltip = page.locator('.mat-mdc-tooltip', {
|
||||
hasText: 'Settings',
|
||||
});
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).not.toHaveClass(/_mat-animation-noopable/);
|
||||
await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-show');
|
||||
await expectMotion(page, 'animationend', 'mat-mdc-tooltip-show');
|
||||
await page.mouse.move(640, 360);
|
||||
await expectMotion(page, 'animationstart', 'mat-mdc-tooltip-hide');
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
|
||||
// Menu: the sources sort menu.
|
||||
await page.locator('.sort-trigger').click();
|
||||
const menu = page.locator('.mat-mdc-menu-panel');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu).not.toHaveClass(
|
||||
/mat-menu-panel-animations-disabled/
|
||||
);
|
||||
await expectMotion(page, 'animationstart', '_mat-menu-enter');
|
||||
// Dismissed only once the enter animation is over: a swap while
|
||||
// it still runs cancels it, and Material treats the cancelled
|
||||
// enter as the exit's end and detaches the panel at once.
|
||||
await expectMotion(page, 'animationend', '_mat-menu-enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await expectMotion(page, 'animationstart', '_mat-menu-exit');
|
||||
await expect(menu).toHaveCount(0);
|
||||
|
||||
// Select: the language list, opened and dismissed unchanged.
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'general');
|
||||
await page.getByTestId('select-language').click();
|
||||
const panel = page.locator('.mat-mdc-select-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(
|
||||
/mat-select-panel-animations-enabled/
|
||||
);
|
||||
await expectMotion(page, 'animationstart', '_mat-select-enter');
|
||||
await expectMotion(page, 'animationend', '_mat-select-enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await expectMotion(page, 'animationstart', '_mat-select-exit');
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
// Dialog: Material drives it with transitions whose duration it
|
||||
// writes on the container; the surface scales in. Confirming
|
||||
// closes it after the exit transition and shows a snack-bar.
|
||||
const beforeDialog = await recordedCount(page);
|
||||
const dialog = await openClearEpgDialog(page);
|
||||
await expect(dialog).not.toHaveClass(/_mat-animation-noopable/);
|
||||
await expect(dialog).toHaveClass(/mdc-dialog--open/);
|
||||
expect(
|
||||
await dialog.evaluate((el) =>
|
||||
el.style.getPropertyValue(
|
||||
'--mat-dialog-transition-duration'
|
||||
)
|
||||
)
|
||||
).toMatch(/^[1-9]\d*ms$/);
|
||||
// The surface scales in while opening.
|
||||
await expectMotion(page, 'transitionend', 'transform', {
|
||||
targetClass: 'mat-mdc-dialog-surface',
|
||||
since: beforeDialog,
|
||||
});
|
||||
const beforeClose = await recordedCount(page);
|
||||
await dialog.getByTestId('confirm-dialog-confirm').click();
|
||||
// Closing fades the inner container out. Material detaches the
|
||||
// container on a timer of the same length, so the fade's end
|
||||
// may never be observed; its start is, in the first frame.
|
||||
await expectMotion(page, 'transitionrun', 'opacity', {
|
||||
targetClass: 'mat-mdc-dialog-inner-container',
|
||||
since: beforeClose,
|
||||
});
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
const snackBar = page.locator('.mat-mdc-snack-bar-container');
|
||||
await expect(snackBar).toBeVisible();
|
||||
await expect(snackBar).toHaveClass(
|
||||
/mat-snack-bar-container-animations-enabled/
|
||||
);
|
||||
await expectMotion(page, 'animationstart', '_mat-snack-bar-enter');
|
||||
// The confirmation toast lasts 2s, then leaves through its exit
|
||||
// animation; the container is removed when that one ends.
|
||||
await expectMotion(page, 'animationstart', '_mat-snack-bar-exit');
|
||||
await expect(snackBar).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@theme @electron reduced motion turns the overlay motion off', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
try {
|
||||
// Material reads the preference once per document, so a fresh
|
||||
// document must start under the emulated media.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await reloadFromRenderer(app);
|
||||
await expectRendererReloadedOnRoute(page, /\/workspace/);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, 'general');
|
||||
await recordMotion(page);
|
||||
|
||||
await page.getByTestId('select-language').click();
|
||||
const panel = page.locator('.mat-mdc-select-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).not.toHaveClass(
|
||||
/mat-select-panel-animations-enabled/
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
const dialog = await openClearEpgDialog(page);
|
||||
await expect(dialog).toHaveClass(/_mat-animation-noopable/);
|
||||
// Material writes the transition duration only when it animates.
|
||||
expect(
|
||||
await dialog.evaluate((el) =>
|
||||
el.style.getPropertyValue(
|
||||
'--mat-dialog-transition-duration'
|
||||
)
|
||||
)
|
||||
).toBe('');
|
||||
await dialog.getByTestId('confirm-dialog-confirm').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
|
||||
const snackBar = page.locator('.mat-mdc-snack-bar-container');
|
||||
await expect(snackBar).toBeVisible();
|
||||
await expect(snackBar).not.toHaveClass(
|
||||
/mat-snack-bar-container-animations-enabled/
|
||||
);
|
||||
await expect(snackBar).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
const names = (await motionEvents(page))
|
||||
.filter((event) => event.type === 'animationstart')
|
||||
.map((event) => event.name);
|
||||
expect(names.filter((name) => /^_mat-/.test(name))).toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
@Component({
|
||||
selector: 'app-startup-status',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe],
|
||||
imports: [MatButtonModule, TranslatePipe],
|
||||
template: `
|
||||
@if (!complete()) {
|
||||
<section
|
||||
@@ -45,7 +44,40 @@ import {
|
||||
/>
|
||||
</svg>
|
||||
@if (!failed()) {
|
||||
<mat-spinner diameter="32" aria-hidden="true" />
|
||||
<div class="startup-spinner" aria-hidden="true">
|
||||
<div class="startup-spinner__container">
|
||||
<div class="startup-spinner__layer">
|
||||
<div
|
||||
class="startup-spinner__clipper startup-spinner__clipper--left"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 25.2 25.2"
|
||||
focusable="false"
|
||||
>
|
||||
<circle r="11" cx="50%" cy="50%" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="startup-spinner__gap">
|
||||
<svg
|
||||
viewBox="0 0 25.2 25.2"
|
||||
focusable="false"
|
||||
>
|
||||
<circle r="11" cx="50%" cy="50%" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="startup-spinner__clipper startup-spinner__clipper--right"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 25.2 25.2"
|
||||
focusable="false"
|
||||
>
|
||||
<circle r="11" cx="50%" cy="50%" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<h1>
|
||||
{{
|
||||
@@ -121,6 +153,148 @@ import {
|
||||
margin-top: 8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
/*
|
||||
* The same drawing as an indeterminate <mat-spinner diameter="32">
|
||||
* (two clipped half circles plus a gap patch, Material's durations,
|
||||
* curves and keyframes, a 10% stroke on a 25.2 viewBox around an
|
||||
* r=11 circle, 1.25x slower under reduced motion), as static markup:
|
||||
* the component would load the progress-spinner chunk before the
|
||||
* app's first screen and set every attribute after insertion.
|
||||
*/
|
||||
.startup-spinner {
|
||||
--startup-spinner-multiplier: 1;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
direction: ltr;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.startup-spinner {
|
||||
--startup-spinner-multiplier: 1.25;
|
||||
}
|
||||
}
|
||||
.startup-spinner__container,
|
||||
.startup-spinner__layer,
|
||||
.startup-spinner svg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.startup-spinner__container {
|
||||
animation: startup-spinner-container-rotate
|
||||
calc(1568.2352941176ms * var(--startup-spinner-multiplier))
|
||||
linear infinite;
|
||||
}
|
||||
.startup-spinner__layer {
|
||||
animation: startup-spinner-layer-rotate
|
||||
calc(5332ms * var(--startup-spinner-multiplier))
|
||||
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
|
||||
}
|
||||
.startup-spinner__clipper {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.startup-spinner__clipper svg {
|
||||
width: 200%;
|
||||
}
|
||||
.startup-spinner__clipper--left svg {
|
||||
animation: startup-spinner-left-spin
|
||||
calc(1333ms * var(--startup-spinner-multiplier))
|
||||
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
|
||||
}
|
||||
.startup-spinner__clipper--right svg {
|
||||
left: -100%;
|
||||
animation: startup-spinner-right-spin
|
||||
calc(1333ms * var(--startup-spinner-multiplier))
|
||||
cubic-bezier(0.4, 0, 0.2, 1) infinite both;
|
||||
}
|
||||
.startup-spinner__gap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 47.5%;
|
||||
box-sizing: border-box;
|
||||
width: 5%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.startup-spinner__gap svg {
|
||||
left: -900%;
|
||||
width: 2000%;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.startup-spinner svg {
|
||||
fill: transparent;
|
||||
}
|
||||
.startup-spinner circle {
|
||||
stroke: var(--mat-sys-primary);
|
||||
stroke-width: 10%;
|
||||
stroke-dasharray: 69.11503837897544px;
|
||||
stroke-dashoffset: 34.55751918948772px;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.startup-spinner circle {
|
||||
stroke: CanvasText;
|
||||
}
|
||||
}
|
||||
@keyframes startup-spinner-container-rotate {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes startup-spinner-layer-rotate {
|
||||
12.5% {
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
37.5% {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(540deg);
|
||||
}
|
||||
62.5% {
|
||||
transform: rotate(675deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(810deg);
|
||||
}
|
||||
87.5% {
|
||||
transform: rotate(945deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(1080deg);
|
||||
}
|
||||
}
|
||||
@keyframes startup-spinner-left-spin {
|
||||
from {
|
||||
transform: rotate(265deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(130deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(265deg);
|
||||
}
|
||||
}
|
||||
@keyframes startup-spinner-right-spin {
|
||||
from {
|
||||
transform: rotate(-265deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-130deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(-265deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AppStartupStatusComponent {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateInstallService } from './services/app-update-install.service';
|
||||
|
||||
/**
|
||||
* The update notification itself: the card with its download, install and
|
||||
* release-notes actions. `AppUpdateNotificationPanelComponent` defers it
|
||||
* until an update is available, so this file and its Material imports must
|
||||
* stay out of every file that loads with the app.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-update-notification-card',
|
||||
imports: [MatButtonModule, MatIconModule, TranslatePipe],
|
||||
template: `
|
||||
<section
|
||||
class="app-update-notification"
|
||||
data-test-id="app-update-notification"
|
||||
>
|
||||
<header class="app-update-notification__header">
|
||||
<span>
|
||||
<mat-icon>system_update</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
|
||||
</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="dismissed.emit()"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="app-update-notification__body">
|
||||
<strong>
|
||||
{{
|
||||
labelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
status().latestVersion ||
|
||||
status().currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (status().progress; as progress) {
|
||||
<progress [value]="progress.percent" max="100"></progress>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="app-update-notification__actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openReleaseNotes()"
|
||||
data-test-id="app-update-notification-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</button>
|
||||
|
||||
@if (status().status === appUpdateStatuses.Downloaded) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installUpdate()"
|
||||
data-test-id="app-update-notification-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
status().status === appUpdateStatuses.Downloading
|
||||
"
|
||||
(click)="downloadUpdate()"
|
||||
data-test-id="app-update-notification-download"
|
||||
>
|
||||
<mat-icon>{{ primaryActionIcon() }}</mat-icon>
|
||||
{{ primaryActionLabelKey() | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.app-update-notification {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 40px);
|
||||
z-index: 901;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 12px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.app-update-notification__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.app-update-notification__header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-update-notification__header mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.app-update-notification__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.app-update-notification__body progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-update-notification__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.app-update-notification {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppUpdateNotificationCardComponent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly installService = inject(AppUpdateInstallService);
|
||||
|
||||
readonly status = input.required<ElectronBridgeAppUpdateStatus>();
|
||||
/** A status the card learnt from the updater (download, install). */
|
||||
readonly statusChange = output<ElectronBridgeAppUpdateStatus>();
|
||||
readonly dismissed = output<void>();
|
||||
|
||||
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
|
||||
readonly labelKey = computed(() =>
|
||||
this.status().status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
|
||||
: 'SETTINGS.APP_UPDATE_AVAILABLE'
|
||||
);
|
||||
readonly primaryActionLabelKey = computed(() =>
|
||||
this.status().supportedSelfUpdate === false
|
||||
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
|
||||
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
|
||||
);
|
||||
readonly primaryActionIcon = computed(() =>
|
||||
this.status().supportedSelfUpdate === false ? 'open_in_new' : 'download'
|
||||
);
|
||||
|
||||
/**
|
||||
* Loaded on demand: the dialog renders Markdown with `marked`, which this
|
||||
* card would otherwise bring along with the first notification. A field
|
||||
* so specs can substitute it.
|
||||
*/
|
||||
loadReleaseNotesDialog = () =>
|
||||
import('./settings/app-update-release-notes-dialog.component');
|
||||
|
||||
async openReleaseNotes(): Promise<void> {
|
||||
const status = this.status();
|
||||
let dialogModule: Awaited<
|
||||
ReturnType<typeof this.loadReleaseNotesDialog>
|
||||
>;
|
||||
try {
|
||||
dialogModule = await this.loadReleaseNotesDialog();
|
||||
} catch (error) {
|
||||
// The same notes are on the releases page; the next click retries.
|
||||
console.error('Could not load the release notes dialog:', error);
|
||||
if (status.manualDownloadUrl) {
|
||||
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
|
||||
|
||||
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
initialVersion: status.latestVersion,
|
||||
},
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
restoreFocus: true,
|
||||
width: '720px',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {
|
||||
const status = this.status();
|
||||
|
||||
if (!status.supportedSelfUpdate) {
|
||||
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.electron?.downloadAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusChange.emit(await window.electron.downloadAppUpdate());
|
||||
}
|
||||
|
||||
async installUpdate(): Promise<void> {
|
||||
// Never window.electron.installAppUpdate() directly: the install
|
||||
// quits the app, and AppUpdateInstallService stands the settings
|
||||
// unload guard down first — this panel is global, so the settings
|
||||
// form can be dirty right now.
|
||||
const status = await this.installService.installAppUpdate();
|
||||
|
||||
if (status) {
|
||||
this.statusChange.emit(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
DeferBlockState,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
|
||||
|
||||
@@ -62,9 +69,34 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('shows a startup update notification when the main process reports an available update', () => {
|
||||
statusHandler?.(availableStatus);
|
||||
/**
|
||||
* Pushes a status and lets the deferred card render: its chunk resolves
|
||||
* asynchronously, after which the panel needs one more check.
|
||||
*/
|
||||
async function showUpdate(
|
||||
status: ElectronBridgeAppUpdateStatus
|
||||
): Promise<void> {
|
||||
statusHandler?.(status);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function card(): AppUpdateNotificationCardComponent {
|
||||
return fixture.debugElement.query(
|
||||
By.directive(AppUpdateNotificationCardComponent)
|
||||
).componentInstance as AppUpdateNotificationCardComponent;
|
||||
}
|
||||
|
||||
it('renders no card before the main process reports an update', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-update-notification-card')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a startup update notification when the main process reports an available update', async () => {
|
||||
await showUpdate(availableStatus);
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
@@ -73,10 +105,27 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens release notes without dismissing the notification', async () => {
|
||||
statusHandler?.(availableStatus);
|
||||
it('hides the notification when it is dismissed', async () => {
|
||||
await showUpdate(availableStatus);
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.app-update-notification__header button'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.dismissedVersion()).toBe('0.23.0');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('opens release notes without dismissing the notification', async () => {
|
||||
await showUpdate(availableStatus);
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification-release-notes"]'
|
||||
@@ -104,12 +153,12 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
const error = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
fixture.componentInstance.loadReleaseNotesDialog = () =>
|
||||
await showUpdate(availableStatus);
|
||||
const notification = card();
|
||||
notification.loadReleaseNotesDialog = () =>
|
||||
Promise.reject(new Error('chunk failed'));
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
|
||||
await fixture.componentInstance.openReleaseNotes();
|
||||
await notification.openReleaseNotes();
|
||||
|
||||
expect(TestBed.inject(MatDialog).open).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
@@ -123,8 +172,7 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
});
|
||||
|
||||
it('starts downloading the update from the notification action', async () => {
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
await showUpdate(availableStatus);
|
||||
|
||||
(
|
||||
fixture.nativeElement.querySelector(
|
||||
@@ -134,15 +182,47 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
|
||||
// The card reports the updater's reply back to the panel.
|
||||
expect(fixture.componentInstance.status()?.status).toBe(
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
|
||||
);
|
||||
});
|
||||
|
||||
it('labels unsupported platform action as a GitHub release link', () => {
|
||||
it('tells the user when the card chunk cannot be loaded', async () => {
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AppUpdateNotificationPanelComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [{ provide: MatDialog, useValue: { open: jest.fn() } }],
|
||||
deferBlockBehavior: DeferBlockBehavior.Manual,
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(AppUpdateNotificationPanelComponent);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
statusHandler?.(availableStatus);
|
||||
fixture.detectChanges();
|
||||
|
||||
const [block] = await fixture.getDeferBlocks();
|
||||
await block.render(DeferBlockState.Error);
|
||||
|
||||
const notice = fixture.nativeElement.querySelector('[role="alert"]');
|
||||
expect(notice?.textContent).toContain(
|
||||
'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE'
|
||||
);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-update-notification-card')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('labels unsupported platform action as a GitHub release link', async () => {
|
||||
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
statusHandler?.({
|
||||
await showUpdate({
|
||||
...availableStatus,
|
||||
supportedSelfUpdate: false,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification-download"]'
|
||||
|
||||
@@ -1,204 +1,78 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppUpdateInstallService } from './services/app-update-install.service';
|
||||
import { AppUpdateNotificationCardComponent } from './app-update-notification-card.component';
|
||||
|
||||
/**
|
||||
* Follows the main process's update status and shows the notification card
|
||||
* once an update is available. The panel is mounted on every launch, so the
|
||||
* card and its Material buttons, icons and dialog load with the first
|
||||
* notification instead of with the app. Should that chunk fail to load, a
|
||||
* notice points at the settings, where the update can still be installed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-update-notification-panel',
|
||||
imports: [MatButtonModule, MatIconModule, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [AppUpdateNotificationCardComponent, TranslatePipe],
|
||||
template: `
|
||||
@if (isVisible(); as visible) {
|
||||
<section
|
||||
class="app-update-notification"
|
||||
data-test-id="app-update-notification"
|
||||
>
|
||||
<header class="app-update-notification__header">
|
||||
<span>
|
||||
<mat-icon>system_update</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}
|
||||
</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="dismiss()"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="app-update-notification__body">
|
||||
<strong>
|
||||
{{
|
||||
labelKey()
|
||||
| translate
|
||||
: {
|
||||
version:
|
||||
status()?.latestVersion ||
|
||||
status()?.currentVersion,
|
||||
}
|
||||
}}
|
||||
</strong>
|
||||
@if (status()?.progress; as progress) {
|
||||
<progress
|
||||
[value]="progress.percent"
|
||||
max="100"
|
||||
></progress>
|
||||
}
|
||||
@defer (when visibleStatus()) {
|
||||
@if (visibleStatus(); as current) {
|
||||
<app-update-notification-card
|
||||
[status]="current"
|
||||
(statusChange)="status.set($event)"
|
||||
(dismissed)="dismiss()"
|
||||
/>
|
||||
}
|
||||
} @error {
|
||||
@if (visibleStatus()) {
|
||||
<div class="app-update-notification-unavailable" role="alert">
|
||||
{{ 'SETTINGS.APP_UPDATE_PANEL_UNAVAILABLE' | translate }}
|
||||
</div>
|
||||
|
||||
<div class="app-update-notification__actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="openReleaseNotes()"
|
||||
data-test-id="app-update-notification-release-notes"
|
||||
>
|
||||
<mat-icon>article</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_RELEASE_NOTES' | translate }}
|
||||
</button>
|
||||
|
||||
@if (status()?.status === appUpdateStatuses.Downloaded) {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
(click)="installUpdate()"
|
||||
data-test-id="app-update-notification-install"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
{{ 'SETTINGS.APP_UPDATE_INSTALL' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="
|
||||
status()?.status ===
|
||||
appUpdateStatuses.Downloading
|
||||
"
|
||||
(click)="downloadUpdate()"
|
||||
data-test-id="app-update-notification-download"
|
||||
>
|
||||
<mat-icon>{{ primaryActionIcon() }}</mat-icon>
|
||||
{{ primaryActionLabelKey() | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
.app-update-notification {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 40px);
|
||||
z-index: 901;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 12px;
|
||||
background: var(--app-widget-bg);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
styles: `
|
||||
.app-update-notification-unavailable {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 901;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 40px);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 12px;
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.app-update-notification-unavailable {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.app-update-notification__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
}
|
||||
|
||||
.app-update-notification__header span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--app-heading-color);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-update-notification__header mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
|
||||
.app-update-notification__body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.app-update-notification__body progress {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-update-notification__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.app-update-notification {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly installService = inject(AppUpdateInstallService);
|
||||
private unsubscribeStatus: (() => void) | null = null;
|
||||
|
||||
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
|
||||
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
|
||||
readonly dismissedVersion = signal<string | null>(null);
|
||||
readonly labelKey = computed(() =>
|
||||
this.status()?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
? 'SETTINGS.APP_UPDATE_DOWNLOADED'
|
||||
: 'SETTINGS.APP_UPDATE_AVAILABLE'
|
||||
);
|
||||
readonly primaryActionLabelKey = computed(() =>
|
||||
this.status()?.supportedSelfUpdate === false
|
||||
? 'SETTINGS.APP_UPDATE_OPEN_RELEASE'
|
||||
: 'SETTINGS.APP_UPDATE_DOWNLOAD'
|
||||
);
|
||||
readonly primaryActionIcon = computed(() =>
|
||||
this.status()?.supportedSelfUpdate === false
|
||||
? 'open_in_new'
|
||||
: 'download'
|
||||
);
|
||||
readonly isVisible = computed(() => {
|
||||
const status = this.status();
|
||||
|
||||
@@ -216,6 +90,10 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
/** The status the card shows, or null while there is nothing to show. */
|
||||
readonly visibleStatus = computed(() =>
|
||||
this.isVisible() ? this.status() : null
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
if (!window.electron?.getAppUpdateStatus) {
|
||||
@@ -237,74 +115,6 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
this.unsubscribeStatus = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded on demand: the dialog renders Markdown with `marked`, which this
|
||||
* always-mounted panel would otherwise put on the initial path. A field so
|
||||
* specs can substitute it.
|
||||
*/
|
||||
loadReleaseNotesDialog = () =>
|
||||
import('./settings/app-update-release-notes-dialog.component');
|
||||
|
||||
async openReleaseNotes(): Promise<void> {
|
||||
const status = this.status();
|
||||
const latestVersion = status?.latestVersion;
|
||||
let dialogModule: Awaited<
|
||||
ReturnType<typeof this.loadReleaseNotesDialog>
|
||||
>;
|
||||
try {
|
||||
dialogModule = await this.loadReleaseNotesDialog();
|
||||
} catch (error) {
|
||||
// The same notes are on the releases page; the next click retries.
|
||||
console.error('Could not load the release notes dialog:', error);
|
||||
if (status?.manualDownloadUrl) {
|
||||
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { AppUpdateReleaseNotesDialogComponent } = dialogModule;
|
||||
|
||||
this.dialog.open(AppUpdateReleaseNotesDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
initialVersion: latestVersion,
|
||||
},
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
restoreFocus: true,
|
||||
width: '720px',
|
||||
});
|
||||
}
|
||||
|
||||
async downloadUpdate(): Promise<void> {
|
||||
const status = this.status();
|
||||
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.supportedSelfUpdate) {
|
||||
window.open(status.manualDownloadUrl, '_blank', 'noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.electron?.downloadAppUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.set(await window.electron.downloadAppUpdate());
|
||||
}
|
||||
|
||||
async installUpdate(): Promise<void> {
|
||||
// Never window.electron.installAppUpdate() directly: the install
|
||||
// quits the app, and AppUpdateInstallService stands the settings
|
||||
// unload guard down first — this panel is global, so the settings
|
||||
// form can be dirty right now.
|
||||
const status = await this.installService.installAppUpdate();
|
||||
|
||||
if (status) {
|
||||
this.status.set(status);
|
||||
}
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.dismissedVersion.set(this.status()?.latestVersion ?? null);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
OverlayContainer,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideEffects } from '@ngrx/effects';
|
||||
@@ -106,7 +105,10 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
...changeDetectionProviders,
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimations(),
|
||||
// No provideAnimations(): nothing declares an Angular animation
|
||||
// trigger, and Material 22 animates with CSS, reading
|
||||
// prefers-reduced-motion itself. The provider only put the 66 KB
|
||||
// animation engine on the initial path.
|
||||
// CDK overlays (menus, tooltips, dialogs) live in a container under
|
||||
// <body>, which the browser hides behind an element in DOM fullscreen.
|
||||
// This container follows the fullscreen element instead, so the
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
PARENTAL_LOCK_PROMPT,
|
||||
ParentalLockPrompt,
|
||||
ParentalLockPromptRequest,
|
||||
} from '@iptvnator/services';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
|
||||
/**
|
||||
* Application implementation of the parental lock prompt: the shared PIN
|
||||
* dialog from `@iptvnator/ui/components`, which the data-access lock
|
||||
* service cannot import itself. The dialog is loaded on first use (the
|
||||
* service is constructed at startup, the dialog is not needed until a PIN
|
||||
* is asked for).
|
||||
* service cannot import itself. The dialog, and Material's dialog service
|
||||
* with it, is loaded on first use (the service is constructed at startup,
|
||||
* the dialog is not needed until a PIN is asked for).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
|
||||
@@ -25,8 +25,12 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
request: ParentalLockPromptRequest
|
||||
): Promise<string | null> {
|
||||
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
|
||||
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
|
||||
try {
|
||||
module = await this.loadPinDialog();
|
||||
[dialog, module] = await Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadPinDialog(),
|
||||
]);
|
||||
} catch (error) {
|
||||
// A failed chunk load reads as a cancelled prompt: the gate it
|
||||
// guards stays closed.
|
||||
@@ -36,17 +40,14 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(
|
||||
this.dialog,
|
||||
{
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
submitKey: request.submitKey,
|
||||
}
|
||||
);
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(dialog, {
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
throttle: request.throttle,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
submitKey: request.submitKey,
|
||||
});
|
||||
const pin = await firstValueFrom(dialogRef.afterClosed());
|
||||
return typeof pin === 'string' ? pin : null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
ExternalPlayerSession,
|
||||
@@ -10,13 +9,14 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PlayerService {
|
||||
private dialog = inject(MatDialog);
|
||||
private lazyDialog = inject(LazyMatDialog);
|
||||
private dataService = inject(DataService);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
|
||||
@@ -85,12 +85,16 @@ export class PlayerService {
|
||||
/**
|
||||
* Loaded on demand: the dialog's Material checkbox brings @angular/forms,
|
||||
* and this service is created with the app, so a static import would put
|
||||
* both on the initial path. Playback does not wait for the dialog.
|
||||
* both, and Material's dialog, on the initial path. Playback does not
|
||||
* wait for the dialog.
|
||||
*/
|
||||
private showExternalPlayerInfoDialog(): void {
|
||||
this.loadExternalPlayerInfoDialog()
|
||||
.then(({ ExternalPlayerInfoDialogComponent }) =>
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent)
|
||||
Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadExternalPlayerInfoDialog(),
|
||||
])
|
||||
.then(([dialog, { ExternalPlayerInfoDialogComponent }]) =>
|
||||
dialog.open(ExternalPlayerInfoDialogComponent)
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
// Informational only: playback is already starting.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import type { MatDialog } from '@angular/material/dialog';
|
||||
import { Router } from '@angular/router';
|
||||
import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util/tokens';
|
||||
import { LazyMatDialog } from '@iptvnator/ui/components/lazy-dialog';
|
||||
import {
|
||||
WORKSPACE_SHELL_ACTIONS,
|
||||
WorkspaceAccountInfoData,
|
||||
@@ -10,15 +11,21 @@ import {
|
||||
WorkspaceStalkerAccountInfoData,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
|
||||
/**
|
||||
* Created with the app (the shell's keyboard shortcuts reach it from the
|
||||
* root), so every dialog it opens, and Material's dialog service itself,
|
||||
* is imported on first use.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
openAddPlaylistDialog(type?: WorkspacePlaylistType): void {
|
||||
void import('@iptvnator/playlist/import/feature').then(
|
||||
({ AddPlaylistDialogComponent }) => {
|
||||
this.dialog.open(AddPlaylistDialogComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/playlist/import/feature'),
|
||||
(dialog, { AddPlaylistDialogComponent }) => {
|
||||
dialog.open(AddPlaylistDialogComponent, {
|
||||
// Width sized for the 5-card method picker plus the
|
||||
// selected method's form below. Matches the v0.22
|
||||
// mockup; falls back to viewport-clamped width on
|
||||
@@ -44,9 +51,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
|
||||
openAccountInfo(data: WorkspaceAccountInfoData): void {
|
||||
void import('@iptvnator/portal/xtream/feature').then(
|
||||
({ AccountInfoComponent }) => {
|
||||
this.dialog.open(AccountInfoComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/portal/xtream/feature'),
|
||||
(dialog, { AccountInfoComponent }) => {
|
||||
dialog.open(AccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
@@ -57,9 +65,10 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
|
||||
openStalkerAccountInfo(data: WorkspaceStalkerAccountInfoData): void {
|
||||
void import('@iptvnator/portal/stalker/feature').then(
|
||||
({ StalkerAccountInfoComponent }) => {
|
||||
this.dialog.open(StalkerAccountInfoComponent, {
|
||||
this.openDialog(
|
||||
import('@iptvnator/portal/stalker/feature'),
|
||||
(dialog, { StalkerAccountInfoComponent }) => {
|
||||
dialog.open(StalkerAccountInfoComponent, {
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
@@ -68,6 +77,16 @@ export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens once both the dialog service and the component's chunk are in. */
|
||||
private openDialog<T>(
|
||||
load: Promise<T>,
|
||||
open: (dialog: MatDialog, module: T) => void
|
||||
): void {
|
||||
void Promise.all([this.lazyDialog.resolve(), load]).then(
|
||||
([dialog, module]) => open(dialog, module)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function provideWorkspaceShellActions(): Provider[] {
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "لم يتم العثور على ملاحظات الإصدار {{version}} على GitHub. ربما تمت إزالة الإصدار أو لم يُنشر بعد.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "تعذّر تحميل ملاحظات الإصدار.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "تعذّر عرض إشعار التحديث. تحقّق من التحديثات في إعدادات التطبيق.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار غير متاحة في هذه النسخة من التطبيق.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
|
||||
"APP_UPDATE_CHANNEL": "قناة التحديث",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "تعذّر تحميل لوحة استيراد EPG. أعد تشغيل التطبيق لمتابعة تقدّم الاستيراد؛ تبقى حالة كل مصدر في إعدادات EPG.",
|
||||
"CHANNELS": "قنوات",
|
||||
"PROGRAMS": "برامج",
|
||||
"LOADING": "جارٍ التحميل",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "إصدار أحدث",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "ما لقيناش ملاحظات الإصدار {{version}} فـ GitHub. يمكن الإصدار تحيد ولا مازال ما تنشرش.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "ما قدرناش نحمّلو ملاحظات الإصدار.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "ما قدرناش نعرضو إشعار التحديث. تأكد من التحديثات فإعدادات التطبيق.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "ملاحظات الإصدار ما متوفراش فهاد النسخة.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "فتح صفحة الإصدارات",
|
||||
"APP_UPDATE_CHANNEL": "قناة التحديث",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG تجلب بنجاح",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "ما قدرناش نحمّلو لوحة استيراد EPG. عاود شغّل التطبيق باش تشوف تقدّم الاستيراد؛ حالة كل مصدر كتبقى فإعدادات EPG.",
|
||||
"CHANNELS": "قنوات",
|
||||
"PROGRAMS": "برامج",
|
||||
"LOADING": "جاري التشريج",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Навейшая версія",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заўвагі да версіі {{version}} на GitHub не знойдзены. Рэліз мог быць выдалены або яшчэ не апублікаваны.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не ўдалося загрузіць заўвагі да версіі.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Не ўдалося паказаць апавяшчэнне пра абнаўленне. Праверце наяўнасць абнаўленняў у наладах праграмы.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заўвагі да версіі недаступныя ў гэтай зборцы.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Адкрыць старонку рэлізаў",
|
||||
"APP_UPDATE_CHANNEL": "Канал абнаўленняў",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
|
||||
"IMPORT_PROGRESS": "Імпарт EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Не ўдалося загрузіць панэль імпарту EPG. Перазапусціце праграму, каб бачыць ход імпарту; стан кожнай крыніцы застаецца ў наладах EPG.",
|
||||
"CHANNELS": "каналаў",
|
||||
"PROGRAMS": "перадач",
|
||||
"LOADING": "Загрузка",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Neuere Version",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Für {{version}} wurden auf GitHub keine Release-Notes gefunden. Das Release wurde möglicherweise entfernt oder ist noch nicht veröffentlicht.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Die Release-Notes konnten nicht geladen werden.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Die Update-Benachrichtigung konnte nicht angezeigt werden. Prüfe in den App-Einstellungen auf Updates.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release-Notes sind in diesem Build nicht verfügbar.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Release-Seite öffnen",
|
||||
"APP_UPDATE_CHANNEL": "Update-Kanal",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
|
||||
"IMPORT_PROGRESS": "EPG-Import",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Das EPG-Importfenster konnte nicht geladen werden. Starte die App neu, um den Importfortschritt zu sehen; der Status jeder Quelle bleibt in den EPG-Einstellungen.",
|
||||
"CHANNELS": "Sender",
|
||||
"PROGRAMS": "Programme",
|
||||
"LOADING": "Wird geladen",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Νεότερη έκδοση",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Δεν βρέθηκαν σημειώσεις έκδοσης για την {{version}} στο GitHub. Η έκδοση μπορεί να αφαιρέθηκε ή να μην έχει δημοσιευτεί ακόμη.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των σημειώσεων έκδοσης.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η εμφάνιση της ειδοποίησης ενημέρωσης. Ελέγξτε για ενημερώσεις στις ρυθμίσεις της εφαρμογής.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Οι σημειώσεις έκδοσης δεν είναι διαθέσιμες σε αυτή τη δομή.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Άνοιγμα σελίδας εκδόσεων",
|
||||
"APP_UPDATE_CHANNEL": "Κανάλι ενημερώσεων",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
|
||||
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η φόρτωση του πίνακα εισαγωγής EPG. Επανεκκινήστε την εφαρμογή για να παρακολουθήσετε την εισαγωγή· η κατάσταση κάθε πηγής παραμένει στις ρυθμίσεις EPG.",
|
||||
"CHANNELS": "κανάλια",
|
||||
"PROGRAMS": "προγράμματα",
|
||||
"LOADING": "Φόρτωση",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Newer release",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No release notes were found for {{version}} on GitHub. The release may have been removed or is not published yet.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Release notes could not be loaded.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "The update notification could not be shown. Check for updates in the app settings.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Release notes are not available in this build.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Open releases page",
|
||||
"APP_UPDATE_CHANNEL": "Update channel",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG was fetched successfully",
|
||||
"IMPORT_PROGRESS": "EPG Import",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "The EPG import panel could not be loaded. Restart the app to see import progress; each source's status stays in the EPG settings.",
|
||||
"CHANNELS": "channels",
|
||||
"PROGRAMS": "programs",
|
||||
"LOADING": "Loading",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versión más reciente",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "No se encontraron notas de la versión {{version}} en GitHub. Es posible que la versión se haya eliminado o aún no esté publicada.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "No se pudieron cargar las notas de la versión.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "No se pudo mostrar la notificación de actualización. Busca actualizaciones en los ajustes de la aplicación.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Las notas de la versión no están disponibles en esta compilación.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versiones",
|
||||
"APP_UPDATE_CHANNEL": "Canal de actualizaciones",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG obtenida correctamente",
|
||||
"IMPORT_PROGRESS": "Importación de EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "No se pudo cargar el panel de importación de EPG. Reinicia la aplicación para ver el progreso de la importación; el estado de cada fuente sigue en los ajustes de EPG.",
|
||||
"CHANNELS": "canales",
|
||||
"PROGRAMS": "programas",
|
||||
"LOADING": "Cargando",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Version plus récente",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Aucune note de version n'a été trouvée pour {{version}} sur GitHub. La version a peut-être été supprimée ou n'est pas encore publiée.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossible de charger les notes de version.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "La notification de mise à jour n'a pas pu être affichée. Recherchez les mises à jour dans les réglages de l'application.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Les notes de version ne sont pas disponibles dans cette compilation.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Ouvrir la page des versions",
|
||||
"APP_UPDATE_CHANNEL": "Canal de mise à jour",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
|
||||
"IMPORT_PROGRESS": "Importation EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Le panneau d'import EPG n'a pas pu être chargé. Redémarrez l'application pour suivre l'import ; l'état de chaque source reste dans les réglages EPG.",
|
||||
"CHANNELS": "chaînes",
|
||||
"PROGRAMS": "programmes",
|
||||
"LOADING": "Chargement",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Újabb kiadás",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "A(z) {{version}} verzióhoz nem található kiadási jegyzet a GitHubon. A kiadást talán eltávolították, vagy még nem tették közzé.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "A kiadási jegyzetek betöltése nem sikerült.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "A frissítési értesítés nem jeleníthető meg. Keress frissítéseket az alkalmazás beállításaiban.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Ebben a buildben nem érhetők el a kiadási jegyzetek.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Kiadások oldalának megnyitása",
|
||||
"APP_UPDATE_CHANNEL": "Frissítési csatorna",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "Az EPG lekérése sikerült",
|
||||
"IMPORT_PROGRESS": "EPG importálása",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Az EPG-importpanel nem tölthető be. Indítsd újra az alkalmazást az importálás követéséhez; az egyes források állapota az EPG-beállításokban marad.",
|
||||
"CHANNELS": "csatorna",
|
||||
"PROGRAMS": "műsor",
|
||||
"LOADING": "Betöltés",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versione più recente",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nessuna nota di rilascio trovata per {{version}} su GitHub. La versione potrebbe essere stata rimossa o non è ancora pubblicata.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Impossibile caricare le note di rilascio.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Impossibile mostrare la notifica di aggiornamento. Cerca gli aggiornamenti nelle impostazioni dell'app.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Le note di rilascio non sono disponibili in questa build.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Apri la pagina delle versioni",
|
||||
"APP_UPDATE_CHANNEL": "Canale di aggiornamento",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG scaricato correttamente",
|
||||
"IMPORT_PROGRESS": "Importazione EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Impossibile caricare il pannello di importazione EPG. Riavvia l'app per seguire l'importazione; lo stato di ogni sorgente resta nelle impostazioni EPG.",
|
||||
"CHANNELS": "canali",
|
||||
"PROGRAMS": "programmi",
|
||||
"LOADING": "Caricamento",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "新しいリリース",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "{{version}} のリリースノートが GitHub に見つかりません。リリースが削除されたか、まだ公開されていない可能性があります。",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "リリースノートを読み込めませんでした。",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "更新の通知を表示できませんでした。アプリの設定で更新を確認してください。",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "このビルドではリリースノートを利用できません。",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "リリースページを開く",
|
||||
"APP_UPDATE_CHANNEL": "更新チャンネル",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPGの取得に成功しました",
|
||||
"IMPORT_PROGRESS": "EPGインポート",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "EPGインポートパネルを読み込めませんでした。インポートの進行状況を確認するにはアプリを再起動してください。各ソースの状態はEPG設定で確認できます。",
|
||||
"CHANNELS": "チャンネル",
|
||||
"PROGRAMS": "番組",
|
||||
"LOADING": "読み込み中",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "이후 릴리스",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub에서 {{version}}의 릴리스 노트를 찾을 수 없습니다. 릴리스가 삭제되었거나 아직 게시되지 않았을 수 있습니다.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "릴리스 노트를 불러올 수 없습니다.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "업데이트 알림을 표시할 수 없습니다. 앱 설정에서 업데이트를 확인하세요.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "이 빌드에서는 릴리스 노트를 사용할 수 없습니다.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "릴리스 페이지 열기",
|
||||
"APP_UPDATE_CHANNEL": "업데이트 채널",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
|
||||
"IMPORT_PROGRESS": "EPG 가져오기",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "EPG 가져오기 패널을 불러올 수 없습니다. 가져오기 진행 상황을 보려면 앱을 다시 시작하세요. 각 소스의 상태는 EPG 설정에서 확인할 수 있습니다.",
|
||||
"CHANNELS": "채널",
|
||||
"PROGRAMS": "프로그램",
|
||||
"LOADING": "불러오는 중",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nieuwere versie",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Er zijn geen releasenotes voor {{version}} gevonden op GitHub. De release is mogelijk verwijderd of nog niet gepubliceerd.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "De releasenotes konden niet worden geladen.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "De updatemelding kon niet worden getoond. Controleer op updates in de app-instellingen.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Releasenotes zijn niet beschikbaar in deze build.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Releasepagina openen",
|
||||
"APP_UPDATE_CHANNEL": "Updatekanaal",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
|
||||
"IMPORT_PROGRESS": "EPG-import",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Het EPG-importpaneel kon niet worden geladen. Herstart de app om de importvoortgang te zien; de status van elke bron blijft in de EPG-instellingen.",
|
||||
"CHANNELS": "kanalen",
|
||||
"PROGRAMS": "programma's",
|
||||
"LOADING": "Laden",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Nowsze wydanie",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nie znaleziono informacji o wydaniu {{version}} w GitHub. Wydanie mogło zostać usunięte lub nie zostało jeszcze opublikowane.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Nie udało się wczytać informacji o wydaniu.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Nie udało się wyświetlić powiadomienia o aktualizacji. Sprawdź dostępność aktualizacji w ustawieniach aplikacji.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Informacje o wydaniu nie są dostępne w tej kompilacji.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Otwórz stronę wydań",
|
||||
"APP_UPDATE_CHANNEL": "Kanał aktualizacji",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
|
||||
"IMPORT_PROGRESS": "Import EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Nie udało się wczytać panelu importu EPG. Uruchom aplikację ponownie, aby śledzić import; stan każdego źródła pozostaje w ustawieniach EPG.",
|
||||
"CHANNELS": "kanałów",
|
||||
"PROGRAMS": "programów",
|
||||
"LOADING": "Ładowanie",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Versão mais recente",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Nenhuma nota de versão foi encontrada para {{version}} no GitHub. A versão pode ter sido removida ou ainda não foi publicada.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Não foi possível carregar as notas da versão.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Não foi possível mostrar a notificação de atualização. Procure atualizações nas definições da aplicação.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "As notas da versão não estão disponíveis nesta compilação.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Abrir página de versões",
|
||||
"APP_UPDATE_CHANNEL": "Canal de atualização",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
|
||||
"IMPORT_PROGRESS": "Importação de EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Não foi possível carregar o painel de importação do EPG. Reinicie a aplicação para acompanhar a importação; o estado de cada fonte continua nas definições de EPG.",
|
||||
"CHANNELS": "canais",
|
||||
"PROGRAMS": "programas",
|
||||
"LOADING": "Carregando",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Более поздняя версия",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "Заметки к версии {{version}} на GitHub не найдены. Релиз мог быть удалён или ещё не опубликован.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Не удалось загрузить заметки к версии.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Не удалось показать уведомление об обновлении. Проверьте наличие обновлений в настройках приложения.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Заметки к версии недоступны в этой сборке.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Открыть страницу релизов",
|
||||
"APP_UPDATE_CHANNEL": "Канал обновлений",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
|
||||
"IMPORT_PROGRESS": "Импорт EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Не удалось загрузить панель импорта EPG. Перезапустите приложение, чтобы видеть ход импорта; статус каждого источника остаётся в настройках EPG.",
|
||||
"CHANNELS": "каналов",
|
||||
"PROGRAMS": "программ",
|
||||
"LOADING": "Загрузка",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "Daha yeni sürüm",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "GitHub'da {{version}} için sürüm notu bulunamadı. Sürüm kaldırılmış ya da henüz yayınlanmamış olabilir.",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "Sürüm notları yüklenemedi.",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "Güncelleme bildirimi gösterilemedi. Güncellemeleri uygulama ayarlarından kontrol edin.",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "Sürüm notları bu derlemede kullanılamıyor.",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "Sürümler sayfasını aç",
|
||||
"APP_UPDATE_CHANNEL": "Güncelleme kanalı",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG verileri başarıyla alındı",
|
||||
"IMPORT_PROGRESS": "EPG İçe Aktarımı",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "EPG içe aktarma paneli yüklenemedi. İçe aktarma ilerlemesini görmek için uygulamayı yeniden başlatın; her kaynağın durumu EPG ayarlarında kalır.",
|
||||
"CHANNELS": "kanallar",
|
||||
"PROGRAMS": "programlar",
|
||||
"LOADING": "Yükleniyor",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "较新版本",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上未找到 {{version}} 的更新说明。该版本可能已被移除或尚未发布。",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "无法加载更新说明。",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "无法显示更新通知。请在应用设置中检查更新。",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此构建中没有可用的更新说明。",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "打开版本发布页面",
|
||||
"APP_UPDATE_CHANNEL": "更新频道",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG 获取成功",
|
||||
"IMPORT_PROGRESS": "EPG 导入",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "无法加载 EPG 导入面板。请重启应用以查看导入进度;每个来源的状态仍可在 EPG 设置中查看。",
|
||||
"CHANNELS": "频道",
|
||||
"PROGRAMS": "节目",
|
||||
"LOADING": "加载中",
|
||||
|
||||
@@ -589,6 +589,7 @@
|
||||
"APP_UPDATE_RELEASE_NOTES_NEWER": "較新版本",
|
||||
"APP_UPDATE_RELEASE_NOTES_NOT_FOUND": "在 GitHub 上找不到 {{version}} 的更新說明。該版本可能已被移除或尚未發布。",
|
||||
"APP_UPDATE_RELEASE_NOTES_LOAD_FAILED": "無法載入更新說明。",
|
||||
"APP_UPDATE_PANEL_UNAVAILABLE": "無法顯示更新通知。請在應用程式設定中檢查更新。",
|
||||
"APP_UPDATE_RELEASE_NOTES_UNAVAILABLE": "此版本未提供更新說明。",
|
||||
"APP_UPDATE_OPEN_RELEASES_PAGE": "開啟版本發布頁面",
|
||||
"APP_UPDATE_CHANNEL": "更新頻道",
|
||||
@@ -980,6 +981,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG 已成功擷取",
|
||||
"IMPORT_PROGRESS": "EPG 匯入",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "無法載入 EPG 匯入面板。請重新啟動應用程式以查看匯入進度;每個來源的狀態仍可在 EPG 設定中查看。",
|
||||
"CHANNELS": "頻道",
|
||||
"PROGRAMS": "節目",
|
||||
"LOADING": "載入中",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use 'sass:map';
|
||||
@use 'sass:meta';
|
||||
@use '@angular/material' as mat;
|
||||
@use 'styles';
|
||||
|
||||
@@ -57,9 +58,58 @@ $dark-theme: mat.define-theme(
|
||||
)
|
||||
);
|
||||
|
||||
// ─── Themed components ───────────────────────────────────────────────────────
|
||||
// Only the Material components the app imports, in Material's own order.
|
||||
// `mat.all-component-themes` also emitted about 24 KB of tokens for 17
|
||||
// components nothing imports. Add an entry with a new
|
||||
// `@angular/material/<entry>` import and remove it with the last one:
|
||||
// `pnpm run styles:material-theme-scope:validate` (CI) checks both
|
||||
// directions, and `core` (ripples, options, pseudo-checkboxes) always stays.
|
||||
$material-components: (
|
||||
core,
|
||||
card,
|
||||
progress-bar,
|
||||
tooltip,
|
||||
form-field,
|
||||
input,
|
||||
select,
|
||||
dialog,
|
||||
slide-toggle,
|
||||
slider,
|
||||
menu,
|
||||
list,
|
||||
checkbox,
|
||||
button,
|
||||
icon-button,
|
||||
fab,
|
||||
snack-bar,
|
||||
progress-spinner,
|
||||
button-toggle,
|
||||
divider,
|
||||
icon
|
||||
);
|
||||
|
||||
@mixin component-themes($theme) {
|
||||
@each $component in $material-components {
|
||||
@include meta.apply(
|
||||
meta.get-mixin('#{$component}-theme', 'mat'),
|
||||
$theme
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin component-colors($theme) {
|
||||
@each $component in $material-components {
|
||||
@include meta.apply(
|
||||
meta.get-mixin('#{$component}-color', 'mat'),
|
||||
$theme
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Themes ────────────────────────────────────────────────────────────
|
||||
html {
|
||||
@include mat.all-component-themes($light-theme);
|
||||
@include component-themes($light-theme);
|
||||
|
||||
// The theme is built with the legacy `define-theme` config, whose
|
||||
// component mixins emit resolved component tokens but never declare the
|
||||
@@ -170,7 +220,7 @@ html {
|
||||
--app-rating-color: #7a4a00;
|
||||
|
||||
.dark-theme {
|
||||
@include mat.all-component-colors($dark-theme);
|
||||
@include component-colors($dark-theme);
|
||||
// Only the color roles differ per theme: the elevation shadows are
|
||||
// the same black in both, so html's declarations already apply here.
|
||||
@include mat.system-level-colors($dark-theme);
|
||||
@@ -278,7 +328,7 @@ html {
|
||||
}
|
||||
|
||||
// ─── Global refined outlined inputs ──────────────────────────────────────────
|
||||
// Applied app-wide AFTER mat.all-component-themes so these overrides win the
|
||||
// Applied app-wide AFTER the component themes so these overrides win the
|
||||
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
|
||||
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
|
||||
// component rendering a form field.
|
||||
|
||||
@@ -238,10 +238,6 @@ body.dark-theme {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.mat-drawer-inner-container {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.cdk-overlay-backdrop {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,55 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
|
||||
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
|
||||
token directly and says why.
|
||||
|
||||
### Material off the startup path
|
||||
|
||||
`renderer.initialBytes` ([performance journeys](performance-journeys.md#rendererinitialbytes))
|
||||
counts every module an eager file imports, and the bundler keeps a module in
|
||||
one chunk, so one import of one export carries the whole module. Material
|
||||
therefore reaches the initial bundle only through the snack-bar, which root
|
||||
services inject (and with it `button`, the icon button and the CDK overlay).
|
||||
Everything else is lazy by these rules:
|
||||
|
||||
- **Form-field defaults.** A component that renders `<mat-form-field>`
|
||||
adds `providers: [provideFormFieldDefaults()]`
|
||||
(`@iptvnator/ui/components/form-field-defaults`); the token is never
|
||||
provided at the root, where its import alone put the 54 KB form-field
|
||||
chunk on the initial path. `pnpm run ui:form-field-defaults:validate` (CI)
|
||||
requires the provider on every such component and allows the token
|
||||
nowhere else; see the Dialogs and forms notes below.
|
||||
- **Dialogs from services created with the app.** A root service that opens a
|
||||
dialog resolves `MatDialog` through `LazyMatDialog`
|
||||
(`@iptvnator/ui/components/lazy-dialog`) and imports the dialog component
|
||||
on demand, as `DialogService`, the player service, the parental lock prompt
|
||||
and the workspace shell actions do; `openConfirmDialog` returns a promise
|
||||
that settles once the dialog is open. A component in a lazy chunk injects
|
||||
`MatDialog` directly.
|
||||
- **Panels the shell mounts on every launch.** The EPG progress panel and the
|
||||
update notification are an eager host with `@defer (when visible)` around
|
||||
the view that imports Material; the startup status draws its spinner as
|
||||
static markup copied from `mat-spinner`. A new always-mounted component
|
||||
follows the same split.
|
||||
- **Theme scope.** `m3-theme.scss` themes only the components in its
|
||||
`$material-components` list, through `meta.apply` for both the light theme
|
||||
and the dark colours. Add an entry with a new `@angular/material/<entry>`
|
||||
import and remove it with the last one;
|
||||
`pnpm run styles:material-theme-scope:validate` (CI) checks both directions
|
||||
(`core` always stays, `button` brings `icon-button` and `fab`).
|
||||
- **No animations module.** Nothing declares an Angular animation trigger and
|
||||
Material 22 animates with CSS, deciding from `prefers-reduced-motion` itself,
|
||||
so `app.config.ts` does not call `provideAnimations()`; the Electron
|
||||
`material-motion.e2e.ts` checks that dialogs, selects, menus, snack-bars and
|
||||
tooltips still animate in and out and stop under reduced motion. One side
|
||||
effect of the module is gone with it: its renderer deferred every DOM
|
||||
removal to the end of the change-detection tick, so a destroy hook still
|
||||
saw its view attached. Without it Angular detaches the view first, where
|
||||
`scrollTop` reads 0 and Chromium has already fired a `focusout` with no
|
||||
related target for the focused control. A destroy hook therefore never
|
||||
reads geometry or focus from its own view: the catalog list tracks the
|
||||
grid offset from scroll events, and the player settings panel decides
|
||||
"focus was inside" in a microtask after such a `focusout`, which the close
|
||||
destroys the panel before. Both have specs that detach the view first.
|
||||
|
||||
The app theme is the `.dark-theme` class that `SettingsService` puts on
|
||||
`<body>`; a "System" choice resolves the OS preference there and nowhere
|
||||
else. Component styles never use a `prefers-color-scheme` query, which
|
||||
|
||||
@@ -1156,6 +1156,24 @@ every build, which only the `development`, `electron-e2e` and
|
||||
`performance-build-config.spec.ts` keeps it that way. Removing the static
|
||||
import lowered the counter by 12,565 bytes.
|
||||
|
||||
The bundler keeps each module in one chunk, so one eager import of any
|
||||
export carries the module's whole code: providing
|
||||
`MAT_FORM_FIELD_DEFAULT_OPTIONS` in `app.config.ts` alone put the 54 KB
|
||||
form-field chunk on the initial path, although every form field renders in a
|
||||
lazy route or dialog. Angular Material therefore stays off the startup path by
|
||||
construction, with the rules and guards in the
|
||||
[UI guidelines](iptvnator-ui-guidelines.md#material-off-the-startup-path):
|
||||
form-field defaults are provided per component (#1928), services created with
|
||||
the app resolve `MatDialog` through `LazyMatDialog`, the panels the shell
|
||||
mounts on every launch defer their Material views, the theme emits tokens only
|
||||
for imported components, and the app does not provide the animations module,
|
||||
which nothing used (Material 22 animates with CSS). Together these lowered the
|
||||
counter from 1,596,201 to 1,344,067 bytes (-252,134, 15.8%) on a local
|
||||
production build at the branch point (#1931); the baseline follows the
|
||||
measurement of the branch merged with `master`, 1,345,807. The snack-bar stays: root
|
||||
services inject it, and with it Material's button, the icon button chunk and
|
||||
the CDK overlay (about 190 KB) remain initial.
|
||||
|
||||
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
|
||||
that the ratchet checker consumes. `--dist <dir>` points the script at another
|
||||
build output, for example the `electron-performance` configuration.
|
||||
|
||||
+20
@@ -792,6 +792,26 @@ describe('CategoryContentViewComponent', () => {
|
||||
expect(catalog.saveScrollPosition).toHaveBeenCalledWith(333);
|
||||
});
|
||||
|
||||
it('saves the offset the grid last reported when it is destroyed detached', () => {
|
||||
const infiniteFixture = createInfiniteFixture();
|
||||
infiniteFixture.detectChanges();
|
||||
const grid = infiniteFixture.nativeElement.querySelector(
|
||||
'app-grid-list'
|
||||
) as HTMLElement;
|
||||
// A detached element reports scrollTop 0, as in a browser.
|
||||
Object.defineProperty(grid, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => (grid.isConnected ? 420 : 0),
|
||||
});
|
||||
grid.dispatchEvent(new Event('scroll'));
|
||||
|
||||
// The view's DOM leaves the document before ngOnDestroy runs.
|
||||
infiniteFixture.nativeElement.remove();
|
||||
infiniteFixture.destroy();
|
||||
|
||||
expect(catalog.saveScrollPosition).toHaveBeenCalledWith(420);
|
||||
});
|
||||
|
||||
it('consumes a saved scroll position once the list is rendered', () => {
|
||||
catalog.consumeSavedScrollPosition.mockReturnValue(500);
|
||||
const infiniteFixture = createInfiniteFixture();
|
||||
|
||||
+30
-2
@@ -198,6 +198,18 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
private previousGridResetKey: string | null = null;
|
||||
private hasAttemptedScrollRestore = false;
|
||||
/**
|
||||
* The grid's last reported offset. On destroy the view is already
|
||||
* detached from the document, where `scrollTop` reads 0, so the hook
|
||||
* saves this instead. Scroll events do not bubble but can be captured.
|
||||
*/
|
||||
private lastGridScrollTop = 0;
|
||||
private readonly trackGridScroll = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement && target.matches('app-grid-list')) {
|
||||
this.lastGridScrollTop = target.scrollTop;
|
||||
}
|
||||
};
|
||||
private readonly routeCategory = signal<{ id: string | null } | null>(null);
|
||||
private initializedRoute: {
|
||||
category: { id: string | null };
|
||||
@@ -205,6 +217,16 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
const host = this.hostElement.nativeElement;
|
||||
host.addEventListener('scroll', this.trackGridScroll, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
this.destroyRef.onDestroy(() =>
|
||||
host.removeEventListener('scroll', this.trackGridScroll, {
|
||||
capture: true,
|
||||
})
|
||||
);
|
||||
effect(() => {
|
||||
const category = this.routeCategory();
|
||||
if (!category || this.catalog.routeReady?.() === false) {
|
||||
@@ -305,11 +327,15 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
// Leaving the list without opening a detail (e.g. switching portal
|
||||
// tabs) still snapshots the spot; the facade validates the selection
|
||||
// before ever restoring it.
|
||||
// before ever restoring it. Angular detaches the view's DOM before
|
||||
// this hook runs, and a detached grid reports a scrollTop of 0, so
|
||||
// the tracked offset stands in for it.
|
||||
if (!this.selectedItem()) {
|
||||
const grid = this.gridElement();
|
||||
if (grid) {
|
||||
this.catalog.saveScrollPosition?.(grid.scrollTop);
|
||||
this.catalog.saveScrollPosition?.(
|
||||
grid.isConnected ? grid.scrollTop : this.lastGridScrollTop
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +372,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private scrollGridToTop(): void {
|
||||
// A grid already at the top fires no scroll event for this call.
|
||||
this.lastGridScrollTop = 0;
|
||||
this.gridElement()?.scrollTo?.({ top: 0 });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/expandable-text/expandable-text.component';
|
||||
export * from './lib/form-field-defaults/form-field-defaults';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/lazy-dialog/lazy-mat-dialog';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { ConfirmDialogComponent } from './confirm-dialog.component';
|
||||
import { DialogService } from './dialog.service';
|
||||
|
||||
describe('DialogService', () => {
|
||||
@@ -25,12 +26,12 @@ describe('DialogService', () => {
|
||||
service = TestBed.inject(DialogService);
|
||||
});
|
||||
|
||||
it('opens the confirm dialog with the expected config', () => {
|
||||
it('opens the confirm dialog with the expected config once its chunk is in', async () => {
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
@@ -38,7 +39,7 @@ describe('DialogService', () => {
|
||||
});
|
||||
|
||||
expect(dialog.open).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
ConfirmDialogComponent,
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
title: 'Remove',
|
||||
@@ -50,12 +51,12 @@ describe('DialogService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a custom dialog width when provided', () => {
|
||||
it('uses a custom dialog width when provided', async () => {
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(false),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Refresh Xtream Playlist',
|
||||
message: 'Confirm refresh?',
|
||||
confirmLabel: 'Refresh playlist',
|
||||
@@ -71,13 +72,13 @@ describe('DialogService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('invokes onConfirm only after the dialog resolves truthy', () => {
|
||||
it('invokes onConfirm only after the dialog resolves truthy', async () => {
|
||||
const onConfirm = jest.fn();
|
||||
dialog.open.mockReturnValue({
|
||||
afterClosed: () => of(true),
|
||||
});
|
||||
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
@@ -86,10 +87,10 @@ describe('DialogService', () => {
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('does not repeat a persistent dialog action on programmatic close', () => {
|
||||
it('does not repeat a persistent dialog action on programmatic close', async () => {
|
||||
const onConfirm = jest.fn();
|
||||
dialog.open.mockReturnValue({ afterClosed: () => of(true) });
|
||||
service.openConfirmDialog({
|
||||
await service.openConfirmDialog({
|
||||
title: 'Recovery',
|
||||
message: 'Path',
|
||||
confirmLabel: 'Copy path',
|
||||
@@ -98,4 +99,25 @@ describe('DialogService', () => {
|
||||
});
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a failed chunk load and performs no action', async () => {
|
||||
const error = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const onConfirm = jest.fn();
|
||||
service.loadConfirmDialog = () =>
|
||||
Promise.reject(new Error('chunk failed'));
|
||||
|
||||
await service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
error.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
import { LazyMatDialog } from '../lazy-dialog/lazy-mat-dialog';
|
||||
import type {
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogData,
|
||||
} from './confirm-dialog.component';
|
||||
@@ -9,20 +9,42 @@ import {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DialogService {
|
||||
private dialog = inject(MatDialog);
|
||||
private readonly lazyDialog = inject(LazyMatDialog);
|
||||
|
||||
/**
|
||||
* Opens the confirm dialog with provided data
|
||||
* The dynamic import; a field so specs can substitute it. The component
|
||||
* loads with the first confirmation: this service is created with the
|
||||
* app, so a static import would put the component, Material's dialog
|
||||
* and buttons on the initial path.
|
||||
*/
|
||||
loadConfirmDialog = () => import('./confirm-dialog.component');
|
||||
|
||||
/**
|
||||
* Opens the confirm dialog with provided data. The returned promise
|
||||
* settles once the dialog is open; a failed chunk load is logged, and
|
||||
* the action is not performed.
|
||||
* @param data dialog meta info
|
||||
*/
|
||||
openConfirmDialog(data: ConfirmDialogData): void {
|
||||
const dialogRef = this.dialog.open<
|
||||
async openConfirmDialog(data: ConfirmDialogData): Promise<void> {
|
||||
let dialogModule: Awaited<ReturnType<typeof this.loadConfirmDialog>>;
|
||||
let dialog: Awaited<ReturnType<LazyMatDialog['resolve']>>;
|
||||
try {
|
||||
[dialog, dialogModule] = await Promise.all([
|
||||
this.lazyDialog.resolve(),
|
||||
this.loadConfirmDialog(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Could not open the confirmation dialog:', error);
|
||||
return;
|
||||
}
|
||||
const dialogRef = dialog.open<
|
||||
ConfirmDialogComponent,
|
||||
ConfirmDialogData
|
||||
>(ConfirmDialogComponent, {
|
||||
>(dialogModule.ConfirmDialogComponent, {
|
||||
data,
|
||||
// Wide enough for two side-by-side action buttons with translated
|
||||
// labels; 300px forced them to wrap into a vertical stack.
|
||||
// Wide enough for two side-by-side action buttons with
|
||||
// translated labels; 300px forced them to wrap into a
|
||||
// vertical stack.
|
||||
width: data.width ?? '420px',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { LazyMatDialog } from './lazy-mat-dialog';
|
||||
|
||||
describe('LazyMatDialog', () => {
|
||||
const dialog = { open: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: MatDialog, useValue: dialog }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the root MatDialog from the dialog chunk once', async () => {
|
||||
const service = TestBed.inject(LazyMatDialog);
|
||||
const load = jest.spyOn(service, 'loadDialogModule');
|
||||
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries the chunk load after a failure instead of caching it', async () => {
|
||||
const service = TestBed.inject(LazyMatDialog);
|
||||
const original = service.loadDialogModule;
|
||||
service.loadDialogModule = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('chunk failed'))
|
||||
.mockImplementation(original);
|
||||
|
||||
await expect(service.resolve()).rejects.toThrow('chunk failed');
|
||||
await expect(service.resolve()).resolves.toBe(dialog);
|
||||
|
||||
expect(service.loadDialogModule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EnvironmentInjector, inject, Injectable } from '@angular/core';
|
||||
import type { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
type DialogModule = typeof import('@angular/material/dialog');
|
||||
|
||||
/**
|
||||
* Resolves the root `MatDialog` on first use. `@angular/material/dialog`
|
||||
* and `@angular/cdk/dialog` share one bundler chunk, and one static import
|
||||
* from a service created with the app puts both on the initial path although
|
||||
* a dialog opens only after a user action. Services that exist at startup
|
||||
* resolve the dialog through this helper; a component in a lazy chunk can
|
||||
* inject `MatDialog` directly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LazyMatDialog {
|
||||
private readonly injector = inject(EnvironmentInjector);
|
||||
private dialog: Promise<MatDialog> | null = null;
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadDialogModule: () => Promise<DialogModule> = () =>
|
||||
import('@angular/material/dialog');
|
||||
|
||||
resolve(): Promise<MatDialog> {
|
||||
this.dialog ??= this.loadDialogModule().then((module) =>
|
||||
this.injector.get(module.MatDialog)
|
||||
);
|
||||
// A failed chunk load must not poison later attempts.
|
||||
this.dialog.catch(() => (this.dialog = null));
|
||||
return this.dialog;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgProgressService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
|
||||
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
|
||||
|
||||
interface EpgTrustConfirmDialogData {
|
||||
confirmLabel: string;
|
||||
message: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-trust-confirm-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
{{ data.message }}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [mat-dialog-close]="true">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
class EpgTrustConfirmDialogComponent {
|
||||
readonly data = inject<EpgTrustConfirmDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* The import progress panel itself. `EpgProgressPanelComponent` defers it
|
||||
* until the first import starts, so this file and its Material dependencies
|
||||
* (tooltip, progress bar, icon, dialog) must stay out of every file that
|
||||
* loads with the app.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-progress-panel-view',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatTooltip,
|
||||
MatProgressBar,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-progress-panel-view.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-progress-panel-view.component.scss',
|
||||
})
|
||||
export class EpgProgressPanelViewComponent {
|
||||
private readonly epgProgress = inject(EpgProgressService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly imports = this.epgProgress.imports;
|
||||
readonly isVisible = this.epgProgress.isVisible;
|
||||
readonly queuedCount = this.epgProgress.queuedCount;
|
||||
readonly activeCount = this.epgProgress.activeCount;
|
||||
readonly minimized = signal(false);
|
||||
|
||||
readonly loadingCount = computed(
|
||||
() => this.imports().filter((i) => i.status === 'loading').length
|
||||
);
|
||||
|
||||
toggleMinimize(): void {
|
||||
this.minimized.update((v) => !v);
|
||||
}
|
||||
|
||||
get activeImports() {
|
||||
return this.imports().filter((item) => item.status !== 'queued');
|
||||
}
|
||||
|
||||
get queuedImports() {
|
||||
return this.imports()
|
||||
.filter((item) => item.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
}
|
||||
|
||||
getStatusIcon(status: EpgImportProgress['status']): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'schedule';
|
||||
case 'loading':
|
||||
return 'sync';
|
||||
case 'complete':
|
||||
return 'check_circle';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'cancelled':
|
||||
return 'cancel';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClass(status: EpgImportProgress['status']): string {
|
||||
return `status-${status}`;
|
||||
}
|
||||
|
||||
getDisplayUrl(url: string): string {
|
||||
return formatEpgImportDisplayUrl(url);
|
||||
}
|
||||
|
||||
formatCount(value: number): string {
|
||||
// App language codes are not all BCP 47 tags (`zhtw`, `ary`, `by`);
|
||||
// the date pipes already map those aliases for Intl.
|
||||
const locale = normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
try {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
dismiss(url: string): void {
|
||||
this.epgProgress.dismiss(url);
|
||||
}
|
||||
|
||||
dismissAll(): void {
|
||||
this.epgProgress.dismissAll();
|
||||
}
|
||||
|
||||
retry(url: string): void {
|
||||
this.epgProgress.retry(url);
|
||||
}
|
||||
|
||||
isActionableSecurityError(item: EpgImportProgress): boolean {
|
||||
return (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked ||
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
|
||||
);
|
||||
}
|
||||
|
||||
getActionLabel(item: EpgImportProgress): string {
|
||||
if (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
|
||||
) {
|
||||
return this.translate.instant('EPG.ALLOW_PRIVATE_SOURCE');
|
||||
}
|
||||
|
||||
return this.translate.instant('EPG.TRUST_TLS_HOST');
|
||||
}
|
||||
|
||||
confirmTrust(item: EpgImportProgress): void {
|
||||
if (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
|
||||
) {
|
||||
this.openTrustDialog(
|
||||
{
|
||||
title: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE_TITLE'
|
||||
),
|
||||
message: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE_WARNING'
|
||||
),
|
||||
confirmLabel: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE'
|
||||
),
|
||||
},
|
||||
() => {
|
||||
void this.epgProgress.trustPrivateNetworkSourceAndRetry(
|
||||
item.url
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openTrustDialog(
|
||||
{
|
||||
title: this.translate.instant('EPG.TRUST_TLS_HOST_TITLE'),
|
||||
message: this.translate.instant('EPG.TRUST_TLS_HOST_WARNING'),
|
||||
confirmLabel: this.translate.instant('EPG.TRUST_TLS_HOST'),
|
||||
},
|
||||
() => {
|
||||
void this.epgProgress.trustInsecureTlsHostAndRetry(
|
||||
item.url,
|
||||
item.errorHost
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private openTrustDialog(
|
||||
data: EpgTrustConfirmDialogData,
|
||||
onConfirm: () => void
|
||||
): void {
|
||||
this.dialog
|
||||
.open<EpgTrustConfirmDialogComponent, EpgTrustConfirmDialogData>(
|
||||
EpgTrustConfirmDialogComponent,
|
||||
{ data, width: '420px' }
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockBehavior,
|
||||
DeferBlockState,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgProgressService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { EpgProgressPanelComponent } from './epg-progress-panel.component';
|
||||
|
||||
describe('EpgProgressPanelComponent', () => {
|
||||
const imports = signal<EpgImportProgress[]>([]);
|
||||
const isVisible = signal(false);
|
||||
const activeCount = signal(0);
|
||||
const queuedCount = signal(0);
|
||||
let fixture: ComponentFixture<EpgProgressPanelComponent>;
|
||||
|
||||
function setImports(items: EpgImportProgress[]): void {
|
||||
imports.set(items);
|
||||
isVisible.set(items.length > 0);
|
||||
activeCount.set(items.filter((i) => i.status !== 'queued').length);
|
||||
queuedCount.set(items.filter((i) => i.status === 'queued').length);
|
||||
}
|
||||
|
||||
/** Lets the deferred view's chunk resolve, then renders it. */
|
||||
async function settle(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
async function configure(
|
||||
deferBlockBehavior = DeferBlockBehavior.Playthrough
|
||||
): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EpgProgressPanelComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: EpgProgressService,
|
||||
useValue: {
|
||||
imports,
|
||||
isVisible,
|
||||
activeCount,
|
||||
queuedCount,
|
||||
dismiss: jest.fn(),
|
||||
dismissAll: jest.fn(),
|
||||
retry: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
deferBlockBehavior,
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(EpgProgressPanelComponent);
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setImports([]);
|
||||
await configure();
|
||||
});
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('renders no view while no import is running', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-progress-panel-view')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the panel with the first import and hides it again when the list empties', async () => {
|
||||
setImports([{ url: 'https://example.com/epg.xml', status: 'loading' }]);
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.epg-progress-panel .import-item.status-loading'
|
||||
)
|
||||
).not.toBeNull();
|
||||
|
||||
setImports([]);
|
||||
await settle();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.epg-progress-panel')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('tells the user when the view chunk cannot be loaded during an import', async () => {
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
await configure(DeferBlockBehavior.Manual);
|
||||
setImports([{ url: 'https://example.com/epg.xml', status: 'loading' }]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const [block] = await fixture.getDeferBlocks();
|
||||
await block.render(DeferBlockState.Error);
|
||||
|
||||
const notice = fixture.nativeElement.querySelector('[role="alert"]');
|
||||
expect(notice?.textContent).toContain('EPG.IMPORT_PANEL_UNAVAILABLE');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-progress-panel-view')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,224 +1,54 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgProgressService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { ELECTRON_BRIDGE_SECURITY_ERROR_CODES } from '@iptvnator/shared/interfaces';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes/date-format';
|
||||
import { formatEpgImportDisplayUrl } from './epg-import-display-url';
|
||||
|
||||
interface EpgTrustConfirmDialogData {
|
||||
confirmLabel: string;
|
||||
message: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-trust-confirm-dialog',
|
||||
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
{{ data.message }}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ 'CANCEL' | translate }}
|
||||
</button>
|
||||
<button mat-flat-button color="primary" [mat-dialog-close]="true">
|
||||
{{ data.confirmLabel }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
class EpgTrustConfirmDialogComponent {
|
||||
readonly data = inject<EpgTrustConfirmDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgProgressService } from '@iptvnator/epg/data-access';
|
||||
import { EpgProgressPanelViewComponent } from './epg-progress-panel-view.component';
|
||||
|
||||
/**
|
||||
* Mounted by the app shell on every launch, but the panel has nothing to
|
||||
* show until an EPG import starts. The view, with its Material tooltip,
|
||||
* progress bar, icon and dialog, is therefore loaded when the first import
|
||||
* makes the panel visible instead of with the app. Should that chunk fail
|
||||
* to load, a notice takes the panel's place while an import is running, so
|
||||
* the import and its errors do not go unreported.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-progress-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatIconModule,
|
||||
MatTooltip,
|
||||
MatProgressBar,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-progress-panel.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrl: './epg-progress-panel.component.scss',
|
||||
imports: [EpgProgressPanelViewComponent, TranslatePipe],
|
||||
template: `
|
||||
@defer (when isVisible()) {
|
||||
<app-epg-progress-panel-view />
|
||||
} @error {
|
||||
@if (isVisible()) {
|
||||
<div class="epg-progress-panel-unavailable" role="alert">
|
||||
{{ 'EPG.IMPORT_PANEL_UNAVAILABLE' | translate }}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.epg-progress-panel-unavailable {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 76px;
|
||||
z-index: 900;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 96px);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--app-separator);
|
||||
border-radius: 12px;
|
||||
background: var(--app-widget-bg);
|
||||
color: var(--app-body-color);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.epg-progress-panel-unavailable {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class EpgProgressPanelComponent {
|
||||
private readonly epgProgress = inject(EpgProgressService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly imports = this.epgProgress.imports;
|
||||
readonly isVisible = this.epgProgress.isVisible;
|
||||
readonly queuedCount = this.epgProgress.queuedCount;
|
||||
readonly activeCount = this.epgProgress.activeCount;
|
||||
readonly minimized = signal(false);
|
||||
|
||||
readonly loadingCount = computed(
|
||||
() => this.imports().filter((i) => i.status === 'loading').length
|
||||
);
|
||||
|
||||
toggleMinimize(): void {
|
||||
this.minimized.update((v) => !v);
|
||||
}
|
||||
|
||||
get activeImports() {
|
||||
return this.imports().filter((item) => item.status !== 'queued');
|
||||
}
|
||||
|
||||
get queuedImports() {
|
||||
return this.imports()
|
||||
.filter((item) => item.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
}
|
||||
|
||||
getStatusIcon(status: EpgImportProgress['status']): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'schedule';
|
||||
case 'loading':
|
||||
return 'sync';
|
||||
case 'complete':
|
||||
return 'check_circle';
|
||||
case 'error':
|
||||
return 'error';
|
||||
case 'cancelled':
|
||||
return 'cancel';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClass(status: EpgImportProgress['status']): string {
|
||||
return `status-${status}`;
|
||||
}
|
||||
|
||||
getDisplayUrl(url: string): string {
|
||||
return formatEpgImportDisplayUrl(url);
|
||||
}
|
||||
|
||||
formatCount(value: number): string {
|
||||
// App language codes are not all BCP 47 tags (`zhtw`, `ary`, `by`);
|
||||
// the date pipes already map those aliases for Intl.
|
||||
const locale = normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
try {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
dismiss(url: string): void {
|
||||
this.epgProgress.dismiss(url);
|
||||
}
|
||||
|
||||
dismissAll(): void {
|
||||
this.epgProgress.dismissAll();
|
||||
}
|
||||
|
||||
retry(url: string): void {
|
||||
this.epgProgress.retry(url);
|
||||
}
|
||||
|
||||
isActionableSecurityError(item: EpgImportProgress): boolean {
|
||||
return (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked ||
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.InvalidTlsCertificate
|
||||
);
|
||||
}
|
||||
|
||||
getActionLabel(item: EpgImportProgress): string {
|
||||
if (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
|
||||
) {
|
||||
return this.translate.instant('EPG.ALLOW_PRIVATE_SOURCE');
|
||||
}
|
||||
|
||||
return this.translate.instant('EPG.TRUST_TLS_HOST');
|
||||
}
|
||||
|
||||
confirmTrust(item: EpgImportProgress): void {
|
||||
if (
|
||||
item.errorCode ===
|
||||
ELECTRON_BRIDGE_SECURITY_ERROR_CODES.EpgPrivateNetworkBlocked
|
||||
) {
|
||||
this.openTrustDialog(
|
||||
{
|
||||
title: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE_TITLE'
|
||||
),
|
||||
message: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE_WARNING'
|
||||
),
|
||||
confirmLabel: this.translate.instant(
|
||||
'EPG.ALLOW_PRIVATE_SOURCE'
|
||||
),
|
||||
},
|
||||
() => {
|
||||
void this.epgProgress.trustPrivateNetworkSourceAndRetry(
|
||||
item.url
|
||||
);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.openTrustDialog(
|
||||
{
|
||||
title: this.translate.instant('EPG.TRUST_TLS_HOST_TITLE'),
|
||||
message: this.translate.instant('EPG.TRUST_TLS_HOST_WARNING'),
|
||||
confirmLabel: this.translate.instant('EPG.TRUST_TLS_HOST'),
|
||||
},
|
||||
() => {
|
||||
void this.epgProgress.trustInsecureTlsHostAndRetry(
|
||||
item.url,
|
||||
item.errorHost
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private openTrustDialog(
|
||||
data: EpgTrustConfirmDialogData,
|
||||
onConfirm: () => void
|
||||
): void {
|
||||
this.dialog
|
||||
.open<EpgTrustConfirmDialogComponent, EpgTrustConfirmDialogData>(
|
||||
EpgTrustConfirmDialogComponent,
|
||||
{ data, width: '420px' }
|
||||
)
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
readonly isVisible = inject(EpgProgressService).isVisible;
|
||||
}
|
||||
+46
@@ -444,6 +444,52 @@ describe('PlayerControlsComponent settings panel', () => {
|
||||
fixture.nativeElement.remove();
|
||||
});
|
||||
|
||||
it('returns focus to tune although removal fires focusout with no related target', async () => {
|
||||
const tune = query(
|
||||
'[data-test-id="player-controls-settings-button"]'
|
||||
) as HTMLButtonElement;
|
||||
document.body.appendChild(fixture.nativeElement);
|
||||
tune.focus();
|
||||
const matches = tune.matches.bind(tune);
|
||||
Object.defineProperty(tune, 'matches', {
|
||||
configurable: true,
|
||||
value: (selector: string) =>
|
||||
selector === ':focus-visible' || matches(selector),
|
||||
});
|
||||
tune.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const sheet = query(
|
||||
'[data-test-id="player-controls-settings-panel"]'
|
||||
) as HTMLElement;
|
||||
expect(document.activeElement).toBe(sheet);
|
||||
|
||||
// Chromium fires focusout with relatedTarget null on the focused
|
||||
// element while its subtree is being removed, in the same task
|
||||
// as the removal; jsdom does not, so the event is dispatched by
|
||||
// hand right before the close destroys the panel.
|
||||
sheet.dispatchEvent(
|
||||
new FocusEvent('focusout', {
|
||||
bubbles: true,
|
||||
relatedTarget: null,
|
||||
})
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
query('[data-test-id="player-controls-settings-panel"]')
|
||||
).toBeNull();
|
||||
expect(document.activeElement).toBe(
|
||||
query('[data-test-id="player-controls-settings-button"]')
|
||||
);
|
||||
fixture.nativeElement.remove();
|
||||
});
|
||||
|
||||
it('leaves focus alone when a pointer opens the sheet', async () => {
|
||||
query('[data-test-id="player-controls-settings-button"]')?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -122,7 +122,20 @@ export class PlayerSettingsPanelComponent implements OnDestroy {
|
||||
|
||||
onFocusOut(event: FocusEvent): void {
|
||||
const next = event.relatedTarget;
|
||||
this.focusInside = next instanceof Node && this.host.contains(next);
|
||||
if (next instanceof Node) {
|
||||
this.focusInside = this.host.contains(next);
|
||||
return;
|
||||
}
|
||||
// No related target: focus went to the page, or the panel is being
|
||||
// removed with focus inside, which Chromium reports with this event
|
||||
// while the panel is still attached. The close destroys the panel in
|
||||
// the same task, before this microtask runs, so a removal leaves the
|
||||
// flag for ngOnDestroy to act on.
|
||||
queueMicrotask(() => {
|
||||
this.focusInside =
|
||||
this.host.isConnected &&
|
||||
this.host.contains(document.activeElement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,9 @@
|
||||
"ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
|
||||
"ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
|
||||
"ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check",
|
||||
"styles:material-theme-scope:test": "node --test tools/nx/check-material-theme-scope.test.mjs",
|
||||
"styles:material-theme-scope:check": "node tools/nx/check-material-theme-scope.mjs",
|
||||
"styles:material-theme-scope:validate": "pnpm run styles:material-theme-scope:test && pnpm run styles:material-theme-scope:check",
|
||||
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
|
||||
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
|
||||
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { isScanned, stripComments } from './check-theme-references.mjs';
|
||||
|
||||
/**
|
||||
* `apps/web/src/m3-theme.scss` themes only the Material components the app
|
||||
* imports, through its `$material-components` list; `all-component-themes`
|
||||
* also emitted about 24 KB of tokens for components nothing uses. This guard
|
||||
* keeps the list in step with the imports in both directions: a component a
|
||||
* new `@angular/material/<entry>` import brings in must be themed, or it
|
||||
* renders with no colours at all, and a component whose last import went
|
||||
* must leave the list.
|
||||
*/
|
||||
|
||||
/** Angular renderer sources that load the theme (the remote control has its own styles). */
|
||||
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
|
||||
|
||||
export const THEME_FILE = 'apps/web/src/m3-theme.scss';
|
||||
|
||||
/**
|
||||
* Runtime imports of a Material entry point: a static import that is not
|
||||
* type-only (a `type` specifier list leaves nothing in the bundle) and a
|
||||
* dynamic `import()` such as the one LazyMatDialog makes. A type-only
|
||||
* import renders no component and so needs no theme.
|
||||
*/
|
||||
const STATIC_IMPORT =
|
||||
/(?<![\w$.])import\s+(?!type\s)[^;]*?from\s+['"]@angular\/material\/([a-z-]+)['"]/g;
|
||||
const DYNAMIC_IMPORT =
|
||||
/\bimport\(\s*['"]@angular\/material\/([a-z-]+)['"]\s*\)/g;
|
||||
|
||||
/**
|
||||
* The theme mixins (`mat.<name>-theme`, `mat.<name>-color`) each Material
|
||||
* entry point needs. An entry point that is not listed has no themed
|
||||
* component.
|
||||
*/
|
||||
export const THEMES_BY_ENTRY_POINT = new Map([
|
||||
['autocomplete', ['autocomplete']],
|
||||
['badge', ['badge']],
|
||||
['bottom-sheet', ['bottom-sheet']],
|
||||
['button', ['button', 'icon-button', 'fab']],
|
||||
['button-toggle', ['button-toggle']],
|
||||
['card', ['card']],
|
||||
['checkbox', ['checkbox']],
|
||||
['chips', ['chips']],
|
||||
['core', ['core']],
|
||||
['datepicker', ['datepicker']],
|
||||
['dialog', ['dialog']],
|
||||
['divider', ['divider']],
|
||||
['expansion', ['expansion']],
|
||||
['form-field', ['form-field']],
|
||||
['grid-list', ['grid-list']],
|
||||
['icon', ['icon']],
|
||||
['input', ['input']],
|
||||
['list', ['list']],
|
||||
['menu', ['menu']],
|
||||
['paginator', ['paginator']],
|
||||
['progress-bar', ['progress-bar']],
|
||||
['progress-spinner', ['progress-spinner']],
|
||||
['radio', ['radio']],
|
||||
['select', ['select']],
|
||||
['sidenav', ['sidenav']],
|
||||
['slide-toggle', ['slide-toggle']],
|
||||
['slider', ['slider']],
|
||||
['snack-bar', ['snack-bar']],
|
||||
['sort', ['sort']],
|
||||
['stepper', ['stepper']],
|
||||
['table', ['table']],
|
||||
['tabs', ['tabs']],
|
||||
['timepicker', ['timepicker']],
|
||||
['toolbar', ['toolbar']],
|
||||
['tooltip', ['tooltip']],
|
||||
['tree', ['tree']],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Themed whatever is imported: `core` carries the ripple, option, optgroup
|
||||
* and pseudo-checkbox tokens that select, menu and list render with.
|
||||
*/
|
||||
export const ALWAYS_THEMED = ['core'];
|
||||
|
||||
/**
|
||||
* Material entry points the runtime sources import at runtime, statically or
|
||||
* dynamically, with one importer each.
|
||||
*/
|
||||
export function usedEntryPoints(sources) {
|
||||
const importers = new Map();
|
||||
for (const { file, source } of sources) {
|
||||
const code = stripComments(source);
|
||||
for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
|
||||
for (const match of code.matchAll(pattern)) {
|
||||
if (!importers.has(match[1])) importers.set(match[1], file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return importers;
|
||||
}
|
||||
|
||||
/** The theme names the imported entry points need, each with a reason. */
|
||||
export function requiredThemes(importers) {
|
||||
const required = new Map(
|
||||
ALWAYS_THEMED.map((name) => [name, 'always themed'])
|
||||
);
|
||||
for (const [entryPoint, file] of importers) {
|
||||
for (const name of THEMES_BY_ENTRY_POINT.get(entryPoint) ?? []) {
|
||||
if (!required.has(name)) {
|
||||
required.set(
|
||||
name,
|
||||
`@angular/material/${entryPoint} in ${file}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
/**
|
||||
* The components the theme file themes: the `$material-components` list
|
||||
* plus any `mat.<name>-theme` included directly. `all` means
|
||||
* `all-component-themes` is still included, which themes everything.
|
||||
*/
|
||||
export function themedComponents(scss) {
|
||||
const themed = new Set();
|
||||
const list = scss.match(/\$material-components\s*:\s*\(([^)]*)\)/);
|
||||
if (list) {
|
||||
for (const item of list[1].split(',')) {
|
||||
const name = item.replace(/\/\/.*$/gm, '').trim();
|
||||
if (name) themed.add(name);
|
||||
}
|
||||
}
|
||||
for (const match of scss.matchAll(/@include\s+mat\.([a-z-]+)-theme\(/g)) {
|
||||
themed.add(match[1]);
|
||||
}
|
||||
return {
|
||||
themed,
|
||||
all: /@include\s+mat\.all-component-themes\(/.test(scss),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Themes that imports need but the file lacks, and themes the file carries
|
||||
* that no import needs.
|
||||
*/
|
||||
export function compareThemeScope(required, { themed, all }) {
|
||||
const unthemed = all
|
||||
? []
|
||||
: [...required].filter(([name]) => !themed.has(name));
|
||||
const unused = [...themed].filter((name) => !required.has(name));
|
||||
return { unthemed, unused };
|
||||
}
|
||||
|
||||
/** Tracked renderer sources the guard reads, at any depth. */
|
||||
export function listScannedFiles(rootDir) {
|
||||
// No shell: `cmd.exe` treats single quotes as literal characters, so a
|
||||
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
|
||||
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter(isScanned);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
const rootDir = process.cwd();
|
||||
const files = listScannedFiles(rootDir);
|
||||
const sources = await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
file,
|
||||
source: await readFile(path.join(rootDir, file), 'utf8'),
|
||||
}))
|
||||
);
|
||||
const required = requiredThemes(usedEntryPoints(sources));
|
||||
const scope = themedComponents(
|
||||
await readFile(path.join(rootDir, THEME_FILE), 'utf8')
|
||||
);
|
||||
const { unthemed, unused } = compareThemeScope(required, scope);
|
||||
|
||||
if (unthemed.length > 0) {
|
||||
console.error(
|
||||
`Material components the app imports but ${THEME_FILE} does not theme; they render without colours. Add them to $material-components:`
|
||||
);
|
||||
for (const [name, reason] of unthemed) {
|
||||
console.error(`- ${name} (${reason})`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (unused.length > 0) {
|
||||
console.error(
|
||||
`Material components ${THEME_FILE} themes although no runtime source imports them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
|
||||
);
|
||||
for (const name of unused) {
|
||||
console.error(`- ${name}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (!process.exitCode) {
|
||||
console.log(
|
||||
scope.all
|
||||
? `Checked ${files.length} renderer sources; ${THEME_FILE} themes every Material component through all-component-themes.`
|
||||
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they import.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
compareThemeScope,
|
||||
requiredThemes,
|
||||
themedComponents,
|
||||
usedEntryPoints,
|
||||
} from './check-material-theme-scope.mjs';
|
||||
|
||||
const lines = (...rows) => rows.join('\n');
|
||||
|
||||
test('usedEntryPoints lists each Material entry point with its first importer', () => {
|
||||
const importers = usedEntryPoints([
|
||||
{
|
||||
file: 'a.component.ts',
|
||||
source: lines(
|
||||
"import { MatButtonModule } from '@angular/material/button';",
|
||||
'import {',
|
||||
' MatDialog,',
|
||||
"} from '@angular/material/dialog';",
|
||||
'import { MatIcon } from "@angular/material/icon";'
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'b.component.ts',
|
||||
source: "import { MatButton } from '@angular/material/button';",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
[...importers],
|
||||
[
|
||||
['button', 'a.component.ts'],
|
||||
['dialog', 'a.component.ts'],
|
||||
['icon', 'a.component.ts'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('usedEntryPoints counts dynamic imports and ignores type-only ones and comments', () => {
|
||||
const importers = usedEntryPoints([
|
||||
{
|
||||
file: 'lazy-mat-dialog.ts',
|
||||
source: lines(
|
||||
"import type { MatDialog } from '@angular/material/dialog';",
|
||||
"type DialogModule = typeof import('@angular/material/dialog');",
|
||||
'loadDialogModule = () =>',
|
||||
" import('@angular/material/dialog');"
|
||||
),
|
||||
},
|
||||
{
|
||||
file: 'types-only.ts',
|
||||
source: lines(
|
||||
"import type { MatTooltip } from '@angular/material/tooltip';",
|
||||
"// import { MatChipsModule } from '@angular/material/chips';",
|
||||
"/* import { MatTabsModule } from '@angular/material/tabs'; */"
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual([...importers], [['dialog', 'lazy-mat-dialog.ts']]);
|
||||
});
|
||||
|
||||
test('requiredThemes always keeps core and expands the button entry point', () => {
|
||||
const required = requiredThemes(
|
||||
new Map([
|
||||
['button', 'a.ts'],
|
||||
['tooltip', 'b.ts'],
|
||||
['testing', 'c.ts'],
|
||||
])
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...required],
|
||||
[
|
||||
['core', 'always themed'],
|
||||
['button', '@angular/material/button in a.ts'],
|
||||
['icon-button', '@angular/material/button in a.ts'],
|
||||
['fab', '@angular/material/button in a.ts'],
|
||||
['tooltip', '@angular/material/tooltip in b.ts'],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('themedComponents reads the list, direct includes and the all-components mixin', () => {
|
||||
assert.deepEqual(
|
||||
themedComponents(
|
||||
lines(
|
||||
'$material-components: (',
|
||||
' core,',
|
||||
' button, // used by every dialog',
|
||||
' icon-button,',
|
||||
');',
|
||||
'@include mat.tooltip-theme($theme);'
|
||||
)
|
||||
),
|
||||
{
|
||||
themed: new Set(['core', 'button', 'icon-button', 'tooltip']),
|
||||
all: false,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
themedComponents('html { @include mat.all-component-themes($t); }'),
|
||||
{ themed: new Set(), all: true }
|
||||
);
|
||||
});
|
||||
|
||||
test('compareThemeScope reports missing and surplus themes', () => {
|
||||
const required = new Map([
|
||||
['core', 'always themed'],
|
||||
['button', '@angular/material/button in a.ts'],
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
compareThemeScope(required, {
|
||||
themed: new Set(['core', 'tabs']),
|
||||
all: false,
|
||||
}),
|
||||
{
|
||||
unthemed: [['button', '@angular/material/button in a.ts']],
|
||||
unused: ['tabs'],
|
||||
}
|
||||
);
|
||||
// all-component-themes leaves nothing unthemed, but is still surplus-free
|
||||
// only when the list is empty.
|
||||
assert.deepEqual(
|
||||
compareThemeScope(required, { themed: new Set(), all: true }),
|
||||
{ unthemed: [], unused: [] }
|
||||
);
|
||||
});
|
||||
@@ -3,11 +3,11 @@
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 1534604,
|
||||
"value": 1345807,
|
||||
"unit": "bytes",
|
||||
"slack": 4096,
|
||||
"updatedAt": "2026-10-13",
|
||||
"evidencePr": 1928,
|
||||
"evidencePr": 1931,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
},
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
"@iptvnator/ui/components/form-field-defaults": [
|
||||
"libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/lazy-dialog": [
|
||||
"libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/parental-lock-pin-dialog": [
|
||||
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user