diff --git a/apps/web/project.json b/apps/web/project.json index 17bd28c6b..07bdb1a66 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -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", diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index e48bb3113..39b78770f 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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 { + 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 */ diff --git a/apps/web/src/app/services/epg-progress.service.ts b/apps/web/src/app/services/epg-progress.service.ts new file mode 100644 index 000000000..c4bd1f7f2 --- /dev/null +++ b/apps/web/src/app/services/epg-progress.service.ts @@ -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>(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()); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index d184d28f2..e5d0cb27c 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -76,6 +76,14 @@ > {{ 'SETTINGS.ADD_EPG_SOURCE' | translate }} + diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index f12c0062e..11b7b51d1 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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 => { + 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() diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 747ed03ac..914f0e1b8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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": "العربية", diff --git a/libs/shared/interfaces/src/lib/epg-program.model.ts b/libs/shared/interfaces/src/lib/epg-program.model.ts index d80d2dbaf..3a41c0b84 100644 --- a/libs/shared/interfaces/src/lib/epg-program.model.ts +++ b/libs/shared/interfaces/src/lib/epg-program.model.ts @@ -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; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index f9b43119e..86c6d4338 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -27,7 +27,7 @@
@if (epgProgram) {
- {{ epgProgram.title?.[0]?.value }} + {{ epgProgram.title }}
- {{ 'EPG.PROGRAM_DIALOG.PROGRAM_DETAILS' | translate }} - - - @if (epgProgram?.title?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.TITLE' | translate }} +
+ +
+
+
+ {{ formatTime(epgProgram.start) }} + — + {{ formatTime(epgProgram.stop) }} +
+
{{ formatDate(epgProgram.start) }}
-

{{ epgProgram.title[0]?.value }}

- @if (epgProgram.title[0].lang) { -
- {{ 'EPG.PROGRAM_DIALOG.LANGUAGE' | translate }} + @if (channelName) { +
+ 📺 + {{ channelName }}
} -

{{ epgProgram.title[0].lang }}

- } - @if (epgProgram?.category?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.CATEGORY' | translate }} +
+ + +
+

{{ epgProgram.title }}

+ @if (epgProgram.category) { + {{ epgProgram.category }} + } +
+ + +
+ @if (epgProgram.desc) { +

{{ epgProgram.desc }}

+ } + + +
+ @if (epgProgram.episodeNum) { +
+ {{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }} + {{ epgProgram.episodeNum }} +
+ } + @if (epgProgram.rating) { +
+ {{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }} + {{ epgProgram.rating }} +
+ } + @if (duration) { +
+ {{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }} + {{ duration }} +
+ }
-

{{ epgProgram.category[0]?.value }}

- } - @if (epgProgram?.desc?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.DESCRIPTION' | translate }} -
-

{{ epgProgram.desc[0]?.value }}

- } - @if (epgProgram?.rating?.length > 0) { -
- {{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }} -
-

- {{ epgProgram.rating[0].value }} [{{ epgProgram.rating[0].system }}] -

- } - - - - +
+ + +
+ +
+
diff --git a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss index fff588eb8..2cf554704 100644 --- a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.scss @@ -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; + } } } diff --git a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts index 3d3369bd8..db0702169 100644 --- a/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-item-description/epg-item-description.component.ts @@ -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(MAT_DIALOG_DATA); + dialogData = inject(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; + } } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html index 3fc5902dc..c91b6b63b 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html +++ b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.html @@ -17,7 +17,7 @@ > } -@if (item?.desc?.length > 0) { +@if (item?.desc) { {{ playingNow - ? (playingNow.title?.[0]?.value || 'EPG.LIVE_STREAM' - | translate) + ? (playingNow.title || 'EPG.LIVE_STREAM' | translate) : ('EPG.LIVE_STREAM' | translate) }}
@@ -64,7 +63,7 @@ />
- {{ program?.title?.[0]?.value }} + {{ program?.title }}
@if (isProgramPlaying(program)) {
@@ -101,7 +100,7 @@
@if (isProgramPlaying(program)) {
diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.html b/libs/ui/components/src/lib/info-overlay/info-overlay.component.html index ee49fb95c..0066246c1 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.html +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.html @@ -9,7 +9,7 @@