mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4
This commit is contained in:
commit
31899d7b58
54 files changed
+4075
-1631
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[] {
|
||||
|
||||
@@ -592,6 +592,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": "قناة التحديث",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "تعذّر تحميل لوحة استيراد EPG. أعد تشغيل التطبيق لمتابعة تقدّم الاستيراد؛ تبقى حالة كل مصدر في إعدادات EPG.",
|
||||
"CHANNELS": "قنوات",
|
||||
"PROGRAMS": "برامج",
|
||||
"LOADING": "جارٍ التحميل",
|
||||
|
||||
@@ -592,6 +592,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": "قناة التحديث",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG تجلب بنجاح",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "ما قدرناش نحمّلو لوحة استيراد EPG. عاود شغّل التطبيق باش تشوف تقدّم الاستيراد؛ حالة كل مصدر كتبقى فإعدادات EPG.",
|
||||
"CHANNELS": "قنوات",
|
||||
"PROGRAMS": "برامج",
|
||||
"LOADING": "جاري التشريج",
|
||||
|
||||
@@ -592,6 +592,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": "Канал абнаўленняў",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
|
||||
"IMPORT_PROGRESS": "Імпарт EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Не ўдалося загрузіць панэль імпарту EPG. Перазапусціце праграму, каб бачыць ход імпарту; стан кожнай крыніцы застаецца ў наладах EPG.",
|
||||
"CHANNELS": "каналаў",
|
||||
"PROGRAMS": "перадач",
|
||||
"LOADING": "Загрузка",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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": "Κανάλι ενημερώσεων",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
|
||||
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Δεν ήταν δυνατή η φόρτωση του πίνακα εισαγωγής EPG. Επανεκκινήστε την εφαρμογή για να παρακολουθήσετε την εισαγωγή· η κατάσταση κάθε πηγής παραμένει στις ρυθμίσεις EPG.",
|
||||
"CHANNELS": "κανάλια",
|
||||
"PROGRAMS": "προγράμματα",
|
||||
"LOADING": "Φόρτωση",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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": "更新チャンネル",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPGの取得に成功しました",
|
||||
"IMPORT_PROGRESS": "EPGインポート",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "EPGインポートパネルを読み込めませんでした。インポートの進行状況を確認するにはアプリを再起動してください。各ソースの状態はEPG設定で確認できます。",
|
||||
"CHANNELS": "チャンネル",
|
||||
"PROGRAMS": "番組",
|
||||
"LOADING": "読み込み中",
|
||||
|
||||
@@ -592,6 +592,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": "업데이트 채널",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
|
||||
"IMPORT_PROGRESS": "EPG 가져오기",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "EPG 가져오기 패널을 불러올 수 없습니다. 가져오기 진행 상황을 보려면 앱을 다시 시작하세요. 각 소스의 상태는 EPG 설정에서 확인할 수 있습니다.",
|
||||
"CHANNELS": "채널",
|
||||
"PROGRAMS": "프로그램",
|
||||
"LOADING": "불러오는 중",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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": "Канал обновлений",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
|
||||
"IMPORT_PROGRESS": "Импорт EPG",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "Не удалось загрузить панель импорта EPG. Перезапустите приложение, чтобы видеть ход импорта; статус каждого источника остаётся в настройках EPG.",
|
||||
"CHANNELS": "каналов",
|
||||
"PROGRAMS": "программ",
|
||||
"LOADING": "Загрузка",
|
||||
|
||||
@@ -592,6 +592,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ı",
|
||||
@@ -983,6 +984,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",
|
||||
|
||||
@@ -592,6 +592,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": "更新频道",
|
||||
@@ -983,6 +984,7 @@
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG 获取成功",
|
||||
"IMPORT_PROGRESS": "EPG 导入",
|
||||
"IMPORT_PANEL_UNAVAILABLE": "无法加载 EPG 导入面板。请重启应用以查看导入进度;每个来源的状态仍可在 EPG 设置中查看。",
|
||||
"CHANNELS": "频道",
|
||||
"PROGRAMS": "节目",
|
||||
"LOADING": "加载中",
|
||||
|
||||
@@ -592,6 +592,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": "更新頻道",
|
||||
@@ -983,6 +984,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
|
||||
@@ -187,7 +237,7 @@ html {
|
||||
--app-popover-backdrop: rgba(20, 24, 32, 0.28);
|
||||
|
||||
.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);
|
||||
@@ -308,7 +358,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);
|
||||
}
|
||||
|
||||
@@ -103,6 +103,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
@@ -816,6 +816,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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+51
-48
@@ -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",
|
||||
@@ -130,16 +133,16 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"dependencies": {
|
||||
"@angular/animations": "22.1.6",
|
||||
"@angular/cdk": "22.1.6",
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/forms": "22.1.6",
|
||||
"@angular/material": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/platform-browser-dynamic": "22.1.6",
|
||||
"@angular/router": "22.1.6",
|
||||
"@angular/animations": "22.2.2",
|
||||
"@angular/cdk": "22.2.2",
|
||||
"@angular/common": "22.2.2",
|
||||
"@angular/compiler": "22.2.2",
|
||||
"@angular/core": "22.2.2",
|
||||
"@angular/forms": "22.2.2",
|
||||
"@angular/material": "22.2.2",
|
||||
"@angular/platform-browser": "22.2.2",
|
||||
"@angular/platform-browser-dynamic": "22.2.2",
|
||||
"@angular/router": "22.2.2",
|
||||
"@ngrx/effects": "22.0.1",
|
||||
"@ngrx/entity": "22.0.1",
|
||||
"@ngrx/router-store": "22.0.1",
|
||||
@@ -153,13 +156,13 @@
|
||||
"axios": "1.20.0",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"date-fns": "4.4.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.4",
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.5.0",
|
||||
"hls.js": "1.7.1",
|
||||
"hls.js": "1.7.3",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.1.0",
|
||||
"mpegts.js": "1.8.2",
|
||||
"ms": "2.1.3",
|
||||
"ngx-indexed-db": "22.0.0",
|
||||
@@ -169,31 +172,31 @@
|
||||
"saxes": "6.0.0",
|
||||
"shaka-player": "5.2.12",
|
||||
"shell-path": "3.1.0",
|
||||
"video.js": "8.24.0",
|
||||
"video.js": "8.24.1",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-quality-selector-hls": "1.1.1",
|
||||
"zone.js": "0.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/core": "22.1.8",
|
||||
"@angular-devkit/schematics": "22.1.8",
|
||||
"@angular-devkit/core": "22.2.2",
|
||||
"@angular-devkit/schematics": "22.2.2",
|
||||
"@angular-eslint/builder": "22.5.0",
|
||||
"@angular-eslint/eslint-plugin": "22.5.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
||||
"@angular-eslint/schematics": "22.5.0",
|
||||
"@angular-eslint/template-parser": "22.5.0",
|
||||
"@angular/build": "22.1.8",
|
||||
"@angular/cli": "22.1.8",
|
||||
"@angular/common": "22.1.6",
|
||||
"@angular/compiler": "22.1.6",
|
||||
"@angular/compiler-cli": "22.1.6",
|
||||
"@angular/core": "22.1.6",
|
||||
"@angular/forms": "22.1.6",
|
||||
"@angular/language-service": "22.1.6",
|
||||
"@angular/platform-browser": "22.1.6",
|
||||
"@angular/platform-browser-dynamic": "22.1.6",
|
||||
"@angular/router": "22.1.6",
|
||||
"@angular/service-worker": "22.1.6",
|
||||
"@angular/cli": "22.2.2",
|
||||
"@angular/common": "22.2.2",
|
||||
"@angular/compiler": "22.2.2",
|
||||
"@angular/compiler-cli": "22.2.2",
|
||||
"@angular/core": "22.2.2",
|
||||
"@angular/forms": "22.2.2",
|
||||
"@angular/language-service": "22.2.2",
|
||||
"@angular/platform-browser": "22.2.2",
|
||||
"@angular/platform-browser-dynamic": "22.2.2",
|
||||
"@angular/router": "22.2.2",
|
||||
"@angular/service-worker": "22.2.2",
|
||||
"@astrojs/mdx": "7.0.7",
|
||||
"@astrojs/sitemap": "3.7.4",
|
||||
"@babel/core": "7.29.7",
|
||||
@@ -219,10 +222,10 @@
|
||||
"@nx/playwright": "23.2.1",
|
||||
"@nx/web": "23.2.1",
|
||||
"@nx/workspace": "23.2.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@schematics/angular": "22.1.8",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@schematics/angular": "22.2.2",
|
||||
"@swc-node/register": "1.12.1",
|
||||
"@swc/core": "1.16.1",
|
||||
"@swc/core": "1.16.13",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
@@ -234,16 +237,16 @@
|
||||
"@types/node": "20.19.9",
|
||||
"@types/proxy-from-env": "1.0.4",
|
||||
"@types/video.js": "7.3.58",
|
||||
"@typescript-eslint/eslint-plugin": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"@typescript-eslint/utils": "^8.69.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.71.1",
|
||||
"@typescript-eslint/parser": "^8.71.1",
|
||||
"@typescript-eslint/utils": "^8.71.1",
|
||||
"angular-eslint": "22.5.0",
|
||||
"astro": "7.2.10",
|
||||
"autoprefixer": "10.5.4",
|
||||
"axe-core": "4.13.0",
|
||||
"autoprefixer": "10.6.1",
|
||||
"axe-core": "4.14.0",
|
||||
"cookie": "2.0.1",
|
||||
"cors": "2.8.6",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.15.7",
|
||||
"electron-playwright-helpers": "1.8.2",
|
||||
@@ -252,39 +255,39 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-playwright": "^1.6.2",
|
||||
"express": "5.2.1",
|
||||
"express": "5.3.0",
|
||||
"github-slugger": "2.0.0",
|
||||
"globals": "15.9.0",
|
||||
"html-escaper": "3.0.3",
|
||||
"istanbul-lib-coverage": "3.2.2",
|
||||
"istanbul-lib-report": "3.0.1",
|
||||
"istanbul-reports": "3.2.0",
|
||||
"jest": "^30.5.1",
|
||||
"jest-environment-jsdom": "^30.5.1",
|
||||
"jest-environment-node": "^30.5.1",
|
||||
"jest-preset-angular": "17.0.0",
|
||||
"jest": "^30.5.2",
|
||||
"jest-environment-jsdom": "^30.5.2",
|
||||
"jest-environment-node": "^30.5.2",
|
||||
"jest-preset-angular": "17.0.2",
|
||||
"jest-util": "^30.5.1",
|
||||
"jsdom": "26.1.0",
|
||||
"jsonc-eslint-parser": "^2.1.0",
|
||||
"material-design-icons-iconfont": "6.7.0",
|
||||
"mrmime": "2.0.1",
|
||||
"ng-mocks": "14.17.6",
|
||||
"ng-mocks": "14.18.1",
|
||||
"node-gyp": "12.4.0",
|
||||
"nx": "23.2.1",
|
||||
"nx-electron": "22.0.0",
|
||||
"parse-srcset": "1.0.2",
|
||||
"parse5": "8.0.1",
|
||||
"prettier": "^3.9.6",
|
||||
"prettier": "^3.9.9",
|
||||
"sharp": "0.35.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-jest": "^29.4.14",
|
||||
"ts-node": "10.9.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "4.23.13",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"yaml": "2.9.0",
|
||||
"zod": "4.5.4"
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"yaml": "2.9.1",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
Generated
+1959
-1040
File diff suppressed because it is too large.
Load diff
@@ -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