From a8aa2897358e485750e2b95632c9ac6f9dec229b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 11 Apr 2026 14:12:43 +0200 Subject: [PATCH] feat(groups): implement channel sorting functionality with UI controls --- libs/portal/shared/util/src/index.ts | 1 + .../shared/util/src/lib/channel-sort.ts | 61 ++++++++ .../unified-live-tab.component.spec.ts | 1 + ...alker-live-stream-layout.component.spec.ts | 1 + .../live-stream-layout.component.spec.ts | 20 ++- .../live-stream-layout.component.ts | 47 +++--- .../portal-channels-list.component.ts | 52 +++---- .../groups-view/groups-view.component.html | 37 +++++ .../groups-view/groups-view.component.scss | 5 + .../groups-view/groups-view.component.spec.ts | 145 +++++++++++++++--- .../groups-view/groups-view.component.ts | 58 +++++-- 11 files changed, 322 insertions(+), 106 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/channel-sort.ts diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 6c02b6681..99d5e3fd1 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/logger'; +export * from './lib/channel-sort'; export * from './lib/portal-catalog-detail'; export * from './lib/portal-catalog-facade'; export * from './lib/portal-external-playback'; diff --git a/libs/portal/shared/util/src/lib/channel-sort.ts b/libs/portal/shared/util/src/lib/channel-sort.ts new file mode 100644 index 000000000..6f86c26ea --- /dev/null +++ b/libs/portal/shared/util/src/lib/channel-sort.ts @@ -0,0 +1,61 @@ +const CHANNEL_SORT_COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', +}); + +export type PortalChannelSortMode = 'server' | 'name-asc' | 'name-desc'; + +export const DEFAULT_PORTAL_CHANNEL_SORT_MODE: PortalChannelSortMode = 'server'; + +export function isPortalChannelSortMode( + value: unknown +): value is PortalChannelSortMode { + return value === 'server' || value === 'name-asc' || value === 'name-desc'; +} + +export function restorePortalChannelSortMode( + storageKey: string, + fallback: PortalChannelSortMode = DEFAULT_PORTAL_CHANNEL_SORT_MODE +): PortalChannelSortMode { + const storedValue = localStorage.getItem(storageKey); + return isPortalChannelSortMode(storedValue) ? storedValue : fallback; +} + +export function persistPortalChannelSortMode( + storageKey: string, + mode: PortalChannelSortMode +): void { + localStorage.setItem(storageKey, mode); +} + +export function getPortalChannelSortModeLabel( + mode: PortalChannelSortMode +): string { + if (mode === 'name-asc') { + return 'Name A-Z'; + } + + if (mode === 'name-desc') { + return 'Name Z-A'; + } + + return 'Server Order'; +} + +export function sortPortalChannelItems( + items: readonly T[], + mode: PortalChannelSortMode, + getDisplayName: (item: T) => string | null | undefined +): readonly T[] { + if (mode === 'server') { + return items; + } + + return [...items].sort((a, b) => { + const result = CHANNEL_SORT_COLLATOR.compare( + getDisplayName(a) ?? '', + getDisplayName(b) ?? '' + ); + return mode === 'name-asc' ? result : -result; + }); +} diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index 6fc34f7dd..98da3beb6 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -66,6 +66,7 @@ class StubGlobalFavoritesListComponent { class StubEpgListComponent { readonly controlledChannel = input(null); readonly controlledPrograms = input(null); + readonly archivePlaybackAvailable = input(null); } @Component({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index c816e2ec1..2fd4576bf 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -51,6 +51,7 @@ class StubEpgListComponent { readonly controlledChannel = input(null); readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); + readonly archivePlaybackAvailable = input(null); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 78e34caf7..a0e08ea33 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -5,7 +5,10 @@ import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { MockPipe } from 'ng-mocks'; import { TranslatePipe } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { PORTAL_PLAYER, ResizableDirective } from '@iptvnator/portal/shared/util'; +import { + PORTAL_PLAYER, + ResizableDirective, +} from '@iptvnator/portal/shared/util'; import { FavoritesService, XtreamStore, @@ -17,6 +20,8 @@ import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; +const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; + @Component({ selector: 'app-portal-channels-list', standalone: true, @@ -54,6 +59,7 @@ class StubEpgViewComponent { class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); + readonly archivePlaybackAvailable = input(null); readonly programActivated = output(); } @@ -110,9 +116,9 @@ describe('LiveStreamLayoutComponent', () => { getFavorites: jest.fn().mockReturnValue(of([])), }; const xtreamUrlService = { - resolveCatchupUrl: jest.fn().mockResolvedValue( - 'https://example.com/timeshift.ts' - ), + resolveCatchupUrl: jest + .fn() + .mockResolvedValue('https://example.com/timeshift.ts'), }; const portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(true), @@ -123,6 +129,7 @@ describe('LiveStreamLayoutComponent', () => { beforeEach(async () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); + localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); window.electron = { updateRemoteControlStatus: jest.fn(), @@ -206,6 +213,7 @@ describe('LiveStreamLayoutComponent', () => { afterEach(() => { fixture.destroy(); jest.useRealTimers(); + localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); window.electron = originalElectron; }); @@ -225,9 +233,7 @@ describe('LiveStreamLayoutComponent', () => { expect( fixture.nativeElement.querySelector('app-epg-list') ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector('app-epg-view') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); it('uses currentEpgItem instead of assuming the first schedule item is current', () => { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 95a03b2c1..bfc6b8a08 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -17,10 +17,14 @@ import { ResizableDirective } from 'components'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { PORTAL_PLAYER, + PortalChannelSortMode, + getPortalChannelSortModeLabel, getAdjacentChannelItem, getChannelItemByNumber, isWorkspaceLayoutRoute, + persistPortalChannelSortMode, queryParamSignal, + restorePortalChannelSortMode, } from '@iptvnator/portal/shared/util'; import { FavoriteItem, @@ -28,17 +32,13 @@ import { XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { - EpgListComponent, - EpgProgramActivationEvent, -} from '@iptvnator/ui/epg'; +import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; -type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; interface XtreamLiveChannelItem { @@ -84,7 +84,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly currentEpgItem = this.xtreamStore.currentEpgItem; readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; readonly selectedCategoryId = this.xtreamStore.selectedCategoryId; - readonly liveChannelSortMode = signal('server'); + readonly liveChannelSortMode = signal('server'); readonly isElectron = Boolean(window.electron); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( @@ -142,12 +142,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.hasPastPrograms() && !this.archivePlaybackAvailable() ); - readonly liveChannelSortLabel = computed(() => { - const mode = this.liveChannelSortMode(); - if (mode === 'name-asc') return 'Name A-Z'; - if (mode === 'name-desc') return 'Name Z-A'; - return 'Server Order'; - }); + readonly liveChannelSortLabel = computed(() => + getPortalChannelSortModeLabel(this.liveChannelSortMode()) + ); readonly selectedCategoryInfo = computed(() => { const categoryId = this.selectedCategoryId(); @@ -275,16 +272,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } } - const savedSortMode = localStorage.getItem( - LIVE_CHANNEL_SORT_STORAGE_KEY + this.liveChannelSortMode.set( + restorePortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY) ); - if ( - savedSortMode === 'server' || - savedSortMode === 'name-asc' || - savedSortMode === 'name-desc' - ) { - this.liveChannelSortMode.set(savedSortMode); - } const playlist = this.xtreamStore.currentPlaylist(); if (playlist) { @@ -312,9 +302,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); } - async onProgramActivated( - event: EpgProgramActivationEvent - ): Promise { + async onProgramActivated(event: EpgProgramActivationEvent): Promise { const selectedItem = this.selectedLiveItem(); if (!selectedItem?.xtream_id) { return; @@ -328,9 +316,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { await this.playCatchup(event.program, selectedItem); } - setLiveChannelSortMode(mode: LiveChannelSortMode): void { + setLiveChannelSortMode(mode: PortalChannelSortMode): void { this.liveChannelSortMode.set(mode); - localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); + persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } ngOnDestroy(): void { @@ -381,8 +369,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } private getVisibleChannels(): XtreamLiveChannelItem[] { - return this.xtreamStore.selectItemsFromSelectedCategory() as - XtreamLiveChannelItem[]; + return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[]; } private async playCatchup( @@ -488,7 +475,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return channelTitle; } - return channelTitle ? `${channelTitle} - ${program.title}` : program.title; + return channelTitle + ? `${channelTitle} - ${program.title}` + : program.title; } private clearAutoOpenHistoryState(): void { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index c028b79a8..368299c56 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -26,13 +26,15 @@ import { XtreamItem, } from 'shared-interfaces'; import { ChannelListItemComponent } from 'components'; +import { + PortalChannelSortMode, + sortPortalChannelItems, +} from '@iptvnator/portal/shared/util'; import { EpgQueueService } from '@iptvnator/portal/xtream/data-access'; import { XtreamCredentials } from '@iptvnator/portal/xtream/data-access'; import { FavoritesService } from '@iptvnator/portal/xtream/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; - export interface XtreamChannelListItem { readonly category_id?: string | number; readonly id?: string | number; @@ -61,7 +63,7 @@ interface XtreamCategoryLike { }) export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly playClicked = output(); - readonly sortMode = input('server'); + readonly sortMode = input('server'); readonly channelsOverride = input(null); readonly searchTermInput = input(''); @@ -78,27 +80,16 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return override; } - return this.xtreamStore.selectItemsFromSelectedCategory() as - XtreamChannelListItem[]; + return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamChannelListItem[]; }); readonly sortedChannels = computed(() => { const mode = this.sortMode(); const channels = this.channels(); - if (mode === 'server') { - return channels; - } - - const collator = new Intl.Collator(undefined, { - numeric: true, - sensitivity: 'base', - }); - - return [...channels].sort((a, b) => { - const titleA = a.title ?? a.name ?? ''; - const titleB = b.title ?? b.name ?? ''; - const result = collator.compare(titleA, titleB); - return mode === 'name-asc' ? result : -result; - }); + return sortPortalChannelItems( + channels, + mode, + (item) => item.title ?? item.name + ); }); readonly filteredChannels = computed(() => { const term = this.searchTermInput().trim().toLowerCase(); @@ -163,14 +154,12 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { // Subscribe to EPG results from the queue service this.subscriptions.add( - this.epgQueueService.epgResult$.subscribe( - ({ streamId, items }) => { - const previewProgram = this.pickPreviewProgram(items); - if (previewProgram) { - this.applyProgram(streamId, previewProgram); - } + this.epgQueueService.epgResult$.subscribe(({ streamId, items }) => { + const previewProgram = this.pickPreviewProgram(items); + if (previewProgram) { + this.applyProgram(streamId, previewProgram); } - ) + }) ); } @@ -201,9 +190,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { password: playlist.password, }; - const visibleIds = new Set( - channels.map((ch) => ch.xtream_id) - ); + const visibleIds = new Set(channels.map((ch) => ch.xtream_id)); const uncachedIds: number[] = []; // Apply cached results immediately @@ -230,10 +217,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { } } - private updateProgramProgress( - streamId: number, - program: EpgItem - ) { + private updateProgramProgress(streamId: number, program: EpgItem) { const now = Date.now(); const start = this.getProgramTimestampMs( program.start, diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 24091cc97..e56ea1da8 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -87,6 +87,43 @@ {{ group.count === 1 ? 'item' : 'items' }} + + + + + + { }); beforeEach(async () => { + localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY); + dialog = { open: jest.fn(), }; @@ -112,11 +116,29 @@ describe('GroupsViewComponent', () => { ], }).compileComponents(); + createComponent(); + }); + + afterEach(() => { + fixture.destroy(); + localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY); + }); + + function createComponent( + overrides: Partial<{ + activeChannelUrl: string | undefined; + favoriteIds: Set; + groupedChannels: Record; + progressTick: number; + searchTerm: string; + sidebarWidth: number | null; + shouldShowEpg: boolean; + }> = {} + ): void { fixture = TestBed.createComponent(GroupsViewComponent); component = fixture.componentInstance; - - setInputs(); - }); + setInputs(overrides); + } function setInputs( overrides: Partial<{ @@ -134,10 +156,7 @@ describe('GroupsViewComponent', () => { overrides.groupedChannels ?? groupedChannels ); fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? ''); - fixture.componentRef.setInput( - 'channelEpgMap', - new Map() - ); + fixture.componentRef.setInput('channelEpgMap', new Map()); fixture.componentRef.setInput( 'progressTick', overrides.progressTick ?? 0 @@ -177,6 +196,78 @@ describe('GroupsViewComponent', () => { ]); }); + it('defaults to server order when no saved sort mode exists', () => { + expect(component.groupChannelSortMode()).toBe('server'); + expect(component.groupChannelSortLabel()).toBe('Server Order'); + }); + + it('restores a saved valid sort mode and ignores invalid stored values', () => { + fixture.destroy(); + localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'name-asc'); + createComponent(); + + expect(component.groupChannelSortMode()).toBe('name-asc'); + expect(component.groupChannelSortLabel()).toBe('Name A-Z'); + + fixture.destroy(); + localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'invalid'); + createComponent(); + + expect(component.groupChannelSortMode()).toBe('server'); + }); + + it('persists sort mode changes', () => { + component.setGroupChannelSortMode('name-desc'); + + expect(component.groupChannelSortMode()).toBe('name-desc'); + expect(localStorage.getItem(GROUP_CHANNEL_SORT_STORAGE_KEY)).toBe( + 'name-desc' + ); + }); + + it('sorts selected group channels by server order, name ascending, and name descending', () => { + const alphaSignal = createChannel( + 'sort-1', + 'Alpha Signal', + 'http://example.com/alpha-signal.m3u8', + 'Sorted' + ); + const zuluVision = createChannel( + 'sort-2', + 'Zulu Vision', + 'http://example.com/zulu-vision.m3u8', + 'Sorted' + ); + const middleNews = createChannel( + 'sort-3', + 'Middle News', + 'http://example.com/middle-news.m3u8', + 'Sorted' + ); + + setInputs({ + groupedChannels: { + Sorted: [zuluVision, alphaSignal, middleNews], + }, + }); + + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['Zulu Vision', 'Alpha Signal', 'Middle News']); + + component.setGroupChannelSortMode('name-asc'); + fixture.detectChanges(); + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['Alpha Signal', 'Middle News', 'Zulu Vision']); + + component.setGroupChannelSortMode('name-desc'); + fixture.detectChanges(); + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['Zulu Vision', 'Middle News', 'Alpha Signal']); + }); + it('prefers the active channel group for initial selection', () => { setInputs({ activeChannelUrl: worldUpdate.url }); @@ -212,6 +303,22 @@ describe('GroupsViewComponent', () => { expect(component.selectedGroupKey()).toBe('Series'); }); + it('keeps group selection behavior unchanged when channel sort mode changes', () => { + component.setGroupChannelSortMode('name-asc'); + component.selectGroup('Movies'); + fixture.detectChanges(); + + setInputs({ activeChannelUrl: sportsCenter.url }); + + expect(component.selectedGroupKey()).toBe('Sports'); + expect(component.filteredGroups().map((group) => group.key)).toEqual([ + 'Movies', + 'News', + 'Series', + 'Sports', + ]); + }); + it('matches group titles as full-group results and channel names as filtered results', () => { setInputs({ searchTerm: 'news' }); @@ -222,10 +329,9 @@ describe('GroupsViewComponent', () => { titleMatches: true, }), ]); - expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([ - 'World Update', - 'Daily Bulletin', - ]); + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['World Update', 'Daily Bulletin']); setInputs({ searchTerm: 'update' }); @@ -236,9 +342,9 @@ describe('GroupsViewComponent', () => { titleMatches: false, }), ]); - expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([ - 'World Update', - ]); + expect( + component.selectedGroupChannels().map((channel) => channel.name) + ).toEqual(['World Update']); }); it('emits channel and favorite events from the selected group pane', () => { @@ -264,13 +370,10 @@ describe('GroupsViewComponent', () => { .spyOn(component.contextMenuTrigger(), 'openMenu') .mockImplementation(); - component.onChannelContextMenu( - movieClassic, - { - clientX: 212, - clientY: 264, - } as MouseEvent - ); + component.onChannelContextMenu(movieClassic, { + clientX: 212, + clientY: 264, + } as MouseEvent); await Promise.resolve(); expect(component.contextMenuChannel()).toBe(movieClassic); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 520362322..3adcffb9b 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -13,16 +13,27 @@ import { viewChild, } from '@angular/core'; import { ScrollingModule } from '@angular/cdk/scrolling'; +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 { Channel, EpgProgram } from 'shared-interfaces'; +import { + PortalChannelSortMode, + getPortalChannelSortModeLabel, + persistPortalChannelSortMode, + restorePortalChannelSortMode, + sortPortalChannelItems, +} from '@iptvnator/portal/shared/util'; import { EnrichedChannel } from '../all-channels-view/all-channels-view.component'; 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'; +const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode'; + interface FilteredGroupView { readonly channels: Channel[]; readonly count: number; @@ -37,8 +48,10 @@ interface FilteredGroupView { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListItemComponent, + MatButtonModule, MatIconModule, MatMenuModule, + MatTooltipModule, ResizableDirective, ScrollingModule, TitleCasePipe, @@ -49,9 +62,8 @@ export class GroupsViewComponent { private readonly dialog = inject(MatDialog); private readonly hostEl = inject(ElementRef); - readonly contextMenuTrigger = viewChild.required( - 'contextMenuTrigger' - ); + readonly contextMenuTrigger = + viewChild.required('contextMenuTrigger'); /** Grouped channels object */ readonly groupedChannels = input.required<{ [key: string]: Channel[] }>(); @@ -91,6 +103,12 @@ export class GroupsViewComponent { readonly sidebarWidthRequestEnded = output(); readonly selectedGroupKey = signal(null); + readonly groupChannelSortMode = signal( + restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY) + ); + readonly groupChannelSortLabel = computed(() => + getPortalChannelSortModeLabel(this.groupChannelSortMode()) + ); readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); readonly contextMenuChannel = signal(null); readonly contextMenuPosition = signal({ @@ -104,7 +122,9 @@ export class GroupsViewComponent { constructor() { effect(() => { const filteredGroups = this.filteredGroups(); - const visibleGroupKeys = new Set(filteredGroups.map((group) => group.key)); + const visibleGroupKeys = new Set( + filteredGroups.map((group) => group.key) + ); const currentSelection = this.selectedGroupKey(); const activeGroupKey = this.activeChannelGroupKey(); const activeChannelUrl = this.activeChannelUrl(); @@ -126,10 +146,7 @@ export class GroupsViewComponent { visibleGroupKeys.has(currentSelection) ) { nextSelection = currentSelection; - } else if ( - activeGroupKey && - visibleGroupKeys.has(activeGroupKey) - ) { + } else if (activeGroupKey && visibleGroupKeys.has(activeGroupKey)) { nextSelection = activeGroupKey; } else { nextSelection = filteredGroups[0]?.key ?? null; @@ -235,13 +252,15 @@ export class GroupsViewComponent { readonly selectedGroup = computed(() => { const selectedGroupKey = this.selectedGroupKey(); return ( - this.filteredGroups().find((group) => group.key === selectedGroupKey) ?? - null + this.filteredGroups().find( + (group) => group.key === selectedGroupKey + ) ?? null ); }); readonly selectedGroupChannels = computed(() => { const group = this.selectedGroup(); + const sortMode = this.groupChannelSortMode(); const epgMap = this.channelEpgMap(); this.progressTick(); @@ -249,7 +268,11 @@ export class GroupsViewComponent { return []; } - return group.channels.map((channel) => { + return sortPortalChannelItems( + group.channels, + sortMode, + (channel) => channel?.name + ).map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null; return { @@ -280,6 +303,11 @@ export class GroupsViewComponent { this.selectedGroupKey.set(groupKey); } + setGroupChannelSortMode(mode: PortalChannelSortMode): void { + this.groupChannelSortMode.set(mode); + persistPortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY, mode); + } + onGroupsNavResizeStart(): void { this.preservedContentWidth = this.measureContentPanelWidth(); } @@ -394,7 +422,8 @@ export class GroupsViewComponent { emitter: OutputEmitterRef ): void { const preservedContentWidth = - this.preservedContentWidth || this.measureContentPanelWidth(navWidth); + this.preservedContentWidth || + this.measureContentPanelWidth(navWidth); const requestedWidth = Math.round(navWidth + preservedContentWidth); if (requestedWidth > 0) { @@ -414,9 +443,8 @@ export class GroupsViewComponent { const hostWidth = this.readWidth(this.hostEl.nativeElement); const totalWidth = hostWidth > 0 ? hostWidth : Math.max(0, this.sidebarWidth() ?? 0); - const navPanel = this.hostEl.nativeElement.querySelector( - '.groups-nav-panel' - ); + const navPanel = + this.hostEl.nativeElement.querySelector('.groups-nav-panel'); const navWidth = currentNavWidth ?? this.readWidth(navPanel); if (totalWidth > 0 && navWidth > 0) {