mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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>
312 lines
12 KiB
TypeScript
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
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|