import { CommonModule } from '@angular/common'; import { Component, computed, inject, Input, OnDestroy, OnInit, signal, ViewEncapsulation, } from '@angular/core'; import { FormArray, FormBuilder, FormControl, ReactiveFormsModule, Validators, } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; import { SettingsContextService } from '@iptvnator/workspace/shell/util'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DialogService } from '@iptvnator/ui/components'; import { PlaylistActions, selectAllPlaylistsMeta, selectIsEpgAvailable, } from '@iptvnator/m3u-state'; import { firstValueFrom, take } from 'rxjs'; import { DatabaseService, DataService, DbOperationEvent, PlaylistBackupImportSummary, PlaylistBackupService, PlaylistsService, RuntimeCapabilitiesService, } from '@iptvnator/services'; import { EmbeddedMpvSupport, CoverSize, Language, normalizeExternalPlayerArguments, Settings, StartupBehavior, StreamFormat, Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from './../services/settings.service'; import { SettingsAboutSectionComponent } from './settings-about-section.component'; import { SettingsBackupSectionComponent } from './settings-backup-section.component'; import { SettingsDeleteAllPlaylistsDialogComponent, SettingsDeleteAllPlaylistsDialogData, } from './settings-delete-all-playlists-dialog.component'; import { SettingsEpgSectionComponent } from './settings-epg-section.component'; import { createEpgUrlControl } from './settings-form.utils'; import { SettingsGeneralSectionComponent } from './settings-general-section.component'; import { SettingsPlaylistDeleteSummary, SettingsSection, } from './settings.models'; import { buildSettingsSectionNavItems, SETTINGS_COVER_SIZE_OPTIONS, SETTINGS_EMBEDDED_PLAYER_OPTIONS, SETTINGS_OS_PLAYER_OPTIONS, SETTINGS_STARTUP_BEHAVIOR_OPTIONS, SETTINGS_THEME_OPTIONS, } from './settings-options'; import { SettingsPlaybackSectionComponent } from './settings-playback-section.component'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component'; import { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; @Component({ templateUrl: './settings.component.html', styleUrls: ['./settings.component.scss'], host: { class: 'settings-page-host', }, encapsulation: ViewEncapsulation.None, imports: [ CommonModule, MatButtonModule, MatIconModule, ReactiveFormsModule, TranslateModule, MatDialogModule, SettingsAboutSectionComponent, SettingsBackupSectionComponent, SettingsEpgSectionComponent, SettingsGeneralSectionComponent, SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsResetSectionComponent, SettingsSectionScrollDirective, ], }) export class SettingsComponent implements OnInit, OnDestroy { private dialogService = inject(DialogService); public dataService = inject(DataService); private epgService = inject(EpgService); private formBuilder = inject(FormBuilder); private playlistsService = inject(PlaylistsService); private router = inject(Router); private settingsService = inject(SettingsService); private snackBar = inject(MatSnackBar); private store = inject(Store); private translate = inject(TranslateService); private matDialog = inject(MatDialog); private playlistBackupService = inject(PlaylistBackupService); private readonly databaseService = inject(DatabaseService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly dialogData = inject<{ isDialog: boolean } | null>( MAT_DIALOG_DATA, { optional: true } ); @Input() isDialog = this.dialogData?.isDialog ?? false; /** List with available languages as enum */ readonly languageEnum = Language; /** List with allowed formats as enum */ readonly streamFormatEnum = StreamFormat; /** Flag that indicates whether the app runs in electron environment */ readonly isDesktop = this.runtime.isElectron; readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave; readonly supportsEpg = this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement; readonly supportsManagedExternalPlayers = this.runtime.supportsManagedExternalPlayers; readonly supportsExternalPlayerPathSettings = this.runtime.supportsExternalPlayerPathSettings; readonly supportsRemoteControl = this.runtime.supportsRemoteControl; readonly embeddedMpvSupport = signal(null); readonly supportsEmbeddedMpv = computed( () => this.isDesktop && !!this.embeddedMpvSupport()?.supported ); readonly isPwa = this.runtime.isPwa; private readonly settingsCtx = inject(SettingsContextService); readonly activeSection = this.settingsCtx.activeSection; readonly osPlayers = computed(() => [ ...(this.supportsEmbeddedMpv() ? [ { id: VideoPlayer.EmbeddedMpv, labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', }, ] : []), ...(this.supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []), ]); /** Player options */ readonly players = computed(() => [ ...SETTINGS_EMBEDDED_PLAYER_OPTIONS, ...this.osPlayers(), ]); /** Current version of the app */ version = ''; /** Update message to show */ updateMessage = ''; /** EPG availability flag */ epgAvailable$ = this.store.select(selectIsEpgAvailable); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); readonly themeOptions = SETTINGS_THEME_OPTIONS; readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS; readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS; /** Settings form object */ settingsForm = this.formBuilder.group({ player: [VideoPlayer.VideoJs], ...(this.supportsEpg ? { epgUrl: new FormArray>([]) } : {}), streamFormat: StreamFormat.M3u8StreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, showCaptions: false, showDashboard: true, startupBehavior: StartupBehavior.FirstView, showExternalPlaybackBar: true, theme: Theme.SystemTheme, mpvPlayerPath: '', mpvPlayerArguments: '', mpvReuseInstance: false, vlcPlayerPath: '', vlcPlayerArguments: '', vlcReuseInstance: false, remoteControl: false, remoteControlPort: [ 8765, [ Validators.required, Validators.min(1), Validators.max(65535), Validators.pattern(/^\d+$/), ], ], recordingFolder: '', coverSize: 'medium' as CoverSize, ...(this.supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), }); /** Form array with epg sources */ epgUrl = this.settingsForm.get('epgUrl') as FormArray; /** Local IP addresses for remote control URL display */ localIpAddresses = signal([]); /** Currently visible QR code IP (null = none visible) */ visibleQrCodeIp = signal(null); readonly isRemovingAllPlaylists = signal(false); readonly isClearingEpgData = signal(false); readonly isExportingData = signal(false); readonly removeAllProgress = signal(null); private settingsStore = inject(SettingsStore); readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems( { supportsEpg: this.supportsEpg, supportsRemoteControl: this.supportsRemoteControl, } ); readonly playlistDeleteSummary = computed( () => { const items = this.playlists(); return { total: items.length, m3u: items.filter((item) => !item.serverUrl && !item.macAddress) .length, xtream: items.filter((item) => Boolean(item.serverUrl)).length, stalker: items.filter((item) => Boolean(item.macAddress)) .length, }; } ); readonly canRemoveAllPlaylists = computed( () => !this.isRemovingAllPlaylists() && this.playlistDeleteSummary().total > 0 ); readonly removeAllProgressLabel = computed(() => { if (!this.isRemovingAllPlaylists()) { return null; } const progress = this.removeAllProgress(); const current = progress?.current; const total = progress?.total; if ( typeof current === 'number' && typeof total === 'number' && total > 0 ) { return this.translate.instant('SETTINGS.REMOVE_ALL_PROGRESS', { current, total, }); } return this.translate.instant('SETTINGS.REMOVE_ALL_IN_PROGRESS'); }); get sectionNav(): SettingsSection[] { return this.sectionNavItems.filter((section) => section.visible); } /** * Reads the config object from the browsers * storage (indexed db) */ async ngOnInit(): Promise { // Wait for settings to load before setting the form await this.settingsStore.loadSettings(); this.setSettings(); void this.loadEmbeddedMpvSupport(); this.checkAppVersion(); void this.fetchLocalIpAddresses(); if (!this.isDialog) { this.settingsCtx.setSections(this.sectionNav); } } private async loadEmbeddedMpvSupport(): Promise { if (!this.isDesktop) { this.embeddedMpvSupport.set({ supported: false, platform: 'web', reason: 'Embedded MPV requires the Electron desktop build.', }); return; } if (!window.electron?.getEmbeddedMpvSupport) { this.embeddedMpvSupport.set({ supported: false, platform: window.electron.platform, reason: 'Embedded MPV support is not available in this build.', }); return; } try { this.embeddedMpvSupport.set( await window.electron.getEmbeddedMpvSupport() ); } catch (error) { this.embeddedMpvSupport.set({ supported: false, platform: window.electron.platform, reason: error instanceof Error ? error.message : String(error), }); } } ngOnDestroy(): void { this.settingsCtx.reset(); } /** * Fetches local IP addresses for remote control URL display */ async fetchLocalIpAddresses(): Promise { if ( this.supportsRemoteControl && window.electron?.getLocalIpAddresses ) { const addresses = await window.electron.getLocalIpAddresses(); this.localIpAddresses.set(addresses); } } /** * Toggle QR code visibility for a given IP address */ toggleQrCode(ip: string): void { if (this.visibleQrCodeIp() === ip) { this.visibleQrCodeIp.set(null); } else { this.visibleQrCodeIp.set(ip); } } /** * Sets saved settings from the indexed db store */ setSettings() { const currentSettings = this.settingsStore.getSettings(); this.settingsForm.patchValue(currentSettings); if (this.supportsEpg && currentSettings.epgUrl) { this.epgUrl.clear(); this.setEpgUrls(currentSettings.epgUrl); } } selectTheme(theme: Theme): void { if (this.settingsForm.value.theme === theme) { return; } this.settingsForm.patchValue({ theme }); this.settingsForm.get('theme')?.markAsDirty(); this.settingsForm.markAsDirty(); this.settingsService.changeTheme(theme); } selectCoverSize(size: CoverSize): void { if (this.settingsForm.value.coverSize === size) { return; } this.settingsForm.patchValue({ coverSize: size }); this.settingsForm.get('coverSize')?.markAsDirty(); this.settingsForm.markAsDirty(); this.settingsStore.updateSettings({ coverSize: size }); } async selectRecordingFolder(): Promise { if ( !this.isDesktop || !window.electron?.selectEmbeddedMpvRecordingFolder ) { return; } const folder = await window.electron.selectEmbeddedMpvRecordingFolder(); if (!folder) { return; } this.settingsForm.patchValue({ recordingFolder: folder }); this.settingsForm.get('recordingFolder')?.markAsDirty(); this.settingsForm.markAsDirty(); } /** * Sets the epg urls to the form array * @param epgUrls urls of the EPG sources */ setEpgUrls(epgUrls: string[] | string): void { const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls]; const filteredUrls = urls .map((url) => url.trim()) .filter((url) => url !== ''); filteredUrls.forEach((url) => { this.epgUrl.push(createEpgUrlControl(url)); }); } /** * Checks whether the latest version of the application * is used and updates the version message in the * settings UI */ checkAppVersion(): void { this.settingsService .getAppVersion() .pipe(take(1)) .subscribe((version) => this.showVersionInformation(version)); } /** * Updates the message in settings UI about the used * version of the app * @param currentVersion current version of the application */ showVersionInformation(currentVersion: string): void { const isOutdated = this.isCurrentVersionOutdated(currentVersion); if (isOutdated) { this.updateMessage = `${ this.translate.instant( 'SETTINGS.NEW_VERSION_AVAILABLE' ) as string }: ${currentVersion}`; } else { this.updateMessage = this.translate.instant( 'SETTINGS.LATEST_VERSION' ); } } /** * Compares actual with latest version of the * application * @param latestVersion latest version * @returns returns true if an update is available */ isCurrentVersionOutdated(latestVersion: string): boolean { this.version = this.dataService.getAppVersion(); return this.settingsService.isVersionOutdated( this.version, latestVersion ); } /** * Triggers on form submit and saves the config object to * the indexed db store */ onSubmit(): void { const settings = this.createSettingsFromFormValue(); this.settingsStore.updateSettings(settings).then(() => { this.applyChangedSettings(); if (window.electron) { window.electron.updateSettings(settings); } if (this.supportsExternalPlayerPathSettings && window.electron) { window.electron.setMpvPlayerPath(settings.mpvPlayerPath); window.electron.setVlcPlayerPath(settings.vlcPlayerPath); } }); if (this.isDialog) { this.matDialog.closeAll(); } } private normalizeExternalPlayerPath( playerPath: string | null | undefined ): string { return playerPath?.trim() ?? ''; } private createSettingsFromFormValue(): Settings { const currentSettings = this.settingsStore.getSettings(); const value = this.settingsForm.value; const epgUrl = Array.isArray(value.epgUrl) ? value.epgUrl.filter( (url): url is string => typeof url === 'string' ) : (currentSettings.epgUrl ?? []); return { player: value.player ?? VideoPlayer.VideoJs, streamFormat: value.streamFormat ?? StreamFormat.M3u8StreamFormat, openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false, language: value.language ?? Language.ENGLISH, showCaptions: value.showCaptions ?? false, showDashboard: value.showDashboard ?? true, startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView, showExternalPlaybackBar: value.showExternalPlaybackBar ?? true, theme: value.theme ?? Theme.SystemTheme, mpvPlayerPath: this.normalizeExternalPlayerPath( value.mpvPlayerPath ), mpvPlayerArguments: normalizeExternalPlayerArguments( value.mpvPlayerArguments ), mpvReuseInstance: value.mpvReuseInstance ?? false, vlcPlayerPath: this.normalizeExternalPlayerPath( value.vlcPlayerPath ), vlcPlayerArguments: normalizeExternalPlayerArguments( value.vlcPlayerArguments ), vlcReuseInstance: value.vlcReuseInstance ?? false, remoteControl: value.remoteControl ?? false, remoteControlPort: Number(value.remoteControlPort ?? 8765), recordingFolder: value.recordingFolder ?? '', coverSize: value.coverSize ?? 'medium', epgUrl, preferUploadedEpgOverXtream: value.preferUploadedEpgOverXtream ?? currentSettings.preferUploadedEpgOverXtream ?? false, }; } /** * Applies the changed settings to the app */ applyChangedSettings(): void { this.settingsForm.markAsPristine(); if (this.supportsEpg) { let epgUrls = this.settingsForm.value.epgUrl; if (epgUrls) { if (!Array.isArray(epgUrls)) { epgUrls = [epgUrls]; } const validEpgUrls = epgUrls.filter( (url): url is string => typeof url === 'string' && url !== '' ); if (validEpgUrls.length > 0) { // Fetch all EPG URLs at once this.epgService.fetchEpg(validEpgUrls); } } } this.translate.use( this.settingsForm.value.language ?? Language.ENGLISH ); this.settingsService.changeTheme( this.settingsForm.value.theme ?? Theme.SystemTheme ); this.openSettingsSnackbar( this.translate.instant('SETTINGS.SETTINGS_SAVED') ); } /** * Navigates back to the applications homepage */ backToHome(): void { if (this.isDialog) { this.matDialog.closeAll(); } else { this.router.navigateByUrl('/'); } } /** * Force-fetch EPG for a single URL, bypassing the 12-hour freshness check. * The plain fetchEpg would short-circuit on fresh data and click the * refresh button would be a no-op — that's exactly not what the user * intends when clicking "Refresh". */ refreshEpg(url: string): void { if (!this.epgBridge.supportsDataManagement || !url) { return; } void this.epgBridge.forceFetchEpg(url); } /** * Force-fetch every configured EPG URL sequentially. Empty fields are * skipped. Each URL flows through the normal progress panel so the user * gets visible per-URL feedback. */ refreshAllEpg(): void { if (!this.epgBridge.supportsDataManagement) return; const urls = (this.epgUrl.value as string[]) .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)); urls.forEach((url) => void this.epgBridge.forceFetchEpg(url)); } /** * Initializes new entry in form array for EPG URL */ addEpgSource(): void { this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl()); } /** * Removes entry from form array for EPG URL * @param index index of the item to remove */ removeEpgSource(index: number): void { this.epgUrl.removeAt(index); this.settingsForm.markAsDirty(); } /** * Clears all EPG data from database and immediately re-fetches every * configured URL so the user isn't left staring at an empty state. * Tracks progress with `isClearingEpgData` so the UI can show a spinner * and block double-clicks, and surfaces failures via a dedicated snackbar. */ clearEpgData(): void { this.dialogService.openConfirmDialog({ title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'), message: this.translate.instant( 'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE' ), onConfirm: async (): Promise => { if ( !this.epgBridge.supportsDataManagement || this.isClearingEpgData() ) { return; } this.isClearingEpgData.set(true); try { const result = await this.epgBridge.clearEpgData(); if (result && result.success === false) { throw new Error('Clear EPG returned success=false'); } this.openSettingsSnackbar( this.translate.instant('SETTINGS.EPG_DATA_CLEARED') ); this.refreshAllEpg(); } catch (error) { console.error('Failed to clear EPG data:', error); this.openSettingsSnackbar( this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') ); } finally { this.isClearingEpgData.set(false); } }, }); } async exportData() { if (this.isExportingData()) { return; } this.isExportingData.set(true); // Let Angular paint the busy state before the backup build and native // save dialog handoff start. await this.waitForUiFeedbackFrame(); try { const backup = await this.playlistBackupService.exportBackup(); if (this.supportsDesktopFileSave && window.electron) { const savePath = await window.electron.saveFileDialog( backup.defaultFileName, [ { name: 'JSON', extensions: ['json'], }, ] ); if (!savePath) { return; } await window.electron.writeFile(savePath, backup.json); } else { const blob = new Blob([backup.json], { type: 'application/json', }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = backup.defaultFileName; link.click(); window.URL.revokeObjectURL(url); } this.openSettingsSnackbar('Playlist backup exported.'); } catch (error) { console.error('Failed to export playlist backup:', error); this.openSettingsSnackbar('Playlist backup export failed.'); } finally { this.isExportingData.set(false); } } importData() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.addEventListener('change', async (event: Event) => { const target = event.target as HTMLInputElement; const file = target.files?.[0]; if (file) { try { const summary = await this.playlistBackupService.importBackup( await file.text() ); if (summary.imported > 0 || summary.merged > 0) { this.store.dispatch( PlaylistActions.removeAllPlaylists() ); this.store.dispatch(PlaylistActions.loadPlaylists()); } this.setSettings(); this.openSettingsSnackbar( this.buildBackupImportSummary(summary) ); if (summary.errors.length > 0) { console.error( 'Playlist backup import completed with issues:', summary.errors ); } } catch (error) { console.error('Failed to import playlist backup:', error); this.openSettingsSnackbar( error instanceof Error ? error.message : this.translate.instant('SETTINGS.IMPORT_ERROR') ); } } }); input.click(); } private buildBackupImportSummary( summary: PlaylistBackupImportSummary ): string { return `Backup import finished: ${summary.imported} imported, ${summary.merged} merged, ${summary.skipped} skipped, ${summary.failed} failed.`; } private async waitForUiFeedbackFrame(): Promise { if (typeof window.requestAnimationFrame !== 'function') { await Promise.resolve(); return; } await new Promise((resolve) => { window.requestAnimationFrame(() => resolve()); }); } removeAll(): void { if (!this.canRemoveAllPlaylists()) { return; } this.matDialog .open< SettingsDeleteAllPlaylistsDialogComponent, SettingsDeleteAllPlaylistsDialogData, boolean >(SettingsDeleteAllPlaylistsDialogComponent, { autoFocus: false, data: { summary: this.playlistDeleteSummary(), }, maxWidth: 'calc(100vw - 32px)', restoreFocus: true, width: '460px', }) .afterClosed() .pipe(take(1)) .subscribe((confirmed) => { if (confirmed) { void this.removeAllConfirmed(); } }); } private async removeAllConfirmed(): Promise { if (this.isRemovingAllPlaylists()) { return; } this.isRemovingAllPlaylists.set(true); this.removeAllProgress.set(null); await this.waitForUiFeedbackFrame(); try { const deleted = this.isDesktop && window.electron ? await this.deleteAllPlaylistsInElectron() : await this.deleteAllPlaylistsInBrowser(); if (!deleted) { throw new Error('Delete all playlists returned success=false'); } this.store.dispatch(PlaylistActions.removeAllPlaylists()); this.openSettingsSnackbar( this.translate.instant('SETTINGS.PLAYLISTS_REMOVED') ); } catch (error) { console.error('Error removing playlists:', error); this.openSettingsSnackbar( this.translate.instant('SETTINGS.PLAYLISTS_REMOVE_FAILED') ); } finally { this.removeAllProgress.set(null); this.isRemovingAllPlaylists.set(false); } } private async deleteAllPlaylistsInElectron(): Promise { return this.databaseService.deleteAllPlaylists({ operationId: this.databaseService.createOperationId( 'settings-delete-all-playlists' ), onEvent: (event) => this.handleDeleteAllPlaylistsEvent(event), }); } private async deleteAllPlaylistsInBrowser(): Promise { await firstValueFrom(this.playlistsService.removeAll()); return true; } private handleDeleteAllPlaylistsEvent(event: DbOperationEvent): void { this.removeAllProgress.set(event); } private openSettingsSnackbar( message: string, config: MatSnackBarConfig = {} ): void { this.snackBar.open(message, undefined, { duration: 2000, horizontalPosition: 'center', verticalPosition: 'bottom', panelClass: ['settings-snackbar'], ...config, }); } }