mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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.
327 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|