mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #750 from 4gray/refactor/optimize-m3u-module
Refactor/optimize-m3u-module
This commit is contained in:
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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
+55
@@ -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>
|
||||
+89
@@ -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);
|
||||
}
|
||||
}
|
||||
+181
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-137
@@ -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>
|
||||
}
|
||||
+24
-82
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
-139
@@ -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 }));
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-66
@@ -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)
|
||||
|
||||
+40
@@ -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>
|
||||
+24
@@ -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;
|
||||
}
|
||||
+110
@@ -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));
|
||||
}
|
||||
}
|
||||
+84
@@ -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>
|
||||
+49
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user