mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat: implement groups tab with infinite scroll and enriched channel data
This commit is contained in:
1 parent
6b18d91ca7
commit
8b95cfea70
3 files changed
+330
No files matched your search
+66
@@ -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>
|
||||
+49
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user