diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index 71820f84e..66a746e76 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -1,5 +1,5 @@
- @if (enrichedChannels().length === 0 && searchTerm()) { + @if (filteredChannels().length === 0 && searchTerm()) {
search_off
@@ -16,7 +16,7 @@ > { ); fixture.detectChanges(); - expect(component.enrichedChannels()[0].logo).toBe(playlistLogo); + expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe( + playlistLogo + ); }); it('falls back to the EPG icon using tvg-name when tvg-id and playlist logo are missing', () => { @@ -150,7 +152,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.detectChanges(); - expect(component.enrichedChannels()[0].logo).toBe( + expect(component.getLogoForChannel(component.filteredChannels()[0])).toBe( 'https://example.com/guide-news.png' ); }); diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index c141cb2d3..4c6e87b68 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -20,10 +20,13 @@ import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; -/** Enriched channel with pre-computed EPG and progress data */ -export interface EnrichedChannel extends Channel { +/** + * Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key. + * Replaces the older EnrichedChannel pattern that spread-cloned every channel + * on every progressTick (~30 s). + */ +export interface ChannelEpgMetadata { epgProgram: EpgProgram | null | undefined; - logo: string; progressPercentage: number; } @@ -86,38 +89,56 @@ export class AllChannelsViewComponent { }); /** - * Computed signal for filtered and enriched channels. + * Filtered channels — just a subset reference, no cloning. + * Recomputes only when the source list or the search term changes. */ - readonly enrichedChannels = computed(() => { + readonly filteredChannels = computed(() => { const term = this.searchTerm().trim().toLowerCase(); const channels = this.channels(); + if (!term) { + return channels; + } + return channels.filter((ch) => + ch.name?.toLowerCase().includes(term) + ); + }); + + /** + * Side-car EPG metadata keyed by channel EPG lookup key. + * Rebuilt every progressTick (~30 s) but only contains entries for channels + * that actually have EPG data — typically a small fraction of the playlist. + * Replaces the previous spread-clone-every-channel pattern that allocated + * ~90K objects per tick on large M3U playlists. + */ + readonly epgMetadataMap = computed(() => { const epgMap = this.channelEpgMap(); - const iconMap = this.channelIconMap(); // Read progressTick to create dependency for progress refresh this.progressTick(); - let result = channels; - - // Filter if search term exists - if (term) { - result = channels.filter((ch) => - ch.name?.toLowerCase().includes(term) - ); - } - - // Enrich with EPG data and pre-calculate progress - return result.map((channel) => { - const channelId = resolveChannelEpgLookupKey(channel); - const epgProgram = channelId ? epgMap.get(channelId) : null; - return { - ...channel, - epgProgram, - logo: resolveChannelLogo(channel, iconMap), - progressPercentage: this.calculateProgress(epgProgram), - } as EnrichedChannel; + const result = new Map(); + epgMap.forEach((program, channelId) => { + result.set(channelId, { + epgProgram: program, + progressPercentage: this.calculateProgress(program), + }); }); + return result; }); + /** Resolves the EPG lookup key the side-car map is keyed by. */ + getChannelEpgKey(channel: Channel): string { + return resolveChannelEpgLookupKey(channel) ?? ''; + } + + /** + * Resolves the channel logo. Called per visible row from the template; under + * OnPush + virtual scroll only ~50 rows check at a time so direct calls are + * cheaper than rebuilding a separate logo map per channels/iconMap change. + */ + getLogoForChannel(channel: Channel): string { + return resolveChannelLogo(channel, this.channelIconMap()); + } + /** * Calculates progress percentage for an EPG program */ diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html index 8df7f967f..91e1b9d73 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.html @@ -5,9 +5,9 @@ (cdkDropListDropped)="onDrop($event)" id="favorites-list" > - @if (enrichedFavorites().length > 0) { + @if (filteredFavorites().length > 0) { @for ( - channel of enrichedFavorites(); + channel of filteredFavorites(); track trackByFn($index, channel); let i = $index ) { @@ -19,11 +19,16 @@ (channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate) " [isDraggable]="!hasSearchTerm()" - [logo]="channel.logo" + [logo]="getLogoForChannel(channel)" [showEpg]="shouldShowEpg()" [isRadio]="channel?.radio === 'true'" - [epgProgram]="channel.epgProgram" - [progressPercentage]="channel.progressPercentage" + [epgProgram]=" + epgMetadataMap().get(getChannelEpgKey(channel))?.epgProgram + " + [progressPercentage]=" + epgMetadataMap().get(getChannelEpgKey(channel)) + ?.progressPercentage ?? 0 + " (clicked)="onChannelClick(channel)" [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index fe4b8c111..72681ddbf 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -13,7 +13,7 @@ import { import { TranslatePipe } from '@ngx-translate/core'; import { resolveChannelEpgLookupKey } from 'm3u-state'; import { Channel, EpgProgram } from 'shared-interfaces'; -import { EnrichedChannel } from '../all-channels-view/all-channels-view.component'; +import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; @@ -71,27 +71,34 @@ export class FavoritesViewComponent { }); /** - * Computed signal for enriched favorites with EPG data + * Side-car EPG metadata keyed by channel EPG lookup key. Rebuilt every + * progressTick (~30 s) but only contains entries for channels with EPG + * data. Replaces the previous spread-clone-every-channel pattern. */ - readonly enrichedFavorites = computed(() => { - const favorites = this.filteredFavorites(); + readonly epgMetadataMap = computed(() => { const epgMap = this.channelEpgMap(); - const iconMap = this.channelIconMap(); - // Read progressTick to trigger recalculation this.progressTick(); - return favorites.map((channel) => { - const channelId = resolveChannelEpgLookupKey(channel); - const epgProgram = channelId ? epgMap.get(channelId) : null; - return { - ...channel, - epgProgram, - logo: resolveChannelLogo(channel, iconMap), - progressPercentage: this.calculateProgress(epgProgram), - } as EnrichedChannel; + const result = new Map(); + epgMap.forEach((program, channelId) => { + result.set(channelId, { + epgProgram: program, + progressPercentage: this.calculateProgress(program), + }); }); + return result; }); + /** Resolves the EPG lookup key the side-car map is keyed by. */ + getChannelEpgKey(channel: Channel): string { + return resolveChannelEpgLookupKey(channel) ?? ''; + } + + /** Resolves the channel logo. Called per visible row from the template. */ + getLogoForChannel(channel: Channel): string { + return resolveChannelLogo(channel, this.channelIconMap()); + } + /** * Calculates progress percentage for an EPG program */ 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 ca3ceff9d..0d702ead7 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 @@ -235,12 +235,18 @@ (channel?.name || ('CHANNELS.UNNAMED_CHANNEL' | translate)) " - [logo]="channel.logo" + [logo]="getLogoForChannel(channel)" [showEpg]="shouldShowEpg()" [showDetailsContextMenu]="true" [isRadio]="channel?.radio === 'true'" - [epgProgram]="channel.epgProgram" - [progressPercentage]="channel.progressPercentage" + [epgProgram]=" + epgMetadataMap().get(getChannelEpgKey(channel)) + ?.epgProgram + " + [progressPercentage]=" + epgMetadataMap().get(getChannelEpgKey(channel)) + ?.progressPercentage ?? 0 + " [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" [isFavorite]=" 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 index aaff1b53c..c111d9b06 100644 --- 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 @@ -250,9 +250,9 @@ describe('GroupsViewComponent', () => { ); fixture.detectChanges(); - expect(component.selectedGroupChannels()[0].logo).toBe( - 'https://example.com/sports.png' - ); + expect( + component.getLogoForChannel(component.selectedGroupChannels()[0]) + ).toBe('https://example.com/sports.png'); }); it('persists sort mode changes', () => { 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 bd9dcf168..539934a18 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 @@ -28,7 +28,7 @@ import { restorePortalChannelSortMode, sortPortalChannelItems, } from '@iptvnator/portal/shared/util'; -import { EnrichedChannel } from '../all-channels-view/all-channels-view.component'; +import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; @@ -313,12 +313,14 @@ export class GroupsViewComponent { ); }); - readonly selectedGroupChannels = computed(() => { + /** + * Channels for the currently selected group, sorted but NOT cloned. + * Recomputes only when the selected group or sort mode changes — no longer + * tied to progressTick, so we don't re-sort/re-allocate every 30 s. + */ + readonly selectedGroupChannels = computed(() => { const group = this.selectedGroup(); const sortMode = this.groupChannelSortMode(); - const epgMap = this.channelEpgMap(); - const iconMap = this.channelIconMap(); - this.progressTick(); if (!group) { return []; @@ -328,18 +330,39 @@ export class GroupsViewComponent { group.channels, sortMode, (channel) => channel?.name - ).map((channel) => { - const channelId = resolveChannelEpgLookupKey(channel); - const epgProgram = channelId ? epgMap.get(channelId) : null; - return { - ...channel, - epgProgram, - logo: resolveChannelLogo(channel, iconMap), - progressPercentage: this.calculateProgress(epgProgram), - } as EnrichedChannel; - }); + ); }); + /** + * Side-car EPG metadata keyed by channel EPG lookup key. Rebuilt every + * progressTick (~30 s) but only contains entries for channels with EPG + * data — typically a small fraction of the playlist. Replaces the previous + * spread-clone-every-channel pattern. + */ + readonly epgMetadataMap = computed(() => { + const epgMap = this.channelEpgMap(); + this.progressTick(); + + const result = new Map(); + epgMap.forEach((program, channelId) => { + result.set(channelId, { + epgProgram: program, + progressPercentage: this.calculateProgress(program), + }); + }); + return result; + }); + + /** Resolves the EPG lookup key the side-car map is keyed by. */ + getChannelEpgKey(channel: Channel): string { + return resolveChannelEpgLookupKey(channel) ?? ''; + } + + /** Resolves the channel logo. Called per visible row from the template. */ + getLogoForChannel(channel: Channel): string { + return resolveChannelLogo(channel, this.channelIconMap()); + } + private readonly groupKeyByChannelUrl = computed(() => { const groupKeys = new Map();