diff --git a/libs/services/src/lib/epg.service.ts b/libs/services/src/lib/epg.service.ts index 773a3ea53..7b1bda605 100644 --- a/libs/services/src/lib/epg.service.ts +++ b/libs/services/src/lib/epg.service.ts @@ -1,10 +1,15 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, from, of } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; +import { catchError, map, tap, timeout } from 'rxjs/operators'; import { EpgProgram } from 'shared-interfaces'; +interface CachedProgram { + program: EpgProgram | null; + timestamp: number; +} + @Injectable({ providedIn: 'root', }) @@ -15,6 +20,10 @@ export class EpgService { private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); + // Cache for channel programs with 60-second TTL + private programCache = new Map(); + private readonly CACHE_TTL = 60000; // 60 seconds + private readonly isDesktop = !!window.electron; readonly epgAvailable$ = this.epgAvailable.asObservable(); @@ -116,4 +125,129 @@ export class EpgService { horizontalPosition: 'start', }); } + + /** + * Gets the current EPG program for a specific channel (with caching) + * @param channelId Channel ID (tvg-id or channel name) + * @returns Observable of current program or null + */ + getCurrentProgramForChannel(channelId: string): Observable { + if (!this.isDesktop || !channelId) { + return of(null); + } + + // Check cache first + const cached = this.programCache.get(channelId); + const now = Date.now(); + + if (cached && (now - cached.timestamp) < this.CACHE_TTL) { + return of(cached.program); + } + + // Fetch from backend + return from(window.electron.getChannelPrograms(channelId)).pipe( + map((programs: EpgProgram[]) => { + if (!programs || programs.length === 0) { + this.programCache.set(channelId, { program: null, timestamp: now }); + return null; + } + + // Normalize date formats to ISO strings for consistency + const transformedPrograms = programs.map((program) => ({ + ...program, + start: new Date(program.start).toISOString(), + stop: new Date(program.stop).toISOString(), + })); + + // Find current program from transformed programs + const currentProgram = this.findCurrentProgram(transformedPrograms); + + // Cache the result + this.programCache.set(channelId, { + program: currentProgram, + timestamp: now + }); + + return currentProgram; + }), + catchError((err) => { + console.error('EPG get current program error:', err); + this.programCache.set(channelId, { program: null, timestamp: now }); + return of(null); + }) + ); + } + + /** + * Finds the current program from a list of programs + */ + private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { + const now = new Date(); + + return programs.find(program => { + const start = new Date(program.start); + const stop = new Date(program.stop); + return start <= now && now <= stop; + }) || null; + } + + /** + * Gets current programs for multiple channels (batch operation) + * @param channelIds Array of channel IDs + * @returns Observable of Map with channelId -> current program + */ + getCurrentProgramsForChannels(channelIds: string[]): Observable> { + if (!this.isDesktop) { + return of(new Map()); + } + + if (!channelIds || channelIds.length === 0) { + return of(new Map()); + } + + const resultMap = new Map(); + const channelsToFetch: string[] = []; + const now = Date.now(); + + // Check cache for each channel + channelIds.forEach(channelId => { + const cached = this.programCache.get(channelId); + if (cached && (now - cached.timestamp) < this.CACHE_TTL) { + resultMap.set(channelId, cached.program); + } else { + channelsToFetch.push(channelId); + } + }); + + // If all channels were cached, return immediately + if (channelsToFetch.length === 0) { + return of(resultMap); + } + + // Fetch uncached channels with timeout and error handling per request + const fetchObservables = channelsToFetch.map(channelId => + this.getCurrentProgramForChannel(channelId).pipe( + timeout(5000), // 5 second timeout per request + map(program => ({ channelId, program })), + catchError(() => of({ channelId, program: null })) + ) + ); + + // Combine all fetches using forkJoin + return forkJoin(fetchObservables).pipe( + map(results => { + results.forEach(result => { + resultMap.set(result.channelId, result.program); + }); + return resultMap; + }) + ); + } + + /** + * Clears the program cache (useful when EPG is refreshed) + */ + clearCache(): void { + this.programCache.clear(); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 8414827f7..df6ae709b 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -21,9 +21,9 @@ /> - +
- +
@@ -55,7 +56,7 @@ {{ 'CHANNELS.GROUPS' | translate }} - +
@for ( groups of groupedChannels | keyvalue: groupsComparator; @@ -113,7 +114,8 @@ @let favorites = favorites$ | async; @if (favorites) { - } } @else { - {{ +
+ {{ 'CHANNELS.NO_FAVORITES' | translate - }} - + }} +
+
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate - }} + }} +
} -
+
}
} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 335f81250..548f167a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -1,11 +1,8 @@ @use '@angular/material' as mat; -.mat-list-base { - padding-top: 0; -} - .scroll-viewport { min-height: calc(100vh - 159px); + overflow-x: hidden; } .search-panel { @@ -22,15 +19,16 @@ } } -mat-nav-list { - overflow: scroll; -} - #all-channels { overflow: hidden; + overflow-x: hidden; max-height: calc(100vh - 167px); } +.channel-list { + overflow-x: hidden; +} + ::ng-deep { .search-bar { div { @@ -71,6 +69,22 @@ mat-nav-list { height: calc(100vh - 105px); } +.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; +} + @media only screen and (max-width: 599px) { .channel-name { max-width: 100px; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 2c74f3747..328165544 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -12,6 +12,7 @@ import { inject, Input, OnDestroy, + OnInit, viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -20,7 +21,6 @@ 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 { MatListModule } from '@angular/material/list'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; import { FilterPipe } from '@iptvnator/pipes'; @@ -35,7 +35,7 @@ import { } from 'm3u-state'; import { map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; -import { Channel } from 'shared-interfaces'; +import { Channel, EpgProgram } from 'shared-interfaces'; import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; @Component({ @@ -53,19 +53,24 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item. MatFormFieldModule, MatIconModule, MatInputModule, - MatListModule, MatTabsModule, ScrollingModule, TitleCasePipe, TranslatePipe, ], }) -export class ChannelListContainerComponent implements OnDestroy { +export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly epgService = inject(EpgService); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private readonly translateService = inject(TranslateService); + /** Map of channel ID to current EPG program */ + channelEpgMap = new Map(); + + /** Interval for refreshing EPG data */ + private epgRefreshInterval?: number; + /** * Channels array * Create local copy of the store for local manipulations without updates in the store @@ -79,6 +84,8 @@ export class ChannelListContainerComponent implements OnDestroy { set channelList(value: Channel[]) { this._channelList = value; this.groupedChannels = _.default.groupBy(value, 'group.title'); + // Fetch EPG for new channel list + this.fetchEpgForChannels(value); } /** Object with channels sorted by groups */ @@ -169,8 +176,53 @@ export class ChannelListContainerComponent implements OnDestroy { ); } + ngOnInit(): void { + // Set up EPG refresh interval (every 60 seconds) + this.epgRefreshInterval = window.setInterval(() => { + this.fetchEpgForChannels(this._channelList); + }, 60000); + } + ngOnDestroy() { this.store.dispatch(PlaylistActions.setChannels({ channels: [] })); + + if (this.epgRefreshInterval) { + clearInterval(this.epgRefreshInterval); + } + } + + /** + * Fetches EPG data for all channels + */ + 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); + + console.log(`[EPG] Fetching EPG for ${channelIds.length} channels`); + + // Batch fetch EPG programs + this.epgService.getCurrentProgramsForChannels(channelIds).subscribe( + (epgMap) => { + console.log(`[EPG] Received EPG data for ${epgMap.size} channels`); + console.log('[EPG] Sample data:', Array.from(epgMap.entries()).slice(0, 3)); + this.channelEpgMap = epgMap; + } + ); + } + + /** + * Gets EPG program for a specific channel + */ + getEpgForChannel(channel: Channel): EpgProgram | null | undefined { + const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); + return channelId ? this.channelEpgMap.get(channelId) : null; } groupsComparator = ( diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index afe5d445f..838b9d0c8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -1,38 +1,159 @@ -.channel-name { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - font-size: 0.9em; - max-width: 290px; - transition: color 0.2s ease; - width: 100%; - position: relative; - overflow: hidden; +// Import refined fonts (same as info-overlay) +@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=DM+Sans:wght@400;500;600&display=swap'); + +// Main channel list item container +.channel-list-item { + display: flex; + align-items: center; + height: 68px; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + gap: 12px; &:hover { - overflow: visible; - text-overflow: clip; - animation: marquee 6s linear infinite; + background-color: rgba(0, 0, 0, 0.04); + } + + &.active { + background-color: rgba(0, 0, 0, 0.08); } - } -.active { - background: #ddd; -} +// Dark theme support +::ng-deep .dark-theme { + .channel-list-item { + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } -::ng-deep { - .dark-theme { - .active { - background: #000; + &.active { + background-color: rgba(255, 255, 255, 0.08); } } } +// Drag handle icon .drag-icon { cursor: move; - margin-left: 0; - margin-right: 0; + margin-right: 4px; + opacity: 0.5; +} + +// Main content area +.channel-content { + display: flex; + align-items: center; + flex: 1; + gap: 12px; + min-width: 0; // Allow text truncation +} + +// Channel logo +.channel-logo { + width: 42px; + height: 42px; + border-radius: 4px; + object-fit: contain; + flex-shrink: 0; +} + +// Channel details (name + EPG) +.channel-details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; // Allow text truncation +} + +// Channel name +.channel-name { + font-family: 'DM Sans', sans-serif; + font-weight: 500; + font-size: 0.9rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// EPG title (program name) +.epg-title { + font-family: 'Crimson Pro', serif; + font-size: 0.8rem; + font-weight: 600; + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; + min-width: 0; + max-width: 100%; +} + +// EPG timeline container +.epg-timeline { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 100%; +} + +// EPG time display +.epg-time { + font-family: 'DM Sans', sans-serif; + font-size: 0.7rem; + opacity: 0.6; + letter-spacing: 0.02em; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// EPG placeholder (when no data available) +.epg-placeholder { + font-family: 'DM Sans', sans-serif; + font-size: 0.75rem; + opacity: 0.5; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// Progress track +.epg-progress-track { + position: relative; + width: 100%; + height: 3px; + background: rgba(128, 128, 128, 0.2); + border-radius: 2px; + overflow: hidden; +} + +// Progress fill +.epg-progress-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%); + border-radius: 2px; + transition: width 0.5s ease-out; + box-shadow: 0 0 6px rgba(124, 58, 237, 0.4); +} + +// Favorite button +.favorite-button { + flex-shrink: 0; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 1eb5a7670..3692c925c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -1,16 +1,31 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; -import { NgIf } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgIf, 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 { MatIcon } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; +import { EpgProgram } from 'shared-interfaces'; @Component({ selector: 'app-channel-list-item', styleUrls: ['./channel-list-item.component.scss'], - template: ` drag_indicator - -

- {{ name }} -

+
+ +
+
{{ name }}
+ @if (showEpg) { + @if (epgProgram) { +
+ {{ epgProgram.title?.[0]?.value }} +
+
+ {{ formatTime(epgProgram.start) }} - {{ formatTime(epgProgram.stop) }} +
+
+
+
+ } @else { +
+ {{ 'EPG.NO_PROGRAM_INFO' | translate }} +
+ } + } +
+
-
`, + `, imports: [ DragDropModule, MatIcon, MatIconButton, - MatListModule, MatTooltip, NgIf, + NgStyle, TranslatePipe, ], }) -export class ChannelListItemComponent { +export class ChannelListItemComponent implements OnInit, OnChanges, OnDestroy { + private readonly cdr = inject(ChangeDetectorRef); + @Input() isDraggable = false; @Input() logo!: string; @Input() name = ''; @Input() showFavoriteButton = false; @Input() selected = false; + @Input() showEpg = true; + @Input() epgProgram?: EpgProgram | null; @Output() clicked = new EventEmitter(); - @Output() favoriteToggled = new EventEmitter(); + @Output() favoriteToggled = new EventEmitter(); + + 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) + */ + formatTime(dateString: string | number): string { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.ts b/libs/ui/components/src/lib/epg-list/epg-list.component.ts index 6a6c2841f..206998108 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.ts @@ -219,11 +219,11 @@ export class EpgListComponent implements OnInit { ) .subscribe((playingNow) => { this.playingNow = playingNow!; - if (this.playingNow) { - this.store.dispatch( - setCurrentEpgProgram({ program: this.playingNow }) - ); - } + // Always dispatch the action, even when playingNow is null/undefined + // This ensures the store is updated and old EPG data is cleared + this.store.dispatch( + setCurrentEpgProgram({ program: this.playingNow }) + ); }); } diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts index 767b5e891..abf7f4b6f 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts @@ -48,9 +48,17 @@ export class InfoOverlayComponent implements OnChanges { this.isVisible = false; }, 10000); } - if (changes['epgProgram'] && changes['epgProgram'].currentValue) { - const { stop, start } = changes['epgProgram'].currentValue; - this.setProgramDuration(start, stop); + if (changes['epgProgram']) { + if (changes['epgProgram'].currentValue) { + const { stop, start } = changes['epgProgram'].currentValue; + this.setProgramDuration(start, stop); + } else { + // Reset EPG data when no program is available + this.start = undefined; + this.stop = undefined; + this.generalDuration = 0; + this.finishedDuration = 0; + } } }