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.
This commit is contained in:
4gray committed 2025-11-22 11:40:16 +01:00
1 parent 3bf328343b
commit d564a0d564
8 files changed
+510 -81

No files matched your search

+136 -2
View File
@@ -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<boolean>(false);
private currentEpgPrograms = new BehaviorSubject<EpgProgram[]>([]);
// Cache for channel programs with 60-second TTL
private programCache = new Map<string, CachedProgram>();
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<EpgProgram | null> {
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<Map<string, EpgProgram | null>> {
if (!this.isDesktop) {
return of(new Map());
}
if (!channelIds || channelIds.length === 0) {
return of(new Map());
}
const resultMap = new Map<string, EpgProgram | null>();
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();
}
}
@@ -21,9 +21,9 @@
/>
</mat-form-field>
</div>
<mat-nav-list id="all-channels">
<div id="all-channels" class="channel-list">
<cdk-virtual-scroll-viewport
itemSize="50"
itemSize="68"
class="scroll-viewport"
>
<app-channel-list-item
@@ -41,11 +41,12 @@
| translate)
"
[logo]="channel?.tvg?.logo"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
/>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
</div>
</mat-tab>
<mat-tab>
@@ -55,7 +56,7 @@
{{ 'CHANNELS.GROUPS' | translate }}
</div>
</ng-template>
<mat-nav-list id="groups-list">
<div id="groups-list" class="channel-list">
<mat-accordion multi>
@for (
groups of groupedChannels | keyvalue: groupsComparator;
@@ -113,7 +114,8 @@
</ng-template>
@let favorites = favorites$ | async;
@if (favorites) {
<mat-nav-list
<div
class="channel-list"
cdkDropList
(cdkDropListDropped)="drop($event, favorites)"
id="favorites-list"
@@ -135,6 +137,7 @@
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
[showFavoriteButton]="true"
@@ -144,18 +147,18 @@
/>
}
} @else {
<mat-list-item
><strong>{{
<div class="empty-favorites">
<strong>{{
'CHANNELS.NO_FAVORITES' | translate
}}</strong></mat-list-item
>
<mat-list-item>
}}</strong>
</div>
<div class="empty-favorites-hint">
{{
'CHANNELS.USE_STAR_TO_FAVORITE' | translate
}}</mat-list-item
>
}}
</div>
}
</mat-nav-list>
</div>
}
</mat-tab>
}
@@ -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;
@@ -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<string, EpgProgram | null>();
/** 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 = (
@@ -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;
}
@@ -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: `<mat-list-item
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div
class="channel-list-item"
cdkDrag
[cdkDragDisabled]="!isDraggable"
cdkDragPreviewContainer="parent"
@@ -20,51 +35,133 @@ import { TranslatePipe } from '@ngx-translate/core';
>
<mat-icon
*ngIf="isDraggable"
matListItemIcon
cdkDragHandle
class="drag-icon"
>drag_indicator</mat-icon
>
<img
matListItemMeta
class="channel-logo"
*ngIf="logo"
[src]="logo"
width="42"
onerror="this.style.display='none'"
alt="Channel Logo"
/>
<p matListItemTitle class="channel-name">
{{ name }}
</p>
<div class="channel-content">
<img
class="channel-logo"
*ngIf="logo"
[src]="logo"
onerror="this.style.display='none'"
alt="Channel Logo"
/>
<div class="channel-details">
<div class="channel-name">{{ name }}</div>
@if (showEpg) {
@if (epgProgram) {
<div class="epg-title">
{{ epgProgram.title?.[0]?.value }}
</div>
<div class="epg-timeline">
<span class="epg-time">{{ formatTime(epgProgram.start) }} - {{ formatTime(epgProgram.stop) }}</span>
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[ngStyle]="{ width: progressPercentage + '%' }"
></div>
</div>
</div>
} @else {
<div class="epg-placeholder">
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</div>
}
}
</div>
</div>
<button
*ngIf="showFavoriteButton"
mat-icon-button
matListItemMeta
class="favorite-button"
color="primary"
[matTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
(click)="favoriteToggled.emit($event)"
>
<mat-icon color="accent">star</mat-icon>
</button>
</mat-list-item>`,
</div>`,
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<void>();
@Output() favoriteToggled = new EventEmitter();
@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)
*/
formatTime(dateString: string | number): string {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
}
@@ -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 })
);
});
}
@@ -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;
}
}
}