feat(ui): improve info overlay and add EPG header search styles

Update info-overlay template to read simplified EPG fields and
avoid indexing into arrays for title, icon and description so bindings
are resilient and clearer:
- Use epgProgram?.iconUrl instead of epgProgram?.icon?.[0]?.src
- Use epgProgram?.title instead of epgProgram?.title?.[0]?.value
- Use epgProgram?.desc instead of epgProgram?.desc?.[0]?.value
- Guard description rendering with epgProgram?.desc

Adjust multi-epg header layout and add search UI styles to support
desktop window chrome and draggable regions:
- Add left padding to #epg-navigation for macOS traffic lights, enable
  backdrop drag via -webkit-app-region: drag and set z-index to keep
  header above content.
- Exclude interactive elements (buttons, search inputs) from drag by
  applying -webkit-app-region: no-drag to maintain native interactions.
- Add extensive styles for a channel search container (search input,
  icons, clear button, filter count, animations) and scaffold a program
  search container.

These changes improve robustness of EPG data rendering and add styled
search controls while ensuring proper window dragging behavior on
desktop builds.
This commit is contained in:
4gray committed 2026-01-02 17:22:09 +01:00
1 parent 895a269200
commit 99b5ad161f
18 files changed
+1479 -593

No files matched your search

+4 -4
View File
@@ -39,8 +39,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "10kb"
"maximumWarning": "10kb",
"maximumError": "15kb"
}
],
"outputHashing": "all",
@@ -61,8 +61,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "10kb"
"maximumWarning": "10kb",
"maximumError": "15kb"
}
],
"outputHashing": "all",
+29 -2
View File
@@ -100,13 +100,13 @@ export class AppComponent implements OnInit {
this.translate.use(settings.language ?? this.DEFAULT_LANG);
// Fetch EPG if URLs are configured
// Fetch EPG if URLs are configured (only fetch stale data)
if (
window.electron &&
settings.epgUrl?.length > 0 &&
settings.epgUrl?.some((u) => u !== '')
) {
this.epgService.fetchEpg(settings.epgUrl);
this.fetchStaleEpgData(settings.epgUrl);
}
if (settings.theme) {
@@ -165,6 +165,33 @@ export class AppComponent implements OnInit {
});
}
/**
* Fetches EPG data only for URLs that have stale or missing data.
* Data is considered fresh if updated within the last 12 hours.
*/
private async fetchStaleEpgData(urls: string[]): Promise<void> {
try {
const result = await window.electron.checkEpgFreshness(urls, 12);
if (result.freshUrls.length > 0) {
console.log(
`EPG: ${result.freshUrls.length} source(s) already fresh, skipping fetch`
);
}
if (result.staleUrls.length > 0) {
console.log(
`EPG: Fetching ${result.staleUrls.length} stale source(s)`
);
this.epgService.fetchEpg(result.staleUrls);
}
} catch (error) {
console.error('Error checking EPG freshness, fetching all:', error);
// Fallback: fetch all URLs if freshness check fails
this.epgService.fetchEpg(urls);
}
}
/**
* Triggers auto-update for playlists that have autoRefresh enabled
*/
@@ -0,0 +1,92 @@
import { Injectable, signal, computed } from '@angular/core';
export interface EpgImportProgress {
url: string;
status: 'queued' | 'loading' | 'complete' | 'error';
stats?: { totalChannels: number; totalPrograms: number };
error?: string;
queuePosition?: number;
}
/**
* Service to track EPG import progress across multiple sources.
* Uses signals for reactive state management.
*/
@Injectable({ providedIn: 'root' })
export class EpgProgressService {
private _imports = signal<Map<string, EpgImportProgress>>(new Map());
private _initialized = false;
/** All current import progress entries */
readonly imports = computed(() => Array.from(this._imports().values()));
/** Whether any imports are currently loading */
readonly hasActiveImports = computed(() =>
this.imports().some((i) => i.status === 'loading')
);
/** Count of active imports */
readonly activeCount = computed(() =>
this.imports().filter((i) => i.status === 'loading').length
);
/** Queued imports waiting to be processed */
readonly queuedImports = computed(() =>
this.imports()
.filter((i) => i.status === 'queued')
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0))
);
/** Count of queued imports */
readonly queuedCount = computed(() => this.queuedImports().length);
/** Whether the panel should be visible */
readonly isVisible = computed(() => this.imports().length > 0);
constructor() {
this.initializeListener();
}
private initializeListener(): void {
if (this._initialized) return;
this._initialized = true;
// Listen for EPG progress updates from Electron
if (window.electron?.onEpgProgress) {
window.electron.onEpgProgress((data) => {
this.updateProgress(data);
});
}
}
private updateProgress(progress: EpgImportProgress): void {
this._imports.update((current) => {
const updated = new Map(current);
updated.set(progress.url, progress);
return updated;
});
// Auto-remove completed/error entries after delay
if (progress.status === 'complete' || progress.status === 'error') {
setTimeout(() => this.removeImport(progress.url), 5000);
}
}
private removeImport(url: string): void {
this._imports.update((current) => {
const updated = new Map(current);
updated.delete(url);
return updated;
});
}
/** Manually dismiss an import notification */
dismiss(url: string): void {
this.removeImport(url);
}
/** Dismiss all notifications */
dismissAll(): void {
this._imports.set(new Map());
}
}
@@ -76,6 +76,14 @@
>
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
</button>
<button
mat-button
color="warn"
type="button"
(click)="clearEpgData()"
>
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
</button>
</div>
</div>
<mat-divider />
@@ -386,6 +386,26 @@ export class SettingsComponent implements OnInit {
this.settingsForm.markAsDirty();
}
/**
* Clears all EPG data from database
*/
clearEpgData(): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'),
message: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.MESSAGE'),
onConfirm: async (): Promise<void> => {
if (window.electron?.clearEpgData) {
await window.electron.clearEpgData();
this.snackBar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEARED'),
null,
{ duration: 2000, horizontalPosition: 'start' }
);
}
},
});
}
exportData() {
this.playlistsService
.getAllData()
+16 -3
View File
@@ -150,6 +150,12 @@
"THEME": "Visual theme",
"SHOW_CAPTIONS": "Show subtitles",
"ADD_EPG_SOURCE": "Add EPG source",
"CLEAR_EPG_DATA": "Clear EPG data",
"CLEAR_EPG_DIALOG": {
"TITLE": "Clear EPG data",
"MESSAGE": "Are you sure you want to remove all EPG (Electronic Program Guide) data from the database? This will delete all cached program schedules."
},
"EPG_DATA_CLEARED": "EPG data has been cleared",
"EPG_URL_ERROR": "The value is not a valid URL",
"VIDEO_PLAYER_DESCRIPTION": "Select default video player",
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
@@ -238,12 +244,19 @@
"PROGRAM_DETAILS": "Details about the TV program",
"TITLE": "Title",
"CATEGORY": "Category",
"PARENTAL_RATING_SYSTEM": "Television content rating system",
"PARENTAL_RATING_SYSTEM": "Rating",
"DESCRIPTION": "Description",
"LANGUAGE": "Language",
"SHOW_PROGRAM_DETAILS": "Show details about this program"
"SHOW_PROGRAM_DETAILS": "Show details about this program",
"EPISODE": "Episode",
"DURATION": "Duration"
},
"FETCH_SUCCESS": "EPG was fetched successfully"
"FETCH_SUCCESS": "EPG was fetched successfully",
"IMPORT_PROGRESS": "EPG Import",
"CHANNELS": "channels",
"PROGRAMS": "programs",
"LOADING": "Loading",
"QUEUED": "Queued"
},
"LANGUAGES": {
"ARABIC": "العربية",
@@ -1,20 +1,15 @@
/**
* EPG Program interface compatible with epg-parser v0.4.0
* Note: start/stop are ISO strings, date is string instead of array
* EPG Program interface - flat structure matching database storage
*/
export interface EpgProgram {
start: string; // ISO string
stop: string; // ISO string
channel: string;
title: { lang: string; value: string }[];
desc: { lang: string; value: string }[];
category: { lang: string; value: string }[];
date: string; // Changed from array to string in v0.4.0
episodeNum: any[];
previouslyShown: any[];
subtitles: any[];
icon: { src: string; width?: number; height?: number }[];
rating: { system: string; value: string }[];
credits: any[];
audio: any[];
title: string;
desc: string | null;
category: string | null;
date?: string;
episodeNum?: string | null;
iconUrl?: string | null;
rating?: string | null;
}
@@ -27,7 +27,7 @@
<div class="epg-content">
@if (epgProgram) {
<div class="epg-title">
{{ epgProgram.title?.[0]?.value }}
{{ epgProgram.title }}
</div>
<div class="epg-timeline">
<span class="epg-time"
@@ -1,42 +1,63 @@
<h2 mat-dialog-title>
{{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
@if (epgProgram?.title?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }}
<div class="program-dialog">
<!-- Header with channel and time -->
<header class="program-header">
<div class="time-block">
<div class="time-display">
<span class="time-start">{{ formatTime(epgProgram.start) }}</span>
<span class="time-separator">—</span>
<span class="time-end">{{ formatTime(epgProgram.stop) }}</span>
</div>
<div class="date-display">{{ formatDate(epgProgram.start) }}</div>
</div>
<p data-test="title">{{ epgProgram.title[0]?.value }}</p>
@if (epgProgram.title[0].lang) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }}
@if (channelName) {
<div class="channel-badge">
<span class="channel-icon">📺</span>
<span class="channel-name">{{ channelName }}</span>
</div>
}
<p data-test="lang">{{ epgProgram.title[0].lang }}</p>
}
@if (epgProgram?.category?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }}
</header>
<!-- Title section -->
<div class="program-title-section">
<h1 class="program-title">{{ epgProgram.title }}</h1>
@if (epgProgram.category) {
<span class="category-tag">{{ epgProgram.category }}</span>
}
</div>
<!-- Content -->
<div class="program-content">
@if (epgProgram.desc) {
<p class="program-description">{{ epgProgram.desc }}</p>
}
<!-- Meta info grid -->
<div class="meta-grid">
@if (epgProgram.episodeNum) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }}</span>
<span class="meta-value">{{ epgProgram.episodeNum }}</span>
</div>
}
@if (epgProgram.rating) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}</span>
<span class="meta-value rating">{{ epgProgram.rating }}</span>
</div>
}
@if (duration) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }}</span>
<span class="meta-value">{{ duration }}</span>
</div>
}
</div>
<p data-test="category">{{ epgProgram.category[0]?.value }}</p>
}
@if (epgProgram?.desc?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }}
</div>
<p data-test="desc">{{ epgProgram.desc[0]?.value }}</p>
}
@if (epgProgram?.rating?.length > 0) {
<div class="subheading-2">
{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}
</div>
<p data-test="rating">
{{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}]
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</mat-dialog-actions>
</div>
<!-- Footer -->
<footer class="program-footer">
<button class="close-button" mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</footer>
</div>
@@ -1,13 +1,300 @@
.subheading-2 {
color: #666;
text-decoration: underline;
text-transform: uppercase;
font-weight: 900;
font-size: 0.9em;
}
// Broadcast control room palette
$bg-deep: #0a0a0f;
$bg-surface: #12121a;
$bg-elevated: #1a1a24;
$accent-cyan: #00d4aa;
$accent-purple: #8b5cf6;
$text-primary: #f8fafc;
$text-secondary: rgba(248, 250, 252, 0.7);
$text-muted: rgba(248, 250, 252, 0.4);
$border-subtle: rgba(255, 255, 255, 0.08);
::ng-deep {
.dark-theme .subheading-2 {
color: #bdbdbd;
.program-dialog {
background: linear-gradient(180deg, $bg-surface 0%, $bg-deep 100%);
color: $text-primary;
min-width: 380px;
max-width: 480px;
border-radius: 16px;
overflow: hidden;
position: relative;
// Ambient glow effect
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
background: radial-gradient(
ellipse at top center,
rgba($accent-cyan, 0.12) 0%,
transparent 70%
);
pointer-events: none;
}
}
// Header with time and channel
.program-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px 24px 16px;
position: relative;
z-index: 1;
}
.time-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.time-display {
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
.time-start {
color: $accent-cyan;
text-shadow: 0 0 20px rgba($accent-cyan, 0.4);
}
.time-separator {
color: $text-muted;
font-weight: 300;
}
.time-end {
color: $text-secondary;
}
}
.date-display {
font-size: 13px;
color: $text-muted;
letter-spacing: 0.02em;
}
.channel-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba($accent-purple, 0.15);
border: 1px solid rgba($accent-purple, 0.3);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: $text-primary;
.channel-icon {
font-size: 14px;
}
.channel-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Title section
.program-title-section {
padding: 0 24px 20px;
border-bottom: 1px solid $border-subtle;
}
.program-title {
margin: 0 0 12px;
font-size: 22px;
font-weight: 600;
line-height: 1.3;
color: $text-primary;
letter-spacing: -0.01em;
}
.category-tag {
display: inline-block;
padding: 6px 12px;
background: rgba($accent-cyan, 0.1);
border: 1px solid rgba($accent-cyan, 0.25);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $accent-cyan;
}
// Content section
.program-content {
padding: 20px 24px;
}
.program-description {
margin: 0 0 24px;
font-size: 14px;
line-height: 1.7;
color: $text-secondary;
}
// Meta grid
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid $border-subtle;
}
.meta-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $text-muted;
}
.meta-value {
font-size: 14px;
font-weight: 500;
color: $text-primary;
&.rating {
color: $accent-purple;
}
}
// Footer
.program-footer {
padding: 16px 24px 24px;
display: flex;
justify-content: flex-end;
}
.close-button {
padding: 10px 24px;
background: transparent;
border: 1px solid rgba($accent-cyan, 0.4);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: $accent-cyan;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba($accent-cyan, 0.1);
border-color: $accent-cyan;
box-shadow: 0 0 20px rgba($accent-cyan, 0.2);
}
}
// Override Material dialog styling
::ng-deep .mat-mdc-dialog-container {
--mdc-dialog-container-color: transparent;
--mdc-dialog-container-shape: 16px;
.mdc-dialog__surface {
background: transparent !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
}
}
// Light theme adjustments
::ng-deep .light-theme {
.program-dialog {
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
color: #1e293b;
&::before {
background: radial-gradient(
ellipse at top center,
rgba(#059669, 0.08) 0%,
transparent 70%
);
}
}
.time-display {
.time-start {
color: #059669;
text-shadow: none;
}
.time-end {
color: #64748b;
}
}
.date-display {
color: #94a3b8;
}
.channel-badge {
background: rgba(#7c3aed, 0.08);
border-color: rgba(#7c3aed, 0.2);
color: #1e293b;
}
.program-title {
color: #0f172a;
}
.program-title-section {
border-bottom-color: rgba(0, 0, 0, 0.06);
}
.category-tag {
background: rgba(#059669, 0.08);
border-color: rgba(#059669, 0.2);
color: #059669;
}
.program-description {
color: #475569;
}
.meta-item {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.06);
}
.meta-label {
color: #94a3b8;
}
.meta-value {
color: #1e293b;
&.rating {
color: #7c3aed;
}
}
.close-button {
border-color: rgba(#059669, 0.4);
color: #059669;
&:hover {
background: rgba(#059669, 0.08);
border-color: #059669;
box-shadow: none;
}
}
}
@@ -3,6 +3,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgram } from 'shared-interfaces';
import { format, differenceInMinutes } from 'date-fns';
@Component({
selector: 'app-epg-item-description',
@@ -11,16 +12,54 @@ import { EpgProgram } from 'shared-interfaces';
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
})
export class EpgItemDescriptionComponent {
dialogData = inject<EpgProgram>(MAT_DIALOG_DATA);
dialogData = inject<any>(MAT_DIALOG_DATA);
/** EPG program object */
epgProgram: EpgProgram;
channelName: string | null = null;
duration: string | null = null;
/**
* Creates an instance of the component and injects the program of the clicked epg program
* @param epgProgram epg program
*/
constructor() {
this.epgProgram = this.dialogData;
// Check multiple possible field names for channel name
this.channelName = this.dialogData.channelName
|| this.dialogData.channel_name
|| this.dialogData.display_name
|| null;
this.duration = this.calculateDuration();
}
formatTime(dateStr: string): string {
if (!dateStr) return '';
try {
return format(new Date(dateStr), 'HH:mm');
} catch {
return '';
}
}
formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
return format(new Date(dateStr), 'EEEE, MMMM d');
} catch {
return '';
}
}
private calculateDuration(): string | null {
if (!this.epgProgram.start || !this.epgProgram.stop) return null;
try {
const start = new Date(this.epgProgram.start);
const stop = new Date(this.epgProgram.stop);
const mins = differenceInMinutes(stop, start);
if (mins < 60) {
return `${mins} min`;
}
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
} catch {
return null;
}
}
}
@@ -17,7 +17,7 @@
>
}
@if (item?.desc?.length > 0) {
@if (item?.desc) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
@@ -16,8 +16,7 @@
<div class="program-name">
{{
playingNow
? (playingNow.title?.[0]?.value || 'EPG.LIVE_STREAM'
| translate)
? (playingNow.title || 'EPG.LIVE_STREAM' | translate)
: ('EPG.LIVE_STREAM' | translate)
}}
</div>
@@ -64,7 +63,7 @@
/>
</div>
<div class="program-title">
{{ program?.title?.[0]?.value }}
{{ program?.title }}
</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
@@ -101,7 +100,7 @@
</div>
<div
class="program-title"
[innerHTML]="program.title?.[0]?.value"
[innerHTML]="program.title"
></div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
@@ -9,7 +9,7 @@
<div class="epg-layout">
<div class="channel-logo">
<img
[src]="epgProgram?.icon?.[0]?.src || channel?.tvg?.logo"
[src]="epgProgram?.iconUrl || channel?.tvg?.logo"
onerror="this.style.display='none'"
alt="channel icon"
/>
@@ -17,15 +17,15 @@
<div class="content">
<div class="content-header">
<div class="program-title">
{{ epgProgram?.title?.[0]?.value }}
{{ epgProgram?.title }}
</div>
<div class="channel-name">
{{ channel.name }}
</div>
</div>
@if (epgProgram?.desc?.[0]?.value) {
@if (epgProgram?.desc) {
<div class="program-description">
{{ epgProgram?.desc?.[0]?.value }}
{{ epgProgram?.desc }}
</div>
}
<div class="program-timeline">
@@ -14,7 +14,7 @@
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
{{ today() | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
@@ -23,16 +23,99 @@
>
<mat-icon>chevron_right</mat-icon>
</button>
<button mat-icon-button (click)="zoomOut()" [disabled]="(hourWidth$ | async)! <= 50" matTooltip="Zoom out">
<button mat-icon-button (click)="zoomOut()" [disabled]="hourWidth() <= 50" matTooltip="Zoom out">
<mat-icon>remove</mat-icon>
</button>
<button mat-icon-button (click)="zoomIn()" [disabled]="(hourWidth$ | async)! >= 800" matTooltip="Zoom in">
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth() >= 800" matTooltip="Zoom in">
<mat-icon>add</mat-icon>
</button>
<div class="search-container" [class.expanded]="isSearchExpanded()">
@if (isSearchExpanded()) {
<div class="channel-search">
<mat-icon class="search-icon">search</mat-icon>
<input
type="text"
[value]="channelFilter()"
(input)="onFilterInput($event)"
placeholder="Filter channels..."
autocomplete="off"
#searchInput
/>
@if (channelFilter()) {
<button class="clear-btn" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
</button>
}
</div>
<span class="filter-count">{{ filteredChannels().length }} / {{ channels().length }}</span>
}
<button
mat-icon-button
(click)="toggleSearch()"
[matTooltip]="isSearchExpanded() ? 'Close filter' : 'Filter channels'"
>
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
</button>
</div>
<!-- Program Search -->
<div class="program-search-container" [class.open]="isProgramSearchOpen()">
@if (isProgramSearchOpen()) {
<div class="program-search-input">
<mat-icon class="search-icon">manage_search</mat-icon>
<input
type="text"
[value]="programSearchQuery()"
(input)="onProgramSearchInput($event)"
placeholder="Search programs..."
autocomplete="off"
#programSearchInput
/>
@if (programSearchQuery()) {
<button class="clear-btn" (click)="clearProgramSearch()">
<mat-icon>clear</mat-icon>
</button>
}
@if (isSearchingPrograms()) {
<div class="search-spinner"></div>
}
</div>
@if (programSearchResults().length > 0) {
<div class="program-search-results">
@for (program of programSearchResults(); track $index) {
<button class="search-result-item" (click)="showProgramDetails(program)">
<div class="result-info">
<span class="result-title">{{ program.title }}</span>
<span class="result-meta">
<span class="result-time">{{ formatProgramTime(program) }}</span>
<span class="result-date">{{ formatProgramDate(program) }}</span>
</span>
</div>
<mat-icon class="result-arrow">arrow_forward</mat-icon>
</button>
}
</div>
}
@if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
<div class="program-search-empty">
<mat-icon>search_off</mat-icon>
<span>No programs found</span>
</div>
}
}
<button
mat-icon-button
(click)="toggleProgramSearch()"
[matTooltip]="isProgramSearchOpen() ? 'Close program search' : 'Search programs'"
>
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
</button>
</div>
</div>
<div class="parent" #epgContainer (scroll)="onScroll($event)">
<svg id="channels-column" [attr.height]="(channels$ | async)!.length * barHeight + barHeight + 50">
@for (item of channels$ | async; track item.id; let i = $index) {
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
@for (item of filteredChannels(); track item.id; let i = $index) {
<g>
<!-- channel name -->
<g class="channel" [class.active]="item.id === activeChannelId">
@@ -62,20 +145,20 @@
</g>
}
</svg>
<div id="epg-container" [style.height.px]="(channels$ | async)!.length * barHeight + barHeight + 50">
<svg [attr.width]="(hourWidth$ | async)! * 24" [attr.height]="(channels$ | async)!.length * barHeight + barHeight + 50" id="epg-svg">
<div id="epg-container" [style.height.px]="filteredChannels().length * barHeight + barHeight + 50">
<svg [attr.width]="hourWidth() * 24" [attr.height]="filteredChannels().length * barHeight + barHeight + 50" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth$ | async"
[attr.width]="hourWidth()"
[attr.height]="barHeight"
[attr.x]="hour * (hourWidth$ | async)!"
[attr.x]="hour * hourWidth()"
fill="#000"
></rect>
<text
[attr.x]="hour * (hourWidth$ | async)!"
[attr.x]="hour * hourWidth()"
y="10"
font-size="14"
fill="white"
@@ -85,34 +168,35 @@
</text>
<!-- vertical hour separator line -->
<line
[attr.x1]="hour * (hourWidth$ | async)!"
[attr.x1]="hour * hourWidth()"
[attr.y1]="barHeight"
[attr.x2]="hour * (hourWidth$ | async)!"
[attr.y2]="(channels$ | async)!.length * barHeight + barHeight"
[attr.x2]="hour * hourWidth()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
class="hour-separator"
></line>
</g>
}
<!-- epg channels with programs -->
@for (item of channels$ | async; track item.id; let i = $index) {
@for (item of filteredChannels(); track item.id; let i = $index) {
<g [class.active-row]="item.id === activeChannelId">
@for (program of item.programs; track trackByProgram($index, program)) {
<g
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title[0].value"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title[0]?.value }}</div>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
}
@@ -121,13 +205,22 @@
<!-- current time line -->
<line
[attr.x1]="currentTimeLine"
[attr.x1]="currentTimeLine()"
y1="0"
[attr.x2]="currentTimeLine"
[attr.y2]="(channels$ | async)!.length * barHeight + barHeight"
[attr.x2]="currentTimeLine()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
</svg>
</div>
</div>
<!-- Empty state when no channels match filter -->
@if (filteredChannels().length === 0 && channelFilter()) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>No channels match "{{ channelFilter() }}"</p>
<button mat-button (click)="clearFilter()">Clear filter</button>
</div>
}
@@ -67,12 +67,14 @@ $gradient-header: linear-gradient(
#epg-navigation {
display: flex;
align-items: center;
padding: 12px 16px;
padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights
background: rgba(18, 18, 26, 0.85);
backdrop-filter: blur(20px);
border-bottom: 1px solid $border-medium;
gap: 8px;
position: relative;
z-index: 100;
-webkit-app-region: drag;
// Subtle glow at bottom
&::after {
@@ -91,12 +93,13 @@ $gradient-header: linear-gradient(
);
}
// Button styling
// Button styling - exclude from drag region
button {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
letter-spacing: 0.02em;
transition: all 0.2s ease;
-webkit-app-region: no-drag;
&:not([disabled]):hover {
background: rgba(139, 92, 246, 0.15);
@@ -112,6 +115,374 @@ $gradient-header: linear-gradient(
}
}
// Search container - exclude from drag region
.search-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
-webkit-app-region: no-drag;
.channel-search {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $text-muted;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.filter-count {
font-size: 11px;
color: $text-muted;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
&.expanded {
.channel-search {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes slideIn {
from {
opacity: 0;
width: 0;
transform: translateX(20px);
}
to {
opacity: 1;
width: 200px;
transform: translateX(0);
}
}
// Program search container
.program-search-container {
display: flex;
align-items: center;
gap: 8px;
position: relative;
-webkit-app-region: no-drag;
.program-search-input {
display: flex;
align-items: center;
gap: 8px;
width: 220px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.search-spinner {
width: 16px;
height: 16px;
border: 2px solid $border-subtle;
border-top-color: $accent-secondary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
}
.program-search-results {
position: fixed;
top: 60px;
right: 16px;
width: 320px;
max-height: 400px;
overflow-y: auto;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid $border-subtle;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: $bg-hover;
.result-arrow {
opacity: 1;
transform: translateX(0);
}
}
.result-info {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.result-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.result-time {
color: $accent-primary;
font-family: 'JetBrains Mono', monospace;
}
.result-date {
color: $text-muted;
}
.result-arrow {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
opacity: 0;
transform: translateX(-4px);
transition: all 0.15s ease;
}
}
}
.program-search-empty {
position: fixed;
top: 60px;
right: 16px;
width: 220px;
padding: 24px 16px;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: $text-muted;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
opacity: 0.5;
}
span {
font-size: 12px;
}
}
&.open {
.program-search-input {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes highlightPulse {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
}
50% {
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
}
}
// No results state
.no-results {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: $text-muted;
text-align: center;
z-index: 100;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
}
button {
color: $accent-primary;
}
}
// Today's date display
.today-date {
flex: 1;
@@ -263,6 +634,19 @@ $gradient-header: linear-gradient(
stroke: rgba(139, 92, 246, 0.4);
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2));
}
// Highlighted program (from search)
&.highlighted rect {
fill: rgba(0, 212, 170, 0.25);
stroke: $accent-primary;
stroke-width: 2px;
animation: highlightPulse 1s ease-in-out 3;
}
&.highlighted .program-title {
color: $accent-primary;
font-weight: 600;
}
}
// Active channel row highlighting
@@ -442,6 +826,10 @@ rect {
// Responsive adjustments
@media (max-width: 768px) {
#epg-navigation {
padding-left: 70px; // Slightly reduced for smaller screens
}
#channels-column {
width: 100px;
@@ -3,13 +3,14 @@ import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@@ -19,7 +20,7 @@ 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 { 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';
@@ -55,18 +56,15 @@ export class MultiEpgContainerComponent
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.originalEpgData.set([]);
this.isLastPage.set(false);
this.initializeVisibleChannels();
this.requestPrograms();
});
@@ -75,34 +73,70 @@ export class MultiEpgContainerComponent
@Input() activeChannelId: string | null = null;
// Signals
readonly hourWidth = signal(150);
readonly today = signal(format(new Date(), 'yyyyMMdd'));
readonly originalEpgData = signal<any[]>([]);
readonly isLoading = signal(false);
readonly isLastPage = signal(false);
readonly channelFilter = signal('');
readonly isSearchExpanded = signal(false);
// Program search signals
readonly isProgramSearchOpen = signal(false);
readonly programSearchQuery = signal('');
readonly programSearchResults = signal<any[]>([]);
readonly isSearchingPrograms = signal(false);
readonly highlightedProgramKey = signal<string | null>(null);
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Computed signal for enriched channels - automatically updates when dependencies change
readonly channels = computed(() => this.enrichProgramData());
// Computed signal for filtered channels based on search term
readonly filteredChannels = computed(() => {
const filter = this.channelFilter().toLowerCase().trim();
const allChannels = this.channels();
if (!filter) {
return allChannels;
}
return allChannels.filter(channel => {
const name = this.getChannelName(channel).toLowerCase();
return name.includes(filter);
});
});
// Computed signal for current time line position
readonly currentTimeLine = computed(() => {
const now = new Date();
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
});
// Constants
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;
// Pagination state
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 dateCache = new Map<string, Date>();
private interval!: ReturnType<typeof setInterval>;
private playlistChannelsSubscription?: Subscription;
private readonly dialog = inject(MatDialog);
private readonly cdr = inject(ChangeDetectorRef);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
ngOnInit() {
this.calculateCurrentTimeBar();
// Update current time line every minute
this.interval = setInterval(() => {
this.calculateCurrentTimeBar();
// Force recomputation by updating hourWidth to same value
// This triggers the computed signal to recalculate
this.hourWidth.update(v => v);
}, 60000);
}
@@ -114,8 +148,7 @@ export class MultiEpgContainerComponent
private scrollToCurrentTime(): void {
const timeNow = new Date();
const scrollPosition =
(timeNow.getHours() + timeNow.getMinutes() / 60) *
this.hourWidth$.value;
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth();
requestAnimationFrame(() => {
const container = document.getElementById('epg-container');
@@ -145,14 +178,15 @@ export class MultiEpgContainerComponent
}
ngOnDestroy(): void {
this.hourWidth$.complete();
this.channels$.complete();
clearInterval(this.interval);
// Clean up playlist channels subscription
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
trackByIndex(index: number): number {
@@ -169,11 +203,11 @@ export class MultiEpgContainerComponent
return;
}
if (this.isLoading || this.isLastPage) {
if (this.isLoading() || this.isLastPage()) {
return;
}
this.isLoading = true;
this.isLoading.set(true);
try {
const response = await window.electron.getEpgChannelsByRange(
@@ -183,21 +217,18 @@ export class MultiEpgContainerComponent
if (response && Array.isArray(response)) {
// Append new data to existing data
this.originalEpgData = [...this.originalEpgData, ...response];
this.channels$.next(this.enrichProgramData());
this.originalEpgData.update(data => [...data, ...response]);
// Update isLastPage based on the number of channels received
this.isLastPage = response.length < this.visibleChannels;
this.isLastPage.set(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;
this.isLoading.set(false);
}
}
@@ -223,25 +254,27 @@ export class MultiEpgContainerComponent
}
private enrichProgramData(): EnrichedChannel[] {
const hourWidth = this.hourWidth$.value;
const hourWidthValue = this.hourWidth();
const todayValue = this.today();
const data = this.originalEpgData();
return this.originalEpgData.map((channel) => {
const filteredPrograms = channel.programs
return data.map((channel) => {
const filteredPrograms = (channel.programs || [])
.filter((item: EpgProgram) => {
const itemDate = format(
this.getCachedDate(item.start),
'yyyyMMdd'
);
return itemDate === this.today;
return itemDate === todayValue;
})
.map((program: EpgProgram) => {
const startDate = this.getCachedDate(program.start);
const stopDate = this.getCachedDate(program.stop);
const startPosition =
(startDate.getHours() + startDate.getMinutes() / 60) *
hourWidth;
hourWidthValue;
const duration = differenceInMinutes(stopDate, startDate);
const width = (duration * hourWidth) / 60;
const width = (duration * hourWidthValue) / 60;
return {
...program,
@@ -263,7 +296,10 @@ export class MultiEpgContainerComponent
* Get display name from EpgChannel
*/
getChannelName(channel: EpgChannel): string {
if (channel.displayName && channel.displayName.length > 0) {
if (typeof channel.displayName === 'string') {
return channel.displayName;
}
if (Array.isArray(channel.displayName) && channel.displayName.length > 0) {
return channel.displayName[0].value;
}
return '';
@@ -273,6 +309,9 @@ export class MultiEpgContainerComponent
* Get icon from EpgChannel
*/
getChannelIcon(channel: EpgChannel): string {
if ((channel as any).iconUrl) {
return (channel as any).iconUrl;
}
if (channel.icon && channel.icon.length > 0) {
return channel.icon[0].src;
}
@@ -280,38 +319,38 @@ export class MultiEpgContainerComponent
}
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();
if (this.hourWidth() >= 800) return;
this.hourWidth.update(v => v + 50);
}
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();
if (this.hourWidth() <= 50) return;
this.hourWidth.update(v => v - 50);
}
calculateCurrentTimeBar(): void {
this.currentTimeLine =
(new Date().getHours() + new Date().getMinutes() / 60) *
this.hourWidth$.value;
this.cdr.detectChanges();
toggleSearch(): void {
this.isSearchExpanded.update(v => !v);
if (!this.isSearchExpanded()) {
this.channelFilter.set('');
}
}
clearFilter(): void {
this.channelFilter.set('');
}
onFilterInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.channelFilter.set(input.value);
}
switchDay(direction: 'prev' | 'next'): void {
const currentDate = parse(this.today, 'yyyyMMdd', new Date());
this.today =
const currentDate = parse(this.today(), 'yyyyMMdd', new Date());
this.today.set(
direction === 'prev'
? format(subDays(currentDate, 1), 'yyyyMMdd')
: format(addDays(currentDate, 1), 'yyyyMMdd');
this.calculateCurrentTimeBar();
this.channels$.next(this.enrichProgramData());
this.cdr.detectChanges();
: format(addDays(currentDate, 1), 'yyyyMMdd')
);
}
showDescription(program: EpgProgram): void {
@@ -320,6 +359,81 @@ export class MultiEpgContainerComponent
});
}
// Program search methods
toggleProgramSearch(): void {
this.isProgramSearchOpen.update(v => !v);
if (!this.isProgramSearchOpen()) {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
}
onProgramSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
const query = input.value.trim();
this.programSearchQuery.set(query);
// Clear previous debounce timer
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
if (query.length < 2) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
}
// Debounce search by 500ms - show spinner only when search actually starts
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
try {
const results = await window.electron.searchEpgPrograms(query, 20);
this.programSearchResults.set(results || []);
} catch (error) {
console.error('Error searching programs:', error);
this.programSearchResults.set([]);
} finally {
this.isSearchingPrograms.set(false);
}
}, 500);
}
clearProgramSearch(): void {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
showProgramDetails(program: any): void {
// Get channel id (DB returns channel_id, EPG uses channel)
const channelId = program.channel_id || program.channel;
// Find channel name
const channel = this.channels().find(ch => ch.id === channelId);
const channelName = channel ? this.getChannelName(channel) : null;
// Open the program details dialog with channel name
this.dialog.open(EpgItemDescriptionComponent, {
data: { ...program, channelName },
});
}
getProgramKey(program: any): string {
const channelId = program.channel_id || program.channel;
return `${channelId}|${program.start}`;
}
formatProgramTime(program: EpgProgram): string {
const start = new Date(program.start);
const stop = new Date(program.stop);
return `${format(start, 'HH:mm')} - ${format(stop, 'HH:mm')}`;
}
formatProgramDate(program: EpgProgram): string {
const start = new Date(program.start);
return format(start, 'MMM d');
}
close() {
this.overlayRef.detach();
}
+212 -422
View File
File diff suppressed because it is too large. Load diff