mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(m3u): add channel list sorting controls
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
7478ea8665
commit
ed6302c059
11 files changed
+387
-63
No files matched your search
@@ -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"
|
||||
|
||||
+51
@@ -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">
|
||||
|
||||
+41
@@ -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;
|
||||
|
||||
+136
-14
@@ -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');
|
||||
});
|
||||
});
|
||||
+39
-10
@@ -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({
|
||||
|
||||
+8
-7
@@ -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;
|
||||
});
|
||||
}
|
||||
+16
-8
@@ -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">
|
||||
{{
|
||||
|
||||
+3
-3
@@ -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',
|
||||
|
||||
+13
-13
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user