mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
* perf(web): make the app root and settings components OnPush Plan item C6 step 3 for apps/web: the fifteen Eager components switch to OnPush, among them the app root and the update notification panel that the idle audit found re-rendering on every idle tick. Their template state is signals from the settings facades, signal inputs and the shared reactive settings form. The checklist flagged the backup import, which patches the form from a detached file input with no template event. A new spec patches only a value, which changes no form status, and confirms the OnPush general section still shows the new theme; it guards that path for the zoneless flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(settings): re-render OnPush sections when the form changes outside them Review follow-ups (Greptile, Codex): - The settings sections read form values in their templates (selected theme and cover size, epgField.value, form().value.player), and the parent changes the form outside their events: Discard and backup import patch it, the store hydrates it, the EPG file picker sets a control after an await. Under OnPush the section kept the old selection or EPG status. Each section now marks itself on its form's events (markSectionForCheckOnFormEvents). - The value-only patch test no longer forces detectChanges(); with the fixture rendering on its own it fails without the marking, and so does a new test for a control set outside the EPG section. - The zoneless guard counts only changeDetection metadata outside comments, so a comment naming the strategy is not an Eager component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(settings): guard the unsaved-changes bar after a save off the sections Review follow-up (Codex): Save marks the form pristine after an async store write, also on Backup, Reset or search, where no form section is rendered. The OnPush page re-renders anyway because pristine and valid read the form's state signals; the new test checks that on the Backup page without forcing a render (it waits for the scheduled one). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
311 lines
12 KiB
TypeScript
311 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',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
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
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|