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

+2
View File
@@ -31,10 +31,12 @@ describe('app routes', () => {
expect(favoritesRoute?.data).toEqual({
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
});
expect(recentRoute?.data).toEqual({
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
});
expect(typeof favoritesRoute?.loadComponent).toBe('function');
+2
View File
@@ -67,6 +67,7 @@ export const routes: Routes = [
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
@@ -79,6 +80,7 @@ export const routes: Routes = [
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
+16
View File
@@ -49,6 +49,9 @@ interface PlaylistState {
// Active channel being played
active: Channel | undefined;
// Whether the current route is still resolving channel data
channelsLoading: boolean;
// All channels from current playlist
channels: Channel[];
@@ -85,6 +88,7 @@ interface PlaylistState {
```typescript
// Channel selectors
selectActive // Current playing channel
selectChannelsLoading // Channel list loading flag
selectChannels // All channels array
selectFavorites // Favorite channel URLs
@@ -165,6 +169,18 @@ channel-list-container/
ChannelActions.setActiveChannel
```
### Loading States
- `M3uWorkspaceRouteSession` owns route-driven channel loading for the player/sidebar routes: `all` and `groups`.
- The route session sets `channelsLoading` before `getPlaylist()` resolves and clears it when `ChannelActions.setChannels` lands.
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
- Empty playlists and empty search results are no longer conflated:
- loading: skeletons
- empty source: no channels in the playlist after loading completes
- empty search: no matches within an already loaded playlist
### EnrichedChannel Pattern
For performance optimization, channels are pre-enriched with EPG data:
+1
View File
@@ -33,6 +33,7 @@ export const PlaylistActions = createActionGroup({
export const ChannelActions = createActionGroup({
source: 'Channels',
events: {
'Set Channels Loading': props<{ loading: boolean }>(),
'Set Channels': props<{ channels: Channel[] }>(),
'Set Active Channel': props<{ channel: Channel }>(),
'Set Active Channel Success': props<{ channel: Channel }>(),
@@ -0,0 +1,53 @@
import { createReducer } from '@ngrx/store';
import { Channel } from 'shared-interfaces';
import { ChannelActions } from '../actions';
import { initialState } from '../state';
import { channelReducers } from './channel.reducers';
const reducer = createReducer(initialState, ...channelReducers);
describe('channelReducers', () => {
const sampleChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Sample TV',
radio: 'false',
tvg: {
id: 'sample-tvg-id',
logo: '',
name: 'Sample TV',
rec: '',
url: '',
},
url: 'https://example.com/live.m3u8',
} as Channel;
it('tracks explicit channel loading state', () => {
const nextState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(nextState.channelsLoading).toBe(true);
});
it('stores channels and clears the loading flag when channel data arrives', () => {
const loadingState = reducer(
initialState,
ChannelActions.setChannelsLoading({ loading: true })
);
const nextState = reducer(
loadingState,
ChannelActions.setChannels({ channels: [sampleChannel] })
);
expect(nextState.channels).toEqual([sampleChannel]);
expect(nextState.channelsLoading).toBe(false);
});
});
@@ -4,6 +4,12 @@ import { ChannelActions } from '../actions';
import { PlaylistState } from '../state';
export const channelReducers = [
on(ChannelActions.setChannelsLoading, (state, action): PlaylistState => {
return {
...state,
channelsLoading: action.loading,
};
}),
on(
ChannelActions.setActiveChannelSuccess,
(state, action): PlaylistState => {
@@ -25,6 +31,7 @@ export const channelReducers = [
on(ChannelActions.setChannels, (state, action): PlaylistState => {
return {
...state,
channelsLoading: false,
channels: action.channels,
};
}),
+2
View File
@@ -27,6 +27,8 @@ export const selectActivePlaybackUrlReducer = (state: PlaylistState) =>
state.activePlaybackUrl;
export const selectCurrentEpgProgramReducer = (state: PlaylistState) =>
state.currentEpgProgram;
export const selectChannelsLoadingReducer = (state: PlaylistState) =>
state.channelsLoading;
export const selectChannelsReducer = (state: PlaylistState) => state.channels;
export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) =>
state.playlists?.allPlaylistsLoaded;
@@ -3,7 +3,7 @@ import { PlaylistActions } from '../actions';
import { playlistsAdapter } from '../playlists.state';
import { initialState } from '../state';
import { playlistReducers } from './playlist.reducers';
import { PlaylistMeta } from 'shared-interfaces';
import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
@@ -41,4 +41,56 @@ describe('playlistReducers', () => {
1712145600000
);
});
it('updates the active playlist channel cache and clears loading on playlist refresh', () => {
const refreshedChannel = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Refreshed TV',
radio: 'false',
tvg: {
id: 'channel-1',
logo: '',
name: 'Refreshed TV',
rec: '',
url: '',
},
url: 'https://example.com/refreshed.m3u8',
} as Channel;
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
count: 1,
importDate: '2026-03-28T00:00:00.000Z',
title: 'Playlist One',
} as PlaylistMeta;
const refreshedPlaylist = {
playlist: {
items: [refreshedChannel],
},
} as Playlist;
const state = {
...initialState,
channelsLoading: true,
playlists: playlistsAdapter.addOne(existingPlaylist, {
...initialState.playlists,
selectedId: 'playlist-1',
}),
};
const nextState = reducer(
state,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: 'playlist-1',
})
);
expect(nextState.channels).toEqual([refreshedChannel]);
expect(nextState.channelsLoading).toBe(false);
});
});
@@ -31,8 +31,13 @@ export const playlistReducers = [
};
}),
on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => {
const isActivePlaylist = state.playlists.selectedId === action.playlistId;
return {
...state,
channels: isActivePlaylist
? (action.playlist.playlist.items as Channel[])
: state.channels,
channelsLoading: isActivePlaylist ? false : state.channelsLoading,
playlists: playlistsAdapter.updateOne(
{
id: action.playlistId,
@@ -162,6 +167,7 @@ export const playlistReducers = [
if (action.isTemporary) {
return {
...state,
channelsLoading: false,
channels: action.playlist.playlist.items as Channel[],
};
} else {
+17 -1
View File
@@ -1,5 +1,6 @@
import { PlaylistMeta } from 'shared-interfaces';
import { selectActivePlaylist } from './selectors';
import { PlaylistState } from './state';
import { selectActivePlaylist, selectChannelsLoading } from './selectors';
describe('selectors', () => {
const playlistOne = {
@@ -26,4 +27,19 @@ describe('selectors', () => {
expect(selectActivePlaylist.projector(entities, '')).toBeNull();
});
});
describe('selectChannelsLoading', () => {
it('returns the current M3U channel loading flag', () => {
expect(
selectChannelsLoading.projector({
channelsLoading: true,
} as PlaylistState)
).toBe(true);
expect(
selectChannelsLoading.projector({
channelsLoading: false,
} as PlaylistState)
).toBe(false);
});
});
});
+5
View File
@@ -27,6 +27,11 @@ export const selectCurrentEpgProgram = createSelector(
fromPlaylistState.selectCurrentEpgProgramReducer
);
export const selectChannelsLoading = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsLoadingReducer
);
export const selectChannels = createSelector(
selectPlaylistState,
fromPlaylistState.selectChannelsReducer
+2
View File
@@ -8,6 +8,7 @@ export interface PlaylistState {
activePlaybackUrl: string | null;
currentEpgProgram: EpgProgram | undefined;
epgAvailable: boolean;
channelsLoading: boolean;
channels: Channel[]; // TODO: use entity store
playlists: PlaylistMetaState;
}
@@ -17,6 +18,7 @@ export const initialState: PlaylistState = {
activePlaybackUrl: null,
currentEpgProgram: undefined,
epgAvailable: false,
channelsLoading: false,
channels: [],
playlists: initialPlaylistMetaState,
};
@@ -6,7 +6,10 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
import { routeParamSignal } from '@iptvnator/portal/shared/util';
import {
CollectionScope,
routeParamSignal,
} from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-m3u-collection-route',
@@ -16,6 +19,7 @@ import { routeParamSignal } from '@iptvnator/portal/shared/util';
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
[defaultScope]="defaultScope()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -25,6 +29,7 @@ export class M3uCollectionRouteComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('m3u');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
@@ -0,0 +1,181 @@
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { ChannelActions, FavoritesActions } from 'm3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from 'services';
import { Channel, Playlist } from 'shared-interfaces';
import { Store } from '@ngrx/store';
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
const PLAYLIST_ID = 'playlist-1';
const NEXT_PLAYLIST_ID = 'playlist-2';
const PRIMARY_CHANNEL = {
epgParams: '',
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id: 'channel-1',
name: 'Primary channel',
radio: 'false',
tvg: {
id: 'primary-channel',
logo: '',
name: 'Primary channel',
rec: '',
url: '',
},
url: 'https://example.com/primary.m3u8',
} as Channel;
const NEXT_CHANNEL = {
...PRIMARY_CHANNEL,
id: 'channel-2',
name: 'Next channel',
tvg: {
...PRIMARY_CHANNEL.tvg,
id: 'next-channel',
name: 'Next channel',
},
url: 'https://example.com/next.m3u8',
} as Channel;
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function getM3uRouteContext(url: string): {
inWorkspace: boolean;
playlistId: string | null;
provider: 'playlists' | null;
section: 'all' | 'favorites' | 'groups' | 'recent' | null;
} {
const match = url.match(
/^\/workspace\/playlists\/([^/]+)\/([^/?]+)(?:\/|$)/
);
return {
inWorkspace: true,
playlistId: match?.[1] ?? null,
provider: match ? 'playlists' : null,
section:
(match?.[2] as 'all' | 'favorites' | 'groups' | 'recent' | null) ??
null,
};
}
describe('M3uWorkspaceRouteSession', () => {
const routerEvents = new Subject<NavigationEnd>();
const playlistContext = {
syncFromUrl: jest.fn(),
};
const playlistsService = {
getPlaylist: jest.fn(),
};
const store = {
dispatch: jest.fn(),
};
const router = {
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
events: routerEvents.asObservable(),
};
beforeEach(async () => {
router.url = `/workspace/playlists/${PLAYLIST_ID}/all`;
playlistContext.syncFromUrl.mockImplementation((url: string) =>
getM3uRouteContext(url)
);
playlistsService.getPlaylist.mockReset();
store.dispatch.mockClear();
await TestBed.configureTestingModule({
providers: [
M3uWorkspaceRouteSession,
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: store,
},
],
});
});
it('does not start channel loading for collection routes like favorites', async () => {
router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`;
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.setChannelsLoading({ loading: false })
);
expect(playlistsService.getPlaylist).not.toHaveBeenCalled();
});
it('ignores stale playlist responses after a newer route request wins', async () => {
const firstResponse = new Subject<Playlist>();
const secondResponse = new Subject<Playlist>();
playlistsService.getPlaylist.mockImplementation((playlistId: string) => {
return playlistId === PLAYLIST_ID
? firstResponse.asObservable()
: secondResponse.asObservable();
});
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
router.url = `/workspace/playlists/${NEXT_PLAYLIST_ID}/all`;
routerEvents.next(new NavigationEnd(1, router.url, router.url));
await flushEffects();
secondResponse.next({
favorites: [NEXT_CHANNEL.url],
playlist: {
items: [NEXT_CHANNEL],
},
} as Playlist);
secondResponse.complete();
await flushEffects();
firstResponse.next({
favorites: [PRIMARY_CHANNEL.url],
playlist: {
items: [PRIMARY_CHANNEL],
},
} as Playlist);
firstResponse.complete();
await flushEffects();
const setChannelsCalls = store.dispatch.mock.calls.filter(
([action]) => action.type === ChannelActions.setChannels.type
);
expect(setChannelsCalls).toEqual([
[ChannelActions.setChannels({ channels: [NEXT_CHANNEL] })],
]);
expect(store.dispatch).toHaveBeenCalledWith(
FavoritesActions.setFavorites({
channelIds: [NEXT_CHANNEL.url],
})
);
});
});
@@ -13,6 +13,8 @@ import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from 'services';
type M3uLoadedSection = 'all' | 'groups';
@Injectable()
export class M3uWorkspaceRouteSession {
private readonly destroyRef = inject(DestroyRef);
@@ -23,6 +25,7 @@ export class M3uWorkspaceRouteSession {
private currentPlaylistId: string | null = null;
private currentSection: string | null = null;
private loadRequestId = 0;
constructor() {
this.router.events
@@ -49,16 +52,18 @@ export class M3uWorkspaceRouteSession {
const section =
routeContext.provider === 'playlists' ? routeContext.section : null;
const previousSection = this.currentSection;
this.currentSection = section;
const playlistChanged = playlistId !== this.currentPlaylistId;
const shouldLoadPlaylist = section === 'all' || section === 'groups';
const shouldLoadPlaylist = this.isLoadedSection(section);
const enteringLoadedSection =
shouldLoadPlaylist &&
previousSection !== 'all' &&
previousSection !== 'groups';
shouldLoadPlaylist && !this.isLoadedSection(previousSection);
if (!playlistId) {
this.currentPlaylistId = null;
this.currentSection = section;
this.loadRequestId += 1;
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
return;
}
@@ -67,11 +72,30 @@ export class M3uWorkspaceRouteSession {
this.store.dispatch(ChannelActions.resetActiveChannel());
}
if (shouldLoadPlaylist && (playlistChanged || enteringLoadedSection)) {
if (!shouldLoadPlaylist) {
this.loadRequestId += 1;
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
return;
}
if (!playlistChanged && !enteringLoadedSection) {
return;
}
const requestId = ++this.loadRequestId;
this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true }));
try {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylist(playlistId)
);
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
return;
}
if (playlist.userAgent) {
window.electron?.setUserAgent(playlist.userAgent, 'localhost');
}
@@ -90,9 +114,32 @@ export class M3uWorkspaceRouteSession {
channelIds: favorites,
})
);
}
} catch {
if (!this.isCurrentLoadRequest(requestId, playlistId, section)) {
return;
}
this.currentSection = section;
this.store.dispatch(ChannelActions.setChannels({ channels: [] }));
this.store.dispatch(
FavoritesActions.setFavorites({ channelIds: [] })
);
}
}
private isLoadedSection(section: string | null): section is M3uLoadedSection {
return section === 'all' || section === 'groups';
}
private isCurrentLoadRequest(
requestId: number,
playlistId: string,
section: M3uLoadedSection
): boolean {
return (
requestId === this.loadRequestId &&
this.currentPlaylistId === playlistId &&
this.currentSection === section
);
}
}
@@ -12,6 +12,7 @@
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
@@ -19,9 +20,9 @@
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
/>
} @loading {
<p>Loading...</p>
<app-channel-list-loading-state [view]="activeView()" />
} @placeholder {
<p>Loading...</p>
<app-channel-list-loading-state [view]="activeView()" />
}
</div>
@@ -8,6 +8,12 @@
}
.sidebar {
> app-channel-list-loading-state {
flex: 1;
min-height: 0;
overflow: hidden;
}
app-sidebar {
flex: 1;
min-height: 0;
@@ -12,6 +12,7 @@ import {
selectActive,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from 'm3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
@@ -37,6 +38,7 @@ describe('VideoPlayerComponent', () => {
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
@@ -72,6 +74,8 @@ describe('VideoPlayerComponent', () => {
return activePlaybackUrl;
case selectChannels:
return channels;
case selectChannelsLoading:
return channelsLoading;
case selectCurrentEpgProgram:
return currentEpgProgram;
default:
@@ -148,6 +152,7 @@ describe('VideoPlayerComponent', () => {
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
activePlaybackUrl.set(null);
channelsLoading.set(false);
currentEpgProgram.set(null);
currentEpgProgram$.next(null);
overlayMock.create.mockClear();
@@ -31,6 +31,7 @@ import {
selectActive,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from 'm3u-state';
import {
@@ -61,6 +62,7 @@ import {
SidebarComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { ChannelListLoadingStateComponent } from 'components';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
@@ -87,6 +89,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
ArtPlayerComponent,
AsyncPipe,
AudioPlayerComponent,
ChannelListLoadingStateComponent,
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
@@ -121,6 +124,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
readonly archivePlaybackAvailable = computed(() =>
isM3uCatchupPlaybackSupported(this.activeChannel())
);
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
@@ -5,8 +6,9 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from 'components';
import { DatabaseService, PlaylistRefreshService } from 'services';
import { PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
import { PlaylistContextFacade } from './playlist-context.facade';
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
function createPlaylistMeta(
@@ -41,6 +43,11 @@ describe('PlaylistRefreshActionService', () => {
let store: {
dispatch: jest.Mock;
};
let playlistRefreshService: {
refreshPlaylist: jest.Mock;
};
let routeProvider: ReturnType<typeof signal<'playlists' | 'xtreams' | null>>;
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
beforeEach(() => {
localStorage.clear();
@@ -72,6 +79,11 @@ describe('PlaylistRefreshActionService', () => {
store = {
dispatch: jest.fn(),
};
playlistRefreshService = {
refreshPlaylist: jest.fn(),
};
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
resolvedPlaylistId = signal<string | null>(null);
TestBed.configureTestingModule({
providers: [
@@ -104,8 +116,13 @@ describe('PlaylistRefreshActionService', () => {
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
},
{
provide: PlaylistContextFacade,
useValue: {
refreshPlaylist: jest.fn(),
routeProvider,
resolvedPlaylistId,
},
},
],
@@ -179,4 +196,38 @@ describe('PlaylistRefreshActionService', () => {
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('marks the active M3U route as loading before refreshing and clears loading after the update action', async () => {
const item = createPlaylistMeta({
_id: 'playlist-1',
filePath: '/tmp/playlist.m3u',
serverUrl: undefined,
url: 'https://example.com/playlist.m3u',
});
const refreshedPlaylist = {
_id: item._id,
playlist: {
items: [],
},
} as Playlist;
routeProvider.set('playlists');
resolvedPlaylistId.set(item._id);
playlistRefreshService.refreshPlaylist.mockResolvedValue(refreshedPlaylist);
service.refresh(item);
await Promise.resolve();
await Promise.resolve();
expect(store.dispatch).toHaveBeenNthCalledWith(
1,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(store.dispatch).toHaveBeenNthCalledWith(
2,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: item._id,
})
);
});
});
@@ -9,8 +9,9 @@ import {
isDbAbortError,
PlaylistRefreshService,
} from 'services';
import { PlaylistActions } from 'm3u-state';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistContextFacade } from './playlist-context.facade';
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
@@ -21,6 +22,7 @@ export class PlaylistRefreshActionService {
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly playlistContext = inject(PlaylistContextFacade);
readonly isRefreshing = signal(false);
@@ -134,7 +136,16 @@ export class PlaylistRefreshActionService {
}
private async refreshM3u(item: PlaylistMeta): Promise<void> {
const isActiveM3uRoute =
this.playlistContext.routeProvider() === 'playlists' &&
this.playlistContext.resolvedPlaylistId() === item._id;
this.isRefreshing.set(true);
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
);
}
try {
const refreshedPlaylist =
@@ -175,6 +186,12 @@ export class PlaylistRefreshActionService {
{ duration: 5000 }
);
}
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
}
} finally {
this.isRefreshing.set(false);
}
@@ -264,6 +264,23 @@ describe('UnifiedCollectionPageComponent', () => {
);
});
it('prefers the route default scope over the persisted collection scope', async () => {
setRouteParams({ id: 'playlist-1' });
playlistsLoaded.set(true);
fixture.componentRef.setInput('portalType', 'm3u');
fixture.componentRef.setInput('defaultScope', 'playlist');
fixture.detectChanges();
await fixture.whenStable();
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
'playlist',
'playlist-1',
'm3u'
);
});
it('does not reload when local item state changes on empty playlist favorites', async () => {
setRouteParams({ id: 'playlist-1' });
setRouteQueryParams({ scope: 'playlist' });
+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];
}
@@ -19,6 +19,7 @@
<app-channel-list-container
[channelList]="channels()"
[channelsLoading]="channelsLoading()"
[activeView]="activeView()"
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
[sidebarWidth]="sidebarWidth()"
@@ -25,6 +25,7 @@ import { ChannelListContainerComponent } from 'components';
})
export class SidebarComponent {
readonly channels = input<Channel[]>([]);
readonly channelsLoading = input(false);
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
readonly sidebarWidth = input<number | null>(null);
@@ -48,6 +49,10 @@ export class SidebarComponent {
});
readonly subtitle = computed(() => {
if (this.channelsLoading()) {
return this.translate.instant('CHANNELS.LOADING');
}
const count = this.channels()?.length ?? 0;
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
});