Files
iptvnator/apps/web/src/app/app.component.ts
T
4grayandClaude Opus 5.5 e8b181fcea fix(ui): Cyrillic/Greek weights, html lang, weight normalisation (#1780)
Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render
real semibold and bold faces instead of a synthetic bold, keep
<html lang> in step with the UI language, and move every font weight onto
the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard
LIVE badge now uses the interface font at 700).

Add the `styles:font-weights:validate` ratchet guard and its CI step. It
reads stylesheets much as Sass and the browser do (cascade, layers,
mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`,
keyframes) and lists what it deliberately does not trace in its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:55:23 +02:00

312 lines
12 KiB
TypeScript

import {
Component,
effect,
HostBinding,
inject,
OnInit,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import {
WorkspaceShellContextDrawerService,
WORKSPACE_SHELL_ACTIONS,
} from '@iptvnator/workspace/shell/util';
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
// File-level entry: the @iptvnator/ui/components barrel would put the whole
// library (channel lists, EPG, forms, date-fns) on the initial path.
import { WindowControlsComponent } from '@iptvnator/ui/components/window-controls';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { filter, take } from 'rxjs';
import {
DataService,
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
EpgSourceSettingsService,
} from '@iptvnator/services';
import {
AUTO_UPDATE_PLAYLISTS,
Language,
Settings,
STORE_KEY,
Theme,
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
import { AppStartupStatusComponent } from './app-startup-status.component';
import { syncDocumentLanguage } from './services/document-language';
const debugAppComponent = createDevLogger('AppComponent');
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
imports: [
AppStartupStatusComponent,
AppUpdateNotificationPanelComponent,
EpgProgressPanelComponent,
RouterOutlet,
WindowControlsComponent,
],
})
export class AppComponent implements OnInit {
readonly routeReady = signal(false);
@HostBinding('class.macos-platform') get isMacOS() {
return this.runtime.isMacOS;
}
get usesCustomWindowControls() {
return this.runtime.usesCustomWindowControls;
}
private actions$ = inject(Actions);
private dataService = inject(DataService);
private readonly dateLocales = inject(AppDateLocaleService);
private epgBridge = inject(EpgRuntimeBridgeService);
private epgService = inject(EpgService);
private snackBar = inject(MatSnackBar);
private router = inject(Router);
private store = inject(Store);
private translate = inject(TranslateService);
private settingsService = inject(SettingsService);
private settingsStore = inject(SettingsStore);
private readonly epgSources = inject(EpgSourceSettingsService);
private playbackKeepAwake = inject(PlaybackKeepAwakeService);
private readonly parentalLock = inject(ParentalLockService);
private readonly parentalLockEnforcement = inject(
ParentalLockEnforcementService
);
private playlistOpenRequests = inject(PlaylistOpenRequestService);
private runtime = inject(RuntimeCapabilitiesService);
private readonly workspaceShellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService);
/** Default language as fallback */
private readonly DEFAULT_LANG = Language.ENGLISH;
constructor() {
syncDocumentLanguage();
// Body-level class (like 'dark-theme') so layout adjustments also
// reach content rendered outside app-root, e.g. cdk-overlay content.
if (this.runtime.usesCustomWindowControls) {
document.body.classList.add('frameless-platform');
}
// Playlist files the OS asked us to open (command line argument, file
// association, macOS `open-file`) are resolved in the main process and
// queued there until the renderer subscribes. Start listening as early
// as possible so a first-launch file is not delayed behind app init.
this.playlistOpenRequests.start();
// Keep the display awake while a built-in player is playing video
// (Electron powerSaveBlocker / PWA Screen Wake Lock, issue #1095).
this.playbackKeepAwake.start();
// Parental lock: load the PIN hash and lock store, then keep the
// in-memory catalogs in step with lock/unlock (issue #285).
void this.parentalLock.initialize();
this.parentalLockEnforcement.start();
effect(() => {
const size = this.settingsStore.coverSize?.() ?? 'medium';
document.documentElement.dataset.coverSize = size;
});
if (this.runtime.isElectron) {
document.addEventListener('keydown', (event) => {
if (event.ctrlKey || event.metaKey) {
// While the phone context drawer is modal, workspace
// shortcuts must not navigate away behind it — same gate
// as Ctrl/Cmd+F, Ctrl/Cmd+K, and the shortcuts dialog.
if (this.contextDrawer.isOpen()) {
return;
}
if (event.key === 'r') {
event.preventDefault();
this.workspaceShellActions.openGlobalRecent();
}
}
});
}
}
ngOnInit() {
this.store.dispatch(PlaylistActions.loadPlaylists());
this.translate.setDefaultLang(this.DEFAULT_LANG);
this.initSettings();
this.triggerAutoUpdatePlaylists();
}
/**
* Reads the settings object from local storage and initializes the
* application based on them
*/
initSettings(): void {
this.settingsService
.getValueFromLocalStorage<Settings>(STORE_KEY.Settings)
.subscribe((settings: Settings) => {
if (settings && Object.keys(settings).length > 0) {
// No need to send settings to Electron on init
// Settings are stored in IndexedDB and loaded by the settings store
// Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component
const resolvedLang = settings.language ?? this.DEFAULT_LANG;
// The switch re-renders every date with the new locale;
// its data is a lazy chunk that must be registered first.
void this.dateLocales.use(resolvedLang);
// Mirror the active language to localStorage so the next
// cold start can read it synchronously in app.config.ts's
// getInitialLanguage() and avoid the English-then-localized
// flash for non-English users.
try {
localStorage.setItem(
'iptvnator:preferred-language',
resolvedLang
);
} catch {
// Ignore quota / privacy mode errors.
}
// Fetch EPG if URLs are configured (only fetch stale data)
if (
this.epgBridge.supportsImport &&
settings.epgUrl?.length > 0 &&
settings.epgUrl?.some((u) => u !== '')
) {
this.fetchStaleEpgData(settings.epgUrl);
}
if (settings.theme) {
this.settingsService.changeTheme(settings.theme);
} else {
this.detectDarkMode();
}
} else {
this.detectDarkMode();
}
});
}
/**
* Applies the operating system color scheme when no explicit theme is set
*/
detectDarkMode(): void {
this.settingsService.changeTheme(Theme.SystemTheme);
}
/**
* Navigate to the specified route
* @param route route to navigate to
*/
navigateToRoute(route: string) {
this.router.navigateByUrl(route);
}
/**
* Fetches EPG data only for URLs that have stale or missing data.
* Data is considered fresh if updated within the last 12 hours.
*/
private async fetchStaleEpgData(urls: string[]): Promise<void> {
await this.settingsStore.loadSettings();
const revision = this.epgSources.revision();
const fetchCurrentSources = async (sources: string[]) => {
await this.epgSources.waitForReconciliation();
this.epgService.fetchEpg(
this.epgSources.retainCurrentSources(sources, revision)
);
};
if (!this.epgBridge.supportsSourceFreshness) {
await fetchCurrentSources(urls);
return;
}
try {
const result = await this.epgBridge.checkFreshness(urls, 12);
if (!result) {
await fetchCurrentSources(urls);
return;
}
if (result.freshUrls.length > 0) {
debugAppComponent(
`EPG: ${result.freshUrls.length} source(s) already fresh, skipping fetch`
);
// Show snackbar if all EPG sources are fresh (no stale URLs)
if (result.staleUrls.length === 0) {
this.snackBar.open(
this.translate.instant('EPG.UP_TO_DATE'),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
}
if (result.staleUrls.length > 0) {
debugAppComponent(
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
);
await fetchCurrentSources(result.staleUrls);
}
} catch (error) {
console.error('Error checking EPG freshness, fetching all:', error);
// Fallback: fetch all URLs if freshness check fails
await fetchCurrentSources(urls);
}
}
/**
* Triggers auto-update for playlists that have autoRefresh enabled
*/
private triggerAutoUpdatePlaylists(): void {
// Wait for playlists to be loaded successfully
this.actions$
.pipe(
ofType(PlaylistActions.loadPlaylistsSuccess),
take(1) // Only trigger once on app startup
)
.subscribe(() => {
// Get all playlists from store
this.store
.select(selectAllPlaylistsMeta)
.pipe(
take(1),
filter((playlists) => playlists.length > 0)
)
.subscribe((playlists) => {
// Filter playlists with autoRefresh enabled
const playlistsToUpdate = playlists.filter(
(playlist) => playlist.autoRefresh === true
);
// Trigger auto-update if there are playlists to update
if (playlistsToUpdate.length > 0) {
debugAppComponent(
`Auto-updating ${playlistsToUpdate.length} playlist(s) on startup`
);
this.dataService.sendIpcEvent(
AUTO_UPDATE_PLAYLISTS,
playlistsToUpdate
);
}
});
});
}
}