From 68b87040ad19b935fab71520463c9cf63d974d5c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Dec 2025 23:59:40 +0100 Subject: [PATCH 1/5] feat(m3u-state): reducers and add selectors Move playlist reducers into a dedicated reducers module and re-export a composed playlistReducer built from smaller reducer arrays (epg, channel, playlist, favorites, filter). This extracts and organizes the previously monolithic reducer implementation into focused reducer files, improving modularity and maintainability. Add simple selectors in the new reducers index for commonly accessed pieces of PlaylistState (playlists, selected playlist id, epg availability, active channel, current EPG program, channels list, playlists loaded flag, current playlist id) so callers can import them from a single place. These changes make reducer logic easier to reason about, enable incremental testing and extension of individual reducer groups, and centralize basic selectors for consistent usage across the app. --- libs/m3u-state/src/lib/actions.ts | 198 ++++-------- libs/m3u-state/src/lib/effects.ts | 21 +- libs/m3u-state/src/lib/reducers.ts | 294 +----------------- .../src/lib/reducers/channel.reducers.ts | 29 ++ .../src/lib/reducers/epg.reducers.ts | 39 +++ .../src/lib/reducers/favorites.reducers.ts | 50 +++ .../src/lib/reducers/filter.reducers.ts | 18 ++ libs/m3u-state/src/lib/reducers/index.ts | 32 ++ .../src/lib/reducers/playlist.reducers.ts | 166 ++++++++++ 9 files changed, 413 insertions(+), 434 deletions(-) create mode 100644 libs/m3u-state/src/lib/reducers/channel.reducers.ts create mode 100644 libs/m3u-state/src/lib/reducers/epg.reducers.ts create mode 100644 libs/m3u-state/src/lib/reducers/favorites.reducers.ts create mode 100644 libs/m3u-state/src/lib/reducers/filter.reducers.ts create mode 100644 libs/m3u-state/src/lib/reducers/index.ts create mode 100644 libs/m3u-state/src/lib/reducers/playlist.reducers.ts diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts index b2dd25083..2ebc8a8d7 100644 --- a/libs/m3u-state/src/lib/actions.ts +++ b/libs/m3u-state/src/lib/actions.ts @@ -1,140 +1,70 @@ -import { createAction, props } from '@ngrx/store'; +import { createActionGroup, emptyProps, props } from '@ngrx/store'; import { Channel, EpgProgram, Playlist, PlaylistMeta } from 'shared-interfaces'; -const STORE_KEY = '[GLOBAL STORE]'; -const PLAYLISTS_STORE_KEY = '[PLAYLISTS STORE]'; +export const PlaylistActions = createActionGroup({ + source: 'Playlists', + events: { + 'Load Playlists': emptyProps(), + 'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(), + 'Add Playlist': props<{ playlist: Playlist }>(), + 'Add Many Playlists': props<{ playlists: Playlist[] }>(), + 'Remove Playlist': props<{ playlistId: string }>(), + 'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(), + 'Update Playlist': props<{ playlist: Playlist; playlistId: string }>(), + 'Update Many Playlists': props<{ playlists: Playlist[] }>(), + 'Parse Playlist': props<{ + uploadType: 'FILE' | 'URL' | 'TEXT'; + playlist: string; + title: string; + path?: string; + }>(), + 'Set Active Playlist': props<{ playlistId: string }>(), + 'Update Playlist Positions': props<{ + positionUpdates: { id: string; changes: { position: number } }[]; + }>(), + 'Remove All Playlists': emptyProps(), + 'Set Current Playlist Id': props<{ playlistId: string | undefined }>(), + 'Handle Adding Playlist By Url': props<{ + isTemporary: boolean; + playlist: Playlist; + }>(), + }, +}); -export const loadPlaylists = createAction( - `${PLAYLISTS_STORE_KEY} Load playlists from db` -); +export const ChannelActions = createActionGroup({ + source: 'Channels', + events: { + 'Set Channels': props<{ channels: Channel[] }>(), + 'Set Active Channel': props<{ channel: Channel }>(), + 'Set Active Channel Success': props<{ channel: Channel }>(), + 'Reset Active Channel': emptyProps(), + 'Set Adjacent Channel As Active': props<{ + direction: 'next' | 'previous'; + }>(), + }, +}); -export const loadPlaylistsSuccess = createAction( - `${PLAYLISTS_STORE_KEY} Successfully loaded playlists from db`, - props<{ playlists: PlaylistMeta[] }>() -); +export const EpgActions = createActionGroup({ + source: 'EPG', + events: { + 'Set Active Epg Program': props<{ program: EpgProgram }>(), + 'Set Current Epg Program': props<{ program: EpgProgram }>(), + 'Reset Active Epg Program': emptyProps(), + 'Set Epg Available Flag': props<{ value: boolean }>(), + }, +}); -export const addPlaylist = createAction( - `${PLAYLISTS_STORE_KEY} Add new playlist`, - props<{ playlist: Playlist }>() -); +export const FavoritesActions = createActionGroup({ + source: 'Favorites', + events: { + 'Update Favorites': props<{ channel: Channel }>(), + 'Set Favorites': props<{ channelIds: string[] }>(), + }, +}); -export const addManyPlaylists = createAction( - `${PLAYLISTS_STORE_KEY} Add many playlists`, - props<{ playlists: Playlist[] }>() -); - -export const removePlaylist = createAction( - `${PLAYLISTS_STORE_KEY} Remove playlist by id`, - props<{ playlistId: string }>() -); - -export const updatePlaylistMeta = createAction( - `${PLAYLISTS_STORE_KEY} update playlist meta`, - props<{ playlist: PlaylistMeta }>() -); - -export const updatePlaylist = createAction( - `${PLAYLISTS_STORE_KEY} update playlist`, - props<{ playlist: Playlist; playlistId: string }>() -); - -export const updateManyPlaylists = createAction( - `${PLAYLISTS_STORE_KEY} Update many playlists (auto-update mechanism)`, - props<{ playlists: Playlist[] }>() -); - -export const parsePlaylist = createAction( - `${PLAYLISTS_STORE_KEY} parse playlist`, - props<{ - uploadType: 'FILE' | 'URL' | 'TEXT'; - playlist: string; - title: string; - path?: string; - }>() -); - -export const setActivePlaylist = createAction( - `${PLAYLISTS_STORE_KEY} set active playlist`, - props<{ playlistId: string }>() -); - -export const updateFavorites = createAction( - `${STORE_KEY} Add/remove provided channel to the favorites`, - props<{ channel: Channel }>() -); - -export const setFavorites = createAction( - `${STORE_KEY} Set favorites`, - props<{ channelIds: string[] }>() -); - -export const setActiveChannel = createAction( - `${STORE_KEY} Set active channel`, - props<{ channel: Channel }>() -); - -export const resetActiveChannel = createAction( - `${STORE_KEY} Reset active channel` -); - -export const setActiveChannelSuccess = createAction( - `${STORE_KEY} Set active channel success`, - props<{ channel: Channel }>() -); - -export const setActiveEpgProgram = createAction( - `${STORE_KEY} Sets the given timestamp for the epg program`, - props<{ program: EpgProgram }>() -); - -export const setCurrentEpgProgram = createAction( - `${STORE_KEY} Updates the active epg program for the active channel`, - props<{ program: EpgProgram }>() -); - -export const resetActiveEpgProgram = createAction( - `${STORE_KEY} Reset active epg program` -); - -export const setEpgAvailableFlag = createAction( - `${STORE_KEY} Reset active epg program`, - props<{ value: boolean }>() -); - -export const setChannels = createAction( - `${STORE_KEY} Set channels`, - props<{ channels: Channel[] }>() -); - -export const updatePlaylistPositions = createAction( - `${STORE_KEY} Update playlist positions`, - props<{ - positionUpdates: { id: string; changes: { position: number } }[]; - }>() -); - -export const removeAllPlaylists = createAction( - `${STORE_KEY} Remove all playlists` -); - -export const setAdjacentChannelAsActive = createAction( - `${STORE_KEY} Set adjacent channel as active`, - props<{ - direction: 'next' | 'previous'; - }>() -); - -export const setSelectedFilters = createAction( - `${STORE_KEY} Set selected filters`, - props<{ selectedFilters: string[] }>() -); - -export const setCurrentPlaylistId = createAction( - `${STORE_KEY} Set current playlist id`, - props<{ playlistId: string | undefined }>() -); - -export const handleAddingPlaylistByUrl = createAction( - `${PLAYLISTS_STORE_KEY} Handle adding playlist by URL`, - props<{ isTemporary: boolean; playlist: Playlist }>() -); +export const FilterActions = createActionGroup({ + source: 'Filters', + events: { + 'Set Selected Filters': props<{ selectedFilters: string[] }>(), + }, +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 749d91b05..dadeb30e9 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -24,7 +24,12 @@ import { STORE_KEY, VideoPlayer, } from 'shared-interfaces'; -import * as PlaylistActions from './actions'; +import { + PlaylistActions, + ChannelActions, + EpgActions, + FavoritesActions, +} from './actions'; import { selectActive, selectActivePlaylistId, @@ -47,7 +52,7 @@ export class PlaylistEffects { updateFavorites$ = createEffect( () => { return this.actions$.pipe( - ofType(PlaylistActions.updateFavorites), + ofType(FavoritesActions.updateFavorites), combineLatestWith( this.store.select(selectFavorites), this.store.select(selectActivePlaylistId) @@ -70,7 +75,7 @@ export class PlaylistEffects { setFavorites$ = createEffect( () => { return this.actions$.pipe( - ofType(PlaylistActions.setFavorites), + ofType(FavoritesActions.setFavorites), combineLatestWith(this.store.select(selectActivePlaylistId)), filter( ([, playlistId]) => @@ -92,7 +97,7 @@ export class PlaylistEffects { setActiveEpgProgram$ = createEffect( () => { return this.actions$.pipe( - ofType(PlaylistActions.setActiveEpgProgram), + ofType(EpgActions.setActiveEpgProgram), combineLatestWith(this.store.select(selectActive)), map(([, activeChannel]) => { firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( @@ -135,7 +140,7 @@ export class PlaylistEffects { setActiveChannel$ = createEffect(() => { return this.actions$.pipe( - ofType(PlaylistActions.setActiveChannel), + ofType(ChannelActions.setActiveChannel), // Skip the effect entirely when channel is falsy filter((action) => !!action.channel), map((action) => { @@ -186,7 +191,7 @@ export class PlaylistEffects { } ); - return PlaylistActions.setActiveChannelSuccess({ + return ChannelActions.setActiveChannelSuccess({ channel: action.channel, }); }) @@ -400,7 +405,7 @@ export class PlaylistEffects { setAdjacentChannelAsActive$ = createEffect(() => { return this.actions$.pipe( - ofType(PlaylistActions.setAdjacentChannelAsActive), + ofType(ChannelActions.setAdjacentChannelAsActive), withLatestFrom( this.store.select(selectChannels), this.store.select(selectActive) @@ -419,7 +424,7 @@ export class PlaylistEffects { adjacentChannel = activeChannel; adjacentChannel = channels[index - 1]; } - return PlaylistActions.setActiveChannelSuccess({ + return ChannelActions.setActiveChannelSuccess({ channel: adjacentChannel!, }); }) diff --git a/libs/m3u-state/src/lib/reducers.ts b/libs/m3u-state/src/lib/reducers.ts index 4ddd0eb59..bb775cb93 100644 --- a/libs/m3u-state/src/lib/reducers.ts +++ b/libs/m3u-state/src/lib/reducers.ts @@ -1,292 +1,2 @@ -import { createReducer, on } from '@ngrx/store'; -import moment from 'moment'; -import { Channel } from 'shared-interfaces'; -import * as PlaylistActions from './actions'; -import { playlistsAdapter } from './playlists.state'; -import { PlaylistState, initialState } from './state'; - -export const playlistReducer = createReducer( - initialState, - on(PlaylistActions.setActiveEpgProgram, (state, action): PlaylistState => { - const { program } = action; - const from = moment(program.start, 'YYYYMMDDHHmm ZZ').unix(); - const now = moment(Date.now()).unix(); - const epgParams = `?utc=${from}&lutc=${now}`; - return { - ...state, - active: { ...state.active, epgParams } as Channel, - }; - }), - on( - PlaylistActions.resetActiveEpgProgram, - (state): PlaylistState => ({ - ...state, - active: { ...state.active, epgParams: '' } as Channel, - }) - ), - on( - PlaylistActions.setActiveChannelSuccess, - (state, action): PlaylistState => { - const { channel } = action; - return { - ...state, - active: { ...channel, epgParams: '' } as Channel, - }; - } - ), - on(PlaylistActions.resetActiveChannel, (state): PlaylistState => { - return { - ...state, - active: undefined, - }; - }), - on( - PlaylistActions.setCurrentEpgProgram, - (state, action): PlaylistState => ({ - ...state, - currentEpgProgram: action.program, - }) - ), - on( - PlaylistActions.setEpgAvailableFlag, - (state, action): PlaylistState => ({ - ...state, - epgAvailable: action.value, - }) - ), - on(PlaylistActions.updateFavorites, (state, action): PlaylistState => { - let favorites; - const { channel } = action; - const playlistFavorites = - state.playlists.entities[state.playlists.selectedId]?.favorites; - if (playlistFavorites?.includes(channel.url)) { - favorites = [ - ...(playlistFavorites ?? []).filter( - (url: string) => url !== channel.url - ), - ]; - } else { - favorites = [...(playlistFavorites ?? []), channel.url]; - } - return { - ...state, - playlists: { - ...state.playlists, - entities: { - ...state.playlists.entities, - [state.playlists.selectedId]: { - ...state.playlists.entities[state.playlists.selectedId], - favorites, - }, - }, - }, - }; - }), - on(PlaylistActions.setFavorites, (state, action): PlaylistState => { - const { channelIds } = action; - return { - ...state, - playlists: { - ...state.playlists, - entities: { - ...state.playlists.entities, - [state.playlists.selectedId]: { - ...state.playlists.entities[state.playlists.selectedId], - favorites: channelIds, - }, - }, - }, - }; - }), - on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.addMany(action.playlists, { - ...state.playlists, - allPlaylistsLoaded: true, - }), - }; - }), - on(PlaylistActions.removePlaylist, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.removeOne( - action.playlistId, - state.playlists - ), - }; - }), - on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.updateOne( - { - id: action.playlistId, - changes: { - ...action.playlist, - _id: action.playlistId, - updateDate: Date.now(), - count: action.playlist.playlist.items.length, - userAgent: action.playlist.userAgent, - favorites: - state.playlists.entities[action.playlistId] - ?.favorites ?? [], - }, - }, - state.playlists - ), - }; - }), - on(PlaylistActions.addPlaylist, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.addOne( - action.playlist, - state.playlists - ), - }; - }), - on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.addMany( - action.playlists, - state.playlists - ), - }; - }), - on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => { - return { - ...state, - playlists: { - ...state.playlists, - selectedId: action.playlistId, - }, - }; - }), - on( - PlaylistActions.updatePlaylistPositions, - (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.updateMany( - action.positionUpdates, - state.playlists - ), - }; - } - ), - on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.updateOne( - { - id: action.playlist._id, - changes: { - title: action.playlist.title, - autoRefresh: action.playlist.autoRefresh || false, - userAgent: action.playlist.userAgent, - ...(action.playlist.serverUrl !== null - ? { serverUrl: action.playlist.serverUrl } - : {}), - ...(action.playlist.username !== null - ? { username: action.playlist.username } - : {}), - ...(action.playlist.password !== null - ? { password: action.playlist.password } - : {}), - ...(action.playlist.macAddress !== null - ? { macAddress: action.playlist.macAddress } - : {}), - ...(action.playlist.portalUrl !== null - ? { portalUrl: action.playlist.portalUrl } - : {}), - }, - }, - state.playlists - ), - }; - }), - on(PlaylistActions.setChannels, (state, action): PlaylistState => { - return { - ...state, - channels: action.channels, - }; - }), - on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.updateMany( - action.playlists.map((updatedPlaylist) => ({ - id: updatedPlaylist._id, - changes: { - ...updatedPlaylist, - updateDate: Date.now(), - }, - })), - state.playlists - ), - }; - }), - on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => { - return { - ...state, - playlists: playlistsAdapter.removeAll(state.playlists), - }; - }), - on( - PlaylistActions.setSelectedFilters, - (state, { selectedFilters }): PlaylistState => { - return { - ...state, - playlists: { - ...state.playlists, - selectedFilters, - }, - }; - } - ), - on( - PlaylistActions.setCurrentPlaylistId, - (state, { playlistId }): PlaylistState => { - return { - ...state, - currentPlaylistId: playlistId, - }; - } - ), - on( - PlaylistActions.handleAddingPlaylistByUrl, - (state, action): PlaylistState => { - if (action.isTemporary) { - return { - ...state, - channels: action.playlist.playlist.items as Channel[], - }; - } else { - return { - ...state, - playlists: playlistsAdapter.addOne( - action.playlist, - state.playlists - ), - }; - } - } - ) -); - -export const selectPlaylists = (state: PlaylistState) => state.playlists; -export const selectPlaylistId = (state: PlaylistState) => - state.playlists?.selectedId; - -export const selectIsEpgAvailableReducer = (state: PlaylistState) => - state.epgAvailable; -export const selectActiveReducer = (state: PlaylistState) => state.active; -export const selectCurrentEpgProgramReducer = (state: PlaylistState) => - state.currentEpgProgram; -export const selectChannelsReducer = (state: PlaylistState) => state.channels; -export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) => - state.playlists?.allPlaylistsLoaded; -export const selectCurrentPlaylistIdReducer = (state: PlaylistState) => - state.currentPlaylistId; +// Re-export from the new modular reducers structure +export * from './reducers/index'; diff --git a/libs/m3u-state/src/lib/reducers/channel.reducers.ts b/libs/m3u-state/src/lib/reducers/channel.reducers.ts new file mode 100644 index 000000000..c4d1de1be --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/channel.reducers.ts @@ -0,0 +1,29 @@ +import { on } from '@ngrx/store'; +import { Channel } from 'shared-interfaces'; +import { ChannelActions } from '../actions'; +import { PlaylistState } from '../state'; + +export const channelReducers = [ + on( + ChannelActions.setActiveChannelSuccess, + (state, action): PlaylistState => { + const { channel } = action; + return { + ...state, + active: { ...channel, epgParams: '' } as Channel, + }; + } + ), + on(ChannelActions.resetActiveChannel, (state): PlaylistState => { + return { + ...state, + active: undefined, + }; + }), + on(ChannelActions.setChannels, (state, action): PlaylistState => { + return { + ...state, + channels: action.channels, + }; + }), +]; diff --git a/libs/m3u-state/src/lib/reducers/epg.reducers.ts b/libs/m3u-state/src/lib/reducers/epg.reducers.ts new file mode 100644 index 000000000..e3f971eca --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/epg.reducers.ts @@ -0,0 +1,39 @@ +import { on } from '@ngrx/store'; +import moment from 'moment'; +import { Channel } from 'shared-interfaces'; +import { EpgActions } from '../actions'; +import { PlaylistState } from '../state'; + +export const epgReducers = [ + on(EpgActions.setActiveEpgProgram, (state, action): PlaylistState => { + const { program } = action; + const from = moment(program.start, 'YYYYMMDDHHmm ZZ').unix(); + const now = moment(Date.now()).unix(); + const epgParams = `?utc=${from}&lutc=${now}`; + return { + ...state, + active: { ...state.active, epgParams } as Channel, + }; + }), + on( + EpgActions.resetActiveEpgProgram, + (state): PlaylistState => ({ + ...state, + active: { ...state.active, epgParams: '' } as Channel, + }) + ), + on( + EpgActions.setCurrentEpgProgram, + (state, action): PlaylistState => ({ + ...state, + currentEpgProgram: action.program, + }) + ), + on( + EpgActions.setEpgAvailableFlag, + (state, action): PlaylistState => ({ + ...state, + epgAvailable: action.value, + }) + ), +]; diff --git a/libs/m3u-state/src/lib/reducers/favorites.reducers.ts b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts new file mode 100644 index 000000000..9b58df853 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/favorites.reducers.ts @@ -0,0 +1,50 @@ +import { on } from '@ngrx/store'; +import { FavoritesActions } from '../actions'; +import { PlaylistState } from '../state'; + +export const favoritesReducers = [ + on(FavoritesActions.updateFavorites, (state, action): PlaylistState => { + let favorites; + const { channel } = action; + const playlistFavorites = + state.playlists.entities[state.playlists.selectedId]?.favorites; + if (playlistFavorites?.includes(channel.url)) { + favorites = [ + ...(playlistFavorites ?? []).filter( + (url: string) => url !== channel.url + ), + ]; + } else { + favorites = [...(playlistFavorites ?? []), channel.url]; + } + return { + ...state, + playlists: { + ...state.playlists, + entities: { + ...state.playlists.entities, + [state.playlists.selectedId]: { + ...state.playlists.entities[state.playlists.selectedId], + favorites, + }, + }, + }, + }; + }), + on(FavoritesActions.setFavorites, (state, action): PlaylistState => { + const { channelIds } = action; + return { + ...state, + playlists: { + ...state.playlists, + entities: { + ...state.playlists.entities, + [state.playlists.selectedId]: { + ...state.playlists.entities[state.playlists.selectedId], + favorites: channelIds, + }, + }, + }, + }; + }), +]; diff --git a/libs/m3u-state/src/lib/reducers/filter.reducers.ts b/libs/m3u-state/src/lib/reducers/filter.reducers.ts new file mode 100644 index 000000000..a4533992c --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/filter.reducers.ts @@ -0,0 +1,18 @@ +import { on } from '@ngrx/store'; +import { FilterActions } from '../actions'; +import { PlaylistState } from '../state'; + +export const filterReducers = [ + on( + FilterActions.setSelectedFilters, + (state, { selectedFilters }): PlaylistState => { + return { + ...state, + playlists: { + ...state.playlists, + selectedFilters, + }, + }; + } + ), +]; diff --git a/libs/m3u-state/src/lib/reducers/index.ts b/libs/m3u-state/src/lib/reducers/index.ts new file mode 100644 index 000000000..dbf3afd18 --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/index.ts @@ -0,0 +1,32 @@ +import { createReducer } from '@ngrx/store'; +import { initialState, PlaylistState } from '../state'; +import { channelReducers } from './channel.reducers'; +import { epgReducers } from './epg.reducers'; +import { favoritesReducers } from './favorites.reducers'; +import { filterReducers } from './filter.reducers'; +import { playlistReducers } from './playlist.reducers'; + +export const playlistReducer = createReducer( + initialState, + ...epgReducers, + ...channelReducers, + ...playlistReducers, + ...favoritesReducers, + ...filterReducers +); + +// Selectors +export const selectPlaylists = (state: PlaylistState) => state.playlists; +export const selectPlaylistId = (state: PlaylistState) => + state.playlists?.selectedId; + +export const selectIsEpgAvailableReducer = (state: PlaylistState) => + state.epgAvailable; +export const selectActiveReducer = (state: PlaylistState) => state.active; +export const selectCurrentEpgProgramReducer = (state: PlaylistState) => + state.currentEpgProgram; +export const selectChannelsReducer = (state: PlaylistState) => state.channels; +export const selectPlaylistsLoadingFlagReducer = (state: PlaylistState) => + state.playlists?.allPlaylistsLoaded; +export const selectCurrentPlaylistIdReducer = (state: PlaylistState) => + state.currentPlaylistId; diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts new file mode 100644 index 000000000..622cc037d --- /dev/null +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -0,0 +1,166 @@ +import { on } from '@ngrx/store'; +import { Channel } from 'shared-interfaces'; +import { PlaylistActions } from '../actions'; +import { playlistsAdapter } from '../playlists.state'; +import { PlaylistState } from '../state'; + +export const playlistReducers = [ + on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addMany(action.playlists, { + ...state.playlists, + allPlaylistsLoaded: true, + }), + }; + }), + on(PlaylistActions.removePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.removeOne( + action.playlistId, + state.playlists + ), + }; + }), + on(PlaylistActions.updatePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateOne( + { + id: action.playlistId, + changes: { + ...action.playlist, + _id: action.playlistId, + updateDate: Date.now(), + count: action.playlist.playlist.items.length, + userAgent: action.playlist.userAgent, + favorites: + state.playlists.entities[action.playlistId] + ?.favorites ?? [], + }, + }, + state.playlists + ), + }; + }), + on(PlaylistActions.addPlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addOne( + action.playlist, + state.playlists + ), + }; + }), + on(PlaylistActions.addManyPlaylists, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.addMany( + action.playlists, + state.playlists + ), + }; + }), + on(PlaylistActions.setActivePlaylist, (state, action): PlaylistState => { + return { + ...state, + playlists: { + ...state.playlists, + selectedId: action.playlistId, + }, + }; + }), + on( + PlaylistActions.updatePlaylistPositions, + (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateMany( + action.positionUpdates, + state.playlists + ), + }; + } + ), + on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateOne( + { + id: action.playlist._id, + changes: { + title: action.playlist.title, + autoRefresh: action.playlist.autoRefresh || false, + userAgent: action.playlist.userAgent, + ...(action.playlist.serverUrl !== null + ? { serverUrl: action.playlist.serverUrl } + : {}), + ...(action.playlist.username !== null + ? { username: action.playlist.username } + : {}), + ...(action.playlist.password !== null + ? { password: action.playlist.password } + : {}), + ...(action.playlist.macAddress !== null + ? { macAddress: action.playlist.macAddress } + : {}), + ...(action.playlist.portalUrl !== null + ? { portalUrl: action.playlist.portalUrl } + : {}), + }, + }, + state.playlists + ), + }; + }), + on(PlaylistActions.updateManyPlaylists, (state, action): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.updateMany( + action.playlists.map((updatedPlaylist) => ({ + id: updatedPlaylist._id, + changes: { + ...updatedPlaylist, + updateDate: Date.now(), + }, + })), + state.playlists + ), + }; + }), + on(PlaylistActions.removeAllPlaylists, (state): PlaylistState => { + return { + ...state, + playlists: playlistsAdapter.removeAll(state.playlists), + }; + }), + on( + PlaylistActions.setCurrentPlaylistId, + (state, { playlistId }): PlaylistState => { + return { + ...state, + currentPlaylistId: playlistId, + }; + } + ), + on( + PlaylistActions.handleAddingPlaylistByUrl, + (state, action): PlaylistState => { + if (action.isTemporary) { + return { + ...state, + channels: action.playlist.playlist.items as Channel[], + }; + } else { + return { + ...state, + playlists: playlistsAdapter.addOne( + action.playlist, + state.playlists + ), + }; + } + } + ), +]; From ac31cf18e512c60c079a625647ab48b7f85cb48f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 21 Dec 2025 00:30:23 +0100 Subject: [PATCH 2/5] refactor(web): replace individual action imports with namespaced imports and update dispatch calls Replace multiple direct or wildcard imports for actions with namespaced action groups from 'm3u-state' (e.g. PlaylistActions, ChannelActions, EpgActions). Update dispatch calls to use the namespaced action creators (for example, setAdjacentChannelAsActive, setEpgAvailableFlag, resetActiveEpgProgram, setCurrentEpgProgram, setActiveEpgProgram, addPlaylist). This cleans up imports, improves tree-shaking and readability, and standardizes how actions are referenced across components. It also fixes inconsistent import styles (wildcard vs. named) and prepares the codebase for clearer action organization. --- apps/web/src/app/app.component.ts | 8 ++++---- .../app/home/file-upload/file-upload.component.ts | 4 ++-- .../stalker-portal-import.component.ts | 4 ++-- .../home/video-player/video-player.component.ts | 12 ++++++------ .../xtream-code-import.component.ts | 4 ++-- apps/web/src/app/services/electron.service.ts | 2 +- apps/web/src/app/services/pwa.service.ts | 2 +- apps/web/src/app/settings/settings.component.ts | 3 +-- .../add-playlist/add-playlist-dialog.component.ts | 8 ++++---- .../stalker-shell/stalker-shell.component.ts | 2 +- .../playlist-error-view.component.ts | 2 +- .../xtream-shell/xtream-shell.component.ts | 2 +- .../playlist-error-view.component.ts | 2 +- .../lib/audio-player/audio-player.component.ts | 4 ++-- .../channel-list-container.component.ts | 10 +++++----- .../src/lib/epg-list/epg-list.component.ts | 15 ++++++--------- .../playlist-info/playlist-info.component.ts | 2 +- .../recent-playlists.component.ts | 2 +- .../lib/video-player/sidebar/sidebar.component.ts | 4 ++-- .../lib/video-player/toolbar/toolbar.component.ts | 4 ++-- 20 files changed, 46 insertions(+), 50 deletions(-) diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index a42953de3..e48bb3113 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -4,10 +4,8 @@ import { Router, RouterOutlet } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state'; import { filter, take } from 'rxjs'; -/* import * as semver from 'semver'; */ -import * as PlaylistActions from 'm3u-state'; -import { selectAllPlaylistsMeta } from 'm3u-state'; import { DataService, EpgService } from 'services'; import { AUTO_UPDATE_PLAYLISTS, @@ -28,7 +26,9 @@ import { SearchResultsComponent } from './xtream-tauri/search-results/search-res }) export class AppComponent implements OnInit { @HostBinding('class.macos-platform') get isMacOS() { - return window.electron && navigator.platform.toLowerCase().includes('mac'); + return ( + window.electron && navigator.platform.toLowerCase().includes('mac') + ); } private actions$ = inject(Actions); private dataService = inject(DataService); diff --git a/apps/web/src/app/home/file-upload/file-upload.component.ts b/apps/web/src/app/home/file-upload/file-upload.component.ts index b4de5ff75..6fea546d4 100644 --- a/apps/web/src/app/home/file-upload/file-upload.component.ts +++ b/apps/web/src/app/home/file-upload/file-upload.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, inject, Output } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { addPlaylist } from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive'; @Component({ @@ -45,7 +45,7 @@ export class FileUploadComponent { playlistObject ); this.store.dispatch( - addPlaylist({ playlist: playlistObject }) + PlaylistActions.addPlaylist({ playlist: playlistObject }) ); } else { // User canceled the dialog diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts index 7312e6997..f0c4d1a90 100644 --- a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts +++ b/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts @@ -11,7 +11,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { addPlaylist } from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { DataService } from 'services'; import { Playlist } from 'shared-interfaces'; import { v4 as uuid } from 'uuid'; @@ -67,7 +67,7 @@ export class StalkerPortalImportComponent { this.form.value.portalUrl ); this.store.dispatch( - addPlaylist({ playlist: this.form.value as Playlist }) + PlaylistActions.addPlaylist({ playlist: this.form.value as Playlist }) ); this.addClicked.emit(); } diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index a7327012f..da9cc285e 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -27,7 +27,7 @@ import { ToolbarComponent, VjsPlayerComponent, } from 'components'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions, ChannelActions, FavoritesActions } from 'm3u-state'; import { selectActive, selectChannels, @@ -169,7 +169,7 @@ export class VideoPlayerComponent implements OnInit { } this.store.dispatch( - PlaylistActions.setChannels({ + ChannelActions.setChannels({ channels: playlist.playlist.items, }) ); @@ -180,14 +180,14 @@ export class VideoPlayerComponent implements OnInit { playlist.favorites.length > 0 ) { this.store.dispatch( - PlaylistActions.setFavorites({ + FavoritesActions.setFavorites({ channelIds: playlist.favorites, }) ); } else { // Clear favorites if playlist has none this.store.dispatch( - PlaylistActions.setFavorites({ + FavoritesActions.setFavorites({ channelIds: [], }) ); @@ -255,7 +255,7 @@ export class VideoPlayerComponent implements OnInit { // Dispatch action to change channel const nextChannel = channels[nextIndex]; this.store.dispatch( - PlaylistActions.setActiveChannel({ + ChannelActions.setActiveChannel({ channel: nextChannel, }) ); @@ -444,7 +444,7 @@ export class VideoPlayerComponent implements OnInit { .subscribe((channel) => { if (channel) { this.store.dispatch( - PlaylistActions.setActiveChannel({ channel }) + ChannelActions.setActiveChannel({ channel }) ); } }); diff --git a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts b/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts index f4f3e20c3..dbdb9e759 100644 --- a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts +++ b/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts @@ -12,7 +12,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { addPlaylist } from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { PortalStatus, PortalStatusService } from 'services'; import { Playlist } from 'shared-interfaces'; import { v4 as uuid } from 'uuid'; @@ -137,7 +137,7 @@ export class XtreamCodeImportComponent { url.port ? ':' + url.port : '' }`; this.store.dispatch( - addPlaylist({ + PlaylistActions.addPlaylist({ playlist: { ...this.form.value, serverUrl, diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index cee7b310f..3bee601e9 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -3,7 +3,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Params } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { DataService } from 'services'; import { AUTO_UPDATE_PLAYLISTS, diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index e4535f2e0..643246807 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -5,7 +5,7 @@ import { Params } from '@angular/router'; import { SwUpdate } from '@angular/service-worker'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { catchError, firstValueFrom, throwError } from 'rxjs'; import { DataService } from 'services'; import { diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 21d578c35..f12c0062e 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -35,8 +35,7 @@ import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; import { DialogService } from 'components'; -import * as PlaylistActions from 'm3u-state'; -import { selectIsEpgAvailable } from 'm3u-state'; +import { PlaylistActions, selectIsEpgAvailable } from 'm3u-state'; import { take } from 'rxjs'; import { DataService, EpgService, PlaylistsService } from 'services'; import { diff --git a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts b/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts index 69ce685be..8dc1f54b8 100644 --- a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts +++ b/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts @@ -8,7 +8,7 @@ import { import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { parsePlaylist } from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { getFilenameFromUrl } from 'm3u-utils'; import { DataService } from 'services'; import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; @@ -57,11 +57,11 @@ export class AddPlaylistDialogComponent { .result as string; this.store.dispatch( - parsePlaylist({ + PlaylistActions.parsePlaylist({ uploadType: 'FILE', playlist, title: payload.file.name, - path: (payload.file as any).path, + path: (payload.file as File & { path?: string }).path, }) ); this.closeDialog(); @@ -93,7 +93,7 @@ export class AddPlaylistDialogComponent { */ uploadAsText(playlist: string): void { this.store.dispatch( - parsePlaylist({ + PlaylistActions.parsePlaylist({ uploadType: 'TEXT', playlist, title: this.translateService.instant('HOME.IMPORTED_AS_TEXT'), diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts index 497d3b38a..caee8a551 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts @@ -1,7 +1,7 @@ import { Component, effect, inject, OnDestroy } from '@angular/core'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { Store } from '@ngrx/store'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { selectPlaylistById } from 'm3u-state'; import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component'; import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface'; diff --git a/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts b/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts index 6b227adb1..a1c6f1ea8 100644 --- a/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts +++ b/apps/web/src/app/xtream-tauri/playlist-error-view/playlist-error-view.component.ts @@ -6,7 +6,7 @@ import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DialogService, PlaylistInfoComponent } from 'components'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { selectCurrentPlaylist } from 'm3u-state'; import { DatabaseService } from 'services'; import { Playlist } from 'shared-interfaces'; diff --git a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts index 18f038dd3..dd0448586 100644 --- a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts @@ -2,7 +2,7 @@ import { Component, effect, inject } from '@angular/core'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component'; import { NavigationComponent } from '../navigation/navigation.component'; import { NavigationItem } from '../navigation/navigation.interface'; diff --git a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts index 9fcb6b741..a66b1acd4 100644 --- a/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts +++ b/apps/web/src/app/xtream/playlist-error-view/playlist-error-view.component.ts @@ -6,7 +6,7 @@ import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DialogService, PlaylistInfoComponent } from 'components'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { selectCurrentPlaylist } from 'm3u-state'; import { Playlist } from 'shared-interfaces'; diff --git a/libs/ui/components/src/lib/audio-player/audio-player.component.ts b/libs/ui/components/src/lib/audio-player/audio-player.component.ts index 73b099235..77a278219 100644 --- a/libs/ui/components/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/components/src/lib/audio-player/audio-player.component.ts @@ -12,7 +12,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatSliderModule } from '@angular/material/slider'; import { Store } from '@ngrx/store'; -import { setAdjacentChannelAsActive } from 'm3u-state'; +import { ChannelActions } from 'm3u-state'; @Component({ selector: 'app-audio-player', @@ -125,6 +125,6 @@ export class AudioPlayerComponent implements OnChanges { } switchChannel(direction: 'next' | 'previous') { - this.store.dispatch(setAdjacentChannelAsActive({ direction })); + this.store.dispatch(ChannelActions.setAdjacentChannelAsActive({ direction })); } } 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 b5ed9594f..1003de803 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 @@ -28,7 +28,7 @@ import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; -import * as PlaylistActions from 'm3u-state'; +import { ChannelActions, FavoritesActions } from 'm3u-state'; import { selectActive, selectActivePlaylistId, @@ -165,7 +165,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { * @param channel selected channel */ selectChannel(channel: Channel): void { - this.store.dispatch(PlaylistActions.setActiveChannel({ channel })); + this.store.dispatch(ChannelActions.setActiveChannel({ channel })); // Use tvg-id for EPG matching, fallback to channel name if not available const epgChannelId = channel?.tvg?.id?.trim() || channel?.name.trim(); @@ -187,7 +187,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { undefined, { duration: 2000 } ); - this.store.dispatch(PlaylistActions.updateFavorites({ channel })); + this.store.dispatch(FavoritesActions.updateFavorites({ channel })); } trackByFn(_: number, channel: Channel): string { @@ -197,7 +197,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { drop(event: CdkDragDrop, favorites: Channel[]) { moveItemInArray(favorites, event.previousIndex, event.currentIndex); this.store.dispatch( - PlaylistActions.setFavorites({ + FavoritesActions.setFavorites({ channelIds: favorites.map((item) => item.url), }) ); @@ -230,7 +230,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } ngOnDestroy() { - this.store.dispatch(PlaylistActions.setChannels({ channels: [] })); + this.store.dispatch(ChannelActions.setChannels({ channels: [] })); if (this.epgRefreshInterval) { clearInterval(this.epgRefreshInterval); diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.ts b/libs/ui/components/src/lib/epg-list/epg-list.component.ts index 434d5716f..830e2f9c3 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.ts @@ -8,11 +8,8 @@ import { MomentDatePipe } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { - resetActiveEpgProgram, selectActive, - setActiveEpgProgram, - setCurrentEpgProgram, - setEpgAvailableFlag, + EpgActions, } from 'm3u-state'; import moment from 'moment'; import { BehaviorSubject, combineLatest, map } from 'rxjs'; @@ -157,7 +154,7 @@ export class EpgListComponent implements OnInit { // Dispatch EPG availability flag this.store.dispatch( - setEpgAvailableFlag({ value: programs.length > 0 }) + EpgActions.setEpgAvailableFlag({ value: programs.length > 0 }) ); if (programs.length > 0) { @@ -165,7 +162,7 @@ export class EpgListComponent implements OnInit { } else { this.channel = {} as EpgChannel; // Clear the current EPG program when no programs available - this.store.dispatch(resetActiveEpgProgram()); + this.store.dispatch(EpgActions.resetActiveEpgProgram()); } // Trigger change detection for OnPush strategy @@ -223,7 +220,7 @@ export class EpgListComponent implements OnInit { // Always dispatch the action, even when playingNow is null/undefined // This ensures the store is updated and old EPG data is cleared this.store.dispatch( - setCurrentEpgProgram({ program: this.playingNow }) + EpgActions.setCurrentEpgProgram({ program: this.playingNow }) ); // Trigger change detection for OnPush strategy this.cdr.markForCheck(); @@ -242,10 +239,10 @@ export class EpgListComponent implements OnInit { timeshift?: boolean ): void { if (isLive) { - this.store.dispatch(resetActiveEpgProgram()); + this.store.dispatch(EpgActions.resetActiveEpgProgram()); } else { if (!timeshift) return; - this.store.dispatch(setActiveEpgProgram({ program })); + this.store.dispatch(EpgActions.setActiveEpgProgram({ program })); } this.playingNow = program; // Trigger change detection for OnPush strategy diff --git a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 495e16f1d..61a674046 100644 --- a/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -17,7 +17,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { firstValueFrom } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { Playlist, PlaylistMeta } from 'shared-interfaces'; diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts index 408c98a13..a87fdcc1a 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts @@ -9,7 +9,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { PlaylistActions } from 'm3u-state'; import { selectActiveTypeFilters, selectAllPlaylistsMeta, diff --git a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts index 00956209d..6fca98c18 100644 --- a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts @@ -6,7 +6,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Router, RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; -import * as PlaylistActions from 'm3u-state'; +import { ChannelActions } from 'm3u-state'; import { selectPlaylistTitle } from 'm3u-state'; import { Channel, SidebarView } from 'shared-interfaces'; import { RecentPlaylistsComponent } from '../../recent-playlists/recent-playlists.component'; @@ -38,7 +38,7 @@ export class SidebarComponent { goBack() { if (this.sidebarView === 'PLAYLISTS') { - this.store.dispatch(PlaylistActions.resetActiveChannel()); + this.store.dispatch(ChannelActions.resetActiveChannel()); this.router.navigate(['/']); } else { this.sidebarView = 'PLAYLISTS'; diff --git a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts index e29e75479..b0258fd48 100644 --- a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts +++ b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.ts @@ -11,7 +11,7 @@ import { selectActivePlaylistId, selectFavorites, selectIsEpgAvailable, - updateFavorites, + FavoritesActions, } from 'm3u-state'; import { Channel } from 'shared-interfaces'; @@ -46,6 +46,6 @@ export class ToolbarComponent { readonly playlistId$ = this.store.select(selectActivePlaylistId); updateFavoriteStatus(channel: Channel) { - this.store.dispatch(updateFavorites({ channel })); + this.store.dispatch(FavoritesActions.updateFavorites({ channel })); } } From 3772d5c0cfb493760990632822766668004cacaa Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Dec 2025 23:35:39 +0100 Subject: [PATCH 3/5] feat(i18n): add filter/sort labels and playlist types Update English, German, and Russian locale files to improve playlist UI text and provide explicit playlist type labels. - Rename and adjust several labels: change FILTER_BY_TYPE to a more descriptive "Playlist Types" and add a new FILTER_AND_SORT string. - Normalize capitalization for SORT_BY ("Sort By") in en.json. - Add PLAYLIST_TYPES object with keys for M3U, XTREAM, and STALKER, providing localized descriptions for each language. - Keep existing sorting options and parsing messages intact. These changes make filtering, sorting, and playlist source options clearer to users in multiple locales and prepare the UI for showing playlist type choices. --- apps/web/src/assets/i18n/de.json | 8 +++++++- apps/web/src/assets/i18n/en.json | 10 ++++++++-- apps/web/src/assets/i18n/ru.json | 8 +++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8ca0cfe62..31a32ebf1 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -150,8 +150,9 @@ "MAC_ADDRESS": "Mac-Adresse", "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com/c oder https://example.com/stalker_portal/c)" }, - "FILTER_BY_TYPE": "Nach Typ filtern", + "FILTER_BY_TYPE": "Playlist-Typen", "FILTER_BY_NAME": "Nach Namen filtern", + "FILTER_AND_SORT": "Filtern & Sortieren", "SORT_PLAYLISTS": "Playlisten sortieren", "SORT_BY": "Sortieren nach", "SORT_OPTIONS": { @@ -160,6 +161,11 @@ "NEWEST": "Hinzugefügt (Neueste zuerst)", "OLDEST": "Hinzugefügt (Älteste zuerst)" }, + "PLAYLIST_TYPES": { + "M3U": "M3U (lokal, URL, Text)", + "XTREAM": "Xtream", + "STALKER": "Stalker" + }, "IMPORTED_AS_TEXT": "Als Text importiert", "PARSING_ERROR": "Fehler: Parsen fehlgeschlagen, keine gültige Playlist:" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8747181b2..747ed03ac 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -112,15 +112,21 @@ "ADD": "Add" }, "FILTER_BY_NAME": "Filter by name", - "FILTER_BY_TYPE": "Filter by type", + "FILTER_BY_TYPE": "Playlist Types", + "FILTER_AND_SORT": "Filter & Sort", "SORT_PLAYLISTS": "Sort playlists", - "SORT_BY": "Sort by", + "SORT_BY": "Sort By", "SORT_OPTIONS": { "NAME_ASC": "Name (A-Z)", "NAME_DESC": "Name (Z-A)", "NEWEST": "Date added (Newest first)", "OLDEST": "Date added (Oldest first)" }, + "PLAYLIST_TYPES": { + "M3U": "M3U (local, url, text)", + "XTREAM": "Xtream", + "STALKER": "Stalker" + }, "IMPORTED_AS_TEXT": "Imported as text", "PARSING_ERROR": "Error: Parsing failed, not a valid playlist:" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c7d6f832c..717ddc390 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -109,8 +109,9 @@ "SERVER_URL": "URL сервера", "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com/c или https://example.com/stalker_portal/c)." }, - "FILTER_BY_TYPE": "Фильтровать по типу", + "FILTER_BY_TYPE": "Типы плейлистов", "FILTER_BY_NAME": "Фильтровать по имени", + "FILTER_AND_SORT": "Фильтр и сортировка", "SORT_PLAYLISTS": "Сортировать плейлисты", "SORT_BY": "Сортировать по", "SORT_OPTIONS": { @@ -119,6 +120,11 @@ "NEWEST": "Дате добавления (сначала новые)", "OLDEST": "Дате добавления (сначала старые)" }, + "PLAYLIST_TYPES": { + "M3U": "M3U (локально, URL, текст)", + "XTREAM": "Xtream", + "STALKER": "Stalker" + }, "IMPORTED_AS_TEXT": "Импортировано как текст", "PARSING_ERROR": "Ошибка парсинга, недействительный плейлист:" }, From c84b42284afe8f5b50b95f35c3673962eaf7ba09 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Dec 2025 23:36:13 +0100 Subject: [PATCH 4/5] style(settings): adjust settings container height to account for header Reduce the settings page container height calculation from calc(100vh - 100px) to calc(100vh - 70px) so the content fits the visible viewport when the header height is 70px. This prevents unnecessary vertical overflow and avoids extra whitespace at the bottom of the settings page. --- apps/web/src/app/settings/settings.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 0e0b4ac55..da5e2ff73 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -5,7 +5,7 @@ button { .settings-container { overflow: auto; - height: calc(100vh - 100px); + height: calc(100vh - 70px); } .row { From a874db0b41163d82e514afa90cd6bdd1399b764c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 20 Dec 2025 23:58:04 +0100 Subject: [PATCH 5/5] feat(ui): add filter and sort menu to header and clean styles Add a new mat-menu component for filtering and sorting playlists in the header area. The filter section iterates playlistTypes and allows toggling type selection with visual check state. The sort section adds options for name (asc/desc) and date added (newest/oldest) with active-state visuals and click handlers that call setSortOptions. A close button is also included. Remove unused/duplicate CSS rules from header styles: drop .filter- container and obsolete .dropdown-menu-header rules to avoid styling conflicts and reduce dead code. Adjust surrounding selector formatting. --- .../filter-sort-menu.component.html | 123 ++++++++++++++++++ .../filter-sort-menu.component.scss | 90 +++++++++++++ .../filter-sort-menu.component.ts | 119 +++++++++++++++++ .../components/header/header.component.html | 100 ++------------ .../components/header/header.component.scss | 37 +----- .../components/header/header.component.ts | 78 ++--------- 6 files changed, 357 insertions(+), 190 deletions(-) create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss create mode 100644 apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html new file mode 100644 index 000000000..0037a9e76 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html @@ -0,0 +1,123 @@ + + +
+ + @for (type of playlistTypes; track type.id) { + + } +
+ + +
+ + + + + +
+ + + +
diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss new file mode 100644 index 000000000..119f9028b --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.scss @@ -0,0 +1,90 @@ +// Combined Filter & Sort menu styles +.filter-section, +.sort-section { + padding: 4px 0; +} + +.menu-section-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + pointer-events: none; + + .section-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(139, 92, 246, 0.6); + } +} + +// Menu item layout with right-aligned checkmark +::ng-deep .filter-sort-menu { + .mat-mdc-menu-item { + display: flex !important; + align-items: center !important; + gap: 12px !important; + min-height: 40px !important; + padding: 0 16px !important; + + .mat-icon:first-child { + margin-right: 0 !important; + flex-shrink: 0; + } + + .menu-item-text { + flex: 1; + text-align: left; + } + + .check-icon { + margin-left: auto !important; + margin-right: 0 !important; + flex-shrink: 0; + opacity: 0; + transform: scale(0.8); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + &.check-visible { + opacity: 1; + transform: scale(1); + } + } + } +} + +.active-option { + background-color: rgba(139, 92, 246, 0.12) !important; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 60%; + background: rgba(139, 92, 246, 0.8); + border-radius: 0 2px 2px 0; + } +} + +.check-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + color: rgba(139, 92, 246, 1) !important; +} + +// Menu footer +.menu-footer { + padding: 4px 8px 4px; + display: flex; + justify-content: center; +} diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts new file mode 100644 index 000000000..6bf906626 --- /dev/null +++ b/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.ts @@ -0,0 +1,119 @@ +import { + Component, + effect, + inject, + input, + output, + viewChild, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { FilterActions, selectActiveTypeFilters } from 'm3u-state'; +import { SortBy, SortOrder, SortService } from 'services'; + +@Component({ + selector: 'app-filter-sort-menu', + templateUrl: './filter-sort-menu.component.html', + styleUrls: ['./filter-sort-menu.component.scss'], + imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule], +}) +export class FilterSortMenuComponent { + private store = inject(Store); + private sortService = inject(SortService); + + readonly menu = viewChild.required('filterSortMenu'); + readonly menuTrigger = input(); + readonly filterChanged = output(); + + playlistTypes = [ + { + id: 'm3u', + icon: 'playlist_play', + translationKey: 'HOME.PLAYLIST_TYPES.M3U', + checked: true, + }, + { + id: 'xtream', + icon: 'cloud', + translationKey: 'HOME.PLAYLIST_TYPES.XTREAM', + checked: true, + }, + { + id: 'stalker', + icon: 'router', + translationKey: 'HOME.PLAYLIST_TYPES.STALKER', + checked: true, + }, + ]; + + private readonly selectedTypeFilters = this.store.selectSignal( + selectActiveTypeFilters + ); + + readonly SortBy = SortBy; + readonly SortOrder = SortOrder; + private readonly currentSortOptions = toSignal( + this.sortService.getSortOptions(), + { + requireSync: true, + } + ); + + constructor() { + effect(() => { + if (this.selectedTypeFilters) { + this.playlistTypes = this.playlistTypes.map((type) => { + type.checked = this.selectedTypeFilters().includes(type.id); + return type; + }); + } + }); + } + + togglePlaylistType(type: { + id: string; + icon: string; + translationKey: string; + checked: boolean; + }) { + const currentlySelectedCount = this.playlistTypes.filter( + (f) => f.checked + ).length; + + // Prevent deselecting if it's the last selected option + if (type.checked && currentlySelectedCount === 1) { + return; + } + + type.checked = !type.checked; + this.store.dispatch( + FilterActions.setSelectedFilters({ + selectedFilters: this.playlistTypes + .filter((f) => f.checked) + .map((f) => f.id), + }) + ); + this.filterChanged.emit(); + } + + setSortOptions(by: SortBy, order: SortOrder): void { + this.sortService.setSortOptions({ by, order }); + } + + isSortActive(by: SortBy, order: SortOrder): boolean { + const options = this.currentSortOptions(); + return options?.by === by && options?.order === order; + } + + get activeFiltersCount(): number { + return this.playlistTypes.filter((type) => !type.checked).length; + } + + closeMenu(): void { + this.menuTrigger()?.closeMenu(); + } +} diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html index dc81325e0..f45a226e3 100644 --- a/apps/web/src/app/shared/components/header/header.component.html +++ b/apps/web/src/app/shared/components/header/header.component.html @@ -34,23 +34,23 @@ - + - + @if (isDesktop) { - - - - - - -
- - @for (type of playlistTypes; track $index) { -

- {{ type.title }} -

- } -
-
} diff --git a/apps/web/src/app/shared/components/header/header.component.scss b/apps/web/src/app/shared/components/header/header.component.scss index a74b0d8bb..1f43821b3 100644 --- a/apps/web/src/app/shared/components/header/header.component.scss +++ b/apps/web/src/app/shared/components/header/header.component.scss @@ -344,10 +344,6 @@ app-region: no-drag; // Allow button interactions } -.filter-container { - padding: 0 10px; -} - .add-playlist-btn { background-color: rgba(139, 92, 246, 0.12) !important; color: rgba(139, 92, 246, 1) !important; @@ -365,35 +361,10 @@ } } -.dropdown-menu-header { - padding: 0 16px; - margin: 8px 0; - font-size: 11px; - font-weight: 600; - color: rgba(255, 255, 255, 0.5); - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.active-sort { - background-color: rgba(139, 92, 246, 0.12) !important; - position: relative; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 60%; - background: rgba(139, 92, 246, 0.8); - border-radius: 0 2px 2px 0; - } -} - -.check-icon { - margin-left: 8px; +// Badge styling for filter count +::ng-deep .mat-badge-accent .mat-badge-content { + background: rgba(139, 92, 246, 1); + color: white; } // Refined Material button and icon overrides diff --git a/apps/web/src/app/shared/components/header/header.component.ts b/apps/web/src/app/shared/components/header/header.component.ts index b3446e34f..6657cb1cb 100644 --- a/apps/web/src/app/shared/components/header/header.component.ts +++ b/apps/web/src/app/shared/components/header/header.component.ts @@ -2,30 +2,28 @@ import { Component, HostBinding, OnInit, - effect, inject, input, output, viewChild, } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { DataService, SortBy, SortOrder, SortService } from 'services'; +import { DataService } from 'services'; //import { shell } from 'electron'; import { AddPlaylistMenuComponent, PlaylistType } from 'components'; -import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state'; import { HomeComponent } from '../../../home/home.component'; import { AboutDialogComponent } from '../about-dialog/about-dialog.component'; import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component'; +import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component'; @Component({ selector: 'app-header', @@ -33,9 +31,10 @@ import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog. styleUrls: ['./header.component.scss'], imports: [ AddPlaylistMenuComponent, + FilterSortMenuComponent, FormsModule, + MatBadgeModule, MatButtonModule, - MatCheckboxModule, MatDividerModule, MatIconModule, MatMenuModule, @@ -53,12 +52,13 @@ export class HeaderComponent implements OnInit { private dialog = inject(MatDialog); private dataService = inject(DataService); private router = inject(Router); - private store = inject(Store); - private sortService = inject(SortService); readonly addPlaylistMenuComponent = viewChild.required( AddPlaylistMenuComponent ); + readonly filterSortMenuComponent = viewChild.required( + FilterSortMenuComponent + ); readonly isDesktop = !!window.electron; readonly title = input.required(); readonly subtitle = input.required(); @@ -66,47 +66,6 @@ export class HeaderComponent implements OnInit { isHome = true; - playlistTypes = [ - { - title: 'M3U (local, url, text)', - id: 'm3u', - checked: true, - }, - { - title: 'Xtream', - id: 'xtream', - checked: true, - }, - { - title: 'Stalker', - id: 'stalker', - checked: true, - }, - ]; - - private readonly selectedTypeFilters = this.store.selectSignal( - selectActiveTypeFilters - ); - - readonly SortBy = SortBy; - readonly SortOrder = SortOrder; - private currentSortOptions: { by: SortBy; order: SortOrder }; - - constructor() { - effect(() => { - if (this.selectedTypeFilters) { - this.playlistTypes = this.playlistTypes.map((type) => { - type.checked = this.selectedTypeFilters().includes(type.id); - return type; - }); - } - }); - - this.sortService.getSortOptions().subscribe((options) => { - this.currentSortOptions = options; - }); - } - ngOnInit() { this.isHome = this.activatedRoute.snapshot.component.name === HomeComponent.name; @@ -154,27 +113,6 @@ export class HeaderComponent implements OnInit { ); } - onPlaylistFilterChange() { - this.store.dispatch( - setSelectedFilters({ - selectedFilters: this.playlistTypes - .filter((f) => f.checked) - .map((f) => f.id), - }) - ); - } - - setSortOptions(by: SortBy, order: SortOrder): void { - this.sortService.setSortOptions({ by, order }); - } - - isSortActive(by: SortBy, order: SortOrder): boolean { - return ( - this.currentSortOptions?.by === by && - this.currentSortOptions?.order === order - ); - } - onSearchQueryUpdate(query: string): void { this.searchQuery.emit(query); }