feat: implement groups tab with infinite scroll and enriched channel data

This commit is contained in:
4gray committed 2026-02-01 20:20:15 +01:00
1 parent 6b18d91ca7
commit 8b95cfea70
3 files changed
+330

No files matched your search

@@ -0,0 +1,66 @@
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="onGroupPanelOpened(groups.key, groups.value.length)"
>
<mat-expansion-panel-header>
{{
groups.key ||
('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels = getEnrichedGroupChannels(groups.value);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(0, limit);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
></app-channel-list-item>
}
<!-- Infinite scroll sentinel - triggers loading more when visible -->
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon class="loading-more-spinner">sync</mat-icon>
<span>{{ 'CHANNELS.LOADING_MORE' | translate }}</span>
</div>
</div>
}
</div>
</ng-template>
</mat-expansion-panel>
}
}
</mat-accordion>
</div>
@@ -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);
}
}
@@ -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<Map<string, EpgProgram | null>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** IntersectionObserver for infinite scroll in groups */
private groupScrollObserver?: IntersectionObserver;
/** Track observed sentinel elements */
private observedSentinels = new Set<Element>();
/** Track expanded groups with their load limits for lazy-loading */
readonly expandedGroupLimits = signal(new Map<string, number>());
/** 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<string, Channel[]>,
b: KeyValue<string, Channel[]>
): 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);
};
}