fix(downloads): show loading skeletons before empty states

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-04-29 06:47:31 +02:00
1 parent f7c6cb3572
commit 20445beaf0
8 files changed
+466 -8

No files matched your search

@@ -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,
},
];
}
@@ -54,6 +54,45 @@
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (!playlistsLoaded()) {
<div
class="downloads__list-wrapper downloads__list-wrapper--loading"
aria-live="polite"
aria-busy="true"
>
<p class="downloads__loading-label">
{{ 'CHANNELS.LOADING' | translate }}
</p>
<div
class="downloads__list downloads__list--skeleton"
aria-hidden="true"
>
@for (slot of skeletonItems; track slot) {
<div class="downloads__skeleton-item">
<div class="downloads__skeleton-poster"></div>
<div class="downloads__skeleton-info">
<div class="downloads__skeleton-title"></div>
<div
class="downloads__skeleton-title downloads__skeleton-title--short"
></div>
<div class="downloads__skeleton-meta">
<span></span>
<span></span>
</div>
<div class="downloads__skeleton-progress"></div>
<div class="downloads__skeleton-actions">
@for (
actionSlot of skeletonActionSlots;
track actionSlot
) {
<span></span>
}
</div>
</div>
</div>
}
</div>
</div>
} @else if (hasNoPlaylists()) {
<app-empty-state
type="no-data"
@@ -65,7 +104,46 @@
(primaryActionClicked)="addPlaylist()"
(secondaryActionClicked)="goToSources()"
/>
} @else if (!hasDownloads()) {
} @else if (showDownloadSkeleton()) {
<div
class="downloads__list-wrapper downloads__list-wrapper--loading"
aria-live="polite"
aria-busy="true"
>
<p class="downloads__loading-label">
{{ 'CHANNELS.LOADING' | translate }}
</p>
<div
class="downloads__list downloads__list--skeleton"
aria-hidden="true"
>
@for (slot of skeletonItems; track slot) {
<div class="downloads__skeleton-item">
<div class="downloads__skeleton-poster"></div>
<div class="downloads__skeleton-info">
<div class="downloads__skeleton-title"></div>
<div
class="downloads__skeleton-title downloads__skeleton-title--short"
></div>
<div class="downloads__skeleton-meta">
<span></span>
<span></span>
</div>
<div class="downloads__skeleton-progress"></div>
<div class="downloads__skeleton-actions">
@for (
actionSlot of skeletonActionSlots;
track actionSlot
) {
<span></span>
}
</div>
</div>
</div>
}
</div>
</div>
} @else if (!hasScopedDownloads()) {
<app-empty-state
type="no-data"
icon="download_for_offline"
@@ -5,6 +5,35 @@
min-height: 0;
}
%downloads-skeleton-shimmer {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: downloads-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.downloads-layout {
display: flex;
flex: 1;
@@ -143,6 +172,12 @@
overflow-x: hidden;
padding: 0 16px;
container-type: inline-size;
&--loading {
display: flex;
flex-direction: column;
gap: 12px;
}
}
&__list {
@@ -151,6 +186,111 @@
gap: 12px;
padding-bottom: 24px;
align-content: start;
&--skeleton {
pointer-events: none;
}
}
&__loading-label {
margin: 0;
padding: 2px 4px 0;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
&__skeleton-item {
display: grid;
grid-template-columns: 64px minmax(0, 1fr);
align-items: stretch;
gap: 1rem;
min-width: 0;
min-height: 124px;
padding: 1rem;
border-radius: 14px;
background: color-mix(
in srgb,
var(--app-widget-bg, var(--mat-sys-surface-container-high)) 88%,
var(--mat-sys-surface-container) 12%
);
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
box-shadow: var(--app-widget-shadow, none);
}
&__skeleton-poster {
@extend %downloads-skeleton-shimmer;
width: 64px;
height: 90px;
border-radius: 10px;
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
}
&__skeleton-info {
display: flex;
flex-direction: column;
gap: 0.55rem;
min-width: 0;
min-height: 90px;
}
&__skeleton-title {
@extend %downloads-skeleton-shimmer;
width: min(70%, 280px);
height: 13px;
border-radius: 999px;
&--short {
width: min(48%, 190px);
}
}
&__skeleton-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
span {
@extend %downloads-skeleton-shimmer;
width: 88px;
height: 20px;
border-radius: 999px;
&:last-child {
width: 132px;
}
}
}
&__skeleton-progress {
@extend %downloads-skeleton-shimmer;
width: 100%;
height: 6px;
border-radius: 999px;
margin-top: 0.05rem;
}
&__skeleton-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
margin-top: auto;
padding-top: 0.65rem;
border-top: 1px solid
color-mix(
in srgb,
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
transparent
);
span {
@extend %downloads-skeleton-shimmer;
width: 32px;
height: 32px;
border-radius: 9px;
}
}
&__item {
@@ -415,4 +555,27 @@
.downloads__item {
align-items: start;
}
.downloads__skeleton-item {
align-items: start;
}
}
@media (prefers-reduced-motion: reduce) {
.downloads__skeleton-poster,
.downloads__skeleton-title,
.downloads__skeleton-meta span,
.downloads__skeleton-progress,
.downloads__skeleton-actions span {
&::after {
animation: none;
transform: none;
}
}
}
@keyframes downloads-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -25,10 +25,10 @@ import {
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
PORTAL_SHELL_ACTIONS,
} from '@iptvnator/portal/shared/util';
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
import { Playlist } from 'shared-interfaces';
@@ -67,13 +67,14 @@ export class DownloadsComponent {
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly shellActions = inject(PORTAL_SHELL_ACTIONS);
readonly downloadsService = inject(DownloadsService);
readonly downloads = this.downloadsService.downloads;
readonly downloadFolder = this.downloadsService.downloadFolder;
readonly isAvailable = this.downloadsService.isAvailable;
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly isLoadingDownloads = this.downloadsService.isLoadingDownloads;
readonly hasLoadedDownloads = this.downloadsService.hasLoadedDownloads;
readonly activeCount = this.downloadsService.activeCount;
readonly playlistId = toSignal(
this.route.paramMap.pipe(map((params) => 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() {
+1
View File
@@ -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';
@@ -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<PortalShellActions>(
'PORTAL_SHELL_ACTIONS'
);
@@ -0,0 +1,162 @@
import { signal, Signal, WritableSignal } from '@angular/core';
import {
DownloadItem,
DownloadsService,
} from './downloads.service';
type TestDownloadsService = {
downloads: WritableSignal<DownloadItem[]>;
isAvailable: () => boolean;
isLoadingDownloads: Signal<boolean>;
hasLoadedDownloads: Signal<boolean>;
loadDownloads: DownloadsService['loadDownloads'];
_isLoadingDownloads: WritableSignal<boolean>;
_hasLoadedDownloads: WritableSignal<boolean>;
loadDownloadsRequestId: number;
};
type DownloadsElectronStub = {
downloadsGetList: jest.Mock<Promise<DownloadItem[]>, [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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<DownloadItem[]>();
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<DownloadItem[]>();
const second = createDeferred<DownloadItem[]>();
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'
);
});
});
+24 -1
View File
@@ -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<DownloadItem[]>([]);
/** 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<void> {
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);
}
}
}