mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
895a269200
commit
99b5ad161f
18 files changed
+1479
-593
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+59
-38
@@ -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>
|
||||
+297
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-6
@@ -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();
|
||||
}
|
||||
|
||||
Generated
+212
-422
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user