mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(m3u): implement group management dialog and hidden group titles functionality for m3u module
This commit is contained in:
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[];
|
||||
}
|
||||
+4
@@ -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)"
|
||||
/>
|
||||
|
||||
+27
-1
@@ -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
|
||||
*/
|
||||
|
||||
+85
@@ -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>
|
||||
+150
@@ -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;
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
+86
-29
@@ -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>
|
||||
|
||||
+25
@@ -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;
|
||||
|
||||
+86
-3
@@ -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();
|
||||
});
|
||||
});
|
||||
+58
-10
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user