Merge pull request #750 from 4gray/refactor/optimize-m3u-module

Refactor/optimize-m3u-module
This commit is contained in:
4gray authored and GitHub committed 2026-02-01 22:51:53 +01:00
commit 243213dc5b
18 files changed
+1152 -469

No files matched your search

@@ -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() {
@@ -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,
],
})
+8 -1
View File
@@ -223,11 +223,18 @@
"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)",
"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",
@@ -0,0 +1,55 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<input
matInput
class="full-width"
type="search"
spellcheck="false"
autocomplete="off"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
(input)="onSearchInput($any($event.target).value)"
/>
</mat-form-field>
</div>
<div id="all-channels" class="channel-list">
@if (enrichedChannels().length === 0 && searchTerm()) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
</div>
<div class="no-results-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</div>
</div>
} @else {
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="scroll-viewport"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of enrichedChannels();
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[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>
}
</div>
@@ -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);
}
}
@@ -0,0 +1,181 @@
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<Channel[]>();
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Item size for virtual scroll */
readonly itemSize = input.required<number>();
/** 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('');
/** Debounce timeout for search */
private searchDebounceTimeout?: number;
/** Search field element */
readonly searchElement = viewChild<ElementRef<HTMLInputElement>>('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 = Date.now();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
// Validate start/stop are finite numbers
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
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 {
return channel?.id;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
ngOnDestroy(): void {
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
}
}
@@ -1,5 +1,6 @@
@if (_channelList) {
<mat-tab-group color="primary" backgroundColor="primary">
<!-- All Channels Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">list</mat-icon>
@@ -7,49 +8,20 @@
{{ 'CHANNELS.ALL_CHANNELS' | translate }}
</div>
</ng-template>
<div class="search-panel">
<mat-form-field class="search-bar">
<input
matInput
class="full-width"
type="search"
spellcheck="false"
autocomplete="off"
#search
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchTerm.name"
/>
</mat-form-field>
</div>
<div id="all-channels" class="channel-list">
<cdk-virtual-scroll-viewport
[itemSize]="itemSize"
class="scroll-viewport"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of _channelList
| filterBy: searchTerm.name : 'name';
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
/>
</cdk-virtual-scroll-viewport>
</div>
<app-all-channels-tab
[channels]="_channelList"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[itemSize]="itemSize()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</mat-tab>
<!-- Groups Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
@@ -57,57 +29,19 @@
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel>
<mat-expansion-panel-header>
{{
groups.key ||
('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="group-channels">
@for (
channel of groups.value;
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="
activeChannel()?.url === channel?.url
"
></app-channel-list-item>
}
</div>
</ng-template>
</mat-expansion-panel>
}
}
</mat-accordion>
</div>
<app-groups-tab
[groupedChannels]="groupedChannels"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</mat-tab>
<!-- Favorites Tab -->
@let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES';
@if (showFavorites) {
<mat-tab>
@@ -119,56 +53,23 @@
</ng-template>
@let favorites = favorites$ | async;
@if (favorites) {
<div
class="channel-list"
cdkDropList
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
>
@if (favorites.length > 0) {
@for (
channel of favorites;
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
[showFavoriteButton]="true"
(favoriteToggled)="
toggleFavoriteChannel(channel, $event)
"
/>
}
} @else {
<div class="empty-favorites">
<strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong>
</div>
<div class="empty-favorites-hint">
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}
</div>
}
</div>
<app-favorites-tab
[favorites]="favorites"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(favoritesReordered)="onFavoritesReordered($event)"
/>
}
</mat-tab>
}
</mat-tab-group>
} @else {
No channels there
<div class="loading-container">
<mat-icon class="loading-spinner">sync</mat-icon>
<div class="loading-text">{{ 'CHANNELS.LOADING' | translate }}</div>
</div>
}
@@ -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);
}
}
@@ -1,35 +1,24 @@
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';
import * as _ from 'lodash';
import { ChannelActions, FavoritesActions } from 'm3u-state';
import {
ChannelActions,
FavoritesActions,
selectActive,
selectActivePlaylistId,
selectFavorites,
@@ -37,26 +26,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 +53,24 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly translateService = inject(TranslateService);
/** Map of channel ID to current EPG program */
channelEpgMap = new Map<string, EpgProgram | null>();
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
/** 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<Channel[]>([]);
@@ -95,33 +81,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<ElementRef<HTMLInputElement>>('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 +104,27 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
)
);
/** List with favorites - combines favorites from store with current channel list */
/** 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]) => {
console.log(
'[ChannelList] favorites$ emit - IDs:',
favoriteChannelIds.length,
'Channels:',
channelList.length
);
const favorites = favoriteChannelIds
// Update the favoriteIds signal for quick lookup
this.favoriteIds.set(new Set(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<Channel[]>, 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 +137,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 +174,51 @@ 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<string, any[]>,
b: KeyValue<string, any[]>
): 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 }));
}
}
@@ -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;
}
}
}
@@ -2,16 +2,10 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
input,
output,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -35,67 +29,22 @@ 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;
@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 */
readonly progressPercentage = input(0);
@Output() clicked = new EventEmitter<void>();
@Output() favoriteToggled = new EventEmitter<MouseEvent>();
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();
}
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
/**
* Formats time for display (HH:mm)
@@ -0,0 +1,40 @@
<div
class="channel-list"
cdkDropList
(cdkDropListDropped)="onDrop($event)"
id="favorites-list"
>
@if (enrichedFavorites().length > 0) {
@for (
channel of enrichedFavorites();
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
(clicked)="onChannelClick(channel)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="true"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
}
} @else {
<div class="empty-favorites">
<strong>{{ 'CHANNELS.NO_FAVORITES' | translate }}</strong>
</div>
<div class="empty-favorites-hint">
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
</div>
}
</div>
@@ -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;
}
@@ -0,0 +1,110 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
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],
})
export class FavoritesTabComponent {
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** Emits when favorite is toggled (removed) */
readonly favoriteToggled = output<{
channel: Channel;
event: MouseEvent;
}>();
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output<string[]>();
/**
* Computed signal for enriched favorites with EPG data
*/
readonly enrichedFavorites = computed(() => {
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<Channel[]>): void {
const favorites = [...this.favorites()];
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
this.favoritesReordered.emit(favorites.map((item) => item.url));
}
}
@@ -0,0 +1,84 @@
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[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 -->
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[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>
</div>
</div>
}
</div>
</ng-template>
</mat-expansion-panel>
}
}
</mat-accordion>
</div>
@@ -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);
}
}
@@ -0,0 +1,279 @@
import { KeyValue, KeyValuePipe, TitleCasePipe } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
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 { EnrichedChannel } from '../all-channels-tab/all-channels-tab.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.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<Map<string, EpgProgram | null>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** 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;
/** Track observed sentinel elements */
private observedSentinels = new Set<Element>();
/** Track expanded groups with their load limits for lazy-loading */
readonly expandedGroupLimits = signal(new Map<string, number>());
/** 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);
}
/**
* Computed signal that memoizes enriched channels for all groups
*/
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<string, EnrichedChannel[]>();
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;
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 });
}
/**
* Comparator for sorting groups - numeric groups first, then alphabetical
*/
groupsComparator = (
a: KeyValue<string, Channel[]>,
b: KeyValue<string, Channel[]>
): 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);
};
}