feat: improve search behavior, add debounce operation, skip FilterPipe

This commit is contained in:
4gray committed 2026-02-01 20:22:20 +01:00
1 parent 8b95cfea70
commit 40173e6498
7 files changed
+425 -411

No files matched your search

@@ -0,0 +1,53 @@
<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"
/>
</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,154 @@
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>();
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** 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 = 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);
}
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,18 @@
{{ '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()"
(channelSelected)="onChannelSelected($event)"
/>
</mat-tab>
<!-- Groups Tab -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">category</mat-icon>
@@ -57,57 +27,17 @@
{{ '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()"
(channelSelected)="onChannelSelected($event)"
/>
</mat-tab>
<!-- Favorites Tab -->
@let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES';
@if (showFavorites) {
<mat-tab>
@@ -119,56 +49,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,29 +1,17 @@
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';
@@ -37,26 +25,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 +52,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 +80,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 +103,22 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
)
);
/** List with favorites - combines favorites from store with current channel list */
/** 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
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 +131,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 +168,49 @@ 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 }));
}
}
@@ -2,16 +2,11 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -35,8 +30,7 @@ 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;
@@ -46,57 +40,12 @@ export class ChannelListItemComponent implements OnInit, OnChanges, OnDestroy {
@Input() selected = false;
@Input() showEpg = true;
@Input() epgProgram?: EpgProgram | null;
/** Progress percentage pre-computed by parent for performance */
@Input() progressPercentage = 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();
}
/**
* Formats time for display (HH:mm)
*/