feat(m3u): implement group management dialog and hidden group titles functionality for m3u module

This commit is contained in:
4gray committed 2026-04-11 22:59:58 +02:00
1 parent 7401ba788d
commit 0e98af2e1a
16 files changed
+800 -45

No files matched your search

@@ -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']);
});
});
@@ -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 }
: {}),
@@ -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,
})
);
});
});
@@ -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 }
: {}),
@@ -21,6 +21,7 @@ export type PlaylistMeta = Pick<
| 'username'
| 'password'
| 'macAddress'
| 'hiddenGroupTitles'
| 'portalUrl'
| 'recentlyViewed'
| 'isFullStalkerPortal'
@@ -65,4 +65,6 @@ export interface Playlist {
tariffPlanName?: string;
status?: number;
};
/** Hidden M3U group titles for the groups view */
hiddenGroupTitles?: string[];
}
@@ -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)"
/>
@@ -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<ChannelListContainerComponent>;
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
beforeEach(async () => {
const routerEvents$ = new Subject<NavigationEnd>();
dispatch = jest.fn();
activePlaylistSignal = signal<PlaylistMeta | null>({
_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,
})
);
});
});
@@ -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
*/
@@ -0,0 +1,85 @@
<h2 mat-dialog-title>{{ 'CHANNELS.GROUP_MANAGEMENT.TITLE' | translate }}</h2>
<mat-dialog-content>
<div class="header-actions">
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="allSelected()"
>
<mat-icon>check_box</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECT_ALL' | translate }}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="selectedCount() === 0"
>
<mat-icon>check_box_outline_blank</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.DESELECT_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'CHANNELS.GROUP_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="groups-list">
@for (group of filteredGroups(); track group.key) {
<div class="group-item" (click)="toggleGroup(group)">
<mat-checkbox
[checked]="group.selected"
(click)="$event.stopPropagation()"
(change)="toggleGroup(group)"
>
</mat-checkbox>
<span class="group-name">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="item-count">({{ group.count }})</span>
</div>
} @empty {
<div class="empty-message">
{{ 'CHANNELS.GROUP_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">
{{ 'CLOSE' | translate }}
</button>
<button mat-flat-button color="primary" (click)="save()">
{{ 'CHANNELS.GROUP_MANAGEMENT.SAVE' | translate }}
</button>
</mat-dialog-actions>
@@ -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;
}
@@ -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<GroupManagementDialogComponent, string[] | undefined>
);
readonly data = inject<GroupManagementDialogData>(MAT_DIALOG_DATA);
readonly searchTerm = signal('');
readonly groups = signal<GroupWithSelection[]>(
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);
}
}
@@ -1,4 +1,4 @@
@if (filteredGroups().length === 0) {
@if (!hasAnyGroups()) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
@@ -35,37 +35,64 @@
>
<div class="groups-nav-shell">
<header class="groups-nav-header">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<div class="groups-nav-header__content">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<button
mat-icon-button
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
</div>
</header>
<div class="groups-nav-list nav-list">
@for (
group of filteredGroups();
track trackByGroupKey($index, group)
) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="selectedGroupKey() === group.key"
[attr.aria-current]="
selectedGroupKey() === group.key ? 'true' : null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
@if (filteredGroups().length > 0) {
@for (
group of filteredGroups();
track trackByGroupKey($index, group)
) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="
selectedGroupKey() === group.key
"
[attr.aria-current]="
selectedGroupKey() === group.key
? 'true'
: null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
}
} @else {
<div class="groups-nav-empty">
{{
(searchTerm()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
}
</div>
</div>
@@ -163,6 +190,36 @@
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</div>
</header>
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{ searchTerm() ? 'search_off' : 'visibility_off' }}
</mat-icon>
<div class="empty-state-title">
{{
(searchTerm()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(searchTerm()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'CHANNELS.MANAGE_GROUPS_HINT'
) | translate
}}
</div>
</div>
}
</section>
</div>
@@ -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;
@@ -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<string>;
groupedChannels: Record<string, Channel[]>;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
@@ -145,6 +148,7 @@ describe('GroupsViewComponent', () => {
activeChannelUrl: string | undefined;
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
@@ -173,6 +177,10 @@ describe('GroupsViewComponent', () => {
'favoriteIds',
overrides.favoriteIds ?? new Set<string>()
);
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();
});
});
@@ -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<Set<string>>(new Set());
readonly hiddenGroupTitles = input<string[]>([]);
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
@@ -101,6 +112,7 @@ export class GroupsViewComponent {
/** Emits when the groups rail resize ends */
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
readonly selectedGroupKey = signal<string | null>(null);
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
@@ -202,26 +214,35 @@ export class GroupsViewComponent {
});
}
readonly sortedGroups = computed(() => {
readonly allGroups = computed<GroupView[]>(() => {
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<FilteredGroupView[]>(() => {
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<FilteredGroupView[]>((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<GroupManagementDialogGroup>(
({ 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<string, Channel[]> | { key: string; value: Channel[] },
b: KeyValue<string, Channel[]> | { key: string; value: Channel[] }
a: KeyValue<string, Channel[]> | { key: string },
b: KeyValue<string, Channel[]> | { key: string }
): number => {
const numA = parseInt(a.key.replace(/\D/g, ''), 10);
const numB = parseInt(b.key.replace(/\D/g, ''), 10);