diff --git a/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.html b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.html new file mode 100644 index 000000000..56eff22df --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.html @@ -0,0 +1,66 @@ +
+ + @for ( + groups of groupedChannels() | keyvalue: groupsComparator; + track $index + ) { + @if (groups.value.length > 0) { + + + {{ + groups.key || + ('CHANNELS.UNGROUPED' | translate) + | titlecase + }} + ({{ groups.value.length }}) + + + + @let enrichedGroupChannels = getEnrichedGroupChannels(groups.value); + @let limit = getGroupLimit(groups.key); +
+ @for ( + channel of enrichedGroupChannels.slice(0, limit); + track trackByFn($index, channel); + let i = $index + ) { + + } + + @if (groups.value.length > limit) { +
+
+ sync + {{ 'CHANNELS.LOADING_MORE' | translate }} +
+
+ } +
+
+
+ } + } +
+
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.scss new file mode 100644 index 000000000..90ea1a540 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.scss @@ -0,0 +1,49 @@ +#groups-list { + height: calc(100vh - 105px); + overflow-x: 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; + display: flex; + justify-content: center; +} + +.loading-more { + display: flex; + align-items: center; + gap: 8px; + color: var(--mdc-theme-primary, #90caf9); + font-size: 0.875rem; + opacity: 0.8; +} + +.loading-more-spinner { + font-size: 20px; + width: 20px; + height: 20px; + animation: spin 1.5s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.ts new file mode 100644 index 000000000..5f794ab22 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.ts @@ -0,0 +1,215 @@ +import { KeyValue, KeyValuePipe, TitleCasePipe } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + NgZone, + OnDestroy, + output, + signal, +} from '@angular/core'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { Channel, EpgProgram } from 'shared-interfaces'; +import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; +import { EnrichedChannel } from '../all-channels-tab/all-channels-tab.component'; + +@Component({ + selector: 'app-groups-tab', + templateUrl: './groups-tab.component.html', + styleUrls: ['./groups-tab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ChannelListItemComponent, + KeyValuePipe, + MatExpansionModule, + MatIconModule, + TitleCasePipe, + TranslatePipe, + ], +}) +export class GroupsTabComponent implements AfterViewInit, OnDestroy { + private readonly cdr = inject(ChangeDetectorRef); + private readonly ngZone = inject(NgZone); + + /** Grouped channels object */ + readonly groupedChannels = input.required<{ [key: string]: Channel[] }>(); + + /** EPG map for channel enrichment */ + readonly channelEpgMap = input.required>(); + + /** Progress tick to trigger progress recalculation */ + readonly progressTick = input.required(); + + /** Whether to show EPG data */ + readonly shouldShowEpg = input.required(); + + /** Currently active channel URL */ + readonly activeChannelUrl = input(); + + /** Emits when a channel is selected */ + readonly channelSelected = output(); + + /** IntersectionObserver for infinite scroll in groups */ + private groupScrollObserver?: IntersectionObserver; + + /** Track observed sentinel elements */ + private observedSentinels = new Set(); + + /** Track expanded groups with their load limits for lazy-loading */ + readonly expandedGroupLimits = signal(new Map()); + + /** Default number of channels to show when group is expanded */ + private readonly DEFAULT_GROUP_LIMIT = 50; + + 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); + + 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, + } + ); + } + + 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); + } + }, 50); + } + + /** + * Gets enriched channels for a specific group + */ + getEnrichedGroupChannels(channels: Channel[]): EnrichedChannel[] { + const epgMap = this.channelEpgMap(); + // Read progressTick to create dependency for progress refresh + this.progressTick(); + + return 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; + }); + } + + /** + * Calculates progress percentage for an EPG program + */ + private calculateProgress(epgProgram: EpgProgram | null | undefined): number { + if (!epgProgram) { + return 0; + } + + const now = new Date().getTime(); + const start = new Date(epgProgram.start).getTime(); + const stop = new Date(epgProgram.stop).getTime(); + + const total = stop - start; + const elapsed = now - start; + + return Math.min(100, Math.max(0, (elapsed / total) * 100)); + } + + trackByFn(_: number, channel: Channel): string { + return channel?.id; + } + + onChannelClick(channel: Channel): void { + this.channelSelected.emit(channel); + } + + /** + * Comparator for sorting groups - numeric groups first, then alphabetical + */ + groupsComparator = ( + a: KeyValue, + b: KeyValue + ): number => { + const numA = parseInt(a.key.replace(/\D/g, '')); + const numB = parseInt(b.key.replace(/\D/g, '')); + + if (!isNaN(numA) && !isNaN(numB)) { + return numA - numB; + } + + return a.key.localeCompare(b.key); + }; +}