From f9ea3070ee16c4ff779c6b7cae0b700ff51261d0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:57:16 +0200 Subject: [PATCH] refactor(settings): split the settings page into per-section facades (#1274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300) and hard maximum, passing lint only because it sat in the max-lines baseline. The behaviour moves into facades the template binds to directly, following the precedent already in this folder: new app-update (218), form (197), epg (123), embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended to 143 and settings-options to 200. The component is now a 259-line coordinator holding capability flags, section nav, players() and the cross-facade flows. settings.component.ts is removed from the max-lines baseline. No behaviour change. One ordering detail: applyChangedSettings now applies language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM theme sync and translate.use does not touch the form, so the two are independent. --- .../settings/settings-app-update.facade.ts | 218 ++++++ .../settings/settings-embedded-mpv.facade.ts | 74 ++ .../src/app/settings/settings-epg.facade.ts | 123 +++ .../src/app/settings/settings-form.facade.ts | 197 +++++ apps/web/src/app/settings/settings-options.ts | 27 + .../settings-playlist-reset.facade.ts | 71 +- .../settings-remote-control.facade.ts | 37 + .../src/app/settings/settings.component.html | 58 +- .../app/settings/settings.component.spec.ts | 162 ++-- .../src/app/settings/settings.component.ts | 708 ++---------------- docs/architecture/remote-control.md | 6 +- tools/eslint/max-lines-baseline.mjs | 1 - 12 files changed, 931 insertions(+), 751 deletions(-) create mode 100644 apps/web/src/app/settings/settings-app-update.facade.ts create mode 100644 apps/web/src/app/settings/settings-embedded-mpv.facade.ts create mode 100644 apps/web/src/app/settings/settings-epg.facade.ts create mode 100644 apps/web/src/app/settings/settings-form.facade.ts create mode 100644 apps/web/src/app/settings/settings-remote-control.facade.ts diff --git a/apps/web/src/app/settings/settings-app-update.facade.ts b/apps/web/src/app/settings/settings-app-update.facade.ts new file mode 100644 index 000000000..05659f6f7 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.ts @@ -0,0 +1,218 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs'; +import { SettingsService } from '../services/settings.service'; +import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; + +const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60; +const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250; + +/** + * Owns everything the About section needs to talk about versions: the + * electron-updater status stream and the "is my build outdated" message. + */ +@Injectable() +export class SettingsAppUpdateFacade { + private readonly dataService = inject(DataService); + private readonly matDialog = inject(MatDialog); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly translate = inject(TranslateService); + + /** Latest updater status, polled once and then pushed by the backend */ + readonly status = signal(null); + + /** Current version of the app */ + readonly version = signal(''); + + /** Update message to show */ + readonly updateMessage = signal(''); + + private unsubscribeStatus: (() => void) | null = null; + + /** Subscribes to status pushes and kicks off the initial status load */ + init(): void { + this.bindStatusEvents(); + void this.loadStatus(); + } + + dispose(): void { + this.unsubscribeStatus?.(); + this.unsubscribeStatus = null; + } + + async checkForAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.checkForAppUpdate) { + return; + } + + this.status.set(await window.electron.checkForAppUpdate()); + } + + async downloadAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.downloadAppUpdate) { + return; + } + + this.status.set(await window.electron.downloadAppUpdate()); + } + + async installAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.installAppUpdate) { + return; + } + + this.status.set(await window.electron.installAppUpdate()); + } + + openManualAppUpdate(): void { + const manualDownloadUrl = this.status()?.manualDownloadUrl; + + if (!manualDownloadUrl) { + return; + } + + window.open(manualDownloadUrl, '_blank', 'noreferrer'); + } + + openReleaseNotes(): void { + const status = this.status(); + const isUpdateRelease = + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available || + status?.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + const initialReleaseNotesVersion = isUpdateRelease + ? (status?.latestVersion ?? + status?.release?.version ?? + status?.currentVersion) + : status?.currentVersion; + + this.matDialog.open(AppUpdateReleaseNotesDialogComponent, { + autoFocus: false, + data: { + ...(!isUpdateRelease ? { fallbackToLatest: true } : {}), + initialVersion: initialReleaseNotesVersion, + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '720px', + }); + } + + /** + * 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.set( + `${ + this.translate.instant( + 'SETTINGS.NEW_VERSION_AVAILABLE' + ) as string + }: ${currentVersion}` + ); + } else { + this.updateMessage.set( + 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.set(this.dataService.getAppVersion()); + return this.settingsService.isVersionOutdated( + this.version(), + latestVersion + ); + } + + private bindStatusEvents(): void { + if ( + !this.runtime.isElectron || + !window.electron?.onAppUpdateStatusChange + ) { + return; + } + + this.unsubscribeStatus = window.electron.onAppUpdateStatusChange( + (status) => { + this.status.set(status); + } + ); + } + + /** + * The updater IPC handlers can still be registering while the settings + * page mounts, so the first load retries until the bridge answers. + */ + private async loadStatus(): Promise { + if (!this.runtime.isElectron) { + return; + } + + let lastError: unknown; + + for ( + let attempt = 1; + attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS; + attempt += 1 + ) { + const electron = window.electron; + + if (electron?.getAppUpdateStatus) { + try { + this.status.set(await electron.getAppUpdateStatus()); + return; + } catch (error) { + lastError = error; + } + } + + if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) { + console.warn( + 'Failed to load app update status:', + lastError ?? + new Error('Desktop app update bridge is unavailable') + ); + return; + } + + await this.waitForRetry(); + } + } + + private async waitForRetry(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS); + }); + } +} diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts new file mode 100644 index 000000000..cfe738c72 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -0,0 +1,74 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; + +/** + * Probes the desktop backend for embedded MPV support so the playback + * section can hide options the current build cannot deliver. + */ +@Injectable() +export class SettingsEmbeddedMpvFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + readonly support = signal(null); + + readonly supported = computed( + () => this.runtime.isElectron && !!this.support()?.supported + ); + + readonly frameCopyAvailable = computed( + () => this.runtime.isElectron && !!this.support()?.frameCopyAvailable + ); + + readonly frameCopyActive = computed( + () => this.support()?.engine === 'frame-copy' + ); + + async load(): Promise { + if (!this.runtime.isElectron) { + this.support.set({ + supported: false, + platform: 'web', + reason: 'Embedded MPV requires the Electron desktop build.', + }); + return; + } + + if (!window.electron?.getEmbeddedMpvSupport) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: 'Embedded MPV support is not available in this build.', + }); + return; + } + + try { + this.support.set(await window.electron.getEmbeddedMpvSupport()); + } catch (error) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Opens the native folder picker for MPV recordings. + * @returns the selected folder, or null when the user cancelled or the + * desktop bridge cannot show the dialog + */ + async selectRecordingFolder(): Promise { + if ( + !this.runtime.isElectron || + !window.electron?.selectEmbeddedMpvRecordingFolder + ) { + return null; + } + + return ( + (await window.electron.selectEmbeddedMpvRecordingFolder()) ?? null + ); + } +} diff --git a/apps/web/src/app/settings/settings-epg.facade.ts b/apps/web/src/app/settings/settings-epg.facade.ts new file mode 100644 index 000000000..7f8670e09 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.ts @@ -0,0 +1,123 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsFormFacade } from './settings-form.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +/** + * EPG maintenance actions offered by the settings page: force refreshes and + * the destructive "clear all EPG data" flow. + */ +@Injectable() +export class SettingsEpgFacade { + private readonly dialogService = inject(DialogService); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly formFacade = inject(SettingsFormFacade); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly isClearing = signal(false); + + /** + * 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". + */ + refresh(url: string): void { + if (!this.epgBridge.supportsDataManagement || !url) { + return; + } + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + /** + * 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. + */ + refreshAll(): void { + if (!this.epgBridge.supportsDataManagement) return; + const options = this.settingsStore.getTrustOptions(); + this.formFacade.epgUrls.forEach( + (url) => void this.epgBridge.forceFetchEpg(url, options) + ); + } + + /** + * 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 `isClearing` so the UI can show a spinner + * and block double-clicks, and surfaces failures via a dedicated snackbar. + */ + clear(): 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.isClearing() + ) { + return; + } + + this.isClearing.set(true); + try { + const result = await this.epgBridge.clearEpgData(); + if (result && result.success === false) { + throw new Error('Clear EPG returned success=false'); + } + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED') + ); + this.refreshAll(); + } catch (error) { + console.error('Failed to clear EPG data:', error); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') + ); + } finally { + this.isClearing.set(false); + } + }, + }); + } + + /** + * Re-fetches the EPG sources that were just saved so the guide reflects + * the new configuration without a restart. + */ + fetchConfiguredEpg(): void { + if (!this.formFacade.supportsEpg) { + return; + } + + const epgUrl = this.formFacade.form.value.epgUrl; + + if (!epgUrl) { + return; + } + + const validEpgUrls = (Array.isArray(epgUrl) ? epgUrl : [epgUrl]).filter( + (url): url is string => typeof url === 'string' && url !== '' + ); + + if (validEpgUrls.length > 0) { + // Fetch all EPG URLs at once + this.epgService.fetchEpg(validEpgUrls); + } + } +} diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts new file mode 100644 index 000000000..a1b4a31a1 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -0,0 +1,197 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder } from '@angular/forms'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + CoverSize, + EpgViewMode, + Language, + Theme, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsService } from '../services/settings.service'; +import { + applyEpgUrlsToFormArray, + createEpgUrlControl, + createSettingsForm, + createSettingsFromFormValue, + SettingsForm, +} from './settings-form.utils'; + +type SettingsFormPatch = Parameters[0]; + +/** + * Owns the settings form: creation, hydration from the store, the small + * mutations the section components trigger, and persisting on submit. + */ +@Injectable() +export class SettingsFormFacade { + private readonly destroyRef = inject(DestroyRef); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly formBuilder = inject(FormBuilder); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly supportsEpg = + this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement; + + /** Settings form object */ + readonly form = createSettingsForm(this.formBuilder, this.supportsEpg); + + /** Form array with epg sources — absent when EPG is unsupported */ + readonly epgUrl = this.form.get('epgUrl') as FormArray; + + /** Trimmed, non-empty EPG source URLs currently in the form */ + get epgUrls(): string[] { + return ((this.epgUrl?.value as string[] | undefined) ?? []) + .map((url) => url?.trim()) + .filter((url): url is string => Boolean(url)); + } + + /** Waits for the persisted settings to be available */ + loadSettings(): Promise { + return this.settingsStore.loadSettings(); + } + + /** + * Sets saved settings from the indexed db store + */ + hydrateFromStore(): void { + const currentSettings = this.settingsStore.getSettings(); + this.form.patchValue(currentSettings); + this.syncDashboardControlsEnabledState( + currentSettings.showDashboard ?? true + ); + + if (this.supportsEpg && currentSettings.epgUrl) { + this.epgUrl.clear(); + this.setEpgUrls(currentSettings.epgUrl); + } + } + + bindDashboardControlsEnabledState(): void { + this.form + .get('showDashboard') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((showDashboard) => + this.syncDashboardControlsEnabledState(showDashboard ?? true) + ); + } + + selectTheme(theme: Theme): void { + if (this.form.value.theme === theme) { + return; + } + + this.patchAndMarkDirty({ theme }, 'theme'); + this.settingsService.changeTheme(theme); + } + + selectCoverSize(coverSize: CoverSize): void { + if (this.form.value.coverSize === coverSize) { + return; + } + + this.patchAndMarkDirty({ coverSize }, 'coverSize'); + void this.settingsStore.updateSettings({ coverSize }); + } + + selectEpgViewMode(epgViewMode: EpgViewMode): void { + if (this.form.value.epgViewMode === epgViewMode) { + return; + } + + this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); + void this.settingsStore.updateSettings({ epgViewMode }); + } + + setRecordingFolder(recordingFolder: string): void { + this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder'); + } + + /** + * Sets the epg urls to the form array + * @param epgUrls urls of the EPG sources + */ + setEpgUrls(epgUrls: string[] | string): void { + applyEpgUrlsToFormArray(this.epgUrl, epgUrls); + } + + /** + * 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.form.markAsDirty(); + } + + /** + * Persists the form to the settings store and mirrors the result to the + * desktop backend. + * @param onSaved runs after the store write, before the backend is + * notified, so the UI confirmation is not delayed by IPC + */ + async save(onSaved: () => void): Promise { + const settings = createSettingsFromFormValue( + this.form, + this.settingsStore.getSettings() + ); + + await this.settingsStore.updateSettings(settings); + onSaved(); + + if (!window.electron) { + return; + } + + window.electron.updateSettings(settings); + + if (this.runtime.supportsExternalPlayerPathSettings) { + window.electron.setMpvPlayerPath(settings.mpvPlayerPath); + window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + } + } + + /** Applies the saved language/theme and resets the dirty state */ + applySavedSettings(): void { + this.form.markAsPristine(); + this.translate.use(this.form.value.language ?? Language.ENGLISH); + this.settingsService.changeTheme( + this.form.value.theme ?? Theme.SystemTheme + ); + } + + private patchAndMarkDirty( + value: SettingsFormPatch, + controlName: string + ): void { + this.form.patchValue(value); + this.form.get(controlName)?.markAsDirty(); + this.form.markAsDirty(); + } + + private syncDashboardControlsEnabledState(showDashboard: boolean): void { + const dashboardRails = this.form.get('dashboardRails'); + if (!dashboardRails) { + return; + } + + if (showDashboard) { + dashboardRails.enable({ emitEvent: false }); + } else { + dashboardRails.disable({ emitEvent: false }); + } + } +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 5add3d301..8df774bc1 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -100,6 +100,33 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [ }, ]; +export interface SettingsPlayerAvailability { + supportsEmbeddedMpv: boolean; + supportsManagedExternalPlayers: boolean; +} + +/** + * Built-in web players are always offered; the OS-backed ones only show up + * when the current runtime can actually launch them. + */ +export function buildSettingsPlayerOptions({ + supportsEmbeddedMpv, + supportsManagedExternalPlayers, +}: SettingsPlayerAvailability): SettingsPlayerOption[] { + return [ + ...SETTINGS_EMBEDDED_PLAYER_OPTIONS, + ...(supportsEmbeddedMpv + ? [ + { + id: VideoPlayer.EmbeddedMpv, + labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', + }, + ] + : []), + ...(supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []), + ]; +} + export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index 2a427760a..8ec1a1fa8 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -1,19 +1,30 @@ -import { inject, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { firstValueFrom, take } from 'rxjs'; +import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { DatabaseService, DbOperationEvent, PlaylistsService, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, +} from './settings-delete-all-playlists-dialog.component'; +import { + buildRemoveAllProgressLabel, + buildSettingsPlaylistDeleteSummary, +} from './settings-playlist-summary.utils'; +import { SettingsPlaylistDeleteSummary } from './settings.models'; import { SettingsSnackbarService } from './settings-snackbar.service'; @Injectable() export class SettingsPlaylistResetFacade { private readonly databaseService = inject(DatabaseService); + private readonly matDialog = inject(MatDialog); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -23,7 +34,59 @@ export class SettingsPlaylistResetFacade { readonly isRemovingAllPlaylists = signal(false); readonly removeAllProgress = signal(null); - async removeAllConfirmed( + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + + readonly deleteSummary = computed(() => + buildSettingsPlaylistDeleteSummary(this.playlists()) + ); + + readonly canRemoveAll = computed( + () => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0 + ); + + readonly removeAllProgressLabel = computed(() => + buildRemoveAllProgressLabel({ + isRemovingAllPlaylists: this.isRemovingAllPlaylists(), + progress: this.removeAllProgress(), + translate: (key, params) => this.translate.instant(key, params), + }) + ); + + /** + * Asks for confirmation in a dedicated dialog before wiping every + * playlist, so the summary of what is about to be lost is visible. + */ + confirmAndRemoveAll(waitForUiFeedbackFrame: () => Promise): void { + if (!this.canRemoveAll()) { + return; + } + + this.matDialog + .open< + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, + boolean + >(SettingsDeleteAllPlaylistsDialogComponent, { + autoFocus: false, + data: { + summary: this.deleteSummary(), + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '460px', + }) + .afterClosed() + .pipe(take(1)) + .subscribe((confirmed) => { + if (confirmed) { + void this.removeAllConfirmed(waitForUiFeedbackFrame); + } + }); + } + + private async removeAllConfirmed( waitForUiFeedbackFrame: () => Promise ): Promise { if (this.isRemovingAllPlaylists()) { diff --git a/apps/web/src/app/settings/settings-remote-control.facade.ts b/apps/web/src/app/settings/settings-remote-control.facade.ts new file mode 100644 index 000000000..6796510cd --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control.facade.ts @@ -0,0 +1,37 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; + +/** + * Backs the remote-control section: the reachable LAN addresses and which of + * them currently shows its pairing QR code. + */ +@Injectable() +export class SettingsRemoteControlFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + /** Local IP addresses for remote control URL display */ + readonly localIpAddresses = signal([]); + + /** Currently visible QR code IP (null = none visible) */ + readonly visibleQrCodeIp = signal(null); + + /** + * Fetches local IP addresses for remote control URL display + */ + async fetchLocalIpAddresses(): Promise { + if ( + this.runtime.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 { + this.visibleQrCodeIp.update((visible) => (visible === ip ? null : ip)); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0ef375d3d..0bdbf2bd0 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -38,8 +38,8 @@ [themeOptions]="themeOptions" [coverSizeOptions]="coverSizeOptions" [startupBehaviorOptions]="startupBehaviorOptions" - (selectTheme)="selectTheme($event)" - (selectCoverSize)="selectCoverSize($event)" + (selectTheme)="form.selectTheme($event)" + (selectCoverSize)="form.selectCoverSize($event)" /> @@ -59,15 +59,15 @@ } @@ -80,9 +80,9 @@ } @@ -94,33 +94,33 @@