From 0e98af2e1a82610d3a9fedf0064eedf6323ad398 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 11 Apr 2026 22:59:58 +0200 Subject: [PATCH] feat(m3u): implement group management dialog and hidden group titles functionality for m3u module --- .../lib/reducers/playlist.reducers.spec.ts | 39 ++++- .../src/lib/reducers/playlist.reducers.ts | 3 + .../src/lib/playlists.service.spec.ts | 82 +++++++++- libs/services/src/lib/playlists.service.ts | 6 + .../interfaces/src/lib/playlist-meta.type.ts | 1 + .../interfaces/src/lib/playlist.interface.ts | 2 + .../channel-list-container.component.html | 4 + .../channel-list-container.component.spec.ts | 28 +++- .../channel-list-container.component.ts | 28 ++++ .../group-management-dialog.component.html | 85 ++++++++++ .../group-management-dialog.component.scss | 150 ++++++++++++++++++ .../group-management-dialog.component.ts | 120 ++++++++++++++ .../groups-view/groups-view.component.html | 115 ++++++++++---- .../groups-view/groups-view.component.scss | 25 +++ .../groups-view/groups-view.component.spec.ts | 89 ++++++++++- .../groups-view/groups-view.component.ts | 68 ++++++-- 16 files changed, 800 insertions(+), 45 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts 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 57f68279d..1c3446d2b 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts @@ -8,7 +8,7 @@ import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces'; const reducer = createReducer(initialState, ...playlistReducers); describe('playlistReducers', () => { - it('persists updateDate when playlist meta is updated', () => { + it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => { const existingPlaylist: PlaylistMeta = { _id: 'playlist-1', title: 'Xtream Playlist', @@ -32,6 +32,7 @@ describe('playlistReducers', () => { PlaylistActions.updatePlaylistMeta({ playlist: { ...existingPlaylist, + hiddenGroupTitles: ['Movies', 'News'], updateDate: 1712145600000, }, }) @@ -40,6 +41,9 @@ describe('playlistReducers', () => { expect(nextState.playlists.entities['playlist-1']?.updateDate).toBe( 1712145600000 ); + expect( + nextState.playlists.entities['playlist-1']?.hiddenGroupTitles + ).toEqual(['Movies', 'News']); }); it('updates the active playlist channel cache and clears loading on playlist refresh', () => { @@ -93,4 +97,37 @@ describe('playlistReducers', () => { expect(nextState.channels).toEqual([refreshedChannel]); expect(nextState.channelsLoading).toBe(false); }); + + it('keeps hiddenGroupTitles on playlist refresh when the refreshed payload omits them', () => { + const existingPlaylist: PlaylistMeta = { + _id: 'playlist-1', + count: 1, + hiddenGroupTitles: ['Radio-de'], + importDate: '2026-03-28T00:00:00.000Z', + title: 'Playlist One', + } as PlaylistMeta; + const state = { + ...initialState, + playlists: playlistsAdapter.addOne(existingPlaylist, { + ...initialState.playlists, + selectedId: 'playlist-1', + }), + }; + + const nextState = reducer( + state, + PlaylistActions.updatePlaylist({ + playlist: { + playlist: { + items: [], + }, + } as Playlist, + playlistId: 'playlist-1', + }) + ); + + expect( + nextState.playlists.entities['playlist-1']?.hiddenGroupTitles + ).toEqual(['Radio-de']); + }); }); diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts index d08e5c1f0..3955733df 100644 --- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts +++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts @@ -127,6 +127,9 @@ export const playlistReducers = [ ...(p.recentlyViewed != null ? { recentlyViewed: p.recentlyViewed } : {}), + ...(p.hiddenGroupTitles != null + ? { hiddenGroupTitles: p.hiddenGroupTitles } + : {}), ...(p.updateDate !== undefined ? { updateDate: p.updateDate } : {}), diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index e086f39e9..f8728429f 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -1,5 +1,5 @@ import { firstValueFrom, of } from 'rxjs'; -import { DbStores, Playlist } from 'shared-interfaces'; +import { DbStores, Playlist, PlaylistMeta } from 'shared-interfaces'; import { PlaylistsService } from './playlists.service'; const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1'; @@ -168,4 +168,84 @@ describe('PlaylistsService', () => { localStorage.getItem(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG) ).toBe('1'); }); + + it('persists hiddenGroupTitles in playlist meta updates', async () => { + const existingPlaylist: Playlist = { + _id: 'playlist-1', + title: 'Playlist One', + count: 2, + importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), + autoRefresh: false, + hiddenGroupTitles: ['News'], + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + (window as Window & { electron?: unknown }).electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylistMeta({ + _id: 'playlist-1', + hiddenGroupTitles: ['Movies', 'Sports'], + } as PlaylistMeta) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + _id: 'playlist-1', + hiddenGroupTitles: ['Movies', 'Sports'], + }) + ); + }); + + it('keeps hiddenGroupTitles when refreshing a playlist payload', async () => { + const existingPlaylist: Playlist = { + _id: 'playlist-2', + title: 'Playlist Two', + count: 1, + importDate: new Date('2026-04-11T00:00:00.000Z').toISOString(), + lastUsage: new Date('2026-04-11T00:00:00.000Z').toISOString(), + autoRefresh: false, + hiddenGroupTitles: ['Radio-de'], + playlist: { + items: [{ id: 'channel-1' }], + }, + } as Playlist; + const dbService = { + getAll: jest.fn(() => of([])), + getByID: jest.fn(() => of(existingPlaylist)), + update: jest.fn((_storeName: string, playlist: Playlist) => + of(playlist) + ), + }; + (window as Window & { electron?: unknown }).electron = undefined; + + const service = createService(dbService); + + await firstValueFrom( + service.updatePlaylist('playlist-2', { + _id: 'playlist-2', + playlist: { + items: [], + }, + } as Playlist) + ); + + expect(dbService.update).toHaveBeenCalledWith( + DbStores.Playlists, + expect.objectContaining({ + _id: 'playlist-2', + hiddenGroupTitles: ['Radio-de'], + count: 0, + }) + ); + }); }); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 0ce5b5e8c..979d74042 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -490,6 +490,12 @@ export class PlaylistsService { ...(updatedPlaylist.recentlyViewed != null ? { recentlyViewed: updatedPlaylist.recentlyViewed } : {}), + ...(updatedPlaylist.hiddenGroupTitles != null + ? { + hiddenGroupTitles: + updatedPlaylist.hiddenGroupTitles, + } + : {}), ...(updatedPlaylist.updateDate !== undefined ? { updateDate: updatedPlaylist.updateDate } : {}), diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index 7128a5012..5a0ea96d9 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -21,6 +21,7 @@ export type PlaylistMeta = Pick< | 'username' | 'password' | 'macAddress' + | 'hiddenGroupTitles' | 'portalUrl' | 'recentlyViewed' | 'isFullStalkerPortal' diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index d20486123..9c95962bb 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -65,4 +65,6 @@ export interface Playlist { tariffPlanName?: string; status?: number; }; + /** Hidden M3U group titles for the groups view */ + hiddenGroupTitles?: string[]; } 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 cc75771c4..e7046172d 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 @@ -42,9 +42,13 @@ [shouldShowEpg]="shouldShowEpg()" [activeChannelUrl]="activeChannelUrl()" [favoriteIds]="favoriteIds()" + [hiddenGroupTitles]="hiddenGroupTitles()" [sidebarWidth]="sidebarWidth()" (channelSelected)="onChannelSelected($event)" (favoriteToggled)="onFavoriteToggled($event)" + (hiddenGroupTitlesChanged)=" + onHiddenGroupTitlesChanged($event) + " (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" /> 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 index 4d1266735..087a5fa44 100644 --- 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 @@ -6,17 +6,26 @@ 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 { ChannelActions, PlaylistActions } from 'm3u-state'; import { PlaylistsService } from 'services'; +import { PlaylistMeta } from 'shared-interfaces'; import { ChannelListContainerComponent } from './channel-list-container.component'; describe('ChannelListContainerComponent', () => { let fixture: ComponentFixture; let dispatch: jest.Mock; + let activePlaylistSignal: ReturnType>; beforeEach(async () => { const routerEvents$ = new Subject(); dispatch = jest.fn(); + activePlaylistSignal = signal({ + _id: 'playlist-1', + title: 'Playlist One', + count: 0, + importDate: '2026-04-11T00:00:00.000Z', + hiddenGroupTitles: ['News'], + } as PlaylistMeta); const route = { snapshot: { @@ -83,6 +92,7 @@ describe('ChannelListContainerComponent', () => { { provide: PlaylistContextFacade, useValue: { + activePlaylist: activePlaylistSignal, resolvedPlaylistId: signal(null), }, }, @@ -109,4 +119,20 @@ describe('ChannelListContainerComponent', () => { ChannelActions.resetActiveChannel() ); }); + + it('dispatches playlist meta updates when hidden group titles change', () => { + fixture.componentInstance.onHiddenGroupTitlesChanged([ + 'Movies', + 'Sports', + ]); + + expect(dispatch).toHaveBeenCalledWith( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: 'playlist-1', + hiddenGroupTitles: ['Movies', 'Sports'], + } as PlaylistMeta, + }) + ); + }); }); 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 1366a8497..07abe3242 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 @@ -172,6 +172,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Route-aware playlist ID for recent-item mutations */ private readonly resolvedPlaylistId = this.playlistContext.resolvedPlaylistId; + private readonly activePlaylist = this.playlistContext.activePlaylist; + + readonly hiddenGroupTitles = computed(() => { + const playlist = this.activePlaylist(); + + if (!playlist || playlist.serverUrl || playlist.macAddress) { + return []; + } + + return playlist.hiddenGroupTitles ?? []; + }); /** Displayed channels - filters out unfavorited channels in global favorites view */ readonly displayedChannels = computed(() => { @@ -332,6 +343,23 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); } + onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void { + const playlist = this.activePlaylist(); + + if (!playlist || playlist.serverUrl || playlist.macAddress) { + return; + } + + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlist._id, + hiddenGroupTitles, + } as PlaylistMeta, + }) + ); + } + /** * Handles favorites reorder from drag-drop */ diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.html new file mode 100644 index 000000000..3222dddf7 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.html @@ -0,0 +1,85 @@ +

{{ 'CHANNELS.GROUP_MANAGEMENT.TITLE' | translate }}

+ + +
+
+ {{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}: + {{ selectedCount() }} / {{ totalCount() }} +
+
+ + +
+
+ + + +
+ @for (group of filteredGroups(); track group.key) { +
+ + + + @if (group.key) { + {{ group.key | titlecase }} + } @else { + {{ 'CHANNELS.UNGROUPED' | translate }} + } + + ({{ group.count }}) +
+ } @empty { +
+ {{ 'CHANNELS.GROUP_MANAGEMENT.NO_RESULTS' | translate }} +
+ } +
+
+ + + + + diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss new file mode 100644 index 000000000..e4bfd5b2f --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss @@ -0,0 +1,150 @@ +.mat-mdc-dialog-content { + display: flex; + gap: 10px; + flex-direction: column; + overflow: hidden !important; + min-width: 480px; + max-width: 600px; + max-height: 85vh !important; +} + +.header-actions { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.selection-info { + font-size: 0.8125rem; + color: var(--mat-sys-on-surface-variant); + + strong { + color: var(--mat-sys-primary); + } +} + +.bulk-actions { + display: flex; + gap: 8px; + + button { + font-size: 12px; + } +} + +.search-inline { + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + min-height: 36px; + border-radius: 10px; + border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant)); + background: var(--app-search-bg, var(--mat-sys-surface-container)); + cursor: text; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + + &:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--mat-sys-primary) 12%, transparent); + } +} + +.search-inline__icon { + width: 18px; + height: 18px; + font-size: 18px; + color: var(--mat-sys-on-surface-variant); + flex-shrink: 0; +} + +.search-inline input { + flex: 1; + width: 100%; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: var(--mat-sys-on-surface); + font: inherit; + font-size: 0.84rem; + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + opacity: 0.6; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.search-inline__clear { + width: 24px; + height: 24px; + min-width: 24px; + padding: 0; + line-height: 24px; + flex-shrink: 0; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + } +} + +.groups-list { + flex: 1; + min-height: 200px; + overflow-y: auto; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; +} + +.group-item { + display: flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 0 10px; + cursor: pointer; + transition: background-color 0.12s ease; + + &:hover { + background-color: var(--mat-sys-surface-container); + } + + &:not(:last-child) { + border-bottom: 1px solid var(--mat-sys-surface-container); + } + + mat-checkbox { + --mdc-checkbox-state-layer-size: 28px; + } +} + +.group-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8125rem; +} + +.item-count { + font-size: 0.8rem; + color: var(--mat-sys-on-surface-variant); +} + +.empty-message { + padding: 24px; + text-align: center; + color: var(--mat-sys-on-surface-variant); + font-size: 0.8125rem; +} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts new file mode 100644 index 000000000..65d577f99 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.ts @@ -0,0 +1,120 @@ +import { TitleCasePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; + +export interface GroupManagementDialogGroup { + readonly count: number; + readonly key: string; +} + +export interface GroupManagementDialogData { + readonly groups: GroupManagementDialogGroup[]; + readonly hiddenGroupTitles: string[]; +} + +interface GroupWithSelection extends GroupManagementDialogGroup { + readonly selected: boolean; +} + +@Component({ + selector: 'app-group-management-dialog', + imports: [ + MatDialogModule, + MatButtonModule, + MatCheckboxModule, + MatIconModule, + TitleCasePipe, + TranslatePipe, + ], + templateUrl: './group-management-dialog.component.html', + styleUrl: './group-management-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GroupManagementDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data = inject(MAT_DIALOG_DATA); + + readonly searchTerm = signal(''); + readonly groups = signal( + this.data.groups.map((group) => ({ + ...group, + selected: !this.data.hiddenGroupTitles.includes(group.key), + })) + ); + + readonly filteredGroups = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + + if (!term) { + return this.groups(); + } + + return this.groups().filter((group) => + group.key.toLowerCase().includes(term) + ); + }); + + readonly selectedCount = computed( + () => this.groups().filter((group) => group.selected).length + ); + readonly totalCount = computed(() => this.groups().length); + readonly allSelected = computed( + () => + this.groups().length > 0 && + this.groups().every((group) => group.selected) + ); + + clearSearch(): void { + this.searchTerm.set(''); + } + + toggleGroup(group: GroupWithSelection): void { + this.groups.update((groups) => + groups.map((current) => + current.key === group.key + ? { ...current, selected: !current.selected } + : current + ) + ); + } + + selectAll(): void { + this.groups.update((groups) => + groups.map((group) => ({ ...group, selected: true })) + ); + } + + deselectAll(): void { + this.groups.update((groups) => + groups.map((group) => ({ ...group, selected: false })) + ); + } + + save(): void { + const hiddenGroupTitles = this.groups() + .filter((group) => !group.selected) + .map((group) => group.key); + + this.dialogRef.close(hiddenGroupTitles); + } + + cancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index e56ea1da8..ada4c952f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -1,4 +1,4 @@ -@if (filteredGroups().length === 0) { +@if (!hasAnyGroups()) {
{{ searchTerm() ? 'search_off' : 'folder_off' @@ -35,37 +35,64 @@ >
-

- {{ 'CHANNELS.GROUPS' | translate }} -

+
+

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

+ +
@@ -163,6 +190,36 @@ (favoriteToggled)="onFavoriteToggle(channel, $event)" /> + } @else { + + +
+ + {{ searchTerm() ? 'search_off' : 'visibility_off' }} + +
+ {{ + (searchTerm() + ? 'CHANNELS.NO_SEARCH_RESULTS' + : 'CHANNELS.NO_VISIBLE_GROUPS' + ) | translate + }} +
+
+ {{ + (searchTerm() + ? 'CHANNELS.TRY_DIFFERENT_SEARCH' + : 'CHANNELS.MANAGE_GROUPS_HINT' + ) | translate + }} +
+
}
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index c9f3bb2cf..e75154c7f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -46,6 +46,13 @@ flex-shrink: 0; } +.groups-nav-header__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + .groups-nav-title { margin: 0; font-size: 0.94rem; @@ -82,6 +89,13 @@ padding-inline: 0 2px; } +.groups-nav-empty { + padding: 16px 12px; + color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); + font-size: 0.82rem; + line-height: 1.4; +} + .group-nav-item { background: transparent; } @@ -126,6 +140,17 @@ } } +.groups-content-empty-state { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; +} + .groups-view-empty-state { display: flex; flex: 1; diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index 6013e7c63..a327daa9b 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -2,8 +2,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { Channel } from 'shared-interfaces'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; +import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component'; import { GroupsViewComponent } from './groups-view.component'; const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode'; @@ -129,6 +131,7 @@ describe('GroupsViewComponent', () => { activeChannelUrl: string | undefined; favoriteIds: Set; groupedChannels: Record; + hiddenGroupTitles: string[]; progressTick: number; searchTerm: string; sidebarWidth: number | null; @@ -145,6 +148,7 @@ describe('GroupsViewComponent', () => { activeChannelUrl: string | undefined; favoriteIds: Set; groupedChannels: Record; + hiddenGroupTitles: string[]; progressTick: number; searchTerm: string; sidebarWidth: number | null; @@ -173,6 +177,10 @@ describe('GroupsViewComponent', () => { 'favoriteIds', overrides.favoriteIds ?? new Set() ); + fixture.componentRef.setInput( + 'hiddenGroupTitles', + overrides.hiddenGroupTitles ?? [] + ); fixture.componentRef.setInput( 'sidebarWidth', overrides.sidebarWidth ?? 460 @@ -347,6 +355,53 @@ describe('GroupsViewComponent', () => { ).toEqual(['World Update']); }); + it('filters hidden groups from the rail and selected pane', () => { + setInputs({ + activeChannelUrl: worldUpdate.url, + hiddenGroupTitles: ['News', 'Sports'], + }); + + expect(component.filteredGroups().map((group) => group.key)).toEqual([ + 'Movies', + 'Series', + ]); + expect(component.selectedGroupKey()).toBe('Movies'); + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['Movie Classic']); + }); + + it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => { + const hiddenGroupTitlesChanged = jest.fn(); + component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged); + dialog.open.mockReturnValue({ + afterClosed: () => of(['News', 'Sports']), + }); + + component.openGroupManagement(); + + expect(dialog.open).toHaveBeenCalledWith( + GroupManagementDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + hiddenGroupTitles: [], + groups: expect.arrayContaining([ + { key: 'Movies', count: 1 }, + { key: 'News', count: 2 }, + { key: 'Series', count: 1 }, + { key: 'Sports', count: 2 }, + ]), + }), + maxHeight: '90vh', + width: '500px', + }) + ); + expect(hiddenGroupTitlesChanged).toHaveBeenCalledWith([ + 'News', + 'Sports', + ]); + }); + it('emits channel and favorite events from the selected group pane', () => { const channelSelected = jest.fn(); const favoriteToggled = jest.fn(); @@ -432,15 +487,23 @@ describe('GroupsViewComponent', () => { expect(committed).toHaveBeenCalledWith(512); }); - it('renders the no-results state for searches without matches', () => { + it('keeps the layout visible for searches without matches', () => { setInputs({ searchTerm: 'zzz' }); - const emptyState = fixture.nativeElement.querySelector( - '.groups-view-empty-state' + const layout = fixture.nativeElement.querySelector( + '.groups-view-layout' ) as HTMLElement | null; + const emptyState = fixture.nativeElement.querySelector( + '.groups-content-empty-state' + ) as HTMLElement | null; + const manageButton = fixture.nativeElement.querySelector( + '.groups-nav-header button' + ) as HTMLButtonElement | null; + expect(layout).not.toBeNull(); expect(emptyState).not.toBeNull(); expect(emptyState?.textContent).toContain('CHANNELS.NO_SEARCH_RESULTS'); + expect(manageButton).not.toBeNull(); }); it('renders the empty-category state when no grouped channels exist', () => { @@ -455,4 +518,24 @@ describe('GroupsViewComponent', () => { 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' ); }); + + it('keeps the manage action visible when all groups are hidden', () => { + setInputs({ + hiddenGroupTitles: ['Movies', 'News', 'Series', 'Sports'], + }); + + const layout = fixture.nativeElement.querySelector( + '.groups-view-layout' + ) as HTMLElement | null; + const emptyState = fixture.nativeElement.querySelector( + '.groups-content-empty-state' + ) as HTMLElement | null; + const manageButton = fixture.nativeElement.querySelector( + '.groups-nav-header button' + ) as HTMLButtonElement | null; + + expect(layout).not.toBeNull(); + expect(emptyState?.textContent).toContain('CHANNELS.NO_VISIBLE_GROUPS'); + expect(manageButton).not.toBeNull(); + }); }); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 3adcffb9b..28cb81cdd 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -31,9 +31,19 @@ import { EnrichedChannel } from '../all-channels-view/all-channels-view.componen import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; import { ResizableDirective } from '../../resizable/resizable.directive'; +import { + GroupManagementDialogComponent, + GroupManagementDialogGroup, +} from './group-management-dialog/group-management-dialog.component'; const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode'; +interface GroupView { + readonly channels: Channel[]; + readonly count: number; + readonly key: string; +} + interface FilteredGroupView { readonly channels: Channel[]; readonly count: number; @@ -83,6 +93,7 @@ export class GroupsViewComponent { /** Set of favorite channel URLs */ readonly favoriteIds = input>(new Set()); + readonly hiddenGroupTitles = input([]); /** Current outer sidebar width */ readonly sidebarWidth = input(null); @@ -101,6 +112,7 @@ export class GroupsViewComponent { /** Emits when the groups rail resize ends */ readonly sidebarWidthRequestEnded = output(); + readonly hiddenGroupTitlesChanged = output(); readonly selectedGroupKey = signal(null); readonly groupChannelSortMode = signal( @@ -202,26 +214,35 @@ export class GroupsViewComponent { }); } - readonly sortedGroups = computed(() => { + readonly allGroups = computed(() => { const grouped = this.groupedChannels(); const groups = Object.entries(grouped).map(([key, channels]) => ({ + channels, + count: channels.length, key, - value: channels, })); return groups.sort(this.groupsComparator); }); + readonly visibleGroups = computed(() => { + const hiddenGroupTitles = new Set(this.hiddenGroupTitles()); + + return this.allGroups().filter( + (group) => + !hiddenGroupTitles.has(group.key) && group.channels.length > 0 + ); + }); + readonly filteredGroups = computed(() => { const term = this.searchTerm().trim().toLowerCase(); - const groups = this.sortedGroups(); + const groups = this.visibleGroups(); if (!term) { return groups - .filter((group) => group.value.length > 0) .map((group) => ({ - channels: group.value, - count: group.value.length, + channels: group.channels, + count: group.count, key: group.key, titleMatches: false, })); @@ -230,8 +251,8 @@ export class GroupsViewComponent { return groups.reduce((acc, group) => { const titleMatches = group.key.toLowerCase().includes(term); const channels = titleMatches - ? group.value - : group.value.filter((channel) => + ? group.channels + : group.channels.filter((channel) => `${channel.name ?? ''}`.toLowerCase().includes(term) ); @@ -249,6 +270,8 @@ export class GroupsViewComponent { }, []); }); + readonly hasAnyGroups = computed(() => this.allGroups().length > 0); + readonly selectedGroup = computed(() => { const selectedGroupKey = this.selectedGroupKey(); return ( @@ -303,6 +326,31 @@ export class GroupsViewComponent { this.selectedGroupKey.set(groupKey); } + openGroupManagement(): void { + const groups = this.allGroups().map( + ({ key, count }) => ({ + key, + count, + }) + ); + const dialogRef = this.dialog.open(GroupManagementDialogComponent, { + data: { + groups, + hiddenGroupTitles: this.hiddenGroupTitles(), + }, + width: '500px', + maxHeight: '90vh', + }); + + dialogRef.afterClosed().subscribe((hiddenGroupTitles) => { + if (hiddenGroupTitles === undefined) { + return; + } + + this.hiddenGroupTitlesChanged.emit(hiddenGroupTitles); + }); + } + setGroupChannelSortMode(mode: PortalChannelSortMode): void { this.groupChannelSortMode.set(mode); persistPortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY, mode); @@ -372,8 +420,8 @@ export class GroupsViewComponent { * Comparator for sorting groups - numeric groups first, then alphabetical */ readonly groupsComparator = ( - a: KeyValue | { key: string; value: Channel[] }, - b: KeyValue | { key: string; value: Channel[] } + a: KeyValue | { key: string }, + b: KeyValue | { key: string } ): number => { const numA = parseInt(a.key.replace(/\D/g, ''), 10); const numB = parseInt(b.key.replace(/\D/g, ''), 10);