From 1677d709fff766f1b6101d8eb4332a25c6657bd4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 28 Mar 2026 15:38:19 +0100 Subject: [PATCH] feat(m3u): refactor GroupsViewComponent with improved state management and UI updates --- .../groups-view/groups-view.component.html | 203 +++++---- .../groups-view/groups-view.component.scss | 189 +++++++-- .../groups-view/groups-view.component.spec.ts | 276 ++++++++++++ .../groups-view/groups-view.component.ts | 398 +++++++++--------- 4 files changed, 740 insertions(+), 326 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts 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 699034b24..be76f4949 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 @@ -1,97 +1,114 @@ -
- @if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) { -
- search_off -
- {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }} -
-
- {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} -
+@if (filteredGroups().length === 0) { +
+ {{ + searchTerm() ? 'search_off' : 'folder_off' + }} +
+ {{ + (searchTerm() + ? 'CHANNELS.NO_SEARCH_RESULTS' + : 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' + ) | translate + }}
- } @else { - - @for ( - groups of filteredGroupedChannels() | keyvalue: groupsComparator; - track $index - ) { - @if (groups.value.length > 0) { - - - {{ - groups.key || ('CHANNELS.UNGROUPED' | translate) - | titlecase - }} - ({{ groups.value.length }}) - +
+ {{ + (searchTerm() + ? 'CHANNELS.TRY_DIFFERENT_SEARCH' + : 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' + ) | translate + }} +
+
+} @else { +
+ + +
+ +
+} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index b5fca9076..beb13bd0b 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -1,58 +1,165 @@ +@use '../../../../../styles/portal-sidebar'; +@use '../../../../../../../apps/web/src/nav-list.scss'; + :host { display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -#groups-list { flex: 1; min-height: 0; - overflow-x: hidden; - overflow-y: auto; + overflow: hidden; } -.channel-list { - overflow-x: hidden; -} - -::ng-deep { - .mat-expansion-panel-body { - padding: 0 !important; - } - - .mat-expansion-panel { - border-radius: 0 !important; - } -} - -.scroll-sentinel { - padding: 16px; +.groups-view-layout { display: flex; - justify-content: center; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--app-content-bg, var(--mat-sys-surface)); } -.loading-more { +.groups-nav-panel { + width: 208px; + min-width: 164px; + max-width: 320px; + flex-shrink: 0; + min-height: 0; display: flex; - align-items: center; + overflow: hidden; + background: var(--app-rail-bg, var(--mat-sys-surface-container-low)); + padding: 12px 10px 10px 12px; + box-sizing: border-box; + container-type: inline-size; +} + +.groups-nav-shell { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.groups-nav-header { + padding: 4px 6px 12px; + flex-shrink: 0; +} + +.groups-nav-title { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--mat-sys-on-surface); +} + +.category-title { + margin: 0; + font-size: 0.94rem; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--mat-sys-on-surface); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.category-count { + flex-shrink: 0; + font-size: 0.72rem; + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + background: var(--mat-sys-surface-container); + padding: 2px 8px; + border-radius: 999px; +} + +.category-heading { + display: flex; + align-items: baseline; gap: 8px; - color: var(--mdc-theme-primary, #90caf9); - font-size: 0.875rem; - opacity: 0.8; + flex: 1; + min-width: 0; } -.loading-more-spinner { - font-size: 20px; - width: 20px; - height: 20px; - animation: spin 1.5s linear infinite; +.groups-nav-list { + height: 100%; + min-height: 0; + padding-top: 0; + padding-inline: 0 2px; } -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); +.group-nav-item { + background: transparent; +} + +.groups-content-panel { + width: auto; + min-width: 0; + max-width: none; + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border-right: none; + background: var(--app-content-bg, var(--mat-sys-surface)); + border-top-left-radius: 16px; +} + +.groups-content-panel > .groups-content-header { + border-bottom: none; + border-top-left-radius: inherit; +} + +.groups-channels-viewport { + flex: 1; + min-height: 0; + width: 100%; + padding: 4px 8px 12px; + box-sizing: border-box; + + ::ng-deep .cdk-virtual-scroll-content-wrapper { + width: 100% !important; + max-width: 100% !important; + } +} + +.groups-view-empty-state { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; +} + +.empty-state-icon { + width: 48px; + height: 48px; + font-size: 48px; + opacity: 0.5; + margin-bottom: 16px; +} + +.empty-state-title { + font-size: 1.05rem; + font-weight: 520; + letter-spacing: -0.01em; + margin-bottom: 8px; +} + +.empty-state-hint { + opacity: 0.65; + font-size: 0.85rem; + max-width: 240px; + line-height: 1.4; +} + +@container (max-width: 220px) { + .groups-nav-list { + padding-inline-end: 0; } } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts new file mode 100644 index 000000000..e0d3af919 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -0,0 +1,276 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { Channel } from 'shared-interfaces'; +import { GroupsViewComponent } from './groups-view.component'; + +function createChannel( + id: string, + name: string, + url: string, + groupTitle: string +): Channel { + return { + epgParams: '', + group: { + title: groupTitle, + }, + http: { + origin: '', + referrer: '', + 'user-agent': '', + }, + id, + name, + radio: 'false', + tvg: { + id: `${id}-tvg`, + logo: '', + name, + rec: '', + url: '', + }, + url, + } as Channel; +} + +describe('GroupsViewComponent', () => { + let fixture: ComponentFixture; + let component: GroupsViewComponent; + + const sportsCenter = createChannel( + 'sports-1', + 'Sports Center', + 'http://example.com/sports-center.m3u8', + 'Sports' + ); + const matchNight = createChannel( + 'sports-2', + 'Match Night', + 'http://example.com/match-night.m3u8', + 'Sports' + ); + const worldUpdate = createChannel( + 'news-1', + 'World Update', + 'http://example.com/world-update.m3u8', + 'News' + ); + const dailyBulletin = createChannel( + 'news-2', + 'Daily Bulletin', + 'http://example.com/daily-bulletin.m3u8', + 'News' + ); + const movieClassic = createChannel( + 'movies-1', + 'Movie Classic', + 'http://example.com/movie-classic.m3u8', + 'Movies' + ); + const scienceNow = createChannel( + 'science-1', + 'Science Now', + 'http://example.com/science-now.m3u8', + 'Series' + ); + + const groupedChannels: Record = { + Movies: [movieClassic], + News: [worldUpdate, dailyBulletin], + Series: [scienceNow], + Sports: [sportsCenter, matchNight], + }; + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: jest.fn(), + writable: true, + }); + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + GroupsViewComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: MatDialog, + useValue: { + open: jest.fn(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(GroupsViewComponent); + component = fixture.componentInstance; + + setInputs(); + }); + + function setInputs( + overrides: Partial<{ + activeChannelUrl: string | undefined; + favoriteIds: Set; + groupedChannels: Record; + progressTick: number; + searchTerm: string; + shouldShowEpg: boolean; + }> = {} + ): void { + fixture.componentRef.setInput( + 'groupedChannels', + overrides.groupedChannels ?? groupedChannels + ); + fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? ''); + fixture.componentRef.setInput( + 'channelEpgMap', + new Map() + ); + fixture.componentRef.setInput( + 'progressTick', + overrides.progressTick ?? 0 + ); + fixture.componentRef.setInput( + 'shouldShowEpg', + overrides.shouldShowEpg ?? true + ); + fixture.componentRef.setInput( + 'activeChannelUrl', + overrides.activeChannelUrl + ); + fixture.componentRef.setInput( + 'favoriteIds', + overrides.favoriteIds ?? new Set() + ); + fixture.detectChanges(); + } + + it('sorts groups with numeric buckets before alphabetic buckets', () => { + setInputs({ + groupedChannels: { + 'Group 10': [sportsCenter], + 'Group 2': [movieClassic], + Alpha: [scienceNow], + }, + }); + + expect(component.filteredGroups().map((group) => group.key)).toEqual([ + 'Group 2', + 'Group 10', + 'Alpha', + ]); + }); + + it('prefers the active channel group for initial selection', () => { + setInputs({ activeChannelUrl: worldUpdate.url }); + + expect(component.selectedGroupKey()).toBe('News'); + expect(component.selectedGroup()?.key).toBe('News'); + }); + + it('retains a visible manual selection and falls back to the first visible group', () => { + component.selectGroup('Movies'); + fixture.detectChanges(); + + setInputs({ + activeChannelUrl: sportsCenter.url, + searchTerm: 'movie', + }); + expect(component.selectedGroupKey()).toBe('Movies'); + + setInputs({ + activeChannelUrl: sportsCenter.url, + searchTerm: 'science', + }); + expect(component.selectedGroupKey()).toBe('Series'); + }); + + it('switches selection to the active channel group when playback changes', () => { + component.selectGroup('Movies'); + fixture.detectChanges(); + + setInputs({ activeChannelUrl: sportsCenter.url }); + expect(component.selectedGroupKey()).toBe('Sports'); + + setInputs({ activeChannelUrl: scienceNow.url }); + expect(component.selectedGroupKey()).toBe('Series'); + }); + + it('matches group titles as full-group results and channel names as filtered results', () => { + setInputs({ searchTerm: 'news' }); + + expect(component.filteredGroups()).toEqual([ + expect.objectContaining({ + count: 2, + key: 'News', + titleMatches: true, + }), + ]); + expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([ + 'World Update', + 'Daily Bulletin', + ]); + + setInputs({ searchTerm: 'update' }); + + expect(component.filteredGroups()).toEqual([ + expect.objectContaining({ + count: 1, + key: 'News', + titleMatches: false, + }), + ]); + expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([ + 'World Update', + ]); + }); + + it('emits channel and favorite events from the selected group pane', () => { + const channelSelected = jest.fn(); + const favoriteToggled = jest.fn(); + const clickEvent = new MouseEvent('click'); + + component.channelSelected.subscribe(channelSelected); + component.favoriteToggled.subscribe(favoriteToggled); + + component.onChannelClick(movieClassic); + component.onFavoriteToggle(movieClassic, clickEvent); + + expect(channelSelected).toHaveBeenCalledWith(movieClassic); + expect(favoriteToggled).toHaveBeenCalledWith({ + channel: movieClassic, + event: clickEvent, + }); + }); + + it('renders the no-results state for searches without matches', () => { + setInputs({ searchTerm: 'zzz' }); + + const emptyState = fixture.nativeElement.querySelector( + '.groups-view-empty-state' + ) as HTMLElement | null; + + expect(emptyState).not.toBeNull(); + expect(emptyState?.textContent).toContain('CHANNELS.NO_SEARCH_RESULTS'); + }); + + it('renders the empty-category state when no grouped channels exist', () => { + setInputs({ groupedChannels: {} }); + + const emptyState = fixture.nativeElement.querySelector( + '.groups-view-empty-state' + ) as HTMLElement | null; + + expect(emptyState).not.toBeNull(); + expect(emptyState?.textContent).toContain( + 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' + ); + }); +}); 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 93f44ddcb..2a06319b7 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 @@ -1,23 +1,29 @@ -import { KeyValue, KeyValuePipe, TitleCasePipe } from '@angular/common'; +import { KeyValue, TitleCasePipe } from '@angular/common'; import { - AfterViewInit, ChangeDetectionStrategy, - ChangeDetectorRef, Component, + ElementRef, computed, + effect, inject, input, - NgZone, - OnDestroy, output, signal, } from '@angular/core'; -import { MatExpansionModule } from '@angular/material/expansion'; +import { ScrollingModule } from '@angular/cdk/scrolling'; import { MatIconModule } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; import { Channel, EpgProgram } from 'shared-interfaces'; import { EnrichedChannel } from '../all-channels-view/all-channels-view.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; +import { ResizableDirective } from '../../resizable/resizable.directive'; + +interface FilteredGroupView { + readonly channels: Channel[]; + readonly count: number; + readonly key: string; + readonly titleMatches: boolean; +} @Component({ selector: 'app-groups-view', @@ -26,16 +32,15 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListItemComponent, - KeyValuePipe, - MatExpansionModule, MatIconModule, + ResizableDirective, + ScrollingModule, TitleCasePipe, TranslatePipe, ], }) -export class GroupsViewComponent implements AfterViewInit, OnDestroy { - private readonly cdr = inject(ChangeDetectorRef); - private readonly ngZone = inject(NgZone); +export class GroupsViewComponent { + private readonly hostEl = inject(ElementRef); /** Grouped channels object */ readonly groupedChannels = input.required<{ [key: string]: Channel[] }>(); @@ -65,187 +70,160 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy { event: MouseEvent; }>(); - /** IntersectionObserver for infinite scroll in groups */ - private groupScrollObserver?: IntersectionObserver; + readonly selectedGroupKey = signal(null); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); - /** Track observed sentinel elements */ - private observedSentinels = new Set(); + private previousActiveChannelUrl: string | undefined; - /** Track expanded groups with their load limits for lazy-loading */ - readonly expandedGroupLimits = signal(new Map()); + constructor() { + effect(() => { + const filteredGroups = this.filteredGroups(); + const visibleGroupKeys = new Set(filteredGroups.map((group) => group.key)); + const currentSelection = this.selectedGroupKey(); + const activeGroupKey = this.activeChannelGroupKey(); + const activeChannelUrl = this.activeChannelUrl(); + const activeChannelChanged = + activeChannelUrl !== this.previousActiveChannelUrl; - /** Default number of channels to show when group is expanded */ - private readonly DEFAULT_GROUP_LIMIT = 50; + this.previousActiveChannelUrl = activeChannelUrl; - ngAfterViewInit(): void { - // Set up IntersectionObserver for infinite scroll in groups - this.groupScrollObserver = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const element = entry.target as HTMLElement; - const groupKey = element.dataset['groupKey']; - const totalInGroup = parseInt( - element.dataset['totalInGroup'] || '0', - 10 - ); + let nextSelection: string | null = null; - if (groupKey) { - // Run inside NgZone to trigger change detection - this.ngZone.run(() => { - this.loadMoreInGroup(groupKey, totalInGroup); - }); - } - } - }); - }, - { - root: null, // Use viewport - rootMargin: '100px', // Load more before reaching the bottom - threshold: 0.1, + if ( + activeChannelChanged && + activeGroupKey && + visibleGroupKeys.has(activeGroupKey) + ) { + nextSelection = activeGroupKey; + } else if ( + currentSelection && + visibleGroupKeys.has(currentSelection) + ) { + nextSelection = currentSelection; + } else if ( + activeGroupKey && + visibleGroupKeys.has(activeGroupKey) + ) { + nextSelection = activeGroupKey; + } else { + nextSelection = filteredGroups[0]?.key ?? null; } - ); - } - ngOnDestroy(): void { - // Clean up IntersectionObserver - if (this.groupScrollObserver) { - this.groupScrollObserver.disconnect(); - this.observedSentinels.clear(); - } - } - - /** - * Gets the current limit for a group, or returns default - */ - getGroupLimit(groupKey: string): number { - return ( - this.expandedGroupLimits().get(groupKey) ?? this.DEFAULT_GROUP_LIMIT - ); - } - - /** - * Loads more channels in a group (used by infinite scroll) - */ - loadMoreInGroup(groupKey: string, totalInGroup: number): void { - const limits = new Map(this.expandedGroupLimits()); - const current = limits.get(groupKey) ?? this.DEFAULT_GROUP_LIMIT; - // Only load more if there are more items to show - if (current < totalInGroup) { - limits.set(groupKey, current + this.DEFAULT_GROUP_LIMIT); - this.expandedGroupLimits.set(limits); - this.cdr.markForCheck(); - } - } - - /** - * Sets up IntersectionObserver for a group's sentinel element. - */ - private observeGroupSentinel( - element: HTMLElement, - groupKey: string, - totalInGroup: number - ): void { - if (!element || this.observedSentinels.has(element)) { - return; - } - - // Store group info on the element for the observer callback - element.dataset['groupKey'] = groupKey; - element.dataset['totalInGroup'] = String(totalInGroup); - - this.groupScrollObserver?.observe(element); - this.observedSentinels.add(element); - } - - /** - * Called when an expansion panel opens - sets up infinite scroll observer - */ - onGroupPanelOpened(groupKey: string, totalInGroup: number): void { - // Use setTimeout to ensure the panel content is rendered - setTimeout(() => { - const sentinel = document.querySelector( - `[data-sentinel-group="${groupKey}"]` - ); - if (sentinel) { - this.observeGroupSentinel( - sentinel as HTMLElement, - groupKey, - totalInGroup - ); + if (nextSelection !== currentSelection) { + this.selectedGroupKey.set(nextSelection); } - }, 50); - } + }); - /** - * Computed signal that memoizes enriched channels for all groups - */ - readonly filteredGroupedChannels = computed(() => { - const grouped = this.groupedChannels(); - const term = this.searchTerm().trim().toLowerCase(); + effect(() => { + const selectedGroupKey = this.selectedGroupKey(); + if (selectedGroupKey == null) { + return; + } - if (!term) { - return grouped; - } + queueMicrotask(() => { + const container = this.hostEl.nativeElement.querySelector( + '.groups-nav-list' + ) as HTMLElement | null; + const candidates = Array.from( + this.hostEl.nativeElement.querySelectorAll( + '[data-group-key]' + ) + ) as HTMLElement[]; + const selected = + candidates.find( + (candidate) => + candidate.dataset['groupKey'] === selectedGroupKey + ) ?? null; - return Object.entries(grouped).reduce>( - (acc, [groupKey, channels]) => { - const groupMatches = groupKey.toLowerCase().includes(term); - const matchingChannels = groupMatches - ? channels - : channels.filter((channel) => - channel.name?.toLowerCase().includes(term) - ); - - if (matchingChannels.length > 0) { - acc[groupKey] = matchingChannels; + if (!container || !selected) { + return; } + const containerRect = container.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + const targetTop = + container.scrollTop + + (selectedRect.top - containerRect.top) - + container.clientHeight / 2 + + selectedRect.height / 2; + const maxScrollTop = Math.max( + 0, + container.scrollHeight - container.clientHeight + ); + + container.scrollTo({ + behavior: 'smooth', + top: Math.min(maxScrollTop, Math.max(0, targetTop)), + }); + }); + }); + } + + readonly sortedGroups = computed(() => { + const grouped = this.groupedChannels(); + const groups = Object.entries(grouped).map(([key, channels]) => ({ + key, + value: channels, + })); + + return groups.sort(this.groupsComparator); + }); + + readonly filteredGroups = computed(() => { + const term = this.searchTerm().trim().toLowerCase(); + const groups = this.sortedGroups(); + + if (!term) { + return groups + .filter((group) => group.value.length > 0) + .map((group) => ({ + channels: group.value, + count: group.value.length, + key: group.key, + titleMatches: false, + })); + } + + return groups.reduce((acc, group) => { + const titleMatches = group.key.toLowerCase().includes(term); + const channels = titleMatches + ? group.value + : group.value.filter((channel) => + `${channel.name ?? ''}`.toLowerCase().includes(term) + ); + + if (channels.length === 0) { return acc; - }, - {} + } + + acc.push({ + channels, + count: channels.length, + key: group.key, + titleMatches, + }); + return acc; + }, []); + }); + + readonly selectedGroup = computed(() => { + const selectedGroupKey = this.selectedGroupKey(); + return ( + this.filteredGroups().find((group) => group.key === selectedGroupKey) ?? + null ); }); - private readonly enrichedGroupChannelsMap = computed(() => { - const grouped = this.filteredGroupedChannels(); + readonly selectedGroupChannels = computed(() => { + const group = this.selectedGroup(); const epgMap = this.channelEpgMap(); - // Read progressTick to create dependency for progress refresh this.progressTick(); - const result = new Map(); - for (const [groupKey, channels] of Object.entries(grouped)) { - result.set( - groupKey, - channels.map((channel) => { - const channelId = - channel?.tvg?.id?.trim() || channel?.name?.trim(); - const epgProgram = channelId ? epgMap.get(channelId) : null; - return { - ...channel, - epgProgram, - progressPercentage: this.calculateProgress(epgProgram), - } as EnrichedChannel; - }) - ); + if (!group) { + return []; } - return result; - }); - /** - * Gets enriched channels for a specific group from the memoized map - */ - getEnrichedGroupChannels( - channels: Channel[], - groupKey?: string - ): EnrichedChannel[] { - // If groupKey provided, use memoized map - if (groupKey !== undefined) { - return this.enrichedGroupChannelsMap().get(groupKey) || []; - } - // Fallback for direct channel array (shouldn't happen with proper template usage) - const epgMap = this.channelEpgMap(); - return channels.map((channel) => { + return group.channels.map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null; return { @@ -254,32 +232,36 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy { progressPercentage: this.calculateProgress(epgProgram), } as EnrichedChannel; }); - } + }); - /** - * Calculates progress percentage for an EPG program - */ - private calculateProgress( - epgProgram: EpgProgram | null | undefined - ): number { - if (!epgProgram) { - return 0; + readonly activeChannelGroupKey = computed(() => { + const activeChannelUrl = this.activeChannelUrl(); + if (!activeChannelUrl) { + return null; } - const now = new Date().getTime(); - const start = new Date(epgProgram.start).getTime(); - const stop = new Date(epgProgram.stop).getTime(); + const grouped = this.groupedChannels(); + for (const [groupKey, channels] of Object.entries(grouped)) { + if (channels.some((channel) => channel.url === activeChannelUrl)) { + return groupKey; + } + } - const total = stop - start; - const elapsed = now - start; + return null; + }); - return Math.min(100, Math.max(0, (elapsed / total) * 100)); + selectGroup(groupKey: string): void { + this.selectedGroupKey.set(groupKey); } - trackByFn(_: number, channel: Channel): string { + trackByChannel(_: number, channel: Channel): string { return channel?.id; } + trackByGroupKey(_: number, group: FilteredGroupView): string { + return group.key; + } + onChannelClick(channel: Channel): void { this.channelSelected.emit(channel); } @@ -291,17 +273,49 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy { /** * Comparator for sorting groups - numeric groups first, then alphabetical */ - groupsComparator = ( - a: KeyValue, - b: KeyValue + readonly groupsComparator = ( + a: KeyValue | { key: string; value: Channel[] }, + b: KeyValue | { key: string; value: Channel[] } ): number => { - const numA = parseInt(a.key.replace(/\D/g, '')); - const numB = parseInt(b.key.replace(/\D/g, '')); + const numA = parseInt(a.key.replace(/\D/g, ''), 10); + const numB = parseInt(b.key.replace(/\D/g, ''), 10); - if (!isNaN(numA) && !isNaN(numB)) { + if (!Number.isNaN(numA) && !Number.isNaN(numB) && numA !== numB) { return numA - numB; } + if (!Number.isNaN(numA) && Number.isNaN(numB)) { + return -1; + } + + if (Number.isNaN(numA) && !Number.isNaN(numB)) { + return 1; + } + return a.key.localeCompare(b.key); }; + + private calculateProgress( + epgProgram: EpgProgram | null | undefined + ): number { + if (!epgProgram) { + return 0; + } + + const now = Date.now(); + const start = new Date(epgProgram.start).getTime(); + const stop = new Date(epgProgram.stop).getTime(); + + if (!Number.isFinite(start) || !Number.isFinite(stop)) { + return 0; + } + + const total = stop - start; + if (total <= 0) { + return 0; + } + + const elapsed = Math.min(total, Math.max(0, now - start)); + return Math.round((elapsed / total) * 100); + } }