mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat: add favorite functionality to channel list items and groups tab
This commit is contained in:
1 parent
fef815058f
commit
ed8c373d78
10 files changed
+189
-79
No files matched your search
+4
-2
@@ -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>
|
||||
}
|
||||
|
||||
+18
-3
@@ -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);
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
|
||||
+12
-4
@@ -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 })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+47
-38
@@ -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>
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -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)
|
||||
|
||||
+2
-3
@@ -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)"
|
||||
/>
|
||||
}
|
||||
|
||||
+26
-8
@@ -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>
|
||||
}
|
||||
|
||||
+38
-8
@@ -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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user