mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
* perf(web): take the Material snack-bar off the startup path The seven services and the app shell created with the app injected MatSnackBar, and snack-bar.mjs statically imports Material's button, the icon button chunk, ripple and the CDK a11y module, so about 110 KB sat in the initial bundle although a toast shows only after an event. A root LazySnackBar (@iptvnator/services/lazy-snack-bar) resolves the snack-bar from a dynamic import the way LazyMatDialog does, retrying after a failed chunk load, and opens text toasts with MatSnackBar.open's arguments; a failed load is logged without the message. The eager importers open their toasts through it with unchanged messages, actions, durations and panel classes; lazy code keeps injecting MatSnackBar. The startup status defers its Retry button, the other eager importer of the button chunk, with a native button as the error fallback. Local production build: renderer.initialBytes 1,349,048 -> 1,236,918 (-112,130, -8.3%); no Material module is in the initial bundle any more. The CDK overlay, scrolling and portal chunks (about 78 KB) stay through the FullscreenOverlayContainer provider in app.config.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(web): keep a native Retry while the startup button chunk loads Greptile (local round 1): the failure screen had no Retry button until the deferred Material button chunk arrived. One native button, drawn in the flat button's shape with theme colours, is now the placeholder and the error view of the defer block, so a retry is possible while the chunk downloads and after a failed load. Baseline and docs follow the re-measured value, 1,237,440 bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(perf): point the initialBytes evidence at the opened pull request The baseline and the performance doc named the predicted number; the pull request opened as #1939. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): cover the toast actions behind the lazy snack-bar Greptile (PR round 1): the Refresh and Trust-host actions now run after LazySnackBar.open resolves, and no test exercised them. The PWA update toast test holds the toast promise, then resolves it and picks Refresh; the page reload is a field the spec substitutes, as jsdom's location cannot be spied on. The Electron test does the same for the TLS trust toast: the confirmation opens only once the toast is up, approving it records the host and retries the fetch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): type the stubbed toast ref as Observable<undefined> CI's spec typecheck rejected `typeof EMPTY` (Observable<never>) for a stub that returns `of(undefined)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the merged measurement Merged with #1938 the branch measures 1,231,455 bytes on a local production build (the radio play control's fab tokens left the stylesheet); the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the zoneless merged measurement Merged with #1929, #1936 and #1938 the branch measures 1,178,109 bytes on a local production build; the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): re-measure initialBytes after merging #1903 and #1942 Merged with #1903 (self-hosted server storage, which raised master's baseline by 7,245 bytes on purpose) and #1942 the branch measures 1,187,983 bytes on a local production build, below master's 1,285,523; no Material module is in the initial bundle. The new favorites effect spec provides the LazySnackBar stub the merged effect injects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(xtream): open favorites toasts through LazySnackBar #1934 gave the Xtream store's favorites feature a static MatSnackBar injection. The store exists from startup, so that one import put Material's snack-bar, button, icon-button and ripple chunks (about 95 KB) back on the renderer's initial path. The feature now opens its two toasts through LazySnackBar with the same messages and durations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): count statically imported chunks in renderer.initialBytes Angular's builder emits at most ten modulepreload hints. Once the startup graph passed ten chunks, the rest were fetched through main.js's own imports but never counted, so the counter stopped growing and an eager MatSnackBar import on master went unnoticed. The script now follows the static import graph of every counted script (dynamic import() targets stay lazy; a missing imported file is an error), which matches the bundler's metafile closure exactly: 1,196,205 bytes for this branch, where the old counter read 1,098,535. The baseline follows the new measurement and its note now explains the current limit (Greptile). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): parse startup imports and count them per request URL Codex (PR round): the regexes also matched import-shaped text in strings, templates and comments, which could count a lazy chunk or fail on a file that does not exist. Static declarations are now read from TypeScript's syntax tree (top-level import/export-from statements only). Imports are also deduplicated by request URL, like the index.html references, so a preload of chunk.js?v=1 no longer hides an import of ./chunk.js. The real build still measures 1,196,205 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
import {
|
|
Component,
|
|
effect,
|
|
inject,
|
|
OnInit,
|
|
signal,
|
|
ChangeDetectionStrategy,
|
|
} from '@angular/core';
|
|
import { LazySnackBar } from '@iptvnator/services/lazy-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);
|
|
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(LazySnackBar);
|
|
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) {
|
|
void 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
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|