mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(groups): implement channel sorting functionality with UI controls
This commit is contained in:
1 parent
0f72efb749
commit
a8aa289735
11 files changed
+322
-106
No files matched your search
@@ -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({
|
||||
|
||||
+1
@@ -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({
|
||||
|
||||
+13
-7
@@ -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', () => {
|
||||
|
||||
+18
-29
@@ -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 {
|
||||
|
||||
+18
-34
@@ -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,
|
||||
|
||||
+37
@@ -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
|
||||
|
||||
+5
@@ -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();
|
||||
}
|
||||
|
||||
+124
-21
@@ -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);
|
||||
|
||||
+43
-15
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user