feat: implement loading state and skeleton UI for channel list

Entire-Checkpoint: e6dee91f3aee
This commit is contained in:
4gray committed 2026-04-04 20:27:25 +02:00
1 parent 70b7f570ef
commit 0661bf08b5
4 files changed
+259 -5

No files matched your search

@@ -1,5 +1,23 @@
<div class="channels-list">
@if (filteredChannels().length > 0) {
@if (isSelectedTypeContentLoading()) {
<div class="channels-loading-state" aria-live="polite">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
</div>
</div>
<div class="channels-loading-state__list" aria-hidden="true">
@for (row of loadingRows; track row) {
<div class="channel-skeleton">
<span class="channel-skeleton__logo"></span>
<span class="channel-skeleton__title"></span>
<span class="channel-skeleton__meta"></span>
</div>
}
</div>
</div>
} @else if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
@@ -142,6 +142,102 @@
}
}
.channels-loading-state {
display: grid;
gap: 14px;
height: 100%;
padding: 16px 12px 18px;
}
.channels-loading-state__copy {
padding: 0 4px;
}
.channels-loading-state__title {
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.channels-loading-state__list {
display: grid;
gap: 8px;
overflow: hidden;
}
.channel-skeleton {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
grid-template-rows: 14px 10px;
align-items: center;
gap: 8px 12px;
min-height: 68px;
padding: 10px 12px;
border-radius: 14px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.channel-skeleton__logo,
.channel-skeleton__title,
.channel-skeleton__meta {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::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: channel-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.channel-skeleton__logo {
grid-row: 1 / span 2;
width: 44px;
height: 44px;
border-radius: 12px;
}
.channel-skeleton__title {
height: 11px;
width: min(68%, 180px);
border-radius: 999px;
}
.channel-skeleton__meta {
height: 9px;
width: min(42%, 120px);
border-radius: 999px;
}
@container (max-width: 350px) {
.scroll-viewport-portals {
padding-inline: 6px;
@@ -159,3 +255,9 @@
transform: translateY(0);
}
}
@keyframes channel-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -0,0 +1,129 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import {
EpgQueueService,
FavoritesService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PortalChannelsListComponent } from './portal-channels-list.component';
describe('PortalChannelsListComponent', () => {
let fixture: ComponentFixture<PortalChannelsListComponent>;
const selectedChannels = signal<unknown[]>([]);
const selectedItem = signal<unknown>(null);
const epgItems = signal<unknown[]>([]);
const selectedTypeContentLoading = signal(true);
const selectedContentType = signal('live');
const currentPlaylist = signal<unknown>(null);
const selectedCategoryId = signal<number | null>(1);
const storeSignals = {
selectItemsFromSelectedCategory: selectedChannels,
selectedItem,
epgItems,
selectedTypeContentLoading,
selectedContentType,
currentPlaylist,
selectedCategoryId,
setSelectedCategory: jest.fn(),
};
const epgResults$ = new Subject<{ streamId: number; items: unknown[] }>();
beforeEach(async () => {
storeSignals.setSelectedCategory.mockClear();
selectedChannels.set([]);
selectedItem.set(null);
epgItems.set([]);
selectedTypeContentLoading.set(true);
selectedContentType.set('live');
currentPlaylist.set(null);
selectedCategoryId.set(1);
await TestBed.configureTestingModule({
imports: [PortalChannelsListComponent, NoopAnimationsModule],
providers: [
{
provide: TranslateService,
useValue: {
instant: (key: string) =>
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key,
get: (key: string) =>
of(
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key
),
stream: (key: string) =>
of(
key === 'CHANNELS.LOADING'
? 'Loading channels...'
: key
),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: storeSignals,
},
{
provide: FavoritesService,
useValue: {
getFavorites: jest.fn().mockReturnValue(of([])),
},
},
{
provide: EpgQueueService,
useValue: {
epgResult$: epgResults$,
getCached: jest.fn().mockReturnValue(null),
enqueue: jest.fn(),
},
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {},
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PortalChannelsListComponent);
});
it('renders a loading placeholder instead of the empty state while xtream live content is still loading', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channels-loading-state')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-search-state')
).toBeNull();
});
it('renders the empty state once loading has finished and the selected category has no channels', () => {
selectedTypeContentLoading.set(false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channels-loading-state')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-search-state')
).not.toBeNull();
});
});
@@ -69,6 +69,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
private readonly favoritesService = inject(FavoritesService);
private readonly epgQueueService = inject(EpgQueueService);
private readonly route = inject(ActivatedRoute);
readonly isSelectedTypeContentLoading =
this.xtreamStore.selectedTypeContentLoading;
readonly loadingRows = Array.from({ length: 9 }, (_, index) => index);
readonly channels = computed(() => {
const override = this.channelsOverride();
if (Array.isArray(override)) {
@@ -261,11 +264,13 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
toggleFavorite(event: Event, item: XtreamChannelListItem) {
event.stopPropagation();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) {
return;
}
this.xtreamStore
.toggleFavorite(
item.xtream_id,
this.xtreamStore.currentPlaylist().id
)
.toggleFavorite(item.xtream_id, playlistId)
.then((result: boolean) => {
if (result) {
this.favorites.set(item.xtream_id, true);