feat: add favorite functionality to channel list items and groups tab

This commit is contained in:
4gray committed 2026-02-01 21:13:52 +01:00
1 parent fef815058f
commit ed8c373d78
10 files changed
+189 -79

No files matched your search

@@ -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)"
/>
</cdk-virtual-scroll-viewport>
}
@@ -59,9 +59,18 @@ export class AllChannelsTabComponent implements OnDestroy {
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** 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);
@@ -15,7 +15,9 @@
[shouldShowEpg]="shouldShowEpg()"
[itemSize]="itemSize()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</mat-tab>
@@ -33,7 +35,9 @@
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</mat-tab>
@@ -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<Set<string>>(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 })
);
}
/**
@@ -1,44 +1,44 @@
<div
class="channel-list-item"
[class.compact]="!showEpg"
[class.compact]="!showEpg()"
cdkDrag
[cdkDragDisabled]="!isDraggable"
[cdkDragDisabled]="!isDraggable()"
cdkDragPreviewContainer="parent"
[class.active]="selected"
[class.active]="selected()"
(click)="clicked.emit()"
data-test-id="channel-item"
>
@if (isDraggable) {
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="channel-content">
@if (logo) {
@if (logo()) {
<img
class="channel-logo"
[src]="logo"
onerror="this.style.display='none'"
[src]="logo()"
onerror="this.style.display = 'none'"
alt="Channel Logo"
/>
}
<div class="channel-details">
<div class="channel-name">{{ name }}</div>
@if (showEpg) {
<div class="channel-name">{{ name() }}</div>
@if (showEpg()) {
<div class="epg-container">
<div class="epg-content">
@if (epgProgram) {
@if (epgProgram(); as program) {
<div class="epg-title">
{{ epgProgram.title }}
{{ program.title }}
</div>
<div class="epg-timeline">
<span class="epg-time"
>{{ formatTime(epgProgram.start) }} -
{{ formatTime(epgProgram.stop) }}</span
>{{ formatTime(program.start) }} -
{{ formatTime(program.stop) }}</span
>
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[ngStyle]="{
width: progressPercentage + '%',
width: progressPercentage() + '%',
}"
></div>
</div>
@@ -49,32 +49,41 @@
</div>
}
</div>
@if (epgProgram) {
<button
mat-icon-button
class="program-info-button"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS'
| translate
"
(click)="showProgramDescription(epgProgram, $event)"
>
<mat-icon>info</mat-icon>
</button>
}
</div>
}
</div>
</div>
@if (showFavoriteButton) {
<button
mat-icon-button
class="favorite-button"
color="primary"
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
(click)="favoriteToggled.emit($event)"
>
<mat-icon color="accent">star</mat-icon>
</button>
}
<!-- Action buttons column -->
<div class="action-buttons">
@if (showEpg() && epgProgram(); as program) {
<button
mat-icon-button
class="program-info-button"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
(click)="showProgramDescription(program, $event)"
>
<mat-icon>info</mat-icon>
</button>
}
@if (showFavoriteButton()) {
<button
mat-icon-button
class="favorite-button"
color="primary"
[matTooltip]="
(isFavorite()
? 'CHANNELS.REMOVE_FAVORITE'
: 'CHANNELS.ADD_FAVORITE'
) | translate
"
(click)="favoriteToggled.emit($event)"
>
<mat-icon [color]="isFavorite() ? 'accent' : ''">{{
isFavorite() ? 'star' : 'star_border'
}}</mat-icon>
</button>
}
</div>
</div>
@@ -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;
}
}
}
@@ -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<string>();
readonly name = input('');
readonly showFavoriteButton = input(false);
readonly isFavorite = input(false);
readonly selected = input(false);
readonly showEpg = input(true);
readonly epgProgram = input<EpgProgram | null | undefined>();
/** Progress percentage pre-computed by parent for performance */
@Input() progressPercentage = 0;
readonly progressPercentage = input(0);
@Output() clicked = new EventEmitter<void>();
@Output() favoriteToggled = new EventEmitter<MouseEvent>();
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
/**
* Formats time for display (HH:mm)
@@ -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)"
/>
}
@@ -6,23 +6,28 @@
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="onGroupPanelOpened(groups.key, groups.value.length)"
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key ||
('CHANNELS.UNGROUPED' | translate)
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels = getEnrichedGroupChannels(groups.value);
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@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)
"
></app-channel-list-item>
}
<!-- Infinite scroll sentinel - triggers loading more when visible -->
@@ -52,8 +66,12 @@
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon class="loading-more-spinner">sync</mat-icon>
<span>{{ 'CHANNELS.LOADING_MORE' | translate }}</span>
<mat-icon class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE' | translate
}}</span>
</div>
</div>
}
@@ -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<string | undefined>();
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** 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
*/