diff --git a/apps/web/src/app/settings/settings-backup.facade.ts b/apps/web/src/app/settings/settings-backup.facade.ts new file mode 100644 index 000000000..6aa377b4c --- /dev/null +++ b/apps/web/src/app/settings/settings-backup.facade.ts @@ -0,0 +1,132 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + PlaylistBackupImportSummary, + PlaylistBackupService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +@Injectable() +export class SettingsBackupFacade { + private readonly playlistBackupService = inject(PlaylistBackupService); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + + readonly isExportingData = signal(false); + + async exportData(waitForUiFeedbackFrame: () => Promise) { + if (this.isExportingData()) { + return; + } + + this.isExportingData.set(true); + await waitForUiFeedbackFrame(); + + try { + const backup = await this.playlistBackupService.exportBackup(); + + if (this.runtime.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 { + this.downloadBackupInBrowser( + backup.defaultFileName, + backup.json + ); + } + + this.settingsSnackbar.open('Playlist backup exported.'); + } catch (error) { + console.error('Failed to export playlist backup:', error); + this.settingsSnackbar.open('Playlist backup export failed.'); + } finally { + this.isExportingData.set(false); + } + } + + importData(onImported: () => void): void { + 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) { + return; + } + + 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()); + } + + onImported(); + this.settingsSnackbar.open( + 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.settingsSnackbar.open( + error instanceof Error + ? error.message + : this.translate.instant('SETTINGS.IMPORT_ERROR') + ); + } + }); + + input.click(); + } + + private downloadBackupInBrowser( + defaultFileName: string, + json: string + ): void { + const blob = new Blob([json], { + type: 'application/json', + }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = defaultFileName; + link.click(); + window.URL.revokeObjectURL(url); + } + + private buildBackupImportSummary( + summary: PlaylistBackupImportSummary + ): string { + return `Backup import finished: ${summary.imported} imported, ${summary.merged} merged, ${summary.skipped} skipped, ${summary.failed} failed.`; + } +} diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index f7c91d176..8bbabcd1e 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -1,7 +1,142 @@ -import { FormControl, Validators } from '@angular/forms'; +import { + FormArray, + FormBuilder, + FormControl, + Validators, +} from '@angular/forms'; +import { + CoverSize, + DEFAULT_DASHBOARD_RAILS_SETTINGS, + Language, + normalizeDashboardRailsSettings, + normalizeExternalPlayerArguments, + Settings, + StartupBehavior, + StreamFormat, + Theme, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; export const EPG_URL_PATTERN = /^(http|https|file):\/\/[^ "]+$/; export function createEpgUrlControl(value = ''): FormControl { return new FormControl(value, [Validators.pattern(EPG_URL_PATTERN)]); } + +export function createSettingsForm( + formBuilder: FormBuilder, + supportsEpg: boolean +) { + return formBuilder.group({ + player: [VideoPlayer.VideoJs], + ...(supportsEpg + ? { epgUrl: new FormArray>([]) } + : {}), + streamFormat: StreamFormat.M3u8StreamFormat, + openStreamOnDoubleClick: false, + language: Language.ENGLISH, + showCaptions: false, + showDashboard: true, + dashboardRails: formBuilder.group({ + hero: DEFAULT_DASHBOARD_RAILS_SETTINGS.hero, + continueWatching: DEFAULT_DASHBOARD_RAILS_SETTINGS.continueWatching, + liveFavorites: DEFAULT_DASHBOARD_RAILS_SETTINGS.liveFavorites, + recentlyWatchedLive: + DEFAULT_DASHBOARD_RAILS_SETTINGS.recentlyWatchedLive, + favoriteMoviesAndSeries: + DEFAULT_DASHBOARD_RAILS_SETTINGS.favoriteMoviesAndSeries, + recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources, + xtreamRecentlyAdded: + DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded, + }), + 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, + ...(supportsEpg ? { preferUploadedEpgOverXtream: false } : {}), + }); +} + +export type SettingsForm = ReturnType; + +export function applyEpgUrlsToFormArray( + epgUrl: FormArray, + epgUrls: string[] | string +): void { + const urls = Array.isArray(epgUrls) ? epgUrls : [epgUrls]; + const filteredUrls = urls + .map((url) => url.trim()) + .filter((url) => url !== ''); + + filteredUrls.forEach((url) => { + epgUrl.push(createEpgUrlControl(url)); + }); +} + +export function createSettingsFromFormValue( + settingsForm: SettingsForm, + currentSettings: Settings +): Settings { + const value = settingsForm.getRawValue(); + 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, + dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails), + startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView, + showExternalPlaybackBar: value.showExternalPlaybackBar ?? true, + theme: value.theme ?? Theme.SystemTheme, + mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath), + mpvPlayerArguments: normalizeExternalPlayerArguments( + value.mpvPlayerArguments + ), + mpvReuseInstance: value.mpvReuseInstance ?? false, + vlcPlayerPath: 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, + trustedPrivateNetworkEpgUrls: + currentSettings.trustedPrivateNetworkEpgUrls ?? [], + trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [], + }; +} + +function normalizeExternalPlayerPath( + playerPath: string | null | undefined +): string { + return playerPath?.trim() ?? ''; +} diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts new file mode 100644 index 000000000..2a427760a --- /dev/null +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -0,0 +1,80 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { + DatabaseService, + DbOperationEvent, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +@Injectable() +export class SettingsPlaylistResetFacade { + private readonly databaseService = inject(DatabaseService); + private readonly playlistsService = inject(PlaylistsService); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly store = inject(Store); + private readonly translate = inject(TranslateService); + + readonly isRemovingAllPlaylists = signal(false); + readonly removeAllProgress = signal(null); + + async removeAllConfirmed( + waitForUiFeedbackFrame: () => Promise + ): Promise { + if (this.isRemovingAllPlaylists()) { + return; + } + + this.isRemovingAllPlaylists.set(true); + this.removeAllProgress.set(null); + + await waitForUiFeedbackFrame(); + + try { + const deleted = + this.runtime.isElectron && 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.settingsSnackbar.open( + this.translate.instant('SETTINGS.PLAYLISTS_REMOVED') + ); + } catch (error) { + console.error('Error removing playlists:', error); + this.settingsSnackbar.open( + 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); + } +} diff --git a/apps/web/src/app/settings/settings-playlist-summary.utils.ts b/apps/web/src/app/settings/settings-playlist-summary.utils.ts new file mode 100644 index 000000000..c6087f913 --- /dev/null +++ b/apps/web/src/app/settings/settings-playlist-summary.utils.ts @@ -0,0 +1,45 @@ +import type { DbOperationEvent } from '@iptvnator/services'; +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import type { SettingsPlaylistDeleteSummary } from './settings.models'; + +export function buildSettingsPlaylistDeleteSummary( + items: readonly PlaylistMeta[] +): SettingsPlaylistDeleteSummary { + 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, + }; +} + +export interface RemoveAllProgressLabelInput { + readonly isRemovingAllPlaylists: boolean; + readonly progress: DbOperationEvent | null; + readonly translate: ( + key: string, + params?: Record + ) => string; +} + +export function buildRemoveAllProgressLabel({ + isRemovingAllPlaylists, + progress, + translate, +}: RemoveAllProgressLabelInput): string | null { + if (!isRemovingAllPlaylists) { + return null; + } + + const current = progress?.current; + const total = progress?.total; + + if (typeof current === 'number' && typeof total === 'number' && total > 0) { + return translate('SETTINGS.REMOVE_ALL_PROGRESS', { + current, + total, + }); + } + + return translate('SETTINGS.REMOVE_ALL_IN_PROGRESS'); +} diff --git a/apps/web/src/app/settings/settings-snackbar.service.ts b/apps/web/src/app/settings/settings-snackbar.service.ts new file mode 100644 index 000000000..2b5a40549 --- /dev/null +++ b/apps/web/src/app/settings/settings-snackbar.service.ts @@ -0,0 +1,17 @@ +import { inject, Injectable } from '@angular/core'; +import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; + +@Injectable() +export class SettingsSnackbarService { + private readonly snackBar = inject(MatSnackBar); + + open(message: string, config: MatSnackBarConfig = {}): void { + this.snackBar.open(message, undefined, { + duration: 2000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + panelClass: ['settings-snackbar'], + ...config, + }); + } +} diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 20110f602..49d5c90a9 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -11,13 +11,7 @@ import { ViewEncapsulation, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { - FormArray, - FormBuilder, - FormControl, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; +import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, @@ -25,7 +19,6 @@ import { 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, @@ -36,29 +29,15 @@ 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 { take } from 'rxjs'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; import { EmbeddedMpvSupport, CoverSize, - DEFAULT_DASHBOARD_RAILS_SETTINGS, Language, - normalizeExternalPlayerArguments, - normalizeDashboardRailsSettings, - Settings, - StartupBehavior, StreamFormat, Theme, VideoPlayer, @@ -73,7 +52,12 @@ import { SettingsDeleteAllPlaylistsDialogData, } from './settings-delete-all-playlists-dialog.component'; import { SettingsEpgSectionComponent } from './settings-epg-section.component'; -import { createEpgUrlControl } from './settings-form.utils'; +import { + applyEpgUrlsToFormArray, + createEpgUrlControl, + createSettingsForm, + createSettingsFromFormValue, +} from './settings-form.utils'; import { SettingsGeneralSectionComponent } from './settings-general-section.component'; import { SettingsPlaylistDeleteSummary, @@ -91,6 +75,13 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component'; import { SettingsSectionScrollDirective } from './settings-section-scroll.directive'; +import { SettingsBackupFacade } from './settings-backup.facade'; +import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade'; +import { + buildRemoveAllProgressLabel, + buildSettingsPlaylistDeleteSummary, +} from './settings-playlist-summary.utils'; +import { SettingsSnackbarService } from './settings-snackbar.service'; @Component({ templateUrl: './settings.component.html', @@ -116,6 +107,11 @@ import { SettingsSectionScrollDirective } from './settings-section-scroll.direct SettingsResetSectionComponent, SettingsSectionScrollDirective, ], + providers: [ + SettingsBackupFacade, + SettingsPlaylistResetFacade, + SettingsSnackbarService, + ], }) export class SettingsComponent implements OnInit, OnDestroy { private dialogService = inject(DialogService); @@ -123,15 +119,14 @@ export class SettingsComponent implements OnInit, OnDestroy { private epgService = inject(EpgService); private formBuilder = inject(FormBuilder); private destroyRef = inject(DestroyRef); - private playlistsService = inject(PlaylistsService); private router = inject(Router); private settingsService = inject(SettingsService); - private snackBar = inject(MatSnackBar); + private settingsSnackbar = inject(SettingsSnackbarService); private store = inject(Store); private translate = inject(TranslateService); private matDialog = inject(MatDialog); - private playlistBackupService = inject(PlaylistBackupService); - private readonly databaseService = inject(DatabaseService); + private readonly backupFacade = inject(SettingsBackupFacade); + private readonly playlistResetFacade = inject(SettingsPlaylistResetFacade); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly epgBridge = inject(EpgRuntimeBridgeService); private readonly dialogData = inject<{ isDialog: boolean } | null>( @@ -201,52 +196,7 @@ export class SettingsComponent implements OnInit, OnDestroy { 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, - dashboardRails: this.formBuilder.group({ - hero: DEFAULT_DASHBOARD_RAILS_SETTINGS.hero, - continueWatching: - DEFAULT_DASHBOARD_RAILS_SETTINGS.continueWatching, - liveFavorites: DEFAULT_DASHBOARD_RAILS_SETTINGS.liveFavorites, - recentlyWatchedLive: - DEFAULT_DASHBOARD_RAILS_SETTINGS.recentlyWatchedLive, - favoriteMoviesAndSeries: - DEFAULT_DASHBOARD_RAILS_SETTINGS.favoriteMoviesAndSeries, - recentSources: DEFAULT_DASHBOARD_RAILS_SETTINGS.recentSources, - xtreamRecentlyAdded: - DEFAULT_DASHBOARD_RAILS_SETTINGS.xtreamRecentlyAdded, - }), - 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 } : {}), - }); + settingsForm = createSettingsForm(this.formBuilder, this.supportsEpg); /** Form array with epg sources */ epgUrl = this.settingsForm.get('epgUrl') as FormArray; @@ -256,10 +206,11 @@ export class SettingsComponent implements OnInit, OnDestroy { /** Currently visible QR code IP (null = none visible) */ visibleQrCodeIp = signal(null); - readonly isRemovingAllPlaylists = signal(false); + readonly isRemovingAllPlaylists = + this.playlistResetFacade.isRemovingAllPlaylists; readonly isClearingEpgData = signal(false); - readonly isExportingData = signal(false); - readonly removeAllProgress = signal(null); + readonly isExportingData = this.backupFacade.isExportingData; + readonly removeAllProgress = this.playlistResetFacade.removeAllProgress; private settingsStore = inject(SettingsStore); readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({ @@ -268,18 +219,7 @@ export class SettingsComponent implements OnInit, OnDestroy { }); 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, - }; - } + () => buildSettingsPlaylistDeleteSummary(this.playlists()) ); readonly canRemoveAllPlaylists = computed( @@ -289,26 +229,11 @@ export class SettingsComponent implements OnInit, OnDestroy { ); 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'); + return buildRemoveAllProgressLabel({ + isRemovingAllPlaylists: this.isRemovingAllPlaylists(), + progress: this.removeAllProgress(), + translate: (key, params) => this.translate.instant(key, params), + }); }); get sectionNav(): SettingsSection[] { @@ -476,14 +401,7 @@ export class SettingsComponent implements OnInit, OnDestroy { * @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)); - }); + applyEpgUrlsToFormArray(this.epgUrl, epgUrls); } /** @@ -557,62 +475,11 @@ export class SettingsComponent implements OnInit, OnDestroy { } } - private normalizeExternalPlayerPath( - playerPath: string | null | undefined - ): string { - return playerPath?.trim() ?? ''; - } - - private createSettingsFromFormValue(): Settings { - const currentSettings = this.settingsStore.getSettings(); - const value = this.settingsForm.getRawValue(); - 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, - dashboardRails: normalizeDashboardRailsSettings( - value.dashboardRails - ), - 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, - trustedPrivateNetworkEpgUrls: - currentSettings.trustedPrivateNetworkEpgUrls ?? [], - trustedInsecureTlsHosts: - currentSettings.trustedInsecureTlsHosts ?? [], - }; + private createSettingsFromFormValue() { + return createSettingsFromFormValue( + this.settingsForm, + this.settingsStore.getSettings() + ); } /** @@ -642,7 +509,7 @@ export class SettingsComponent implements OnInit, OnDestroy { this.settingsService.changeTheme( this.settingsForm.value.theme ?? Theme.SystemTheme ); - this.openSettingsSnackbar( + this.settingsSnackbar.open( this.translate.instant('SETTINGS.SETTINGS_SAVED') ); } @@ -730,13 +597,13 @@ export class SettingsComponent implements OnInit, OnDestroy { if (result && result.success === false) { throw new Error('Clear EPG returned success=false'); } - this.openSettingsSnackbar( + this.settingsSnackbar.open( this.translate.instant('SETTINGS.EPG_DATA_CLEARED') ); this.refreshAllEpg(); } catch (error) { console.error('Failed to clear EPG data:', error); - this.openSettingsSnackbar( + this.settingsSnackbar.open( this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') ); } finally { @@ -747,108 +614,11 @@ export class SettingsComponent implements OnInit, OnDestroy { } 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); - } + await this.backupFacade.exportData(() => this.waitForUiFeedbackFrame()); } 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.`; + this.backupFacade.importData(() => this.setSettings()); } private async waitForUiFeedbackFrame(): Promise { @@ -885,74 +655,10 @@ export class SettingsComponent implements OnInit, OnDestroy { .pipe(take(1)) .subscribe((confirmed) => { if (confirmed) { - void this.removeAllConfirmed(); + void this.playlistResetFacade.removeAllConfirmed(() => + this.waitForUiFeedbackFrame() + ); } }); } - - 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, - }); - } } diff --git a/libs/workspace/dashboard/feature/src/index.ts b/libs/workspace/dashboard/feature/src/index.ts index 64f95abe1..363b10a9a 100644 --- a/libs/workspace/dashboard/feature/src/index.ts +++ b/libs/workspace/dashboard/feature/src/index.ts @@ -1,2 +1,35 @@ export * from './lib/rails/workspace-dashboard-rails.component'; export * from './lib/rails/dashboard-rail.component'; +export { resolveDashboardHeroArtwork } from './lib/rails/dashboard-hero.utils'; +export type { + DashboardHeroArtwork, + DashboardHeroArtworkInput, + DashboardHeroBackdropSource, +} from './lib/rails/dashboard-hero.utils'; +export { + buildDashboardLiveEpgDetails, + buildLiveEpgCardsForEnabledRails, + buildLiveEpgLookupKeys, + calcEpgProgress, + formatEpgTimeRange, + getLiveEpgProgramForCard, +} from './lib/rails/dashboard-live-epg.utils'; +export type { DashboardLiveEpgDetails } from './lib/rails/dashboard-live-epg.utils'; +export { + buildPlaybackPositionReloadKey, + formatRemainingLabel, + isContinueWatchingRecentItem, + playbackProgressPercent, +} from './lib/rails/dashboard-playback.utils'; +export type { DashboardRemainingLabel } from './lib/rails/dashboard-playback.utils'; +export { + buildDashboardCollectionViewState, + buildDashboardRailSeeAllState, + buildDashboardSourceActions, + liveRailTitleKeyForSource, + shouldShowRecentContentSkeleton, +} from './lib/rails/dashboard-rail.utils'; +export type { + DashboardRecentContentSkeletonInput, + DashboardSourceActionId, +} from './lib/rails/dashboard-rail.utils'; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts new file mode 100644 index 000000000..25cb8dd23 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts @@ -0,0 +1,105 @@ +import type { CollectionContentType } from '@iptvnator/portal/shared/util'; +import type { DashboardRemainingLabel } from './dashboard-playback.utils'; + +export interface DashboardHeroModel { + readonly backdropUrl?: string; + readonly backdropSource: DashboardHeroBackdropSource; + readonly contentType?: CollectionContentType; + readonly fallbackBackdropBackground: string; + readonly fallbackPosterBackground: string; + readonly hasBackdrop: boolean; + readonly icon: string; + readonly link: string[]; + readonly posterUrl?: string; + readonly state?: Record; + readonly subtitle: string; + readonly title: string; + /** 0-100 watched, when a resume position is known. */ + readonly watchProgress?: number | null; + readonly remainingLabel?: DashboardRemainingLabel | null; + readonly nowPlayingTitle?: string | null; + readonly nowPlayingTimeRange?: string | null; + readonly nowPlayingProgress?: number | null; +} + +export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback'; + +export interface DashboardHeroArtworkInput { + readonly backdropUrl?: string | null; + readonly posterUrl?: string | null; + readonly title: string; +} + +export interface DashboardHeroArtwork { + readonly backdropUrl?: string; + readonly backdropSource: DashboardHeroBackdropSource; + readonly fallbackBackdropBackground: string; + readonly fallbackPosterBackground: string; + readonly hasBackdrop: boolean; + readonly posterUrl?: string; +} + +export function resolveDashboardHeroArtwork( + item: DashboardHeroArtworkInput, + failedImages: Record +): DashboardHeroArtwork { + const posterUrl = + item.posterUrl && !failedImages[item.posterUrl] + ? item.posterUrl + : undefined; + const explicitBackdropUrl = + item.backdropUrl && !failedImages[item.backdropUrl] + ? item.backdropUrl + : undefined; + const backdropUrl = explicitBackdropUrl ?? posterUrl; + const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl + ? 'backdrop' + : posterUrl + ? 'poster' + : 'fallback'; + + return { + backdropUrl, + backdropSource, + fallbackBackdropBackground: buildFallbackBackground( + item.title, + 50, + 15, + 80, + 5, + 60 + ), + fallbackPosterBackground: buildFallbackBackground( + item.title, + 40, + 25, + 50, + 15, + 40 + ), + hasBackdrop: backdropSource === 'backdrop', + posterUrl, + }; +} + +function buildFallbackBackground( + title: string, + saturationA: number, + lightnessA: number, + saturationB: number, + lightnessB: number, + hueOffset: number +): string { + const hue = calculateHue(title || 'placeholder'); + const h2 = (hue + hueOffset) % 360; + return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`; +} + +function calculateHue(text: string): number { + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + return Math.abs(hash) % 360; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts new file mode 100644 index 000000000..5e3099c45 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -0,0 +1,137 @@ +import type { + DashboardRailsSettings, + EpgProgram, +} from '@iptvnator/shared/interfaces'; +import type { DashboardRailCard } from './dashboard-rail.component'; + +// EPG "now" data ticks every 30s: short enough that the progress bar moves +// visibly between long-tail program changes, long enough that we don't hammer +// the SQLite backend with a batched IPC every animation frame. +export const LIVE_EPG_TICK_MS = 30_000; + +// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp` +// when present. The parsed XMLTV pipeline populates both, but legacy rows +// only carry the strings. +function epgTimestampMs( + program: EpgProgram, + side: 'start' | 'stop' +): number | null { + const cached = + side === 'start' ? program.startTimestamp : program.stopTimestamp; + if (cached != null) { + return cached; + } + const iso = side === 'start' ? program.start : program.stop; + const ms = iso ? new Date(iso).getTime() : NaN; + return Number.isFinite(ms) ? ms : null; +} + +function formatEpgTime(ms: number): string { + const d = new Date(ms); + return `${d.getHours().toString().padStart(2, '0')}:${d + .getMinutes() + .toString() + .padStart(2, '0')}`; +} + +export function formatEpgTimeRange(program: EpgProgram): string | null { + const start = epgTimestampMs(program, 'start'); + const stop = epgTimestampMs(program, 'stop'); + if (start == null || stop == null) { + return null; + } + return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`; +} + +export function calcEpgProgress( + program: EpgProgram, + nowMs: number +): number | null { + const start = epgTimestampMs(program, 'start'); + const stop = epgTimestampMs(program, 'stop'); + if (start == null || stop == null || stop <= start) { + return null; + } + const ratio = (nowMs - start) / (stop - start); + if (!Number.isFinite(ratio)) { + return null; + } + return Math.max(0, Math.min(100, ratio * 100)); +} + +export interface DashboardLiveEpgDetails { + readonly nowPlayingTitle: string | null; + readonly nowPlayingTimeRange: string | null; + readonly nowPlayingProgress: number | null; +} + +export function buildDashboardLiveEpgDetails( + program: EpgProgram | null, + nowMs: number +): DashboardLiveEpgDetails | null { + if (!program) { + return null; + } + + const details: DashboardLiveEpgDetails = { + nowPlayingTitle: program.title?.trim() || null, + nowPlayingTimeRange: formatEpgTimeRange(program), + nowPlayingProgress: calcEpgProgress(program, nowMs), + }; + + return details.nowPlayingTitle || + details.nowPlayingTimeRange || + details.nowPlayingProgress !== null + ? details + : null; +} + +function liveEpgLookupKeyForCard(card: DashboardRailCard): string { + return card.epgLookupKey?.trim() || card.title.trim(); +} + +export function buildLiveEpgLookupKeys( + cards: readonly DashboardRailCard[] +): string[] { + const seen = new Set(); + const keys: string[] = []; + for (const card of cards) { + const key = liveEpgLookupKeyForCard(card); + if (!key || seen.has(key)) continue; + seen.add(key); + keys.push(key); + } + return keys; +} + +type DashboardLiveEpgRailSettings = Pick< + DashboardRailsSettings, + 'hero' | 'liveFavorites' | 'recentlyWatchedLive' +>; + +export function buildLiveEpgCardsForEnabledRails( + rails: DashboardLiveEpgRailSettings, + heroLiveCard: DashboardRailCard | null, + liveFavoriteCards: readonly DashboardRailCard[], + recentLiveCards: readonly DashboardRailCard[] +): DashboardRailCard[] { + return [ + ...(rails.hero && heroLiveCard ? [heroLiveCard] : []), + ...(rails.liveFavorites ? liveFavoriteCards : []), + ...(rails.recentlyWatchedLive ? recentLiveCards : []), + ]; +} + +export function getLiveEpgProgramForCard( + card: DashboardRailCard, + epgMap: ReadonlyMap +): EpgProgram | null { + const key = liveEpgLookupKeyForCard(card); + const program = epgMap.get(key); + if (program) { + return program; + } + + const titleKey = card.title.trim(); + return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts new file mode 100644 index 000000000..baa0a5a75 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts @@ -0,0 +1,85 @@ +import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access'; + +export interface DashboardRemainingLabel { + readonly key: string; + readonly params: Record; +} + +export function buildPlaybackPositionReloadKey( + items: readonly Pick< + GlobalRecentItem, + 'playlist_id' | 'type' | 'xtream_id' + >[] +): string { + return items + .filter((item) => item.type === 'movie' || item.type === 'series') + .map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`) + .sort() + .join('|'); +} + +export function isContinueWatchingRecentItem( + item: Pick +): boolean { + return item.type === 'movie' || item.type === 'series'; +} + +export function playbackProgressPercent( + position: { positionSeconds: number; durationSeconds?: number } | null +): number | null { + if ( + !position || + position.durationSeconds == null || + position.durationSeconds <= 0 + ) { + return null; + } + const ratio = position.positionSeconds / position.durationSeconds; + if (!Number.isFinite(ratio)) { + return null; + } + // Integer percent keeps "92% watched" out of "92.4% watched" territory, + // and matches the resolution of a 3px-tall progress bar on a 280px card. + return Math.max(0, Math.min(100, Math.floor(ratio * 100))); +} + +export function formatRemainingLabel( + position: { positionSeconds: number; durationSeconds?: number } | null +): DashboardRemainingLabel | null { + if ( + !position || + position.durationSeconds == null || + position.durationSeconds <= 0 + ) { + return null; + } + const remaining = Math.max( + 0, + Math.round(position.durationSeconds - position.positionSeconds) + ); + if (remaining < 60) { + return { + key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS', + params: { seconds: remaining }, + }; + } + const totalMinutes = Math.round(remaining / 60); + if (totalMinutes < 60) { + return { + key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES', + params: { minutes: totalMinutes }, + }; + } + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (minutes === 0) { + return { + key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS', + params: { hours }, + }; + } + return { + key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES', + params: { hours, minutes }, + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts new file mode 100644 index 000000000..382a256b3 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.utils.ts @@ -0,0 +1,142 @@ +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + buildCollectionViewState, + COLLECTION_VIEW_STATE_KEY, + CollectionContentType, +} from '@iptvnator/portal/shared/util'; +import type { + DashboardRailAction, + DashboardRailCard, +} from './dashboard-rail.component'; +import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces'; + +// Cap dashboard rails at 20 items. Users get ~3x what's visible at once, +// the DOM stays cheap, and the "Manage all" link is one click away for the +// full list. Matches the single-rail density of Netflix / Apple TV+. +export const RAIL_ITEM_LIMIT = 20; + +// Six placeholder slots per skeleton rail fills a typical viewport without +// taking the whole page. Mirrors the recently-added skeleton density. +export const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const; +export const SKELETON_RAILS = [1, 2, 3] as const; + +export type DashboardSourceActionId = + | 'refresh' + | 'playlist-info' + | 'account-info' + | 'remove'; + +export function buildDashboardSourceActions( + playlist: PlaylistMeta, + canRefresh: boolean +): DashboardRailAction[] { + const actions: DashboardRailAction[] = []; + + if (canRefresh) { + actions.push({ + id: 'refresh', + icon: 'sync', + labelKey: playlist.serverUrl + ? 'HOME.PLAYLISTS.REFRESH_XTREAM' + : 'HOME.PLAYLISTS.REFRESH', + }); + } + + actions.push({ + id: 'playlist-info', + icon: 'edit', + labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS', + }); + + if (isXtreamAccountPlaylist(playlist)) { + actions.push({ + id: 'account-info', + icon: 'person', + labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO', + }); + } + + actions.push({ + id: 'remove', + icon: 'delete', + labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE', + destructive: true, + separatorBefore: true, + }); + + return actions; +} + +export function isXtreamAccountPlaylist( + playlist: PlaylistMeta +): playlist is PlaylistMeta & { + serverUrl: string; + username: string; + password: string; +} { + return Boolean( + playlist.serverUrl && playlist.username && playlist.password + ); +} + +export function liveRailTitleKeyForSource( + source: 'favorites' | 'recent' +): string { + return source === 'favorites' + ? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES' + : 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV'; +} + +export function buildDashboardCollectionViewState( + selectedContentType: CollectionContentType +): Record { + return { + [COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({ + selectedContentType, + }), + }; +} + +export function buildDashboardRailSeeAllState( + cards: readonly Pick[], + fallbackContentType: CollectionContentType = 'movie' +): Record { + const firstContentType = + cards.find( + ( + card + ): card is Pick & { + contentType: CollectionContentType; + } => + card.contentType === 'live' || + card.contentType === 'movie' || + card.contentType === 'series' + )?.contentType ?? fallbackContentType; + + return buildDashboardCollectionViewState(firstContentType); +} + +type DashboardRecentRailSettings = Pick< + DashboardRailsSettings, + 'continueWatching' | 'recentlyWatchedLive' +>; + +export interface DashboardRecentContentSkeletonInput { + readonly continueWatchingCount: number; + readonly globalRecentLoading: boolean; + readonly recentLiveCount: number; +} + +export function shouldShowRecentContentSkeleton( + rails: DashboardRecentRailSettings, + input: DashboardRecentContentSkeletonInput +): boolean { + if (!input.globalRecentLoading) { + return false; + } + + return ( + (rails.continueWatching && input.continueWatchingCount === 0) || + (rails.recentlyWatchedLive && input.recentLiveCount === 0) + ); +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 328f1e6fb..15dc7c3dd 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -2,22 +2,26 @@ import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { DEFAULT_DASHBOARD_RAILS_SETTINGS } from '@iptvnator/shared/interfaces'; import { buildDashboardCollectionViewState, + buildDashboardRailSeeAllState, + buildDashboardSourceActions, + liveRailTitleKeyForSource, + shouldShowRecentContentSkeleton, +} from './dashboard-rail.utils'; +import { buildDashboardLiveEpgDetails, buildLiveEpgCardsForEnabledRails, buildLiveEpgLookupKeys, - buildPlaybackPositionReloadKey, - buildDashboardSourceActions, calcEpgProgress, formatEpgTimeRange, - formatRemainingLabel, getLiveEpgProgramForCard, +} from './dashboard-live-epg.utils'; +import { + buildPlaybackPositionReloadKey, + formatRemainingLabel, isContinueWatchingRecentItem, - liveRailTitleKeyForSource, playbackProgressPercent, - resolveDashboardHeroArtwork, - buildDashboardRailSeeAllState, - shouldShowRecentContentSkeleton, -} from './workspace-dashboard-rails.component'; +} from './dashboard-playback.utils'; +import { resolveDashboardHeroArtwork } from './dashboard-hero.utils'; import type { DashboardRailCard } from './dashboard-rail.component'; import { COLLECTION_VIEW_STATE_KEY } from '@iptvnator/portal/shared/util'; @@ -516,9 +520,7 @@ describe('playback-position helpers', () => { describe('formatRemainingLabel', () => { it('returns null without a usable position/duration', () => { expect(formatRemainingLabel(null)).toBeNull(); - expect( - formatRemainingLabel({ positionSeconds: 100 }) - ).toBeNull(); + expect(formatRemainingLabel({ positionSeconds: 100 })).toBeNull(); expect( formatRemainingLabel({ positionSeconds: 100, diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 2cdc0fcba..b5a932fc4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -11,7 +11,6 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { interval, of, startWith, switchMap } from 'rxjs'; import { EpgService } from '@iptvnator/epg/data-access'; import { - type DashboardRailsSettings, type EpgProgram, normalizeDashboardRailsSettings, } from '@iptvnator/shared/interfaces'; @@ -46,472 +45,38 @@ import { } from '@iptvnator/workspace/dashboard/data-access'; import { DashboardRailComponent } from './dashboard-rail.component'; import type { - DashboardRailAction, DashboardRailCard, DashboardRailActionSelection, } from './dashboard-rail.component'; import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import type { DashboardHeroModel } from './dashboard-hero.utils'; +import { resolveDashboardHeroArtwork } from './dashboard-hero.utils'; import { - buildCollectionViewState, - COLLECTION_VIEW_STATE_KEY, - CollectionContentType, -} from '@iptvnator/portal/shared/util'; - -// Cap dashboard rails at 20 items. Users get ~3× what's visible at once, -// the DOM stays cheap, and the "Manage all" link is one click away for the -// full list. Matches the single-rail density of Netflix / Apple TV+. -const RAIL_ITEM_LIMIT = 20; - -// EPG "now" data ticks every 30s — short enough that the progress bar moves -// visibly between long-tail program changes, long enough that we don't hammer -// the SQLite backend with a batched IPC every animation frame. Matches the -// channel-list-container's existing progress cadence so the two surfaces -// can't drift out of sync. -const LIVE_EPG_TICK_MS = 30_000; - -// Six placeholder slots per skeleton rail — fills a typical viewport without -// taking the whole page. Mirrors the recently-added skeleton density. -const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const; -const SKELETON_RAILS = [1, 2, 3] as const; - -interface DashboardHeroModel { - readonly backdropUrl?: string; - readonly backdropSource: DashboardHeroBackdropSource; - readonly contentType?: 'live' | 'movie' | 'series'; - readonly fallbackBackdropBackground: string; - readonly fallbackPosterBackground: string; - readonly hasBackdrop: boolean; - readonly icon: string; - readonly link: string[]; - readonly posterUrl?: string; - readonly state?: Record; - readonly subtitle: string; - readonly title: string; - /** 0–100 watched, when a resume position is known. */ - readonly watchProgress?: number | null; - readonly remainingLabel?: DashboardRemainingLabel | null; - readonly nowPlayingTitle?: string | null; - readonly nowPlayingTimeRange?: string | null; - readonly nowPlayingProgress?: number | null; -} - -export interface DashboardRemainingLabel { - readonly key: string; - readonly params: Record; -} - -export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback'; - -export interface DashboardHeroArtworkInput { - readonly backdropUrl?: string | null; - readonly posterUrl?: string | null; - readonly title: string; -} - -export interface DashboardHeroArtwork { - readonly backdropUrl?: string; - readonly backdropSource: DashboardHeroBackdropSource; - readonly fallbackBackdropBackground: string; - readonly fallbackPosterBackground: string; - readonly hasBackdrop: boolean; - readonly posterUrl?: string; -} - -export function resolveDashboardHeroArtwork( - item: DashboardHeroArtworkInput, - failedImages: Record -): DashboardHeroArtwork { - const posterUrl = - item.posterUrl && !failedImages[item.posterUrl] - ? item.posterUrl - : undefined; - const explicitBackdropUrl = - item.backdropUrl && !failedImages[item.backdropUrl] - ? item.backdropUrl - : undefined; - const backdropUrl = explicitBackdropUrl ?? posterUrl; - const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl - ? 'backdrop' - : posterUrl - ? 'poster' - : 'fallback'; - - return { - backdropUrl, - backdropSource, - fallbackBackdropBackground: buildFallbackBackground( - item.title, - 50, - 15, - 80, - 5, - 60 - ), - fallbackPosterBackground: buildFallbackBackground( - item.title, - 40, - 25, - 50, - 15, - 40 - ), - hasBackdrop: backdropSource === 'backdrop', - posterUrl, - }; -} - -function buildFallbackBackground( - title: string, - saturationA: number, - lightnessA: number, - saturationB: number, - lightnessB: number, - hueOffset: number -): string { - const hue = calculateHue(title || 'placeholder'); - const h2 = (hue + hueOffset) % 360; - return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`; -} - -function calculateHue(text: string): number { - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = text.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; - } - return Math.abs(hash) % 360; -} - -export type DashboardSourceActionId = - | 'refresh' - | 'playlist-info' - | 'account-info' - | 'remove'; - -export function buildDashboardSourceActions( - playlist: PlaylistMeta, - canRefresh: boolean -): DashboardRailAction[] { - const actions: DashboardRailAction[] = []; - - if (canRefresh) { - actions.push({ - id: 'refresh', - icon: 'sync', - labelKey: playlist.serverUrl - ? 'HOME.PLAYLISTS.REFRESH_XTREAM' - : 'HOME.PLAYLISTS.REFRESH', - }); - } - - actions.push({ - id: 'playlist-info', - icon: 'edit', - labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS', - }); - - if (isXtreamAccountPlaylist(playlist)) { - actions.push({ - id: 'account-info', - icon: 'person', - labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO', - }); - } - - actions.push({ - id: 'remove', - icon: 'delete', - labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE', - destructive: true, - separatorBefore: true, - }); - - return actions; -} - -// Reads either an ISO `start`/`stop` or the pre-computed `startTimestamp` -// when present (the parsed XMLTV pipeline populates both, but legacy rows -// only carry the strings). -function epgTimestampMs( - program: EpgProgram, - side: 'start' | 'stop' -): number | null { - const cached = - side === 'start' ? program.startTimestamp : program.stopTimestamp; - if (cached != null) { - return cached; - } - const iso = side === 'start' ? program.start : program.stop; - const ms = iso ? new Date(iso).getTime() : NaN; - return Number.isFinite(ms) ? ms : null; -} - -function formatEpgTime(ms: number): string { - const d = new Date(ms); - return `${d.getHours().toString().padStart(2, '0')}:${d - .getMinutes() - .toString() - .padStart(2, '0')}`; -} - -export function formatEpgTimeRange(program: EpgProgram): string | null { - const start = epgTimestampMs(program, 'start'); - const stop = epgTimestampMs(program, 'stop'); - if (start == null || stop == null) { - return null; - } - return `${formatEpgTime(start)} – ${formatEpgTime(stop)}`; -} - -export function calcEpgProgress( - program: EpgProgram, - nowMs: number -): number | null { - const start = epgTimestampMs(program, 'start'); - const stop = epgTimestampMs(program, 'stop'); - if (start == null || stop == null || stop <= start) { - return null; - } - const ratio = (nowMs - start) / (stop - start); - if (!Number.isFinite(ratio)) { - return null; - } - return Math.max(0, Math.min(100, ratio * 100)); -} - -export interface DashboardLiveEpgDetails { - readonly nowPlayingTitle: string | null; - readonly nowPlayingTimeRange: string | null; - readonly nowPlayingProgress: number | null; -} - -export function buildDashboardLiveEpgDetails( - program: EpgProgram | null, - nowMs: number -): DashboardLiveEpgDetails | null { - if (!program) { - return null; - } - - const details: DashboardLiveEpgDetails = { - nowPlayingTitle: program.title?.trim() || null, - nowPlayingTimeRange: formatEpgTimeRange(program), - nowPlayingProgress: calcEpgProgress(program, nowMs), - }; - - return details.nowPlayingTitle || - details.nowPlayingTimeRange || - details.nowPlayingProgress !== null - ? details - : null; -} - -function liveEpgLookupKeyForCard(card: DashboardRailCard): string { - return card.epgLookupKey?.trim() || card.title.trim(); -} - -export function buildLiveEpgLookupKeys( - cards: readonly DashboardRailCard[] -): string[] { - const seen = new Set(); - const keys: string[] = []; - for (const card of cards) { - const key = liveEpgLookupKeyForCard(card); - if (!key || seen.has(key)) continue; - seen.add(key); - keys.push(key); - } - return keys; -} - -type DashboardLiveEpgRailSettings = Pick< - DashboardRailsSettings, - 'hero' | 'liveFavorites' | 'recentlyWatchedLive' ->; - -export function buildLiveEpgCardsForEnabledRails( - rails: DashboardLiveEpgRailSettings, - heroLiveCard: DashboardRailCard | null, - liveFavoriteCards: readonly DashboardRailCard[], - recentLiveCards: readonly DashboardRailCard[] -): DashboardRailCard[] { - return [ - ...(rails.hero && heroLiveCard ? [heroLiveCard] : []), - ...(rails.liveFavorites ? liveFavoriteCards : []), - ...(rails.recentlyWatchedLive ? recentLiveCards : []), - ]; -} - -export function getLiveEpgProgramForCard( - card: DashboardRailCard, - epgMap: ReadonlyMap -): EpgProgram | null { - const key = liveEpgLookupKeyForCard(card); - const program = epgMap.get(key); - if (program) { - return program; - } - - const titleKey = card.title.trim(); - return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null; -} - -export function liveRailTitleKeyForSource( - source: 'favorites' | 'recent' -): string { - return source === 'favorites' - ? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES' - : 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV'; -} - -export function buildDashboardCollectionViewState( - selectedContentType: CollectionContentType -): Record { - return { - [COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({ - selectedContentType, - }), - }; -} - -export function buildDashboardRailSeeAllState( - cards: readonly Pick[], - fallbackContentType: CollectionContentType = 'movie' -): Record { - const firstContentType = - cards.find( - ( - card - ): card is Pick & { - contentType: CollectionContentType; - } => - card.contentType === 'live' || - card.contentType === 'movie' || - card.contentType === 'series' - )?.contentType ?? fallbackContentType; - - return buildDashboardCollectionViewState(firstContentType); -} - -export function buildPlaybackPositionReloadKey( - items: readonly Pick< - GlobalRecentItem, - 'playlist_id' | 'type' | 'xtream_id' - >[] -): string { - return items - .filter((item) => item.type === 'movie' || item.type === 'series') - .map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`) - .sort() - .join('|'); -} - -export function isContinueWatchingRecentItem( - item: Pick -): boolean { - return item.type === 'movie' || item.type === 'series'; -} - -type DashboardRecentRailSettings = Pick< - DashboardRailsSettings, - 'continueWatching' | 'recentlyWatchedLive' ->; - -export interface DashboardRecentContentSkeletonInput { - readonly continueWatchingCount: number; - readonly globalRecentLoading: boolean; - readonly recentLiveCount: number; -} - -export function shouldShowRecentContentSkeleton( - rails: DashboardRecentRailSettings, - input: DashboardRecentContentSkeletonInput -): boolean { - if (!input.globalRecentLoading) { - return false; - } - - return ( - (rails.continueWatching && input.continueWatchingCount === 0) || - (rails.recentlyWatchedLive && input.recentLiveCount === 0) - ); -} - -// ── Playback-position helpers (used by both hero + Continue Watching cards) -// — kept as plain functions so they're easy to unit-test without mounting the -// component or the data service. - -export function playbackProgressPercent( - position: { positionSeconds: number; durationSeconds?: number } | null -): number | null { - if ( - !position || - position.durationSeconds == null || - position.durationSeconds <= 0 - ) { - return null; - } - const ratio = position.positionSeconds / position.durationSeconds; - if (!Number.isFinite(ratio)) { - return null; - } - // Integer percent — keeps "92% watched" out of "92.4% watched" territory, - // and matches the resolution of a 3px-tall progress bar on a 280px card. - return Math.max(0, Math.min(100, Math.floor(ratio * 100))); -} - -export function formatRemainingLabel( - position: { positionSeconds: number; durationSeconds?: number } | null -): DashboardRemainingLabel | null { - if ( - !position || - position.durationSeconds == null || - position.durationSeconds <= 0 - ) { - return null; - } - const remaining = Math.max( - 0, - Math.round(position.durationSeconds - position.positionSeconds) - ); - if (remaining < 60) { - return { - key: 'WORKSPACE.DASHBOARD.REMAINING_SECONDS', - params: { seconds: remaining }, - }; - } - const totalMinutes = Math.round(remaining / 60); - if (totalMinutes < 60) { - return { - key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES', - params: { minutes: totalMinutes }, - }; - } - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (minutes === 0) { - return { - key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS', - params: { hours }, - }; - } - return { - key: 'WORKSPACE.DASHBOARD.REMAINING_HOURS_MINUTES', - params: { hours, minutes }, - }; -} - -function isXtreamAccountPlaylist( - playlist: PlaylistMeta -): playlist is PlaylistMeta & { - serverUrl: string; - username: string; - password: string; -} { - return Boolean( - playlist.serverUrl && playlist.username && playlist.password - ); -} + buildDashboardLiveEpgDetails, + buildLiveEpgCardsForEnabledRails, + buildLiveEpgLookupKeys, + getLiveEpgProgramForCard, + LIVE_EPG_TICK_MS, +} from './dashboard-live-epg.utils'; +import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils'; +import { + buildPlaybackPositionReloadKey, + formatRemainingLabel, + isContinueWatchingRecentItem, + playbackProgressPercent, +} from './dashboard-playback.utils'; +import { + buildDashboardCollectionViewState, + buildDashboardRailSeeAllState, + buildDashboardSourceActions, + isXtreamAccountPlaylist, + liveRailTitleKeyForSource, + RAIL_ITEM_LIMIT, + shouldShowRecentContentSkeleton, + SKELETON_CARDS_PER_RAIL, + SKELETON_RAILS, +} from './dashboard-rail.utils'; +import type { DashboardSourceActionId } from './dashboard-rail.utils'; @Component({ selector: 'lib-workspace-dashboard-rails',