Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4

This commit is contained in:
4gray committed 2026-10-14 03:53:06 +02:00
commit 31899d7b58
54 files changed
+4075 -1631

No files matched your search

@@ -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);
}
+4 -2
View File
@@ -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;
}
+10 -6
View File
@@ -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[] {
+2
View File
@@ -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": "جارٍ التحميل",
+2
View File
@@ -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": "جاري التشريج",
+2
View File
@@ -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": "Загрузка",
+2
View File
@@ -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",
+2
View File
@@ -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": "Φόρτωση",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "読み込み中",
+2
View File
@@ -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": "불러오는 중",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Загрузка",
+2
View File
@@ -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",
+2
View File
@@ -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": "加载中",
+2
View File
@@ -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": "載入中",
+53 -3
View File
@@ -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.
-4
View File
@@ -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);
}