refactor(m3u): add loading state for channel list and improve playlist handling

- Implemented a loading state for channel lists in various components, enhancing user experience during data fetching.
- Added `ChannelListLoadingStateComponent` to display loading skeletons for channels and groups.
- Updated `M3uWorkspaceRouteSession` to manage loading states more effectively when fetching playlists.
- Modified `VideoPlayerComponent`, `SidebarComponent`, and `ChannelListContainerComponent` to utilize the new loading state.
- Enhanced tests to cover new loading behaviors and ensure proper dispatching of loading actions.
This commit is contained in:
4gray committed 2026-04-11 13:20:22 +02:00
1 parent 7cc25380dd
commit 0f72efb749
33 files changed
+1104 -41

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/channel-list-loading-state/channel-list-loading-state.component';
export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
@@ -1,4 +1,6 @@
@if (displayedChannels().length) {
@if (channelsLoading()) {
<app-channel-list-loading-state [view]="activeView()" />
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups') {
<header class="context-header">
<div class="context-header__top">
@@ -77,8 +79,12 @@
}
}
} @else {
<div class="loading-container">
<mat-icon class="loading-spinner">sync</mat-icon>
<div class="loading-text">{{ 'CHANNELS.LOADING' | translate }}</div>
<div class="empty-source-state">
<div class="empty-source-state__icon-wrapper">
<mat-icon class="empty-source-state__icon">folder_off</mat-icon>
</div>
<h3 class="empty-source-state__title">
{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}
</h3>
</div>
}
@@ -6,42 +6,56 @@
container-type: inline-size;
}
:host > app-channel-list-loading-state {
flex: 1;
min-height: 0;
}
::ng-deep {
.mat-mdc-tab.mdc-tab {
padding: 0 5px;
}
}
.loading-container {
.empty-source-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 120px);
height: 100%;
min-height: 0;
padding: 32px 24px;
text-align: center;
}
.loading-spinner {
font-size: 48px;
width: 48px;
height: 48px;
animation: spin 1.5s linear infinite;
opacity: 0.7;
margin-bottom: 16px;
.empty-source-state__icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow:
0 8px 24px -8px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.loading-text {
font-size: 1rem;
opacity: 0.7;
.empty-source-state__icon {
width: 32px;
height: 32px;
font-size: 32px;
opacity: 0.8;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
.empty-source-state__title {
font-size: 1.05rem;
font-weight: 520;
margin: 0;
letter-spacing: -0.01em;
}
@media only screen and (max-width: 599px) {
@@ -0,0 +1,112 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of, Subject } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { ChannelListContainerComponent } from './channel-list-container.component';
describe('ChannelListContainerComponent', () => {
let fixture: ComponentFixture<ChannelListContainerComponent>;
let dispatch: jest.Mock;
beforeEach(async () => {
const routerEvents$ = new Subject<NavigationEnd>();
dispatch = jest.fn();
const route = {
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
queryParamMap: convertToParamMap({}),
params: {},
queryParams: {},
},
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
params: {},
},
paramMap: of(convertToParamMap({})),
},
],
paramMap: of(convertToParamMap({})),
queryParamMap: of(convertToParamMap({})),
} as unknown as ActivatedRoute;
await TestBed.configureTestingModule({
imports: [ChannelListContainerComponent],
providers: [
{
provide: EpgService,
useValue: {
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
},
},
{
provide: PlaylistsService,
useValue: {},
},
{
provide: StorageMap,
useValue: {
get: jest.fn().mockReturnValue(of({})),
},
},
{
provide: Store,
useValue: {
dispatch,
select: jest.fn().mockReturnValue(of([])),
selectSignal: jest.fn(() => signal(undefined)),
},
},
{
provide: Router,
useValue: {
url: '/workspace/playlists/demo/all',
events: routerEvents$.asObservable(),
},
},
{
provide: ActivatedRoute,
useValue: route,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: signal(null),
},
},
],
})
.overrideComponent(ChannelListContainerComponent, {
set: {
template: '',
imports: [],
},
})
.compileComponents();
fixture = TestBed.createComponent(ChannelListContainerComponent);
});
it('does not clear the shared channel list on destroy', () => {
fixture.detectChanges();
fixture.destroy();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
});
});
@@ -56,6 +56,7 @@ import {
RecentViewComponent,
RecentViewItem,
} from './recent-view/recent-view.component';
import { ChannelListLoadingStateComponent } from '../channel-list-loading-state/channel-list-loading-state.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
@@ -75,6 +76,7 @@ function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AllChannelsViewComponent,
ChannelListLoadingStateComponent,
CommonModule,
FavoritesViewComponent,
GroupsViewComponent,
@@ -113,6 +115,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly channelsLoading = input(false);
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
@@ -281,7 +284,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.store.dispatch(ChannelActions.resetActiveChannel());
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
if (this.epgRefreshInterval) {
clearInterval(this.epgRefreshInterval);
@@ -0,0 +1,98 @@
<div class="channel-list-loading-state" aria-live="polite" role="status">
<span class="visually-hidden">
{{ 'CHANNELS.LOADING' | translate }}
</span>
@if (isGroupsView()) {
<div class="groups-loading-layout" aria-hidden="true">
<aside class="groups-loading-nav">
<header class="groups-loading-nav__header">
<h2 class="groups-loading-nav__title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</header>
<div class="groups-loading-nav__list">
@for (row of groupRows; track row) {
<div class="groups-loading-nav__item">
<span
class="grid-skeleton-line groups-loading-nav__label"
[style.width.%]="groupLabelWidths[row]"
></span>
<span
class="grid-skeleton-line groups-loading-nav__count"
></span>
</div>
}
</div>
</aside>
<section class="groups-loading-content">
<header class="groups-loading-content__header">
<div class="groups-loading-content__meta">
<span
class="grid-skeleton-line groups-loading-content__title"
></span>
<span
class="grid-skeleton-line groups-loading-content__subtitle"
></span>
</div>
</header>
<div class="groups-loading-content__list">
@for (row of channelRows; track row) {
<div class="channel-loading-row">
<span
class="list-skeleton-avatar channel-loading-row__logo"
></span>
<span class="channel-loading-row__text">
<span
class="grid-skeleton-line channel-loading-row__title"
[style.width.%]="channelTitleWidths[row]"
></span>
<span
class="grid-skeleton-line channel-loading-row__meta"
[style.width.%]="channelMetaWidths[row]"
></span>
</span>
<span
class="list-skeleton-star channel-loading-row__favorite"
></span>
</div>
}
</div>
</section>
</div>
} @else {
<div class="channels-loading-state">
<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 channelRows; track row) {
<div class="channel-loading-row">
<span
class="list-skeleton-avatar channel-loading-row__logo"
></span>
<span class="channel-loading-row__text">
<span
class="grid-skeleton-line channel-loading-row__title"
[style.width.%]="channelTitleWidths[row]"
></span>
<span
class="grid-skeleton-line channel-loading-row__meta"
[style.width.%]="channelMetaWidths[row]"
></span>
</span>
<span
class="list-skeleton-star channel-loading-row__favorite"
></span>
</div>
}
</div>
</div>
}
</div>
@@ -0,0 +1,253 @@
@use '../../../../../portal/shared/ui/src/lib/styles/skeleton';
:host {
display: block;
height: 100%;
width: 100%;
min-height: 0;
container-type: inline-size;
}
.channel-list-loading-state {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.channels-loading-state {
display: grid;
gap: 14px;
height: 100%;
min-height: 0;
padding: 16px 12px 18px;
}
.channels-loading-state__copy {
padding: 0 4px;
}
.channels-loading-state__title,
.groups-loading-nav__title {
margin: 0;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.channels-loading-state__list,
.groups-loading-content__list {
display: grid;
gap: 8px;
overflow: hidden;
}
.channel-loading-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 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-loading-row__logo {
width: 44px;
height: 44px;
border-radius: 12px;
}
.channel-loading-row__text {
min-width: 0;
display: grid;
gap: 8px;
}
.channel-loading-row__title {
height: 11px;
border-radius: 999px;
}
.channel-loading-row__meta {
height: 9px;
border-radius: 999px;
}
.channel-loading-row__favorite {
justify-self: end;
width: 18px;
height: 18px;
border-radius: 999px;
}
.groups-loading-layout {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--app-content-bg, var(--mat-sys-surface));
}
.groups-loading-nav {
width: 208px;
min-width: 164px;
max-width: 320px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
padding: 12px 10px 10px 12px;
box-sizing: border-box;
}
.groups-loading-nav__header {
padding: 4px 6px 12px;
flex-shrink: 0;
}
.groups-loading-nav__list {
display: grid;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.groups-loading-nav__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
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
);
}
.groups-loading-nav__label,
.groups-loading-nav__count,
.groups-loading-content__title,
.groups-loading-content__subtitle {
border-radius: 999px;
}
.groups-loading-nav__label {
height: 9px;
}
.groups-loading-nav__count {
width: 22px;
height: 9px;
justify-self: end;
}
.groups-loading-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--app-content-bg, var(--mat-sys-surface));
border-top-left-radius: 16px;
}
.groups-loading-content__header {
flex-shrink: 0;
min-height: 56px;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--app-separator);
}
.groups-loading-content__meta {
max-width: 180px;
display: grid;
gap: 8px;
}
.groups-loading-content__title {
width: 100%;
max-width: 156px;
height: 12px;
}
.groups-loading-content__subtitle {
width: 82px;
height: 9px;
}
.groups-loading-content__list {
flex: 1;
min-height: 0;
align-content: start;
padding: 12px 8px 12px;
}
@container (max-width: 380px) {
.channels-loading-state {
padding-inline: 8px;
}
.channel-loading-row {
grid-template-columns: 40px minmax(0, 1fr) 16px;
gap: 10px;
padding-inline: 10px;
}
.channel-loading-row__logo {
width: 40px;
height: 40px;
border-radius: 10px;
}
.groups-loading-nav {
min-width: 136px;
width: 42%;
padding-inline: 8px;
}
.groups-loading-content__header {
padding-inline: 12px;
}
.groups-loading-content__list {
padding-inline: 6px;
}
}
@@ -0,0 +1,48 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ChannelListLoadingStateComponent } from './channel-list-loading-state.component';
describe('ChannelListLoadingStateComponent', () => {
let fixture: ComponentFixture<ChannelListLoadingStateComponent>;
let component: ChannelListLoadingStateComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ChannelListLoadingStateComponent,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelListLoadingStateComponent);
component = fixture.componentInstance;
});
it('renders channel rows for non-group views', () => {
fixture.componentRef.setInput('view', 'all');
fixture.detectChanges();
expect(component.isGroupsView()).toBe(false);
expect(
fixture.nativeElement.querySelectorAll('.channel-loading-row')
.length
).toBe(component.channelRows.length);
expect(
fixture.nativeElement.querySelector('.groups-loading-layout')
).toBeNull();
});
it('renders a two-column group loading layout for the groups view', () => {
fixture.componentRef.setInput('view', 'groups');
fixture.detectChanges();
expect(component.isGroupsView()).toBe(true);
expect(
fixture.nativeElement.querySelectorAll('.groups-loading-nav__item')
.length
).toBe(component.groupRows.length);
expect(
fixture.nativeElement.querySelector('.groups-loading-content')
).not.toBeNull();
});
});
@@ -0,0 +1,25 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-channel-list-loading-state',
templateUrl: './channel-list-loading-state.component.html',
styleUrl: './channel-list-loading-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe],
})
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly isGroupsView = computed(() => this.view() === 'groups');
readonly channelRows = Array.from({ length: 9 }, (_, index) => index);
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);
readonly channelMetaWidths = [38, 34, 42, 36, 44, 35, 40, 32, 39];
readonly channelTitleWidths = [72, 61, 68, 54, 78, 64, 70, 57, 75];
readonly groupLabelWidths = [78, 66, 84, 58, 73, 69, 81, 62, 76, 71];
}