mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
3bf328343b
commit
d564a0d564
8 files changed
+510
-81
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
+16
-13
@@ -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>
|
||||
}
|
||||
|
||||
+22
-8
@@ -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;
|
||||
|
||||
+56
-4
@@ -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 = (
|
||||
|
||||
+146
-25
@@ -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;
|
||||
}
|
||||
+118
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user