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 }}
+
+
+ @for (slot of skeletonItems; track slot) {
+
+
+
+
+
+
+
+
+
+
+
+ @for (
+ actionSlot of skeletonActionSlots;
+ track actionSlot
+ ) {
+
+ }
+
+
+
+ }
+
+
} @else if (hasNoPlaylists()) {
- } @else if (!hasDownloads()) {
+ } @else if (showDownloadSkeleton()) {
+
+
+ {{ 'CHANNELS.LOADING' | translate }}
+
+
+ @for (slot of skeletonItems; track slot) {
+
+
+
+
+
+
+
+
+
+
+
+ @for (
+ actionSlot of skeletonActionSlots;
+ track actionSlot
+ ) {
+
+ }
+
+
+
+ }
+
+
+ } @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);
+ }
}
}