refactor: split dashboard rails and settings logic

Split dashboard rails and settings logic into focused helpers/facades while preserving behavior.
This commit is contained in:
4gray authored and GitHub committed 2026-06-14 13:34:23 +02:00
1 parent bc4a5958dc
commit 1444047a1d
13 files changed
+1001 -817

No files matched your search

@@ -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<void>) {
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.`;
}
}
@@ -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<string | null> {
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<FormControl<string | null>>([]) }
: {}),
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<typeof createSettingsForm>;
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() ?? '';
}
@@ -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<DbOperationEvent | null>(null);
async removeAllConfirmed(
waitForUiFeedbackFrame: () => Promise<void>
): Promise<void> {
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<boolean> {
return this.databaseService.deleteAllPlaylists({
operationId: this.databaseService.createOperationId(
'settings-delete-all-playlists'
),
onEvent: (event) => this.handleDeleteAllPlaylistsEvent(event),
});
}
private async deleteAllPlaylistsInBrowser(): Promise<boolean> {
await firstValueFrom(this.playlistsService.removeAll());
return true;
}
private handleDeleteAllPlaylistsEvent(event: DbOperationEvent): void {
this.removeAllProgress.set(event);
}
}
@@ -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, number>
) => 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');
}
@@ -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,
});
}
}
+49 -343
View File
@@ -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<FormControl<string | null>>([]) }
: {}),
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<string | null>(null);
readonly isRemovingAllPlaylists = signal(false);
readonly isRemovingAllPlaylists =
this.playlistResetFacade.isRemovingAllPlaylists;
readonly isClearingEpgData = signal(false);
readonly isExportingData = signal(false);
readonly removeAllProgress = signal<DbOperationEvent | null>(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<SettingsPlaylistDeleteSummary>(
() => {
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<void> {
@@ -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<void> {
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<boolean> {
return this.databaseService.deleteAllPlaylists({
operationId: this.databaseService.createOperationId(
'settings-delete-all-playlists'
),
onEvent: (event) => this.handleDeleteAllPlaylistsEvent(event),
});
}
private async deleteAllPlaylistsInBrowser(): Promise<boolean> {
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,
});
}
}
@@ -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';
@@ -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<string, unknown>;
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<string, true>
): 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;
}
@@ -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<string>();
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<string, EpgProgram | null>
): 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;
}
@@ -0,0 +1,85 @@
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
export interface DashboardRemainingLabel {
readonly key: string;
readonly params: Record<string, number>;
}
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<GlobalRecentItem, 'type'>
): 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 },
};
}
@@ -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<string, unknown> {
return {
[COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({
selectedContentType,
}),
};
}
export function buildDashboardRailSeeAllState(
cards: readonly Pick<DashboardRailCard, 'contentType'>[],
fallbackContentType: CollectionContentType = 'movie'
): Record<string, unknown> {
const firstContentType =
cards.find(
(
card
): card is Pick<DashboardRailCard, 'contentType'> & {
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)
);
}
@@ -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,
@@ -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<string, unknown>;
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<string, number>;
}
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<string, true>
): 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<string>();
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<string, EpgProgram | null>
): 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<string, unknown> {
return {
[COLLECTION_VIEW_STATE_KEY]: buildCollectionViewState({
selectedContentType,
}),
};
}
export function buildDashboardRailSeeAllState(
cards: readonly Pick<DashboardRailCard, 'contentType'>[],
fallbackContentType: CollectionContentType = 'movie'
): Record<string, unknown> {
const firstContentType =
cards.find(
(
card
): card is Pick<DashboardRailCard, 'contentType'> & {
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<GlobalRecentItem, 'type'>
): 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',