From 8b95cfea707ca9068119a18807c6a524c80b0e0b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 20:20:15 +0100 Subject: [PATCH 1/8] feat: implement groups tab with infinite scroll and enriched channel data --- .../groups-tab/groups-tab.component.html | 66 ++++++ .../groups-tab/groups-tab.component.scss | 49 ++++ .../groups-tab/groups-tab.component.ts | 215 ++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/groups-tab/groups-tab.component.ts 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); + }; +} From 40173e6498f77b122cf23b2848a991f8de872342 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 20:22:20 +0100 Subject: [PATCH 2/8] feat: improve search behavior, add debounce operation, skip FilterPipe --- .../all-channels-tab.component.html | 53 +++++ .../all-channels-tab.component.scss | 89 ++++++++ .../all-channels-tab.component.ts | 154 +++++++++++++ .../channel-list-container.component.html | 171 +++------------ .../channel-list-container.component.scss | 106 ++------- .../channel-list-container.component.ts | 206 ++++++------------ .../channel-list-item.component.ts | 57 +---- 7 files changed, 425 insertions(+), 411 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html new file mode 100644 index 000000000..d20d7fd50 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html @@ -0,0 +1,53 @@ +
+ + + +
+
+ @if (enrichedChannels().length === 0 && searchTerm()) { +
+ search_off +
+ {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }} +
+
+ {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }} +
+
+ } @else { + + + + } +
diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss new file mode 100644 index 000000000..3cb935f40 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.scss @@ -0,0 +1,89 @@ +@use '@angular/material' as mat; + +.scroll-viewport { + min-height: calc(100vh - 190px); + overflow-x: hidden; +} + +.search-panel { + padding: 10px 0 0 0; + display: flex; + justify-content: center; +} + +.search-bar { + @include mat.form-field-density(-5); + + & { + width: 96%; + } +} + +#all-channels { + overflow: hidden; + overflow-x: hidden; + max-height: calc(100vh - 167px); +} + +.channel-list { + overflow-x: hidden; +} + +::ng-deep { + .search-bar { + div { + border-radius: 20px; + } + } + + div[matformfieldlineripple], + .mat-mdc-form-field-subscript-wrapper { + display: none; + } +} + +[type='search']::-webkit-search-cancel-button { + -webkit-appearance: none; + cursor: pointer; + appearance: none; + height: 10px; + width: 10px; + background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA5NiA5NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgOTYgOTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iI2VlZSIgcG9pbnRzPSI5NiwxNCA4MiwwIDQ4LDM0IDE0LDAgMCwxNCAzNCw0OCAwLDgyIDE0LDk2IDQ4LDYyIDgyLDk2IDk2LDgyIDYyLDQ4ICIvPg0KPC9zdmc+DQo='); + background-size: 10px 10px; + color: #fff; +} + +.no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + height: calc(100vh - 220px); +} + +.no-results-icon { + font-size: 48px; + width: 48px; + height: 48px; + opacity: 0.5; + margin-bottom: 16px; +} + +.no-results-title { + font-size: 1.1rem; + font-weight: 500; + margin-bottom: 8px; +} + +.no-results-hint { + opacity: 0.7; + font-size: 0.9rem; +} + +@media only screen and (max-width: 599px) { + .scroll-viewport { + min-height: calc(50vh - 159px); + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts new file mode 100644 index 000000000..32f950e9c --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts @@ -0,0 +1,154 @@ +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + HostListener, + input, + OnDestroy, + output, + signal, + viewChild, +} from '@angular/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { TranslatePipe } from '@ngx-translate/core'; +import { Channel, EpgProgram } from 'shared-interfaces'; +import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; + +/** Enriched channel with pre-computed EPG and progress data */ +export interface EnrichedChannel extends Channel { + epgProgram: EpgProgram | null | undefined; + progressPercentage: number; +} + +@Component({ + selector: 'app-all-channels-tab', + templateUrl: './all-channels-tab.component.html', + styleUrls: ['./all-channels-tab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ChannelListItemComponent, + CommonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + ScrollingModule, + TranslatePipe, + ], +}) +export class AllChannelsTabComponent implements OnDestroy { + /** All channels (will be filtered by search) */ + readonly channels = input.required(); + + /** 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(); + + /** Item size for virtual scroll */ + readonly itemSize = input.required(); + + /** Currently active channel URL */ + readonly activeChannelUrl = input(); + + /** Emits when a channel is selected */ + readonly channelSelected = output(); + + /** Search term signal for debounced filtering */ + readonly searchTerm = signal(''); + + /** Debounce timeout for search */ + private searchDebounceTimeout?: number; + + /** Search field element */ + readonly searchElement = viewChild>('search'); + + /** Register ctrl+f as keyboard hotkey to focus the search input field */ + @HostListener('document:keypress', ['$event']) + handleKeyboardEvent(event: KeyboardEvent): void { + if (event.key === 'f' && event.ctrlKey) { + this.searchElement()?.nativeElement.focus(); + } + } + + /** + * Computed signal for filtered and enriched channels. + */ + readonly enrichedChannels = computed(() => { + const term = this.searchTerm().toLowerCase(); + const channels = this.channels(); + const epgMap = this.channelEpgMap(); + // 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 = channel?.tvg?.id?.trim() || channel?.name?.trim(); + const epgProgram = channelId ? epgMap.get(channelId) : null; + return { + ...channel, + epgProgram, + progressPercentage: this.calculateProgress(epgProgram), + } as EnrichedChannel; + }); + }); + + /** + * Handles debounced search input + */ + onSearchInput(value: string): void { + clearTimeout(this.searchDebounceTimeout); + this.searchDebounceTimeout = window.setTimeout(() => { + this.searchTerm.set(value); + }, 300); + } + + /** + * 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); + } + + ngOnDestroy(): void { + if (this.searchDebounceTimeout) { + clearTimeout(this.searchDebounceTimeout); + } + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 3b3b9764f..db709a724 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -1,5 +1,6 @@ @if (_channelList) { + list @@ -7,49 +8,18 @@ {{ 'CHANNELS.ALL_CHANNELS' | translate }} -
- - - -
-
- - - -
+
+ category @@ -57,57 +27,17 @@ {{ 'CHANNELS.GROUPS' | translate }} -
- - @for ( - groups of groupedChannels | keyvalue: groupsComparator; - track $index - ) { - @if (groups.value.length > 0) { - - - {{ - groups.key || - ('CHANNELS.UNGROUPED' | translate) - | titlecase - }} - ({{ groups.value.length }}) - - - -
- @for ( - channel of groups.value; - track trackByFn($index, channel); - let i = $index - ) { - - } -
-
-
- } - } -
-
+
+ @let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES'; @if (showFavorites) { @@ -119,56 +49,23 @@ @let favorites = favorites$ | async; @if (favorites) { -
- @if (favorites.length > 0) { - @for ( - channel of favorites; - track trackByFn($index, channel); - let i = $index - ) { - - } - } @else { -
- {{ - 'CHANNELS.NO_FAVORITES' | translate - }} -
-
- {{ - 'CHANNELS.USE_STAR_TO_FAVORITE' | translate - }} -
- } -
+ }
}
} @else { - No channels there +
+ sync +
{{ 'CHANNELS.LOADING' | translate }}
+
} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 396684de6..d5e003a78 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -1,96 +1,42 @@ -@use '@angular/material' as mat; - -.scroll-viewport { - min-height: calc(100vh - 190px); - overflow-x: hidden; -} - -.search-panel { - padding: 10px 0 0 0; - display: flex; - justify-content: center; -} - -.search-bar { - @include mat.form-field-density(-5); - - & { - width: 96%; - } -} - -#all-channels { - overflow: hidden; - overflow-x: hidden; - max-height: calc(100vh - 167px); -} - -.channel-list { - overflow-x: hidden; -} - ::ng-deep { - .search-bar { - div { - border-radius: 20px; - } - } - - div[matformfieldlineripple], - .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - .mat-expansion-panel-body { - padding: 0 !important; - } - - .mat-expansion-panel { - border-radius: 0 !important; - } - .mat-mdc-tab.mdc-tab { padding: 0 5px; } } -[type='search']::-webkit-search-cancel-button { - -webkit-appearance: none; - cursor: pointer; - appearance: none; - height: 10px; - width: 10px; - background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA5NiA5NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgOTYgOTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iI2VlZSIgcG9pbnRzPSI5NiwxNCA4MiwwIDQ4LDM0IDE0LDAgMCwxNCAzNCw0OCAwLDgyIDE0LDk2IDQ4LDYyIDgyLDk2IDk2LDgyIDYyLDQ4ICIvPg0KPC9zdmc+DQo='); - background-size: 10px 10px; - color: #fff; -} - -#groups-list, -#favorites-list { - height: calc(100vh - 105px); -} - -.empty-favorites, -.empty-favorites-hint { - padding: 16px 24px; +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: calc(100vh - 120px); text-align: center; } -.empty-favorites { - margin-top: 40px; - font-size: 1rem; +.loading-spinner { + font-size: 48px; + width: 48px; + height: 48px; + animation: spin 1.5s linear infinite; + opacity: 0.7; + margin-bottom: 16px; } -.empty-favorites-hint { +.loading-text { + font-size: 1rem; opacity: 0.7; - font-size: 0.9rem; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } @media only screen and (max-width: 599px) { - .channel-name { - max-width: 100px; - } - .tab-title { display: none; } @@ -98,8 +44,4 @@ .tab-icon { display: block; } - - .scroll-viewport { - min-height: calc(50vh - 159px); - } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 1003de803..b628cb4a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -1,29 +1,17 @@ +import { CommonModule } from '@angular/common'; import { - CdkDragDrop, - DragDropModule, - moveItemInArray, -} from '@angular/cdk/drag-drop'; -import { ScrollingModule } from '@angular/cdk/scrolling'; -import { CommonModule, KeyValue, TitleCasePipe } from '@angular/common'; -import { + ChangeDetectionStrategy, Component, - ElementRef, - HostListener, + computed, inject, Input, OnDestroy, OnInit, - viewChild, + signal, } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatExpansionModule } from '@angular/material/expansion'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { FilterPipe } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -37,26 +25,22 @@ import { import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces'; -import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; +import { AllChannelsTabComponent } from './all-channels-tab/all-channels-tab.component'; +import { FavoritesTabComponent } from './favorites-tab/favorites-tab.component'; +import { GroupsTabComponent } from './groups-tab/groups-tab.component'; @Component({ selector: 'app-channel-list-container', templateUrl: './channel-list-container.component.html', styleUrls: ['./channel-list-container.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - ChannelListItemComponent, + AllChannelsTabComponent, CommonModule, - DragDropModule, - FilterPipe, - FormsModule, - MatDividerModule, - MatExpansionModule, - MatFormFieldModule, + FavoritesTabComponent, + GroupsTabComponent, MatIconModule, - MatInputModule, MatTabsModule, - ScrollingModule, - TitleCasePipe, TranslatePipe, ], }) @@ -68,23 +52,24 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly translateService = inject(TranslateService); /** Map of channel ID to current EPG program */ - channelEpgMap = new Map(); + readonly channelEpgMap = signal(new Map()); /** Interval for refreshing EPG data */ private epgRefreshInterval?: number; - /** Whether to show EPG data in channel items (false in PWA mode or when EPG is not configured) */ - shouldShowEpg = false; + /** Global progress tick signal - triggers re-computation of progress percentages */ + readonly progressTick = signal(0); + + /** Interval for global progress updates */ + private progressInterval?: number; + + /** Whether to show EPG data in channel items */ + readonly shouldShowEpg = signal(false); /** Item size for virtual scroll - compact when no EPG */ - get itemSize(): number { - return this.shouldShowEpg ? 68 : 48; - } + readonly itemSize = computed(() => this.shouldShowEpg() ? 68 : 48); - /** - * Channels array - * Create local copy of the store for local manipulations without updates in the store - */ + /** Channels array */ _channelList: Channel[] = []; private channelList$ = new BehaviorSubject([]); @@ -95,33 +80,19 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { @Input() set channelList(value: Channel[]) { this._channelList = value; - this.channelList$.next(value); // Emit to observable + this.channelList$.next(value); this.groupedChannels = _.default.groupBy(value, 'group.title'); - // Fetch EPG for new channel list this.fetchEpgForChannels(value); } /** Object with channels sorted by groups */ - groupedChannels!: { [key: string]: Channel[] }; + groupedChannels: { [key: string]: Channel[] } = {}; /** Selected channel */ readonly activeChannel = this.store.selectSignal(selectActive); - /** Search term for channel filter */ - searchTerm: { name: string } = { - name: '', - }; - - /** Search field element */ - readonly searchElement = viewChild>('search'); - - /** Register ctrl+f as keyboard hotkey to focus the search input field */ - @HostListener('document:keypress', ['$event']) - handleKeyboardEvent(event: KeyboardEvent): void { - if (event.key === 'f' && event.ctrlKey) { - this.searchElement()?.nativeElement.focus(); - } - } + /** Active channel URL for highlighting */ + readonly activeChannelUrl = computed(() => this.activeChannel()?.url); /** ID of the current playlist */ playlistId$ = this.store @@ -132,77 +103,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ); - /** List with favorites - combines favorites from store with current channel list */ + /** List with favorites */ favorites$ = combineLatest([ this.store.select(selectFavorites), this.channelList$, ]).pipe( map(([favoriteChannelIds, channelList]) => { - console.log( - '[ChannelList] favorites$ emit - IDs:', - favoriteChannelIds.length, - 'Channels:', - channelList.length - ); - const favorites = favoriteChannelIds + return favoriteChannelIds .map((favoriteChannelId) => channelList.find( (channel) => channel.url === favoriteChannelId ) ) - .filter((channel): channel is Channel => channel !== undefined); // Filter out undefined channels - console.log( - '[ChannelList] favorites$ result:', - favorites.length, - 'favorites' - ); - return favorites; + .filter((channel): channel is Channel => channel !== undefined); }) ); - /** - * Sets clicked channel as active and dispatches to store - * @param channel selected channel - */ - selectChannel(channel: Channel): void { - this.store.dispatch(ChannelActions.setActiveChannel({ channel })); - - // Use tvg-id for EPG matching, fallback to channel name if not available - const epgChannelId = channel?.tvg?.id?.trim() || channel?.name.trim(); - - if (epgChannelId) { - this.epgService.getChannelPrograms(epgChannelId); - } - } - - /** - * Toggles favorite flag for the given channel - * @param channel channel to update - * @param clickEvent mouse click event - */ - toggleFavoriteChannel(channel: Channel, clickEvent: MouseEvent): void { - clickEvent.stopPropagation(); - this.snackBar.open( - this.translateService.instant('CHANNELS.FAVORITES_UPDATED'), - undefined, - { duration: 2000 } - ); - this.store.dispatch(FavoritesActions.updateFavorites({ channel })); - } - - trackByFn(_: number, channel: Channel): string { - return channel?.id; - } - - drop(event: CdkDragDrop, favorites: Channel[]) { - moveItemInArray(favorites, event.previousIndex, event.currentIndex); - this.store.dispatch( - FavoritesActions.setFavorites({ - channelIds: favorites.map((item) => item.url), - }) - ); - } - ngOnInit(): void { // Check if EPG should be shown (only in Electron with configured EPG URL) const isElectron = !!window['electron']; @@ -215,28 +131,35 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { Object.keys(settings as Settings).length > 0 ) { const epgUrl = (settings as Settings).epgUrl; - this.shouldShowEpg = !!(epgUrl && epgUrl.length > 0); + this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0)); } }); } else { - // PWA mode - don't show EPG - this.shouldShowEpg = false; + this.shouldShowEpg.set(false); } // Set up EPG refresh interval (every 60 seconds) this.epgRefreshInterval = window.setInterval(() => { this.fetchEpgForChannels(this._channelList); }, 60000); + + // Set up global progress update interval (every 30 seconds) + this.progressInterval = window.setInterval(() => { + this.progressTick.update(v => v + 1); + }, 30000); } - ngOnDestroy() { + ngOnDestroy(): void { this.store.dispatch(ChannelActions.setChannels({ channels: [] })); if (this.epgRefreshInterval) { clearInterval(this.epgRefreshInterval); } - // Clean up BehaviorSubject + if (this.progressInterval) { + clearInterval(this.progressInterval); + } + this.channelList$.complete(); } @@ -245,42 +168,49 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { */ private fetchEpgForChannels(channels: Channel[]): void { if (!channels || channels.length === 0) { - console.log('[EPG] No channels to fetch EPG for'); return; } - // Get channel IDs (prefer tvg-id, fallback to name) const channelIds = channels .map((channel) => channel?.tvg?.id?.trim() || channel?.name?.trim()) .filter((id) => !!id); - // Batch fetch EPG programs this.epgService .getCurrentProgramsForChannels(channelIds) .subscribe((epgMap) => { - this.channelEpgMap = epgMap; + this.channelEpgMap.set(epgMap); }); } /** - * Gets EPG program for a specific channel + * Handles channel selection from any tab */ - getEpgForChannel(channel: Channel): EpgProgram | null | undefined { - const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); - return channelId ? this.channelEpgMap.get(channelId) : null; + onChannelSelected(channel: Channel): void { + this.store.dispatch(ChannelActions.setActiveChannel({ channel })); + + const epgChannelId = channel?.tvg?.id?.trim() || channel?.name.trim(); + if (epgChannelId) { + this.epgService.getChannelPrograms(epgChannelId); + } } - groupsComparator = ( - a: KeyValue, - b: KeyValue - ): number => { - const numA = parseInt(a.key.replace(/\D/g, '')); - const numB = parseInt(b.key.replace(/\D/g, '')); + /** + * Handles favorite toggle from favorites tab + */ + onFavoriteToggled(event: { channel: Channel; event: MouseEvent }): void { + event.event.stopPropagation(); + this.snackBar.open( + this.translateService.instant('CHANNELS.FAVORITES_UPDATED'), + undefined, + { duration: 2000 } + ); + this.store.dispatch(FavoritesActions.updateFavorites({ channel: event.channel })); + } - if (!isNaN(numA) && !isNaN(numB)) { - return numA - numB; - } - - return a.key.localeCompare(b.key); - }; + /** + * Handles favorites reorder from drag-drop + */ + onFavoritesReordered(channelIds: string[]): void { + this.store.dispatch(FavoritesActions.setFavorites({ channelIds })); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 30f442de0..58ad5ef67 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -2,16 +2,11 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; import { NgStyle } from '@angular/common'; import { ChangeDetectionStrategy, - ChangeDetectorRef, Component, EventEmitter, inject, Input, - OnChanges, - OnDestroy, - OnInit, Output, - SimpleChanges, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -35,8 +30,7 @@ import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description TranslatePipe, ], }) -export class ChannelListItemComponent implements OnInit, OnChanges, OnDestroy { - private readonly cdr = inject(ChangeDetectorRef); +export class ChannelListItemComponent { private readonly dialog = inject(MatDialog); @Input() isDraggable = false; @@ -46,57 +40,12 @@ export class ChannelListItemComponent implements OnInit, OnChanges, OnDestroy { @Input() selected = false; @Input() showEpg = true; @Input() epgProgram?: EpgProgram | null; + /** Progress percentage pre-computed by parent for performance */ + @Input() progressPercentage = 0; @Output() clicked = new EventEmitter(); @Output() favoriteToggled = new EventEmitter(); - progressPercentage = 0; - private progressInterval?: number; - - ngOnInit(): void { - this.calculateProgress(); - // Update progress every 30 seconds - this.progressInterval = window.setInterval(() => { - this.calculateProgress(); - }, 30000); - } - - ngOnChanges(changes: SimpleChanges): void { - // Recalculate progress when epgProgram changes - if (changes['epgProgram']) { - this.calculateProgress(); - } - } - - ngOnDestroy(): void { - if (this.progressInterval) { - clearInterval(this.progressInterval); - } - } - - /** - * Calculates the progress percentage for the EPG program - */ - private calculateProgress(): void { - if (!this.epgProgram) { - this.progressPercentage = 0; - return; - } - - const now = new Date().getTime(); - const start = new Date(this.epgProgram.start).getTime(); - const stop = new Date(this.epgProgram.stop).getTime(); - - const total = stop - start; - const elapsed = now - start; - - this.progressPercentage = Math.min( - 100, - Math.max(0, (elapsed / total) * 100) - ); - this.cdr.markForCheck(); - } - /** * Formats time for display (HH:mm) */ From 3988708ab5d227f1fba26527673e4582ca9c2dee Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 20:22:46 +0100 Subject: [PATCH 3/8] refactor: move favorites tab to own component --- .../favorites-tab.component.html | 41 +++++++ .../favorites-tab.component.scss | 24 ++++ .../favorites-tab/favorites-tab.component.ts | 108 ++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html new file mode 100644 index 000000000..97861373b --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html @@ -0,0 +1,41 @@ +
+ @if (enrichedFavorites.length > 0) { + @for ( + channel of enrichedFavorites; + track trackByFn($index, channel); + let i = $index + ) { + + } + } @else { +
+ {{ 'CHANNELS.NO_FAVORITES' | translate }} +
+
+ {{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }} +
+ } +
diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.scss b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.scss new file mode 100644 index 000000000..9cbd2f7b0 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.scss @@ -0,0 +1,24 @@ +#favorites-list { + height: calc(100vh - 105px); + overflow-x: hidden; +} + +.channel-list { + overflow-x: hidden; +} + +.empty-favorites, +.empty-favorites-hint { + padding: 16px 24px; + text-align: center; +} + +.empty-favorites { + margin-top: 40px; + font-size: 1rem; +} + +.empty-favorites-hint { + opacity: 0.7; + font-size: 0.9rem; +} diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts new file mode 100644 index 000000000..c345f73b0 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts @@ -0,0 +1,108 @@ +import { + CdkDragDrop, + DragDropModule, + moveItemInArray, +} from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +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-favorites-tab', + templateUrl: './favorites-tab.component.html', + styleUrls: ['./favorites-tab.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ChannelListItemComponent, + DragDropModule, + TranslatePipe, + ], +}) +export class FavoritesTabComponent { + /** Favorite channels */ + readonly favorites = input.required(); + + /** 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(); + + /** Emits when favorite is toggled (removed) */ + readonly favoriteToggled = output<{ channel: Channel; event: MouseEvent }>(); + + /** Emits when favorites order changes via drag-drop */ + readonly favoritesReordered = output(); + + /** + * Gets enriched favorites with EPG data + */ + get enrichedFavorites(): EnrichedChannel[] { + const favorites = this.favorites(); + const epgMap = this.channelEpgMap(); + // Read progressTick to trigger recalculation + this.progressTick(); + + return favorites.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); + } + + onFavoriteToggle(channel: Channel, event: MouseEvent): void { + this.favoriteToggled.emit({ channel, event }); + } + + onDrop(event: CdkDragDrop): void { + const favorites = [...this.favorites()]; + moveItemInArray(favorites, event.previousIndex, event.currentIndex); + this.favoritesReordered.emit(favorites.map(item => item.url)); + } +} From fef815058f702c6fe5eeb7d476b61e64f885737a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 20:22:54 +0100 Subject: [PATCH 4/8] feat: add new loading and search related messages to the English localization --- apps/web/src/assets/i18n/en.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index fb4a0e27b..98de993f5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -227,7 +227,13 @@ "SORT_BY": "Sort by", "SORT_BY_DATE": "Sort by Date Added (Newest First)", "SORT_BY_RATING": "Sort by Rating", - "SORT_BY_NAME": "Sort by Name (A-Z)" + "SORT_BY_NAME": "Sort by Name (A-Z)", + "LOAD_MORE": "Load more", + "REMAINING": "remaining", + "LOADING": "Loading channels...", + "LOADING_MORE": "Loading more...", + "NO_SEARCH_RESULTS": "No channels found", + "TRY_DIFFERENT_SEARCH": "Try a different search term" }, "TOP_MENU": { "OPEN_CHANNELS_LIST": "Open channel list", From ed8c373d78ac10ddd123085d27b26320f8dde01b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 21:13:52 +0100 Subject: [PATCH 5/8] feat: add favorite functionality to channel list items and groups tab --- .../all-channels-tab.component.html | 6 +- .../all-channels-tab.component.ts | 21 ++++- .../channel-list-container.component.html | 4 + .../channel-list-container.component.ts | 16 +++- .../channel-list-item.component.html | 85 ++++++++++--------- .../channel-list-item.component.scss | 25 ++++++ .../channel-list-item.component.ts | 26 +++--- .../favorites-tab.component.html | 5 +- .../groups-tab/groups-tab.component.html | 34 ++++++-- .../groups-tab/groups-tab.component.ts | 46 ++++++++-- 10 files changed, 189 insertions(+), 79 deletions(-) diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html index d20d7fd50..71ed28750 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.html @@ -38,8 +38,7 @@ i + 1 + '. ' + - (channel?.name || 'CHANNELS.UNNAMED_CHANNEL' - | translate) + (channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate) " [logo]="channel?.tvg?.logo" [showEpg]="shouldShowEpg()" @@ -47,6 +46,9 @@ [progressPercentage]="channel.progressPercentage" (clicked)="onChannelClick(channel)" [selected]="activeChannelUrl() === channel?.url" + [showFavoriteButton]="true" + [isFavorite]="favoriteIds().has(channel?.url)" + (favoriteToggled)="onFavoriteToggle(channel, $event)" /> } diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts index 32f950e9c..6b9575a63 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts @@ -59,9 +59,18 @@ export class AllChannelsTabComponent implements OnDestroy { /** Currently active channel URL */ readonly activeChannelUrl = input(); + /** Set of favorite channel URLs */ + readonly favoriteIds = input>(new Set()); + /** Emits when a channel is selected */ readonly channelSelected = output(); + /** Emits when favorite is toggled */ + readonly favoriteToggled = output<{ + channel: Channel; + event: MouseEvent; + }>(); + /** Search term signal for debounced filtering */ readonly searchTerm = signal(''); @@ -93,13 +102,13 @@ export class AllChannelsTabComponent implements OnDestroy { // Filter if search term exists if (term) { - result = channels.filter(ch => + result = channels.filter((ch) => ch.name?.toLowerCase().includes(term) ); } // Enrich with EPG data and pre-calculate progress - return result.map(channel => { + return result.map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null; return { @@ -123,7 +132,9 @@ export class AllChannelsTabComponent implements OnDestroy { /** * Calculates progress percentage for an EPG program */ - private calculateProgress(epgProgram: EpgProgram | null | undefined): number { + private calculateProgress( + epgProgram: EpgProgram | null | undefined + ): number { if (!epgProgram) { return 0; } @@ -146,6 +157,10 @@ export class AllChannelsTabComponent implements OnDestroy { this.channelSelected.emit(channel); } + onFavoriteToggle(channel: Channel, event: MouseEvent): void { + this.favoriteToggled.emit({ channel, event }); + } + ngOnDestroy(): void { if (this.searchDebounceTimeout) { clearTimeout(this.searchDebounceTimeout); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index db709a724..068b9c9b7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -15,7 +15,9 @@ [shouldShowEpg]="shouldShowEpg()" [itemSize]="itemSize()" [activeChannelUrl]="activeChannelUrl()" + [favoriteIds]="favoriteIds()" (channelSelected)="onChannelSelected($event)" + (favoriteToggled)="onFavoriteToggled($event)" /> @@ -33,7 +35,9 @@ [progressTick]="progressTick()" [shouldShowEpg]="shouldShowEpg()" [activeChannelUrl]="activeChannelUrl()" + [favoriteIds]="favoriteIds()" (channelSelected)="onChannelSelected($event)" + (favoriteToggled)="onFavoriteToggled($event)" /> diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index b628cb4a1..52d2f2c28 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -16,8 +16,9 @@ import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; -import { ChannelActions, FavoritesActions } from 'm3u-state'; import { + ChannelActions, + FavoritesActions, selectActive, selectActivePlaylistId, selectFavorites, @@ -67,7 +68,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { readonly shouldShowEpg = signal(false); /** Item size for virtual scroll - compact when no EPG */ - readonly itemSize = computed(() => this.shouldShowEpg() ? 68 : 48); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); /** Channels array */ _channelList: Channel[] = []; @@ -103,12 +104,17 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ); + /** Set of favorite channel URLs for quick lookup */ + readonly favoriteIds = signal>(new Set()); + /** List with favorites */ favorites$ = combineLatest([ this.store.select(selectFavorites), this.channelList$, ]).pipe( map(([favoriteChannelIds, channelList]) => { + // Update the favoriteIds signal for quick lookup + this.favoriteIds.set(new Set(favoriteChannelIds)); return favoriteChannelIds .map((favoriteChannelId) => channelList.find( @@ -145,7 +151,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { // Set up global progress update interval (every 30 seconds) this.progressInterval = window.setInterval(() => { - this.progressTick.update(v => v + 1); + this.progressTick.update((v) => v + 1); }, 30000); } @@ -204,7 +210,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { undefined, { duration: 2000 } ); - this.store.dispatch(FavoritesActions.updateFavorites({ channel: event.channel })); + this.store.dispatch( + FavoritesActions.updateFavorites({ channel: event.channel }) + ); } /** diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 86c6d4338..396d95ee0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -1,44 +1,44 @@
- @if (isDraggable) { + @if (isDraggable()) { drag_indicator }
- @if (logo) { + @if (logo()) { }
-
{{ name }}
- @if (showEpg) { +
{{ name() }}
+ @if (showEpg()) {
- @if (epgProgram) { + @if (epgProgram(); as program) {
- {{ epgProgram.title }} + {{ program.title }}
{{ formatTime(epgProgram.start) }} - - {{ formatTime(epgProgram.stop) }}{{ formatTime(program.start) }} - + {{ formatTime(program.stop) }}
@@ -49,32 +49,41 @@
}
- @if (epgProgram) { - - }
}
- @if (showFavoriteButton) { - - } + +
+ @if (showEpg() && epgProgram(); as program) { + + } + @if (showFavoriteButton()) { + + } +
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 1e053113a..187e74762 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -199,3 +199,28 @@ opacity: 1; } } + +// Action buttons column (info + favorite stacked vertically) +.action-buttons { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + gap: 0; + + button { + width: 32px; + height: 32px; + line-height: 32px; + padding: 0; + + .mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; + } + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 58ad5ef67..d1f9889d9 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -3,10 +3,9 @@ import { NgStyle } from '@angular/common'; import { ChangeDetectionStrategy, Component, - EventEmitter, inject, - Input, - Output, + input, + output, } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -33,18 +32,19 @@ import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description export class ChannelListItemComponent { private readonly dialog = inject(MatDialog); - @Input() isDraggable = false; - @Input() logo!: string; - @Input() name = ''; - @Input() showFavoriteButton = false; - @Input() selected = false; - @Input() showEpg = true; - @Input() epgProgram?: EpgProgram | null; + readonly isDraggable = input(false); + readonly logo = input.required(); + readonly name = input(''); + readonly showFavoriteButton = input(false); + readonly isFavorite = input(false); + readonly selected = input(false); + readonly showEpg = input(true); + readonly epgProgram = input(); /** Progress percentage pre-computed by parent for performance */ - @Input() progressPercentage = 0; + readonly progressPercentage = input(0); - @Output() clicked = new EventEmitter(); - @Output() favoriteToggled = new EventEmitter(); + readonly clicked = output(); + readonly favoriteToggled = output(); /** * Formats time for display (HH:mm) diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html index 97861373b..1e0fd3702 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html @@ -15,9 +15,7 @@ i + 1 + '. ' + - (channel?.name || - 'CHANNELS.UNNAMED_CHANNEL' - | translate) + (channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate) " [isDraggable]="true" [logo]="channel?.tvg?.logo" @@ -27,6 +25,7 @@ (clicked)="onChannelClick(channel)" [selected]="activeChannelUrl() === channel?.url" [showFavoriteButton]="true" + [isFavorite]="true" (favoriteToggled)="onFavoriteToggle(channel, $event)" /> } 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 index 56eff22df..f5cd906e5 100644 --- 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 @@ -6,23 +6,28 @@ ) { @if (groups.value.length > 0) { {{ - groups.key || - ('CHANNELS.UNGROUPED' | translate) + groups.key || ('CHANNELS.UNGROUPED' | translate) | titlecase }} ({{ groups.value.length }}) - @let enrichedGroupChannels = getEnrichedGroupChannels(groups.value); + @let enrichedGroupChannels = + getEnrichedGroupChannels(groups.value); @let limit = getGroupLimit(groups.key);
@for ( - channel of enrichedGroupChannels.slice(0, limit); + channel of enrichedGroupChannels.slice( + 0, + limit + ); track trackByFn($index, channel); let i = $index ) { @@ -38,11 +43,20 @@ [logo]="channel?.tvg?.logo" [showEpg]="shouldShowEpg()" [epgProgram]="channel.epgProgram" - [progressPercentage]="channel.progressPercentage" + [progressPercentage]=" + channel.progressPercentage + " (clicked)="onChannelClick(channel)" [selected]=" activeChannelUrl() === channel?.url " + [showFavoriteButton]="true" + [isFavorite]=" + favoriteIds().has(channel?.url) + " + (favoriteToggled)=" + onFavoriteToggle(channel, $event) + " > } @@ -52,8 +66,12 @@ [attr.data-sentinel-group]="groups.key" >
- sync - {{ 'CHANNELS.LOADING_MORE' | translate }} + sync + {{ + 'CHANNELS.LOADING_MORE' | translate + }}
} 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 index 5f794ab22..3c7266bbb 100644 --- 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 @@ -15,8 +15,8 @@ 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'; +import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; @Component({ selector: 'app-groups-tab', @@ -51,9 +51,18 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { /** Currently active channel URL */ readonly activeChannelUrl = input(); + /** Set of favorite channel URLs */ + readonly favoriteIds = input>(new Set()); + /** Emits when a channel is selected */ readonly channelSelected = output(); + /** Emits when favorite is toggled */ + readonly favoriteToggled = output<{ + channel: Channel; + event: MouseEvent; + }>(); + /** IntersectionObserver for infinite scroll in groups */ private groupScrollObserver?: IntersectionObserver; @@ -74,7 +83,10 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { if (entry.isIntersecting) { const element = entry.target as HTMLElement; const groupKey = element.dataset['groupKey']; - const totalInGroup = parseInt(element.dataset['totalInGroup'] || '0', 10); + const totalInGroup = parseInt( + element.dataset['totalInGroup'] || '0', + 10 + ); if (groupKey) { // Run inside NgZone to trigger change detection @@ -105,7 +117,9 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { * Gets the current limit for a group, or returns default */ getGroupLimit(groupKey: string): number { - return this.expandedGroupLimits().get(groupKey) ?? this.DEFAULT_GROUP_LIMIT; + return ( + this.expandedGroupLimits().get(groupKey) ?? this.DEFAULT_GROUP_LIMIT + ); } /** @@ -125,7 +139,11 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { /** * Sets up IntersectionObserver for a group's sentinel element. */ - private observeGroupSentinel(element: HTMLElement, groupKey: string, totalInGroup: number): void { + private observeGroupSentinel( + element: HTMLElement, + groupKey: string, + totalInGroup: number + ): void { if (!element || this.observedSentinels.has(element)) { return; } @@ -144,9 +162,15 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { onGroupPanelOpened(groupKey: string, totalInGroup: number): void { // Use setTimeout to ensure the panel content is rendered setTimeout(() => { - const sentinel = document.querySelector(`[data-sentinel-group="${groupKey}"]`); + const sentinel = document.querySelector( + `[data-sentinel-group="${groupKey}"]` + ); if (sentinel) { - this.observeGroupSentinel(sentinel as HTMLElement, groupKey, totalInGroup); + this.observeGroupSentinel( + sentinel as HTMLElement, + groupKey, + totalInGroup + ); } }, 50); } @@ -159,7 +183,7 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { // Read progressTick to create dependency for progress refresh this.progressTick(); - return channels.map(channel => { + return channels.map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null; return { @@ -173,7 +197,9 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { /** * Calculates progress percentage for an EPG program */ - private calculateProgress(epgProgram: EpgProgram | null | undefined): number { + private calculateProgress( + epgProgram: EpgProgram | null | undefined + ): number { if (!epgProgram) { return 0; } @@ -196,6 +222,10 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { this.channelSelected.emit(channel); } + onFavoriteToggle(channel: Channel, event: MouseEvent): void { + this.favoriteToggled.emit({ channel, event }); + } + /** * Comparator for sorting groups - numeric groups first, then alphabetical */ From e420ef1c8e910619de0259c996bdc5f5aad49c4a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 21:21:07 +0100 Subject: [PATCH 6/8] feat: add new message to localization --- apps/web/src/assets/i18n/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 98de993f5..f93bfe454 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -223,6 +223,7 @@ "NO_FAVORITES": "No favorites yet", "USE_STAR_TO_FAVORITE": "Please use 'star' icon to add channels to your favorites.", "REMOVE_FAVORITE": "Remove from favorites", + "ADD_FAVORITE": "Add to favorites", "FAVORITES_UPDATED": "Favorites were updated!", "SORT_BY": "Sort by", "SORT_BY_DATE": "Sort by Date Added (Newest First)", From bffcc24f673c784bfd045597ab909cdb43a661d2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 21:22:43 +0100 Subject: [PATCH 7/8] refactor: optimize imports in downloads component and fix download folder access --- apps/web/src/app/services/settings-store.service.ts | 5 +++-- .../src/app/xtream-tauri/downloads/downloads.component.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/services/settings-store.service.ts b/apps/web/src/app/services/settings-store.service.ts index 4a4172e57..12719bed9 100644 --- a/apps/web/src/app/services/settings-store.service.ts +++ b/apps/web/src/app/services/settings-store.service.ts @@ -29,6 +29,7 @@ const DEFAULT_SETTINGS: Settings = { remoteControl: false, remoteControlPort: 8765, epgUrl: [], + downloadFolder: '', }; export const SettingsStore = signalStore( @@ -79,12 +80,12 @@ export const SettingsStore = signalStore( remoteControl: store.remoteControl(), remoteControlPort: store.remoteControlPort(), epgUrl: store.epgUrl(), - downloadFolder: store.downloadFolder?.(), + downloadFolder: store.downloadFolder(), }; }, getDownloadFolder() { - return store.downloadFolder?.(); + return store.downloadFolder(); }, getPlayer() { diff --git a/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts index 09e9b10c3..eb282a930 100644 --- a/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts +++ b/apps/web/src/app/xtream-tauri/downloads/downloads.component.ts @@ -6,9 +6,9 @@ import { OnInit, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; +import { MatIcon } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; import { @@ -23,9 +23,9 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, - MatIconModule, + MatIcon, MatProgressBarModule, - MatTooltipModule, + MatTooltip, TranslatePipe, ], }) From bb1f3af0822744aa003b0790fbf1dc40900e2c84 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 22:35:10 +0100 Subject: [PATCH 8/8] refactor: optimize progress calculation and enhance enriched channel retrieval in favorites and groups tabs --- .../all-channels-tab.component.ts | 20 ++++++++-- .../favorites-tab.component.html | 4 +- .../favorites-tab/favorites-tab.component.ts | 28 +++++++------- .../groups-tab/groups-tab.component.html | 2 +- .../groups-tab/groups-tab.component.ts | 38 ++++++++++++++++++- 5 files changed, 70 insertions(+), 22 deletions(-) diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts index 6b9575a63..9ed052b8d 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-tab/all-channels-tab.component.ts @@ -139,14 +139,26 @@ export class AllChannelsTabComponent implements OnDestroy { return 0; } - const now = new Date().getTime(); + const now = Date.now(); const start = new Date(epgProgram.start).getTime(); const stop = new Date(epgProgram.stop).getTime(); - const total = stop - start; - const elapsed = now - start; + // Validate start/stop are finite numbers + if (!Number.isFinite(start) || !Number.isFinite(stop)) { + return 0; + } - return Math.min(100, Math.max(0, (elapsed / total) * 100)); + const total = stop - start; + + // Bail out if duration is zero or negative + if (total <= 0) { + return 0; + } + + // Clamp elapsed to [0, total] + const elapsed = Math.min(total, Math.max(0, now - start)); + + return Math.round((elapsed / total) * 100); } trackByFn(_: number, channel: Channel): string { diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html index 1e0fd3702..a30bce896 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.html @@ -4,9 +4,9 @@ (cdkDropListDropped)="onDrop($event)" id="favorites-list" > - @if (enrichedFavorites.length > 0) { + @if (enrichedFavorites().length > 0) { @for ( - channel of enrichedFavorites; + channel of enrichedFavorites(); track trackByFn($index, channel); let i = $index ) { diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts index c345f73b0..ce882a472 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-tab/favorites-tab.component.ts @@ -6,24 +6,21 @@ import { import { ChangeDetectionStrategy, Component, + computed, input, output, } from '@angular/core'; 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'; +import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; @Component({ selector: 'app-favorites-tab', templateUrl: './favorites-tab.component.html', styleUrls: ['./favorites-tab.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - ChannelListItemComponent, - DragDropModule, - TranslatePipe, - ], + imports: [ChannelListItemComponent, DragDropModule, TranslatePipe], }) export class FavoritesTabComponent { /** Favorite channels */ @@ -45,21 +42,24 @@ export class FavoritesTabComponent { readonly channelSelected = output(); /** Emits when favorite is toggled (removed) */ - readonly favoriteToggled = output<{ channel: Channel; event: MouseEvent }>(); + readonly favoriteToggled = output<{ + channel: Channel; + event: MouseEvent; + }>(); /** Emits when favorites order changes via drag-drop */ readonly favoritesReordered = output(); /** - * Gets enriched favorites with EPG data + * Computed signal for enriched favorites with EPG data */ - get enrichedFavorites(): EnrichedChannel[] { + readonly enrichedFavorites = computed(() => { const favorites = this.favorites(); const epgMap = this.channelEpgMap(); // Read progressTick to trigger recalculation this.progressTick(); - return favorites.map(channel => { + return favorites.map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null; return { @@ -68,12 +68,14 @@ export class FavoritesTabComponent { progressPercentage: this.calculateProgress(epgProgram), } as EnrichedChannel; }); - } + }); /** * Calculates progress percentage for an EPG program */ - private calculateProgress(epgProgram: EpgProgram | null | undefined): number { + private calculateProgress( + epgProgram: EpgProgram | null | undefined + ): number { if (!epgProgram) { return 0; } @@ -103,6 +105,6 @@ export class FavoritesTabComponent { onDrop(event: CdkDragDrop): void { const favorites = [...this.favorites()]; moveItemInArray(favorites, event.previousIndex, event.currentIndex); - this.favoritesReordered.emit(favorites.map(item => item.url)); + this.favoritesReordered.emit(favorites.map((item) => item.url)); } } 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 index f5cd906e5..80a8edd61 100644 --- 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 @@ -20,7 +20,7 @@ @let enrichedGroupChannels = - getEnrichedGroupChannels(groups.value); + getEnrichedGroupChannels(groups.value, groups.key); @let limit = getGroupLimit(groups.key);
@for ( 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 index 3c7266bbb..0c9bef53a 100644 --- 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 @@ -4,6 +4,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, inject, input, NgZone, @@ -176,13 +177,46 @@ export class GroupsTabComponent implements AfterViewInit, OnDestroy { } /** - * Gets enriched channels for a specific group + * Computed signal that memoizes enriched channels for all groups */ - getEnrichedGroupChannels(channels: Channel[]): EnrichedChannel[] { + private readonly enrichedGroupChannelsMap = computed(() => { + const grouped = this.groupedChannels(); const epgMap = this.channelEpgMap(); // Read progressTick to create dependency for progress refresh this.progressTick(); + const result = new Map(); + for (const [groupKey, channels] of Object.entries(grouped)) { + result.set( + groupKey, + 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; + }) + ); + } + return result; + }); + + /** + * Gets enriched channels for a specific group from the memoized map + */ + getEnrichedGroupChannels( + channels: Channel[], + groupKey?: string + ): EnrichedChannel[] { + // If groupKey provided, use memoized map + if (groupKey !== undefined) { + return this.enrichedGroupChannelsMap().get(groupKey) || []; + } + // Fallback for direct channel array (shouldn't happen with proper template usage) + const epgMap = this.channelEpgMap(); return channels.map((channel) => { const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); const epgProgram = channelId ? epgMap.get(channelId) : null;