mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
+13
-11
@@ -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,
|
||||
|
||||
+27
-462
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user