From ed8c373d78ac10ddd123085d27b26320f8dde01b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 1 Feb 2026 21:13:52 +0100 Subject: [PATCH] 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 */