Files
iptvnator/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts
T
4gray 6a8ea69492 feat(multi-epg): redesign multi-EPG styles with modern dark theme
Revamp the Multi-EPG component stylesheet to introduce a polished,
broadcast-control-room inspired dark theme and improved layout
styling.

- Add Google fonts (Outfit, JetBrains Mono) for a clean UI and mono
  date display.
- Introduce color variables, gradients and accents for consistent
  theming and easier maintenance.
- Rework base container (.parent) with subtle grid overlay and updated
  typography.
- Implement a frosted glass navigation bar (#epg-navigation) with
  blur, glow, and accessible button styles.
- Add today-date styling using a monospace font and glowing accent.
- Expand channels column width, update background, borders, shadows,
  and hover transitions for clearer channel separation.
- Replace many hard-coded colors with variables and add transitions
  for smoother interactions.
- Add utility classes for navigation buttons and improved disabled/
  hover states.

These changes improve visual hierarchy, accessibility, and maintainabiity
of the EPG UI while setting up a consistent design system for further
enhancements.
2025-11-22 11:40:16 +01:00

327 lines
10 KiB
TypeScript

import { OverlayRef } from '@angular/cdk/overlay';
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
interface EnrichedProgram extends EpgProgram {
startDate: Date;
stopDate: Date;
startPosition: number;
width: number;
}
interface EnrichedChannel extends EpgChannel {
programs: EnrichedProgram[];
}
@Component({
imports: [
CommonModule,
MatButtonModule,
MatIcon,
MatTooltip,
MomentDatePipe,
TranslatePipe,
],
selector: 'app-multi-epg-container',
templateUrl: './multi-epg-container.component.html',
styleUrls: ['./multi-epg-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiEpgContainerComponent
implements OnInit, AfterViewInit, OnDestroy
{
readonly epgContainer = viewChild.required<ElementRef>('epgContainer');
@Input() set playlistChannels(value: Observable<Channel[]>) {
// Unsubscribe from previous subscription if it exists
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (value) {
this.playlistChannelsSubscription = value.subscribe(() => {
// Reset data for new playlist
this.channelsLowerRange = 0;
this.originalEpgData = [];
this.channels$.next([]);
this.isLastPage = false;
this.initializeVisibleChannels();
this.requestPrograms();
});
}
}
@Input() activeChannelId: string | null = null;
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
readonly hourWidth$ = new BehaviorSubject<number>(150);
readonly barHeight = 50;
channels$ = new BehaviorSubject<EnrichedChannel[]>([]);
today = format(new Date(), 'yyyyMMdd');
currentTimeLine = 0;
visibleChannels = 20;
channelsLowerRange = 0;
channelsUpperRange = this.visibleChannels;
originalEpgData: any[] = [];
private dateCache = new Map<string, Date>();
private interval!: number;
private playlistChannelsSubscription?: Subscription;
isLastPage = false;
isLoading = false;
totalChannels = 0;
private readonly dialog = inject(MatDialog);
private readonly cdr = inject(ChangeDetectorRef);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
ngOnInit() {
this.calculateCurrentTimeBar();
this.interval = setInterval(() => {
this.calculateCurrentTimeBar();
}, 60000);
}
ngAfterViewInit(): void {
this.initializeVisibleChannels();
this.scrollToCurrentTime();
}
private scrollToCurrentTime(): void {
const timeNow = new Date();
const scrollPosition =
(timeNow.getHours() + timeNow.getMinutes() / 60) *
this.hourWidth$.value;
requestAnimationFrame(() => {
const container = document.getElementById('epg-container');
if (container) {
container.scrollTo(
scrollPosition < 1000 ? 0 : scrollPosition - 150,
0
);
}
});
}
private initializeVisibleChannels(): void {
const epgContainer = this.epgContainer();
if (epgContainer) {
const containerHeight = epgContainer.nativeElement.offsetHeight;
const calculatedVisibleChannels = Math.floor(
(containerHeight - this.barHeight) / this.barHeight
);
this.visibleChannels = Math.max(
10,
Math.min(calculatedVisibleChannels, 20)
);
this.channelsUpperRange = this.visibleChannels;
}
}
ngOnDestroy(): void {
this.hourWidth$.complete();
this.channels$.complete();
clearInterval(this.interval);
// Clean up playlist channels subscription
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
}
trackByIndex(index: number): number {
return index;
}
trackByProgram(_: number, program: EnrichedProgram): string {
return `${program.start}|${program?.title?.toString() ?? ''}`;
}
async requestPrograms(): Promise<void> {
if (!window.electron) {
console.warn('Multi-EPG not available: Electron not detected');
return;
}
if (this.isLoading || this.isLastPage) {
return;
}
this.isLoading = true;
try {
const response = await window.electron.getEpgChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
);
if (response && Array.isArray(response)) {
// Append new data to existing data
this.originalEpgData = [...this.originalEpgData, ...response];
this.channels$.next(this.enrichProgramData());
// Update isLastPage based on the number of channels received
this.isLastPage = response.length < this.visibleChannels;
// Update range for next fetch
this.channelsLowerRange += response.length;
this.cdr.detectChanges();
}
} catch (error) {
console.error('Error fetching EPG data:', error);
} finally {
this.isLoading = false;
}
}
onScroll(event: Event): void {
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// Load more when user scrolls to within 200px of the bottom
if (scrollHeight - scrollTop - clientHeight < 200) {
this.requestPrograms();
}
}
private getCachedDate(dateStr: string): Date {
let date = this.dateCache.get(dateStr);
if (!date) {
date = new Date(dateStr);
this.dateCache.set(dateStr, date);
}
return date;
}
private enrichProgramData(): EnrichedChannel[] {
const hourWidth = this.hourWidth$.value;
return this.originalEpgData.map((channel) => {
const filteredPrograms = channel.programs
.filter((item: EpgProgram) => {
const itemDate = format(
this.getCachedDate(item.start),
'yyyyMMdd'
);
return itemDate === this.today;
})
.map((program: EpgProgram) => {
const startDate = this.getCachedDate(program.start);
const stopDate = this.getCachedDate(program.stop);
const startPosition =
(startDate.getHours() + startDate.getMinutes() / 60) *
hourWidth;
const duration = differenceInMinutes(stopDate, startDate);
const width = (duration * hourWidth) / 60;
return {
...program,
startDate,
stopDate,
startPosition,
width,
};
});
return {
...channel,
programs: filteredPrograms,
};
});
}
/**
* Get display name from EpgChannel
*/
getChannelName(channel: EpgChannel): string {
if (channel.displayName && channel.displayName.length > 0) {
return channel.displayName[0].value;
}
return '';
}
/**
* Get icon from EpgChannel
*/
getChannelIcon(channel: EpgChannel): string {
if (channel.icon && channel.icon.length > 0) {
return channel.icon[0].src;
}
return '';
}
zoomIn(): void {
if (this.hourWidth$.value >= 800) return;
this.hourWidth$.next(this.hourWidth$.value + 50);
this.channels$.next(this.enrichProgramData());
this.calculateCurrentTimeBar();
this.cdr.detectChanges();
}
zoomOut(): void {
if (this.hourWidth$.value <= 50) return;
this.hourWidth$.next(this.hourWidth$.value - 50);
this.channels$.next(this.enrichProgramData());
this.calculateCurrentTimeBar();
this.cdr.detectChanges();
}
calculateCurrentTimeBar(): void {
this.currentTimeLine =
(new Date().getHours() + new Date().getMinutes() / 60) *
this.hourWidth$.value;
this.cdr.detectChanges();
}
switchDay(direction: 'prev' | 'next'): void {
const currentDate = parse(this.today, 'yyyyMMdd', new Date());
this.today =
direction === 'prev'
? format(subDays(currentDate, 1), 'yyyyMMdd')
: format(addDays(currentDate, 1), 'yyyyMMdd');
this.calculateCurrentTimeBar();
this.channels$.next(this.enrichProgramData());
this.cdr.detectChanges();
}
showDescription(program: EpgProgram): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}
close() {
this.overlayRef.detach();
}
}