feat(groups): implement channel sorting functionality with UI controls

This commit is contained in:
4gray committed 2026-04-11 14:12:43 +02:00
1 parent 0f72efb749
commit a8aa289735
11 files changed
+322 -106

No files matched your search

+1
View File
@@ -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';
@@ -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<T>(
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;
});
}
@@ -66,6 +66,7 @@ class StubGlobalFavoritesListComponent {
class StubEpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
}
@Component({
@@ -51,6 +51,7 @@ class StubEpgListComponent {
readonly controlledChannel = input<unknown>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
}
@Component({
@@ -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<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly programActivated = output<EpgProgramActivationEvent>();
}
@@ -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', () => {
@@ -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<LiveChannelSortMode>('server');
readonly liveChannelSortMode = signal<PortalChannelSortMode>('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<void> {
async onProgramActivated(event: EpgProgramActivationEvent): Promise<void> {
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 {
@@ -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<XtreamChannelListItem>();
readonly sortMode = input<LiveChannelSortMode>('server');
readonly sortMode = input<PortalChannelSortMode>('server');
readonly channelsOverride = input<XtreamChannelListItem[] | null>(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<number>(
channels.map((ch) => ch.xtream_id)
);
const visibleIds = new Set<number>(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,
@@ -87,6 +87,43 @@
{{ group.count === 1 ? 'item' : 'items' }}
</span>
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
(click)="setGroupChannelSortMode('server')"
>
<span>Server Order</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
<cdk-virtual-scroll-viewport
@@ -1,3 +1,4 @@
@use '@angular/material' as mat;
@use '../../../../../styles/portal-sidebar';
@use '../../../../../styles/panel-header' as panel;
@use '../../../../../../../apps/web/src/nav-list.scss';
@@ -58,6 +59,10 @@
border-top-left-radius: inherit;
}
.groups-content-header button {
@include mat.form-field-density(-5);
}
.category-meta {
@include panel.standard-panel-meta();
}
@@ -6,6 +6,8 @@ import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupsViewComponent } from './groups-view.component';
const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode';
function createChannel(
id: string,
name: string,
@@ -94,6 +96,8 @@ describe('GroupsViewComponent', () => {
});
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<string>;
groupedChannels: Record<string, Channel[]>;
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<string, null>()
);
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
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);
@@ -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<HTMLElement>);
readonly contextMenuTrigger = viewChild.required<MatMenuTrigger>(
'contextMenuTrigger'
);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
@@ -91,6 +103,12 @@ export class GroupsViewComponent {
readonly sidebarWidthRequestEnded = output<number>();
readonly selectedGroupKey = signal<string | null>(null);
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
);
readonly groupChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.groupChannelSortMode())
);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
readonly contextMenuChannel = signal<Channel | null>(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<EnrichedChannel[]>(() => {
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<number>
): 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) {