From 0f72efb749856bbde49571f66b5350de70795ae4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 11 Apr 2026 13:20:22 +0200 Subject: [PATCH] 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. --- apps/web/src/app/app.routes.spec.ts | 2 + apps/web/src/app/app.routes.ts | 2 + docs/architecture/m3u-playlist-module.md | 16 ++ libs/m3u-state/src/lib/actions.ts | 1 + .../src/lib/reducers/channel.reducers.spec.ts | 53 ++++ .../src/lib/reducers/channel.reducers.ts | 7 + libs/m3u-state/src/lib/reducers/index.ts | 2 + .../lib/reducers/playlist.reducers.spec.ts | 54 +++- .../src/lib/reducers/playlist.reducers.ts | 6 + libs/m3u-state/src/lib/selectors.spec.ts | 18 +- libs/m3u-state/src/lib/selectors.ts | 5 + libs/m3u-state/src/lib/state.ts | 2 + .../m3u-collection-route.component.ts | 7 +- ...3u-workspace-route-session.service.spec.ts | 181 +++++++++++++ .../m3u-workspace-route-session.service.ts | 63 ++++- .../video-player/video-player.component.html | 5 +- .../video-player/video-player.component.scss | 6 + .../video-player.component.spec.ts | 5 + .../video-player/video-player.component.ts | 4 + .../playlist-refresh-action.service.spec.ts | 57 +++- .../lib/playlist-refresh-action.service.ts | 19 +- .../unified-collection-page.component.spec.ts | 17 ++ libs/ui/components/src/index.ts | 1 + .../channel-list-container.component.html | 14 +- .../channel-list-container.component.scss | 52 ++-- .../channel-list-container.component.spec.ts | 112 ++++++++ .../channel-list-container.component.ts | 4 +- .../channel-list-loading-state.component.html | 98 +++++++ .../channel-list-loading-state.component.scss | 253 ++++++++++++++++++ ...annel-list-loading-state.component.spec.ts | 48 ++++ .../channel-list-loading-state.component.ts | 25 ++ .../sidebar/sidebar.component.html | 1 + .../video-player/sidebar/sidebar.component.ts | 5 + 33 files changed, 1104 insertions(+), 41 deletions(-) create mode 100644 libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html create mode 100644 libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 5ee8a3d91..900553481 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -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'); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8ff52ba1b..3bf8568d8 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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', }, }, { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 0019d9e28..bc55a1ddf 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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: diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index 05db351b0..3d10cd25f 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -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 }>(), diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts new file mode 100644 index 000000000..1b22c939d --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.spec.ts @@ -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); + }); +}); diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.ts index f86664a3f..f640081aa 100644 --- a/libs/m3u-state/src/lib/reducers/channel.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.ts @@ -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, }; }), diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts index 0a473c2e4..177a008f3 100644 --- a/libs/m3u-state/src/lib/reducers/index.ts +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -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; diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts index bd2f67b1a..57f68279d 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -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); + }); }); diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index 2f413c808..d08e5c1f0 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -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 { diff --git a/libs/m3u-state/src/lib/selectors.spec.ts b/libs/m3u-state/src/lib/selectors.spec.ts index 09292edef..861afb5ae 100644 --- a/libs/m3u-state/src/lib/selectors.spec.ts +++ b/libs/m3u-state/src/lib/selectors.spec.ts @@ -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); + }); + }); }); diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts index 6010f35cf..fe9a6fdd1 100644 --- a/libs/m3u-state/src/lib/selectors.ts +++ b/libs/m3u-state/src/lib/selectors.ts @@ -27,6 +27,11 @@ export const selectCurrentEpgProgram = createSelector( fromPlaylistState.selectCurrentEpgProgramReducer ); +export const selectChannelsLoading = createSelector( + selectPlaylistState, + fromPlaylistState.selectChannelsLoadingReducer +); + export const selectChannels = createSelector( selectPlaylistState, fromPlaylistState.selectChannelsReducer diff --git a/libs/m3u-state/src/lib/state.ts b/libs/m3u-state/src/lib/state.ts index 7ac73f58d..f0a2abc7a 100644 --- a/libs/m3u-state/src/lib/state.ts +++ b/libs/m3u-state/src/lib/state.ts @@ -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, }; diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts index b3375312f..7736f7493 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-collection-route/m3u-collection-route.component.ts @@ -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(undefined); readonly playlistId = routeParamSignal( this.route, 'id', diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts new file mode 100644 index 000000000..9a133cbec --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -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 { + 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(); + 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(); + const secondResponse = new Subject(); + + 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], + }) + ); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 92acb592b..9965b0fbb 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -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 + ); } } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 1b8a9b2cc..e2a5c0271 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -12,6 +12,7 @@ @defer { } @loading { -

Loading...

+ } @placeholder { -

Loading...

+ } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index b73dac0a6..806f3a2e8 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -8,6 +8,12 @@ } .sidebar { + > app-channel-list-loading-state { + flex: 1; + min-height: 0; + overflow: hidden; + } + app-sidebar { flex: 1; min-height: 0; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 13d74fe37..a7c1062de 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -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(null); const activePlaybackUrl = signal(null); const channels = signal([]); + const channelsLoading = signal(false); const currentEpgProgram = signal(null); const channels$ = new BehaviorSubject([]); @@ -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(); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 7e573627f..ed152714e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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()) ); diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts index 2678e3547..2f6feadf1 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.spec.ts @@ -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>; + let resolvedPlaylistId: ReturnType>; 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(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, + }) + ); + }); }); diff --git a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts index 6e895c4a0..2388bca9e 100644 --- a/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/util/src/lib/playlist-refresh-action.service.ts @@ -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 { + 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); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index a667136db..5ea29f57e 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -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' }); diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 114e582b4..e13958e6e 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index d1e9e8f32..cc75771c4 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -1,4 +1,6 @@ -@if (displayedChannels().length) { +@if (channelsLoading()) { + +} @else if (displayedChannels().length) { @if (viewTitle() && activeView() !== 'groups') {
@@ -77,8 +79,12 @@ } } } @else { -
- sync -
{{ 'CHANNELS.LOADING' | translate }}
+
+
+ folder_off +
+

+ {{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }} +

} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 8a852b2ba..bf3560dc0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -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) { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts new file mode 100644 index 000000000..4d1266735 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -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; + let dispatch: jest.Mock; + + beforeEach(async () => { + const routerEvents$ = new Subject(); + 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() + ); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 4da626dab..1366a8497 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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 { return channels.reduce>((groups, channel) => { @@ -75,6 +76,7 @@ function groupChannelsByTitle(channels: Channel[]): Record { 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('all'); + readonly channelsLoading = input(false); readonly recentItems = input([]); readonly sidebarWidth = input(null); readonly sidebarWidthRequested = output(); @@ -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); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html new file mode 100644 index 000000000..54defc1a4 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html @@ -0,0 +1,98 @@ +
+ + {{ 'CHANNELS.LOADING' | translate }} + + + @if (isGroupsView()) { + + } @else { +
+
+
+ {{ 'CHANNELS.LOADING' | translate }} +
+
+ + +
+ } +
diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss new file mode 100644 index 000000000..a1732a8cc --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts new file mode 100644 index 000000000..1e088688e --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts new file mode 100644 index 000000000..0750d232d --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts @@ -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('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]; +} diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index 68413c06d..290e030d2 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -19,6 +19,7 @@ ([]); + readonly channelsLoading = input(false); readonly showPlaylistHeader = input(true); readonly activeView = input('all'); readonly sidebarWidth = input(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')}`; });