diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts index 2e2395d20..bf379c6ea 100644 --- a/apps/web/src/app/services/workspace-shell-actions.service.ts +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable, Provider } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; +import { PORTAL_SHELL_ACTIONS } from '@iptvnator/portal/shared/util'; import { WORKSPACE_SHELL_ACTIONS, WorkspaceAccountInfoData, @@ -67,5 +68,9 @@ export function provideWorkspaceShellActions(): Provider[] { provide: WORKSPACE_SHELL_ACTIONS, useExisting: AppWorkspaceShellActionsService, }, + { + provide: PORTAL_SHELL_ACTIONS, + useExisting: AppWorkspaceShellActionsService, + }, ]; } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index 61876a00c..2f0cbf38c 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -54,6 +54,45 @@

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} + } @else if (!playlistsLoaded()) { +
+

+ {{ 'CHANNELS.LOADING' | translate }} +

+ +
} @else if (hasNoPlaylists()) { - } @else if (!hasDownloads()) { + } @else if (showDownloadSkeleton()) { +
+

+ {{ 'CHANNELS.LOADING' | translate }} +

+ +
+ } @else if (!hasScopedDownloads()) { params.get('id') ?? '')), @@ -83,9 +84,15 @@ export class DownloadsComponent { (value ?? '').trim().toLowerCase() ); readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), { - initialValue: [] as Playlist[], + initialValue: null as Playlist[] | null, }); - readonly hasNoPlaylists = computed(() => this.playlists().length === 0); + readonly playlistsLoaded = computed(() => this.playlists() !== null); + readonly playlistItems = computed(() => this.playlists() ?? []); + readonly hasNoPlaylists = computed( + () => this.playlistsLoaded() && this.playlistItems().length === 0 + ); + readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); + readonly skeletonActionSlots = Array.from({ length: 4 }, (_, index) => index); addPlaylist(): void { this.shellActions.openAddPlaylistDialog(); @@ -106,6 +113,14 @@ export class DownloadsComponent { ? downloads.filter((item) => item.playlistId === playlistId) : downloads; }); + readonly hasScopedDownloads = computed( + () => this.scopedDownloads().length > 0 + ); + readonly showDownloadSkeleton = computed( + () => + !this.hasScopedDownloads() && + (!this.hasLoadedDownloads() || this.isLoadingDownloads()) + ); readonly categories = computed(() => { const downloads = this.scopedDownloads(); @@ -165,7 +180,7 @@ export class DownloadsComponent { ) ); readonly availablePlaylistIds = computed( - () => new Set(this.playlists().map((playlist) => playlist._id)) + () => new Set(this.playlistItems().map((playlist) => playlist._id)) ); constructor() { diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 2ada08585..974b814eb 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/portal-external-playback'; export * from './lib/portal-player'; export * from './lib/portal-playback-positions'; export * from './lib/portal-navigation-actions'; +export * from './lib/portal-shell-actions'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-context.service'; export * from './lib/portal-collection-items'; diff --git a/libs/portal/shared/util/src/lib/portal-shell-actions.ts b/libs/portal/shared/util/src/lib/portal-shell-actions.ts new file mode 100644 index 000000000..d1898edb5 --- /dev/null +++ b/libs/portal/shared/util/src/lib/portal-shell-actions.ts @@ -0,0 +1,11 @@ +import { InjectionToken } from '@angular/core'; + +export type PortalPlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker'; + +export interface PortalShellActions { + openAddPlaylistDialog(type?: PortalPlaylistType): void; +} + +export const PORTAL_SHELL_ACTIONS = new InjectionToken( + 'PORTAL_SHELL_ACTIONS' +); diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts new file mode 100644 index 000000000..43f6d6689 --- /dev/null +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -0,0 +1,162 @@ +import { signal, Signal, WritableSignal } from '@angular/core'; +import { + DownloadItem, + DownloadsService, +} from './downloads.service'; + +type TestDownloadsService = { + downloads: WritableSignal; + isAvailable: () => boolean; + isLoadingDownloads: Signal; + hasLoadedDownloads: Signal; + loadDownloads: DownloadsService['loadDownloads']; + _isLoadingDownloads: WritableSignal; + _hasLoadedDownloads: WritableSignal; + loadDownloadsRequestId: number; +}; + +type DownloadsElectronStub = { + downloadsGetList: jest.Mock, [string?]>; +}; + +describe('DownloadsService', () => { + const testWindow = window as unknown as { + electron?: DownloadsElectronStub; + }; + const originalElectron = testWindow.electron; + + afterEach(() => { + testWindow.electron = originalElectron; + jest.restoreAllMocks(); + }); + + function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem { + return { + id, + playlistId, + xtreamId: id, + contentType: 'vod', + title: `Movie ${id}`, + url: `https://example.com/${id}.mp4`, + status: 'completed', + }; + } + + function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; + } + + function createService(initialDownloads: DownloadItem[] = []) { + const downloads = signal(initialDownloads); + const isLoadingDownloads = signal(false); + const hasLoadedDownloads = signal(false); + const service = Object.create( + DownloadsService.prototype + ) as TestDownloadsService; + + Object.assign(service, { + downloads, + isAvailable: () => true, + _isLoadingDownloads: isLoadingDownloads, + isLoadingDownloads: isLoadingDownloads.asReadonly(), + _hasLoadedDownloads: hasLoadedDownloads, + hasLoadedDownloads: hasLoadedDownloads.asReadonly(), + loadDownloadsRequestId: 0, + }); + + return service; + } + + it('tracks loading and loaded state around a successful download list request', async () => { + const item = createDownload(1); + const pending = createDeferred(); + const electron = { + downloadsGetList: jest.fn(() => pending.promise), + }; + testWindow.electron = electron; + const service = createService(); + + const request = service.loadDownloads('playlist-1'); + + expect(service.isLoadingDownloads()).toBe(true); + expect(service.hasLoadedDownloads()).toBe(false); + expect(electron.downloadsGetList).toHaveBeenCalledWith('playlist-1'); + + pending.resolve([item]); + await request; + + expect(service.downloads()).toEqual([item]); + expect(service.isLoadingDownloads()).toBe(false); + expect(service.hasLoadedDownloads()).toBe(true); + }); + + it('marks downloads as loaded after a failed request while preserving existing data', async () => { + const existing = createDownload(1); + const error = new Error('download query failed'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + testWindow.electron = { + downloadsGetList: jest.fn(async () => { + throw error; + }), + }; + const service = createService([existing]); + + await service.loadDownloads(); + + expect(service.downloads()).toEqual([existing]); + expect(service.isLoadingDownloads()).toBe(false); + expect(service.hasLoadedDownloads()).toBe(true); + expect(console.error).toHaveBeenCalledWith( + '[DownloadsService] Error loading downloads:', + error + ); + }); + + it('keeps only the latest overlapping download list request result', async () => { + const staleItem = createDownload(1, 'playlist-old'); + const latestItem = createDownload(2, 'playlist-new'); + const first = createDeferred(); + const second = createDeferred(); + const electron = { + downloadsGetList: jest + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + }; + testWindow.electron = electron; + const service = createService(); + + const firstRequest = service.loadDownloads('playlist-old'); + const secondRequest = service.loadDownloads('playlist-new'); + + expect(service.isLoadingDownloads()).toBe(true); + + second.resolve([latestItem]); + await secondRequest; + + expect(service.downloads()).toEqual([latestItem]); + expect(service.isLoadingDownloads()).toBe(false); + expect(service.hasLoadedDownloads()).toBe(true); + + first.resolve([staleItem]); + await firstRequest; + + expect(service.downloads()).toEqual([latestItem]); + expect(service.isLoadingDownloads()).toBe(false); + expect(electron.downloadsGetList).toHaveBeenNthCalledWith( + 1, + 'playlist-old' + ); + expect(electron.downloadsGetList).toHaveBeenNthCalledWith( + 2, + 'playlist-new' + ); + }); +}); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 992c3a45d..61372f503 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -33,10 +33,20 @@ export interface DownloadItem { export class DownloadsService implements OnDestroy { private readonly settingsStore = inject(SettingsStore); private unsubscribe?: () => void; + private loadDownloadsRequestId = 0; + + private readonly _isLoadingDownloads = signal(false); + private readonly _hasLoadedDownloads = signal(false); /** Signal for the list of downloads */ readonly downloads = signal([]); + /** Whether the download list is currently being loaded */ + readonly isLoadingDownloads = this._isLoadingDownloads.asReadonly(); + + /** Whether the first download list request has completed */ + readonly hasLoadedDownloads = this._hasLoadedDownloads.asReadonly(); + /** Whether the download feature is available (Electron only) */ readonly isAvailable = computed(() => !!window.electron?.downloadsGetList); @@ -98,11 +108,24 @@ export class DownloadsService implements OnDestroy { async loadDownloads(playlistId?: string): Promise { if (!this.isAvailable()) return; + const requestId = ++this.loadDownloadsRequestId; + this._isLoadingDownloads.set(true); + try { const list = await window.electron.downloadsGetList(playlistId); - this.downloads.set(list); + if (requestId === this.loadDownloadsRequestId) { + this.downloads.set(list); + this._hasLoadedDownloads.set(true); + } } catch (error) { console.error('[DownloadsService] Error loading downloads:', error); + if (requestId === this.loadDownloadsRequestId) { + this._hasLoadedDownloads.set(true); + } + } finally { + if (requestId === this.loadDownloadsRequestId) { + this._isLoadingDownloads.set(false); + } } }