mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
7cc25380dd
commit
0f72efb749
33 files changed
+1104
-41
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,11 @@ export const selectCurrentEpgProgram = createSelector(
|
||||
fromPlaylistState.selectCurrentEpgProgramReducer
|
||||
);
|
||||
|
||||
export const selectChannelsLoading = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectChannelsLoadingReducer
|
||||
);
|
||||
|
||||
export const selectChannels = createSelector(
|
||||
selectPlaylistState,
|
||||
fromPlaylistState.selectChannelsReducer
|
||||
|
||||
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
+17
@@ -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,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';
|
||||
|
||||
+10
-4
@@ -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>
|
||||
}
|
||||
+33
-19
@@ -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) {
|
||||
|
||||
+112
@@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -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);
|
||||
|
||||
+98
@@ -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>
|
||||
+253
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
});
|
||||
});
|
||||
+25
@@ -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')}`;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user