feat(m3u): add channel list sorting controls

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-06 00:14:52 +02:00
1 parent 7478ea8665
commit ed6302c059
11 files changed
+387 -63

No files matched your search

+20 -7
View File
@@ -9,7 +9,7 @@ The M3U playlist module provides:
- Channel list display with virtual scrolling (90,000+ channels support)
- EPG (Electronic Program Guide) integration
- Favorites management with drag-and-drop reordering
- Channel grouping and search
- Channel grouping, search, and per-list channel sorting
- Per-playlist group visibility management in the groups view
- Video playback with multiple player backends
@@ -209,6 +209,19 @@ channel-list-container/
hidden by separating "playlist has no groups" from "no visible/search-matching
groups" empty states.
### Channel Sorting
- `AllChannelsViewComponent` owns sorting for the all-channels list and
persists the selected mode under `m3u-all-channels-sort-mode`.
- `GroupsViewComponent` owns sorting for the selected group's channel pane and
persists the selected mode under `m3u-groups-channel-sort-mode`.
- Both views support three modes: `Playlist Order`, `Name A-Z`, and `Name Z-A`.
`Playlist Order` is the default and preserves the original channel order from
the M3U playlist.
- Sorting is applied after the current search filter and before virtual-scroll
rendering. Playlist order avoids cloning the full list when no search term is
active.
### EnrichedChannel Pattern
For performance optimization, channels are pre-enriched with EPG data:
@@ -252,25 +265,25 @@ EPG lookup keys use the same precedence in both program and icon paths:
### Tab Components
#### AllChannelsTabComponent
#### AllChannelsViewComponent
- **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds`
- **Outputs**: `channelSelected`, `favoriteToggled`
- **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder
- **Features**: Workspace search, persisted channel sorting, virtual scrolling, no-results placeholder
#### GroupsTabComponent
#### GroupsViewComponent
- **Inputs**: Same as AllChannelsTab + `groupedChannels`
- **Outputs**: `channelSelected`, `favoriteToggled`
- **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading
- **Features**: Resizable groups rail, local group search, group visibility management, persisted selected-group channel sorting
#### FavoritesTabComponent
#### FavoritesViewComponent
- **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
- **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered`
- **Features**: Drag-and-drop reordering with CDK DragDrop, read-only channel details context menu
#### RecentTabComponent
#### RecentViewComponent
- **Inputs**: recent channels, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl`
- **Outputs**: `channelSelected`, `favoriteToggled`, `recentItemRemoved`
@@ -10,7 +10,7 @@
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups') {
@if (activeView() !== 'groups' && activeView() !== 'all') {
<button
mat-icon-button
type="button"
@@ -1,3 +1,54 @@
<header class="all-channels-header">
<div class="all-channels-header__top">
<h2>{{ 'CHANNELS.ALL_CHANNELS' | translate }}</h2>
<button
type="button"
mat-icon-button
class="all-channels-sort-button"
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + allChannelsSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="all-channels-sidebar-toggle"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
(click)="sidebarToggleRequested.emit()"
>
<mat-icon>chevron_left</mat-icon>
</button>
</div>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setAllChannelsSortMode('server')">
<mat-icon>drag_indicator</mat-icon>
<span>Playlist Order</span>
@if (allChannelsSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setAllChannelsSortMode('name-asc')">
<mat-icon>sort_by_alpha</mat-icon>
<span>Name A-Z</span>
@if (allChannelsSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setAllChannelsSortMode('name-desc')">
<mat-icon>sort_by_alpha</mat-icon>
<span>Name Z-A</span>
@if (allChannelsSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
<div class="all-channels-divider"></div>
<div id="all-channels" class="channel-list">
@if (filteredChannels().length === 0 && searchTerm()) {
<div class="no-results">
@@ -32,6 +32,47 @@
}
}
.all-channels-header {
padding: 12px 16px 8px;
flex-shrink: 0;
}
.all-channels-header__top {
display: flex;
align-items: center;
gap: 8px;
h2 {
flex: 1;
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--mat-sys-on-surface);
font-size: 0.94rem;
font-weight: 600;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.all-channels-sort-button {
flex: 0 0 auto;
}
.all-channels-sidebar-toggle {
flex: 0 0 auto;
margin-right: -4px;
}
.all-channels-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
#all-channels {
flex: 1;
min-height: 0;
@@ -6,6 +6,8 @@ import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { AllChannelsViewComponent } from './all-channels-view.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
function createChannel(id: string, name: string, url: string): Channel {
return {
epgParams: '',
@@ -47,6 +49,8 @@ describe('AllChannelsViewComponent', () => {
);
beforeEach(async () => {
localStorage.removeItem(ALL_CHANNELS_SORT_STORAGE_KEY);
dialog = {
open: jest.fn(),
};
@@ -71,7 +75,10 @@ describe('AllChannelsViewComponent', () => {
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput('channelIconMap', new Map<string, string>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
@@ -79,18 +86,133 @@ describe('AllChannelsViewComponent', () => {
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
localStorage.removeItem(ALL_CHANNELS_SORT_STORAGE_KEY);
});
it('defaults to playlist order when no saved sort mode exists', () => {
expect(component.allChannelsSortMode()).toBe('server');
expect(component.allChannelsSortLabel()).toBe('Playlist Order');
});
it('restores a saved valid sort mode and ignores invalid stored values', () => {
fixture.destroy();
localStorage.setItem(ALL_CHANNELS_SORT_STORAGE_KEY, 'name-asc');
fixture = TestBed.createComponent(AllChannelsViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
fixture.componentRef.setInput('favoriteIds', new Set<string>());
fixture.detectChanges();
expect(component.allChannelsSortMode()).toBe('name-asc');
expect(component.allChannelsSortLabel()).toBe('Name A-Z');
fixture.destroy();
localStorage.setItem(ALL_CHANNELS_SORT_STORAGE_KEY, 'invalid');
fixture = TestBed.createComponent(AllChannelsViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
fixture.componentRef.setInput('favoriteIds', new Set<string>());
fixture.detectChanges();
expect(component.allChannelsSortMode()).toBe('server');
});
it('persists sort mode changes', () => {
component.setAllChannelsSortMode('name-desc');
expect(component.allChannelsSortMode()).toBe('name-desc');
expect(localStorage.getItem(ALL_CHANNELS_SORT_STORAGE_KEY)).toBe(
'name-desc'
);
});
it('sorts all channels by playlist order, name ascending, and name descending', () => {
const alphaSignal = createChannel(
'sort-1',
'Alpha Signal',
'https://example.com/alpha-signal.m3u8'
);
const zuluVision = createChannel(
'sort-2',
'Zulu Vision',
'https://example.com/zulu-vision.m3u8'
);
const middleNews = createChannel(
'sort-3',
'Middle News',
'https://example.com/middle-news.m3u8'
);
fixture.componentRef.setInput('channels', [
zuluVision,
alphaSignal,
middleNews,
]);
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['Zulu Vision', 'Alpha Signal', 'Middle News']);
component.setAllChannelsSortMode('name-asc');
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['Alpha Signal', 'Middle News', 'Zulu Vision']);
component.setAllChannelsSortMode('name-desc');
fixture.detectChanges();
expect(
component.filteredChannels().map((channel) => channel.name)
).toEqual(['Zulu Vision', 'Middle News', 'Alpha Signal']);
});
it('emits sidebar toggle requests from the inline header action', () => {
const sidebarToggleRequested = jest.fn();
component.sidebarToggleRequested.subscribe(sidebarToggleRequested);
const toggleButton = fixture.nativeElement.querySelector(
'.all-channels-sidebar-toggle'
) as HTMLButtonElement;
toggleButton.click();
expect(sidebarToggleRequested).toHaveBeenCalledTimes(1);
});
it('stores viewport coordinates for the context menu and opens the dialog for that channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(
primaryChannel,
{
clientX: 144,
clientY: 188,
} as MouseEvent
);
component.onChannelContextMenu(primaryChannel, {
clientX: 144,
clientY: 188,
} as MouseEvent);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
@@ -129,9 +251,9 @@ describe('AllChannelsViewComponent', () => {
);
fixture.detectChanges();
expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe(
playlistLogo
);
expect(
component.getLogoForChannel(component.filteredChannels()[0])
).toBe(playlistLogo);
});
it('falls back to the EPG icon using tvg-name when tvg-id and playlist logo are missing', () => {
@@ -152,8 +274,8 @@ describe('AllChannelsViewComponent', () => {
);
fixture.detectChanges();
expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe(
'https://example.com/guide-news.png'
);
expect(
component.getLogoForChannel(component.filteredChannels()[0])
).toBe('https://example.com/guide-news.png');
});
});
@@ -10,16 +10,27 @@ import {
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import {
PlaylistChannelSortMode,
getPlaylistChannelSortModeLabel,
persistPlaylistChannelSortMode,
restorePlaylistChannelSortMode,
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
/**
* Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key.
* Replaces the older EnrichedChannel pattern that spread-cloned every channel
@@ -37,8 +48,10 @@ export interface ChannelEpgMetadata {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
ScrollingModule,
TranslatePipe,
],
@@ -46,9 +59,8 @@ export interface ChannelEpgMetadata {
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger = viewChild.required<MatMenuTrigger>(
'contextMenuTrigger'
);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
@@ -82,24 +94,36 @@ export class AllChannelsViewComponent {
event: MouseEvent;
}>();
/** Emits when the user clicks the inline collapse toggle in the list header */
readonly sidebarToggleRequested = output<void>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
readonly allChannelsSortMode = signal<PlaylistChannelSortMode>(
restorePlaylistChannelSortMode(ALL_CHANNELS_SORT_STORAGE_KEY)
);
readonly allChannelsSortLabel = computed(() =>
getPlaylistChannelSortModeLabel(this.allChannelsSortMode())
);
/**
* Filtered channels — just a subset reference, no cloning.
* Recomputes only when the source list or the search term changes.
* Filtered and sorted channels. Playlist order keeps the original input
* reference when there is no search term, so large lists avoid cloning.
*/
readonly filteredChannels = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
if (!term) {
return channels;
}
return channels.filter((ch) =>
ch.name?.toLowerCase().includes(term)
const filteredChannels = term
? channels.filter((ch) => ch.name?.toLowerCase().includes(term))
: channels;
return sortPlaylistChannelItems(
filteredChannels,
this.allChannelsSortMode(),
(channel) => channel?.name
);
});
@@ -183,6 +207,11 @@ export class AllChannelsViewComponent {
this.favoriteToggled.emit({ channel, event });
}
setAllChannelsSortMode(mode: PlaylistChannelSortMode): void {
this.allChannelsSortMode.set(mode);
persistPlaylistChannelSortMode(ALL_CHANNELS_SORT_STORAGE_KEY, mode);
}
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
@@ -1,16 +1,18 @@
@if (channelsLoading()) {
<app-channel-list-loading-state [view]="activeView()" />
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups') {
@if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
<header class="context-header">
<div class="context-header__top">
<h2>{{ viewTitle() | translate }}</h2>
@if (
activeView() === 'recent' &&
recentChannelItems().length > 0
activeView() === 'recent' && recentChannelItems().length > 0
) {
<button mat-button (click)="clearRecentChannels()">
{{ 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' | translate }}
{{
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
| translate
}}
</button>
}
</div>
@@ -32,6 +34,7 @@
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
/>
}
@case ('groups') {
@@ -48,9 +51,7 @@
[sidebarWidth]="sidebarWidth()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(hiddenGroupTitlesChanged)="
onHiddenGroupTitlesChanged($event)
"
(hiddenGroupTitlesChanged)="onHiddenGroupTitlesChanged($event)"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
@@ -0,0 +1,59 @@
const CHANNEL_LIST_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type PlaylistChannelSortMode = 'server' | 'name-asc' | 'name-desc';
export function isPlaylistChannelSortMode(
value: unknown
): value is PlaylistChannelSortMode {
return value === 'server' || value === 'name-asc' || value === 'name-desc';
}
export function restorePlaylistChannelSortMode(
storageKey: string,
fallback: PlaylistChannelSortMode = 'server'
): PlaylistChannelSortMode {
const storedValue = localStorage.getItem(storageKey);
return isPlaylistChannelSortMode(storedValue) ? storedValue : fallback;
}
export function persistPlaylistChannelSortMode(
storageKey: string,
mode: PlaylistChannelSortMode
): void {
localStorage.setItem(storageKey, mode);
}
export function getPlaylistChannelSortModeLabel(
mode: PlaylistChannelSortMode
): string {
if (mode === 'name-asc') {
return 'Name A-Z';
}
if (mode === 'name-desc') {
return 'Name Z-A';
}
return 'Playlist Order';
}
export function sortPlaylistChannelItems<T>(
items: readonly T[],
mode: PlaylistChannelSortMode,
getDisplayName: (item: T) => string | null | undefined
): readonly T[] {
if (mode === 'server') {
return items;
}
return [...items].sort((a, b) => {
const result = CHANNEL_LIST_SORT_COLLATOR.compare(
getDisplayName(a) ?? '',
getDisplayName(b) ?? ''
);
return mode === 'name-asc' ? result : -result;
});
}
@@ -44,7 +44,9 @@
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--search"
[matTooltip]="'CHANNELS.SEARCH_GROUPS' | translate"
[matTooltip]="
'CHANNELS.SEARCH_GROUPS' | translate
"
[attr.aria-pressed]="
isGroupSearchOpen() ? 'true' : 'false'
"
@@ -59,7 +61,9 @@
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--manage"
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[matTooltip]="
'CHANNELS.MANAGE_GROUPS' | translate
"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
@@ -139,7 +143,9 @@
<div class="groups-nav-empty">
<mat-icon class="groups-nav-empty__icon">
{{
hasSearchQuery() ? 'search_off' : 'folder_off'
hasSearchQuery()
? 'search_off'
: 'folder_off'
}}
</mat-icon>
<div class="groups-nav-empty__title">
@@ -153,7 +159,8 @@
@if (hasSearchQuery()) {
<div class="groups-nav-empty__hint">
{{
'CHANNELS.TRY_DIFFERENT_SEARCH' | translate
'CHANNELS.TRY_DIFFERENT_SEARCH'
| translate
}}
</div>
}
@@ -206,7 +213,8 @@
mat-menu-item
(click)="setGroupChannelSortMode('server')"
>
<span>Server Order</span>
<mat-icon>drag_indicator</mat-icon>
<span>Playlist Order</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
@@ -215,6 +223,7 @@
mat-menu-item
(click)="setGroupChannelSortMode('name-asc')"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name A-Z</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
@@ -224,6 +233,7 @@
mat-menu-item
(click)="setGroupChannelSortMode('name-desc')"
>
<mat-icon>sort_by_alpha</mat-icon>
<span>Name Z-A</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
@@ -286,9 +296,7 @@
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{
hasSearchQuery() ? 'search_off' : 'visibility_off'
}}
{{ hasSearchQuery() ? 'search_off' : 'visibility_off' }}
</mat-icon>
<div class="empty-state-title">
{{
@@ -208,9 +208,9 @@ describe('GroupsViewComponent', () => {
]);
});
it('defaults to server order when no saved sort mode exists', () => {
it('defaults to playlist order when no saved sort mode exists', () => {
expect(component.groupChannelSortMode()).toBe('server');
expect(component.groupChannelSortLabel()).toBe('Server Order');
expect(component.groupChannelSortLabel()).toBe('Playlist Order');
});
it('restores a saved valid sort mode and ignores invalid stored values', () => {
@@ -264,7 +264,7 @@ describe('GroupsViewComponent', () => {
);
});
it('sorts selected group channels by server order, name ascending, and name descending', () => {
it('sorts selected group channels by playlist order, name ascending, and name descending', () => {
const alphaSignal = createChannel(
'sort-1',
'Alpha Signal',
@@ -21,14 +21,14 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import {
PortalChannelSortMode,
getPortalChannelSortModeLabel,
persistPortalChannelSortMode,
restorePortalChannelSortMode,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import {
PlaylistChannelSortMode,
getPlaylistChannelSortModeLabel,
persistPlaylistChannelSortMode,
restorePlaylistChannelSortMode,
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -125,11 +125,11 @@ export class GroupsViewComponent {
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
readonly groupChannelSortMode = signal<PlaylistChannelSortMode>(
restorePlaylistChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
);
readonly groupChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.groupChannelSortMode())
getPlaylistChannelSortModeLabel(this.groupChannelSortMode())
);
readonly hasSearchQuery = computed(
() =>
@@ -329,7 +329,7 @@ export class GroupsViewComponent {
return [];
}
return sortPortalChannelItems(
return sortPlaylistChannelItems(
group.channels,
sortMode,
(channel) => channel?.name
@@ -439,9 +439,9 @@ export class GroupsViewComponent {
});
}
setGroupChannelSortMode(mode: PortalChannelSortMode): void {
setGroupChannelSortMode(mode: PlaylistChannelSortMode): void {
this.groupChannelSortMode.set(mode);
persistPortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY, mode);
persistPlaylistChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY, mode);
}
onGroupsNavResizeStart(): void {