From 40173e6498f77b122cf23b2848a991f8de872342 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 20:22:20 +0100 Subject: [PATCH] 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) */