From d564a0d5646c0df58a0c6b69b07d36749d4fffd5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 22 Nov 2025 10:53:03 +0100 Subject: [PATCH] feat(m3u): show current EPG program in channels list Reset EPG fields when epgProgram becomes null to avoid stale displayed durations and ensure consistent behavior when no program is available. Improve channel list container and item styling: - hide horizontal overflow to prevent unexpected scrollbars (scroll-viewport, #all-channels, .channel-list). - add empty-favorites layout and hint styles for clearer empty state presentation. - refactor channel-list-item SCSS: introduce structured layout (.channel-list-item, .channel-content, .channel-logo, .channel-details), apply responsive truncation rules and font imports, add hover/active visuals and dark-theme support, and tweak drag-icon spacing. These changes fix UI glitches (stale EPG, horizontal scrollbars) and modernize the channel list presentation for better readability and theming. --- libs/services/src/lib/epg.service.ts | 138 +++++++++++++- .../channel-list-container.component.html | 29 +-- .../channel-list-container.component.scss | 30 ++- .../channel-list-container.component.ts | 60 +++++- .../channel-list-item.component.scss | 171 +++++++++++++++--- .../channel-list-item.component.ts | 139 +++++++++++--- .../src/lib/epg-list/epg-list.component.ts | 10 +- .../info-overlay/info-overlay.component.ts | 14 +- 8 files changed, 510 insertions(+), 81 deletions(-) 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; + } } }