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();