From ce6aa1b3d110c66315695747a2267196c212822d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Nov 2025 18:06:26 +0100 Subject: [PATCH 01/13] feat(info-overlay): add no-EPG layout and gradient blur Introduce a separate no-EPG presentation and move backdrop blur into an isolated pseudo-element to avoid visual bleed and improve layout control. - Replace direct backdrop-filter on the overlay with a ::before pseudo-element that applies backdrop-filter and mask-image gradients (with -webkit- prefixed fallbacks) for a smoother gradient blur effect. - Add isolation on the overlay to contain stacking context and set z-index of the pseudo-element to -1 to keep content above the blur. - Add .no-epg modifier on the overlay and new .no-epg-layout, .channel-logo-large and .channel-name-large styles to provide a centered, accessible layout when no EPG data is available. - Update template to render two distinct layouts: one when epgProgram exists (original detailed view) and one fallback view when epgProgram is missing, including larger channel logo and centered channel name. These changes improve visual fidelity of the blur effect and add a clear fallback UI for channels without EPG data. --- .../info-overlay/info-overlay.component.html | 75 ++++++++++++------- .../info-overlay/info-overlay.component.scss | 61 ++++++++++++++- 2 files changed, 108 insertions(+), 28 deletions(-) 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 520ea511b..b50e47eca 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 @@ -1,37 +1,58 @@ -@if (channel && epgProgram) { +@if (channel) {
- -
-
- {{ epgProgram?.title?.[0]?.value }} | {{ channel.name }} + @if (epgProgram) { + + -
- {{ epgProgram?.desc?.[0]?.value }} -
-
-
-
- {{ start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} - - - {{ stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} +
+
+ {{ epgProgram?.title?.[0]?.value + ' |' }} + {{ channel.name }} +
+
+ {{ epgProgram?.desc?.[0]?.value }} +
+
+
+
+ {{ start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} + - + {{ stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} +
-
+ } @else { + +
+ @if (channel?.tvg?.logo) { +
+ channel icon +
+ } +
+ {{ channel.name }} +
+
+ }
} diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss index b2f8afd59..a3244e736 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss @@ -8,10 +8,30 @@ margin-right: auto; left: 0; right: 0; - backdrop-filter: blur(10px); flex-direction: row; box-sizing: border-box; display: flex; + isolation: isolate; + + // Gradient blur effect using pseudo-element + &::before { + content: ''; + position: absolute; + inset: 0; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 20%, rgba(0, 0, 0, 0.8) 60%, black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 20%, rgba(0, 0, 0, 0.8) 60%, black 100%); + z-index: -1; + } + + &.no-epg { + min-height: 100px; + max-height: 180px; + display: flex; + align-items: center; + justify-content: center; + } } .program-name, @@ -85,3 +105,42 @@ opacity: 0; transition: visibility 0s 2s, opacity 2s linear; } + +// No EPG layout styles +.no-epg-layout { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + padding: 20px; + gap: 20px; +} + +.channel-logo-large { + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 150px; + max-height: 100px; + width: auto; + height: auto; + object-fit: contain; + } +} + +.channel-name-large { + font-size: 2.5rem; + font-weight: 600; + text-align: center; + line-height: 1.2; + max-width: 90%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); +} From 3bf328343b4b0dbc1cbf908152416d6778f1ed98 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Nov 2025 18:27:51 +0100 Subject: [PATCH 02/13] style(ui/info-overlay): redesign EPG layout and styles Refactor the info-overlay template to introduce a structured EPG layout: replace the previous flat channel-logo + content structure with an .epg-layout wrapper that groups .channel-logo and a richer .content area. Split content into .content-header (separate program title and channel name), conditional program description rendering, and a program-timeline containing explicit start/end times and a progress track. Remove fixed image width attribute and rely on responsive CSS. Revamp component SCSS: import refined fonts, increase overlay max-height, add horizontal padding, and add new styles for .epg-layout, .channel-logo, .content, .content-header, .program-title and related elements. Add animations, improved image handling (object-fit + drop shadow), and layout spacing to support varying content lengths and improve visual hierarchy. These changes improve responsiveness, accessibility of EPG text display, and visual polish of the overlay. --- apps/web/src/assets/i18n/en.json | 1 + .../info-overlay/info-overlay.component.html | 91 ++++--- .../info-overlay/info-overlay.component.scss | 242 ++++++++++++------ .../info-overlay/info-overlay.component.ts | 5 +- 4 files changed, 214 insertions(+), 125 deletions(-) diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e50568ceb..dd3c0e9fb 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -213,6 +213,7 @@ "FETCH_EPG": "Fetching EPG data...", "ERROR": "Ooops, EPG could not be loaded.", "DOWNLOAD_SUCCESS": "EPG was successfully downloaded.", + "NO_PROGRAM_INFO": "No program information available", "PROGRAM_DIALOG": { "PROGRAM_DETAILS": "Details about the TV program", "TITLE": "Title", 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 b50e47eca..ee49fb95c 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 @@ -3,54 +3,67 @@ class="channel-overlay" [class.visible]="isVisible" [class.hidden]="!isVisible" - [class.no-epg]="!epgProgram" > @if (epgProgram) { - -
-
- {{ epgProgram?.title?.[0]?.value + ' |' }} - {{ channel.name }} +
+ -
- {{ epgProgram?.desc?.[0]?.value }} -
-
-
-
- {{ start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} - - - {{ stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} +
+
+
+ {{ epgProgram?.title?.[0]?.value }} +
+
+ {{ channel.name }} +
+
+ @if (epgProgram?.desc?.[0]?.value) { +
+ {{ epgProgram?.desc?.[0]?.value }} +
+ } +
+
+ {{ start | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} + — + {{ stop | momentDate: 'YYYYMMDDHHmm ZZ' : 'HH:mm' }} +
+
+
+
} @else { - -
- @if (channel?.tvg?.logo) { -
- channel icon + +
+ +
+
+
+ {{ channel.name }} +
+
+ {{ 'EPG.NO_PROGRAM_INFO' | translate }} +
- } -
- {{ channel.name }}
} diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss index a3244e736..44f63c748 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss @@ -1,17 +1,20 @@ +// Import refined fonts +@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=DM+Sans:wght@400;500;600&display=swap'); + .channel-overlay { position: absolute; bottom: 35px; width: 100%; min-height: 130px; - max-height: 165px; + max-height: 200px; margin-left: auto; margin-right: auto; left: 0; right: 0; - flex-direction: row; box-sizing: border-box; display: flex; isolation: isolate; + padding: 0 40px; // Gradient blur effect using pseudo-element &::before { @@ -25,74 +28,172 @@ z-index: -1; } - &.no-epg { - min-height: 100px; - max-height: 180px; - display: flex; - align-items: center; - justify-content: center; - } } -.program-name, -.program-description { - margin: 20px 0; - display: -webkit-box; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; +// EPG Layout with data +.epg-layout { + display: flex; + gap: 28px; + width: 100%; + align-items: flex-start; + padding: 24px 0; + animation: fadeInUp 0.5s ease-out; } .channel-logo { - margin-top: 20px; - place-content: flex-start center; - align-items: flex-start; - flex-direction: row; - box-sizing: border-box; + flex-shrink: 0; display: flex; - flex: 1 1 100px; - max-width: 100px; - min-width: 100px; + align-items: center; + justify-content: center; + width: 90px; + height: 90px; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.4)); + } } .content { - width: 100%; + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; // Allow text truncation } -.program-progress { - width: 96%; - background: #ccc; - height: 20px; - margin-bottom: 8px; +.content-header { + display: flex; + flex-direction: column; + gap: 6px; + animation: fadeIn 0.6s ease-out 0.1s backwards; } -.program-name { - width: 96%; - max-height: 20px; - -webkit-line-clamp: 1; +.program-title { + font-family: 'Crimson Pro', serif; + font-size: 2.25rem; + font-weight: 700; + line-height: 1.2; + color: #ffffff; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: -0.02em; +} + +.channel-name { + font-family: 'DM Sans', sans-serif; + font-size: 0.95rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.75); + text-transform: uppercase; + letter-spacing: 0.08em; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); } .program-description { - width: 96%; - max-height: 55px; - -webkit-line-clamp: 3; + font-family: 'DM Sans', sans-serif; + font-size: 1.05rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.88); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + animation: fadeIn 0.6s ease-out 0.2s backwards; } -.program-finished { - background: #717171; - height: 20px; - font-size: 0.8em; - padding-left: 10px; - color: #000; - line-height: 20px; +.program-timeline { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; + animation: fadeIn 0.6s ease-out 0.3s backwards; } -.duration { +.timeline-info { + display: flex; + align-items: center; + gap: 8px; + font-family: 'DM Sans', sans-serif; + font-size: 0.875rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + letter-spacing: 0.02em; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); +} + +.time-separator { + opacity: 0.5; + margin: 0 2px; +} + +.progress-track { position: relative; - top: -18px; - font-size: 0.8em; - color: #000; - left: 10px; + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + overflow: hidden; + backdrop-filter: blur(4px); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.progress-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%); + border-radius: 2px; + transition: width 0.3s ease-out; + box-shadow: 0 0 12px rgba(124, 58, 237, 0.6); + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%); + animation: shimmer 2s ease-in-out infinite; + } +} + +// Animations +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } } .visible { @@ -106,41 +207,14 @@ transition: visibility 0s 2s, opacity 2s linear; } -// No EPG layout styles -.no-epg-layout { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - padding: 20px; - gap: 20px; +// No EPG state variations +.no-epg-title { + // When there's no EPG, the channel name becomes the main title + font-size: 2.5rem; // Slightly larger since it's the only content } -.channel-logo-large { - display: flex; - align-items: center; - justify-content: center; - - img { - max-width: 150px; - max-height: 100px; - width: auto; - height: auto; - object-fit: contain; - } -} - -.channel-name-large { - font-size: 2.5rem; - font-weight: 600; - text-align: center; - line-height: 1.2; - max-width: 90%; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); +.no-epg-subtitle { + // Subtle message indicating no EPG data + font-style: italic; + opacity: 0.6; } diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts index f01013bef..767b5e891 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts @@ -1,11 +1,12 @@ import { NgStyle } from '@angular/common'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { MomentDatePipe } from '@iptvnator/pipes'; +import { TranslatePipe } from '@ngx-translate/core'; import moment from 'moment'; import { Channel, EpgProgram } from 'shared-interfaces'; @Component({ - imports: [MomentDatePipe, NgStyle], + imports: [MomentDatePipe, NgStyle, TranslatePipe], selector: 'app-info-overlay', templateUrl: './info-overlay.component.html', styleUrls: ['./info-overlay.component.scss'], @@ -45,7 +46,7 @@ export class InfoOverlayComponent implements OnChanges { this.isVisible = true; this.currentTimeout = setTimeout(() => { this.isVisible = false; - }, 4000); + }, 10000); } if (changes['epgProgram'] && changes['epgProgram'].currentValue) { const { stop, start } = changes['epgProgram'].currentValue; From d564a0d5646c0df58a0c6b69b07d36749d4fffd5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 22 Nov 2025 10:53:03 +0100 Subject: [PATCH 03/13] feat(m3u): show current EPG program in channels list Reset EPG fields when epgProgram becomes null to avoid stale displayed durations and ensure consistent behavior when no program is available. Improve channel list container and item styling: - hide horizontal overflow to prevent unexpected scrollbars (scroll-viewport, #all-channels, .channel-list). - add empty-favorites layout and hint styles for clearer empty state presentation. - refactor channel-list-item SCSS: introduce structured layout (.channel-list-item, .channel-content, .channel-logo, .channel-details), apply responsive truncation rules and font imports, add hover/active visuals and dark-theme support, and tweak drag-icon spacing. These changes fix UI glitches (stale EPG, horizontal scrollbars) and modernize the channel list presentation for better readability and theming. --- libs/services/src/lib/epg.service.ts | 138 +++++++++++++- .../channel-list-container.component.html | 29 +-- .../channel-list-container.component.scss | 30 ++- .../channel-list-container.component.ts | 60 +++++- .../channel-list-item.component.scss | 171 +++++++++++++++--- .../channel-list-item.component.ts | 139 +++++++++++--- .../src/lib/epg-list/epg-list.component.ts | 10 +- .../info-overlay/info-overlay.component.ts | 14 +- 8 files changed, 510 insertions(+), 81 deletions(-) diff --git a/libs/services/src/lib/epg.service.ts b/libs/services/src/lib/epg.service.ts index 773a3ea53..7b1bda605 100644 --- a/libs/services/src/lib/epg.service.ts +++ b/libs/services/src/lib/epg.service.ts @@ -1,10 +1,15 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject, from, of } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, from, Observable, of } from 'rxjs'; +import { catchError, map, tap, timeout } from 'rxjs/operators'; import { EpgProgram } from 'shared-interfaces'; +interface CachedProgram { + program: EpgProgram | null; + timestamp: number; +} + @Injectable({ providedIn: 'root', }) @@ -15,6 +20,10 @@ export class EpgService { private epgAvailable = new BehaviorSubject(false); private currentEpgPrograms = new BehaviorSubject([]); + // Cache for channel programs with 60-second TTL + private programCache = new Map(); + private readonly CACHE_TTL = 60000; // 60 seconds + private readonly isDesktop = !!window.electron; readonly epgAvailable$ = this.epgAvailable.asObservable(); @@ -116,4 +125,129 @@ export class EpgService { horizontalPosition: 'start', }); } + + /** + * Gets the current EPG program for a specific channel (with caching) + * @param channelId Channel ID (tvg-id or channel name) + * @returns Observable of current program or null + */ + getCurrentProgramForChannel(channelId: string): Observable { + if (!this.isDesktop || !channelId) { + return of(null); + } + + // Check cache first + const cached = this.programCache.get(channelId); + const now = Date.now(); + + if (cached && (now - cached.timestamp) < this.CACHE_TTL) { + return of(cached.program); + } + + // Fetch from backend + return from(window.electron.getChannelPrograms(channelId)).pipe( + map((programs: EpgProgram[]) => { + if (!programs || programs.length === 0) { + this.programCache.set(channelId, { program: null, timestamp: now }); + return null; + } + + // Normalize date formats to ISO strings for consistency + const transformedPrograms = programs.map((program) => ({ + ...program, + start: new Date(program.start).toISOString(), + stop: new Date(program.stop).toISOString(), + })); + + // Find current program from transformed programs + const currentProgram = this.findCurrentProgram(transformedPrograms); + + // Cache the result + this.programCache.set(channelId, { + program: currentProgram, + timestamp: now + }); + + return currentProgram; + }), + catchError((err) => { + console.error('EPG get current program error:', err); + this.programCache.set(channelId, { program: null, timestamp: now }); + return of(null); + }) + ); + } + + /** + * Finds the current program from a list of programs + */ + private findCurrentProgram(programs: EpgProgram[]): EpgProgram | null { + const now = new Date(); + + return programs.find(program => { + const start = new Date(program.start); + const stop = new Date(program.stop); + return start <= now && now <= stop; + }) || null; + } + + /** + * Gets current programs for multiple channels (batch operation) + * @param channelIds Array of channel IDs + * @returns Observable of Map with channelId -> current program + */ + getCurrentProgramsForChannels(channelIds: string[]): Observable> { + if (!this.isDesktop) { + return of(new Map()); + } + + if (!channelIds || channelIds.length === 0) { + return of(new Map()); + } + + const resultMap = new Map(); + const channelsToFetch: string[] = []; + const now = Date.now(); + + // Check cache for each channel + channelIds.forEach(channelId => { + const cached = this.programCache.get(channelId); + if (cached && (now - cached.timestamp) < this.CACHE_TTL) { + resultMap.set(channelId, cached.program); + } else { + channelsToFetch.push(channelId); + } + }); + + // If all channels were cached, return immediately + if (channelsToFetch.length === 0) { + return of(resultMap); + } + + // Fetch uncached channels with timeout and error handling per request + const fetchObservables = channelsToFetch.map(channelId => + this.getCurrentProgramForChannel(channelId).pipe( + timeout(5000), // 5 second timeout per request + map(program => ({ channelId, program })), + catchError(() => of({ channelId, program: null })) + ) + ); + + // Combine all fetches using forkJoin + return forkJoin(fetchObservables).pipe( + map(results => { + results.forEach(result => { + resultMap.set(result.channelId, result.program); + }); + return resultMap; + }) + ); + } + + /** + * Clears the program cache (useful when EPG is refreshed) + */ + clearCache(): void { + this.programCache.clear(); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 8414827f7..df6ae709b 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -21,9 +21,9 @@ />
- +
- +
@@ -55,7 +56,7 @@ {{ 'CHANNELS.GROUPS' | translate }}
- +
@for ( groups of groupedChannels | keyvalue: groupsComparator; @@ -113,7 +114,8 @@ @let favorites = favorites$ | async; @if (favorites) { - } } @else { - {{ +
+ {{ 'CHANNELS.NO_FAVORITES' | translate - }} - + }} +
+
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate - }} + }} +
} -
+
} } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss index 335f81250..548f167a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.scss @@ -1,11 +1,8 @@ @use '@angular/material' as mat; -.mat-list-base { - padding-top: 0; -} - .scroll-viewport { min-height: calc(100vh - 159px); + overflow-x: hidden; } .search-panel { @@ -22,15 +19,16 @@ } } -mat-nav-list { - overflow: scroll; -} - #all-channels { overflow: hidden; + overflow-x: hidden; max-height: calc(100vh - 167px); } +.channel-list { + overflow-x: hidden; +} + ::ng-deep { .search-bar { div { @@ -71,6 +69,22 @@ mat-nav-list { height: calc(100vh - 105px); } +.empty-favorites, +.empty-favorites-hint { + padding: 16px 24px; + text-align: center; +} + +.empty-favorites { + margin-top: 40px; + font-size: 1rem; +} + +.empty-favorites-hint { + opacity: 0.7; + font-size: 0.9rem; +} + @media only screen and (max-width: 599px) { .channel-name { max-width: 100px; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 2c74f3747..328165544 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -12,6 +12,7 @@ import { inject, Input, OnDestroy, + OnInit, viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; @@ -20,7 +21,6 @@ import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { MatListModule } from '@angular/material/list'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; import { FilterPipe } from '@iptvnator/pipes'; @@ -35,7 +35,7 @@ import { } from 'm3u-state'; import { map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; -import { Channel } from 'shared-interfaces'; +import { Channel, EpgProgram } from 'shared-interfaces'; import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; @Component({ @@ -53,19 +53,24 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item. MatFormFieldModule, MatIconModule, MatInputModule, - MatListModule, MatTabsModule, ScrollingModule, TitleCasePipe, TranslatePipe, ], }) -export class ChannelListContainerComponent implements OnDestroy { +export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly epgService = inject(EpgService); private readonly snackBar = inject(MatSnackBar); private readonly store = inject(Store); private readonly translateService = inject(TranslateService); + /** Map of channel ID to current EPG program */ + channelEpgMap = new Map(); + + /** Interval for refreshing EPG data */ + private epgRefreshInterval?: number; + /** * Channels array * Create local copy of the store for local manipulations without updates in the store @@ -79,6 +84,8 @@ export class ChannelListContainerComponent implements OnDestroy { set channelList(value: Channel[]) { this._channelList = value; this.groupedChannels = _.default.groupBy(value, 'group.title'); + // Fetch EPG for new channel list + this.fetchEpgForChannels(value); } /** Object with channels sorted by groups */ @@ -169,8 +176,53 @@ export class ChannelListContainerComponent implements OnDestroy { ); } + ngOnInit(): void { + // Set up EPG refresh interval (every 60 seconds) + this.epgRefreshInterval = window.setInterval(() => { + this.fetchEpgForChannels(this._channelList); + }, 60000); + } + ngOnDestroy() { this.store.dispatch(PlaylistActions.setChannels({ channels: [] })); + + if (this.epgRefreshInterval) { + clearInterval(this.epgRefreshInterval); + } + } + + /** + * Fetches EPG data for all channels + */ + private fetchEpgForChannels(channels: Channel[]): void { + if (!channels || channels.length === 0) { + console.log('[EPG] No channels to fetch EPG for'); + return; + } + + // Get channel IDs (prefer tvg-id, fallback to name) + const channelIds = channels + .map(channel => channel?.tvg?.id?.trim() || channel?.name?.trim()) + .filter(id => !!id); + + console.log(`[EPG] Fetching EPG for ${channelIds.length} channels`); + + // Batch fetch EPG programs + this.epgService.getCurrentProgramsForChannels(channelIds).subscribe( + (epgMap) => { + console.log(`[EPG] Received EPG data for ${epgMap.size} channels`); + console.log('[EPG] Sample data:', Array.from(epgMap.entries()).slice(0, 3)); + this.channelEpgMap = epgMap; + } + ); + } + + /** + * Gets EPG program for a specific channel + */ + getEpgForChannel(channel: Channel): EpgProgram | null | undefined { + const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); + return channelId ? this.channelEpgMap.get(channelId) : null; } groupsComparator = ( diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index afe5d445f..838b9d0c8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -1,38 +1,159 @@ -.channel-name { - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - font-size: 0.9em; - max-width: 290px; - transition: color 0.2s ease; - width: 100%; - position: relative; - overflow: hidden; +// Import refined fonts (same as info-overlay) +@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=DM+Sans:wght@400;500;600&display=swap'); + +// Main channel list item container +.channel-list-item { + display: flex; + align-items: center; + height: 68px; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s ease; + gap: 12px; &:hover { - overflow: visible; - text-overflow: clip; - animation: marquee 6s linear infinite; + background-color: rgba(0, 0, 0, 0.04); + } + + &.active { + background-color: rgba(0, 0, 0, 0.08); } - } -.active { - background: #ddd; -} +// Dark theme support +::ng-deep .dark-theme { + .channel-list-item { + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } -::ng-deep { - .dark-theme { - .active { - background: #000; + &.active { + background-color: rgba(255, 255, 255, 0.08); } } } +// Drag handle icon .drag-icon { cursor: move; - margin-left: 0; - margin-right: 0; + margin-right: 4px; + opacity: 0.5; +} + +// Main content area +.channel-content { + display: flex; + align-items: center; + flex: 1; + gap: 12px; + min-width: 0; // Allow text truncation +} + +// Channel logo +.channel-logo { + width: 42px; + height: 42px; + border-radius: 4px; + object-fit: contain; + flex-shrink: 0; +} + +// Channel details (name + EPG) +.channel-details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; // Allow text truncation +} + +// Channel name +.channel-name { + font-family: 'DM Sans', sans-serif; + font-weight: 500; + font-size: 0.9rem; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// EPG title (program name) +.epg-title { + font-family: 'Crimson Pro', serif; + font-size: 0.8rem; + font-weight: 600; + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; + min-width: 0; + max-width: 100%; +} + +// EPG timeline container +.epg-timeline { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 100%; +} + +// EPG time display +.epg-time { + font-family: 'DM Sans', sans-serif; + font-size: 0.7rem; + opacity: 0.6; + letter-spacing: 0.02em; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// EPG placeholder (when no data available) +.epg-placeholder { + font-family: 'DM Sans', sans-serif; + font-size: 0.75rem; + opacity: 0.5; + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + max-width: 100%; +} + +// Progress track +.epg-progress-track { + position: relative; + width: 100%; + height: 3px; + background: rgba(128, 128, 128, 0.2); + border-radius: 2px; + overflow: hidden; +} + +// Progress fill +.epg-progress-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%); + border-radius: 2px; + transition: width 0.5s ease-out; + box-shadow: 0 0 6px rgba(124, 58, 237, 0.4); +} + +// Favorite button +.favorite-button { + flex-shrink: 0; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 1eb5a7670..3692c925c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -1,16 +1,31 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; -import { NgIf } from '@angular/common'; -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgIf, NgStyle } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; +import { EpgProgram } from 'shared-interfaces'; @Component({ selector: 'app-channel-list-item', styleUrls: ['./channel-list-item.component.scss'], - template: ` drag_indicator - -

- {{ name }} -

+
+ +
+
{{ name }}
+ @if (showEpg) { + @if (epgProgram) { +
+ {{ epgProgram.title?.[0]?.value }} +
+
+ {{ formatTime(epgProgram.start) }} - {{ formatTime(epgProgram.stop) }} +
+
+
+
+ } @else { +
+ {{ 'EPG.NO_PROGRAM_INFO' | translate }} +
+ } + } +
+
-
`, +
`, imports: [ DragDropModule, MatIcon, MatIconButton, - MatListModule, MatTooltip, NgIf, + NgStyle, TranslatePipe, ], }) -export class ChannelListItemComponent { +export class ChannelListItemComponent implements OnInit, OnChanges, OnDestroy { + private readonly cdr = inject(ChangeDetectorRef); + @Input() isDraggable = false; @Input() logo!: string; @Input() name = ''; @Input() showFavoriteButton = false; @Input() selected = false; + @Input() showEpg = true; + @Input() epgProgram?: EpgProgram | null; @Output() clicked = new EventEmitter(); - @Output() favoriteToggled = new EventEmitter(); + @Output() favoriteToggled = new EventEmitter(); + + progressPercentage = 0; + private progressInterval?: number; + + ngOnInit(): void { + this.calculateProgress(); + // Update progress every 30 seconds + this.progressInterval = window.setInterval(() => { + this.calculateProgress(); + }, 30000); + } + + ngOnChanges(changes: SimpleChanges): void { + // Recalculate progress when epgProgram changes + if (changes['epgProgram']) { + this.calculateProgress(); + } + } + + ngOnDestroy(): void { + if (this.progressInterval) { + clearInterval(this.progressInterval); + } + } + + /** + * Calculates the progress percentage for the EPG program + */ + private calculateProgress(): void { + if (!this.epgProgram) { + this.progressPercentage = 0; + return; + } + + const now = new Date().getTime(); + const start = new Date(this.epgProgram.start).getTime(); + const stop = new Date(this.epgProgram.stop).getTime(); + + const total = stop - start; + const elapsed = now - start; + + this.progressPercentage = Math.min( + 100, + Math.max(0, (elapsed / total) * 100) + ); + this.cdr.markForCheck(); + } + + /** + * Formats time for display (HH:mm) + */ + formatTime(dateString: string | number): string { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.ts b/libs/ui/components/src/lib/epg-list/epg-list.component.ts index 6a6c2841f..206998108 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.ts @@ -219,11 +219,11 @@ export class EpgListComponent implements OnInit { ) .subscribe((playingNow) => { this.playingNow = playingNow!; - if (this.playingNow) { - this.store.dispatch( - setCurrentEpgProgram({ program: this.playingNow }) - ); - } + // Always dispatch the action, even when playingNow is null/undefined + // This ensures the store is updated and old EPG data is cleared + this.store.dispatch( + setCurrentEpgProgram({ program: this.playingNow }) + ); }); } diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts index 767b5e891..abf7f4b6f 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts @@ -48,9 +48,17 @@ export class InfoOverlayComponent implements OnChanges { this.isVisible = false; }, 10000); } - if (changes['epgProgram'] && changes['epgProgram'].currentValue) { - const { stop, start } = changes['epgProgram'].currentValue; - this.setProgramDuration(start, stop); + if (changes['epgProgram']) { + if (changes['epgProgram'].currentValue) { + const { stop, start } = changes['epgProgram'].currentValue; + this.setProgramDuration(start, stop); + } else { + // Reset EPG data when no program is available + this.start = undefined; + this.stop = undefined; + this.generalDuration = 0; + this.finishedDuration = 0; + } } } From 592f6e34f95a8f9490dc0b2bc2ccc39b7fe1f484 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Nov 2025 20:25:15 +0100 Subject: [PATCH 04/13] feat(toolbar): add info button and overlay toggle Add "Show program info (Press I)" translation and an info icon button to the video player toolbar. Emit an infoOverlayClicked event from the toolbar and expose isLeftDrawerOpened to control the menu icon state. Update the menu icon to reflect drawer open/closed state. Implement showOverlay() on the info overlay component to toggle visibility and auto-hide after 10 seconds when shown. This enables manual toggling of the program info overlay via the new toolbar button or keyboard shortcut, improving discoverability and user control of program information. --- .../video-player/video-player.component.html | 2 ++ apps/web/src/assets/i18n/en.json | 3 ++- .../info-overlay/info-overlay.component.ts | 19 +++++++++++++++++++ .../toolbar/toolbar.component.html | 9 ++++++++- .../video-player/toolbar/toolbar.component.ts | 2 ++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/apps/web/src/app/home/video-player/video-player.component.html index a6880df8b..e3d6d3c95 100644 --- a/apps/web/src/app/home/video-player/video-player.component.html +++ b/apps/web/src/app/home/video-player/video-player.component.html @@ -16,7 +16,9 @@ (toggleLeftDrawerClicked)="drawer.toggle()" (toggleRightDrawerClicked)="drawerRight.toggle()" (multiEpgClicked)="openMultiEpgView()" + (infoOverlayClicked)="toggleInfoOverlay()" [activeChannel]="activeChannel" + [isLeftDrawerOpened]="drawer.opened" (settingsClicked)="openSettings()" /> @if (activeChannel.radio === 'true') { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index dd3c0e9fb..061a5513b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -198,7 +198,8 @@ "OPEN_CHANNELS_LIST": "Open channel list", "TOGGLE_FAVORITE_FLAG": "Toggle favorite flag", "OPEN_EPG_LIST": "Open EPG list", - "OPEN_MULTI_EPG": "Open Multi-EPG view" + "OPEN_MULTI_EPG": "Open Multi-EPG view", + "SHOW_INFO": "Show program info (Press I)" }, "EPG": { "TITLE": "Electronic Program Guide", diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts index abf7f4b6f..838af02c4 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.ts @@ -80,4 +80,23 @@ export class InfoOverlayComponent implements OnChanges { .duration(this.stop.diff(timeNow)) .asMilliseconds(); } + + /** + * Manually shows the overlay (triggered by user action like 'I' key or info button) + * Toggles visibility if already shown, or shows for 10 seconds if hidden + */ + showOverlay(): void { + if (this.isVisible) { + // If already visible, hide it (toggle behavior) + clearTimeout(this.currentTimeout); + this.isVisible = false; + } else { + // Show overlay for 10 seconds + clearTimeout(this.currentTimeout); + this.isVisible = true; + this.currentTimeout = setTimeout(() => { + this.isVisible = false; + }, 10000); + } + } } diff --git a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.html b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.html index 98fd2f5ba..8b14a972a 100644 --- a/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.html +++ b/libs/ui/components/src/lib/video-player/toolbar/toolbar.component.html @@ -5,7 +5,7 @@ [matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate" class="sidenav-toggle-button" > - menu + {{ isLeftDrawerOpened ? 'menu' : 'menu_open' }} @if ((playlistId$ | async) !== 'GLOBAL_FAVORITES') { } + {{ activeChannel?.name }}
@let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES'; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 328165544..83af308a9 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -33,7 +33,7 @@ import { selectActivePlaylistId, selectFavorites, } from 'm3u-state'; -import { map, skipWhile } from 'rxjs'; +import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; import { Channel, EpgProgram } from 'shared-interfaces'; import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; @@ -76,6 +76,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { * Create local copy of the store for local manipulations without updates in the store */ _channelList: Channel[] = []; + private channelList$ = new BehaviorSubject([]); + get channelList(): Channel[] { return this._channelList; } @@ -83,6 +85,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { @Input() set channelList(value: Channel[]) { this._channelList = value; + this.channelList$.next(value); // Emit to observable this.groupedChannels = _.default.groupBy(value, 'group.title'); // Fetch EPG for new channel list this.fetchEpgForChannels(value); @@ -119,18 +122,23 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ); - /** List with favorites */ - favorites$ = this.store.select(selectFavorites).pipe( - map( - ( - favoriteChannelIds // TODO: move to selector - ) => - favoriteChannelIds.map((favoriteChannelId) => - this.channelList.find( + /** List with favorites - combines favorites from store with current channel list */ + favorites$ = combineLatest([ + this.store.select(selectFavorites), + this.channelList$, + ]).pipe( + map(([favoriteChannelIds, channelList]) => { + console.log('[ChannelList] favorites$ emit - IDs:', favoriteChannelIds.length, 'Channels:', channelList.length); + const favorites = favoriteChannelIds + .map((favoriteChannelId) => + channelList.find( (channel) => channel.url === favoriteChannelId ) ) - ) + .filter((channel): channel is Channel => channel !== undefined); // Filter out undefined channels + console.log('[ChannelList] favorites$ result:', favorites.length, 'favorites'); + return favorites; + }) ); /** @@ -189,6 +197,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { if (this.epgRefreshInterval) { clearInterval(this.epgRefreshInterval); } + + // Clean up BehaviorSubject + this.channelList$.complete(); } /** From 5cf96d7112bac2387b83a6bd9a85538f380ef10b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 22 Nov 2025 09:47:20 +0100 Subject: [PATCH 06/13] feat(video-player): add channel number overlay UI Add a transient channel number overlay to the video player UI so users see the numeric channel feedback when entering or changing channels. - Insert conditional overlay markup in the video-player template that renders when showChannelNumberOverlay is true and displays the channelNumberInput value. - Add styles for .channel-number-overlay and .channel-number-display to center the overlay, style the numeric display (large bold font, dark translucent background, rounded corners, shadow) and include a brief scale-in animation. - Keep overlay pointer-events disabled so it doesn't block player input. This improves user experience by giving clear visual confirmation of channel changes. --- .../video-player/video-player.component.html | 9 +++++ .../video-player/video-player.component.scss | 36 +++++++++++++++++++ .../lib/vjs-player/vjs-player.component.ts | 4 +-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/apps/web/src/app/home/video-player/video-player.component.html index e3d6d3c95..5926a7050 100644 --- a/apps/web/src/app/home/video-player/video-player.component.html +++ b/apps/web/src/app/home/video-player/video-player.component.html @@ -100,6 +100,15 @@ [channel]="activeChannel" [epgProgram]="(epgProgram$ | async)!" /> + + + @if (showChannelNumberOverlay) { +
+
+ {{ channelNumberInput }} +
+
+ } } } diff --git a/apps/web/src/app/home/video-player/video-player.component.scss b/apps/web/src/app/home/video-player/video-player.component.scss index 1a2ffa0c7..c876706e4 100644 --- a/apps/web/src/app/home/video-player/video-player.component.scss +++ b/apps/web/src/app/home/video-player/video-player.component.scss @@ -54,3 +54,39 @@ } } } + +.channel-number-overlay { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + pointer-events: none; +} + +.channel-number-display { + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 6rem; + font-weight: 700; + font-family: 'DM Sans', sans-serif; + padding: 2rem 3rem; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + border: 2px solid rgba(255, 255, 255, 0.1); + min-width: 120px; + text-align: center; + letter-spacing: 0.05em; + animation: scaleIn 0.15s ease-out; +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts index 172c4fecc..c1db22c9c 100644 --- a/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/components/src/lib/vjs-player/vjs-player.component.ts @@ -56,11 +56,9 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { }); } ); - /* this.player.hlsQualitySelector({ + this.player.hlsQualitySelector({ displayCurrentQuality: true, }); - // this one produces warning in console - */ this.player['aspectRatioPanel'](); } From 9073bf1bb3ca3d5a44c9fff42cf02e15e44131cd Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 22 Nov 2025 11:17:42 +0100 Subject: [PATCH 07/13] feat(m3u): add compact mode and conditional EPG display Add compact layout support for channel list items and make EPG display conditional based on runtime environment and stored settings. - Introduce .compact styles in channel-list-item.component.scss to reduce item height, padding, and logo size when EPG is hidden. - Add StorageMap injection and read STORE_KEY.Settings in ChannelListContainerComponent to determine whether EPG is configured (only enable EPG in Electron when epgUrl exists; disable in PWA). - Expose shouldShowEpg flag and itemSize getter to switch virtual scroll item size between 68 and 48 pixels. - Bind [class.compact] on channel list item and wire [showEpg] input to channel-list-item components so item rendering adapts to mode. - Replace hardcoded itemSize in cdk-virtual-scroll-viewport with dynamic [itemSize] to match compact/expanded layouts. This reduces visual clutter and avoids showing EPG in environments (where it is not configured or supported), improving UX and resource usage. --- .../channel-list-container.component.html | 7 +- .../channel-list-container.component.ts | 74 +++++++++++++++---- .../channel-list-item.component.scss | 16 +++- .../channel-list-item.component.ts | 1 + 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 77e70e8b5..3b3b9764f 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -23,7 +23,7 @@
} @@ -140,6 +142,7 @@ " [isDraggable]="true" [logo]="channel?.tvg?.logo" + [showEpg]="shouldShowEpg" [epgProgram]="getEpgForChannel(channel)" (clicked)="selectChannel(channel)" [selected]="activeChannel()?.url === channel?.url" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 83af308a9..496e4643c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -25,6 +25,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; import { FilterPipe } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import * as _ from 'lodash'; import * as PlaylistActions from 'm3u-state'; @@ -33,9 +34,16 @@ import { selectActivePlaylistId, selectFavorites, } from 'm3u-state'; -import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs'; +import { + BehaviorSubject, + combineLatest, + map, + skipWhile, + Subject, + takeUntil, +} from 'rxjs'; import { EpgService } from 'services'; -import { Channel, EpgProgram } from 'shared-interfaces'; +import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces'; import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; @Component({ @@ -62,6 +70,7 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item. export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly epgService = inject(EpgService); private readonly snackBar = inject(MatSnackBar); + private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly translateService = inject(TranslateService); @@ -71,6 +80,16 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Interval for refreshing EPG data */ private epgRefreshInterval?: number; + private destroy$ = new Subject(); + + /** Whether to show EPG data in channel items (false in PWA mode or when EPG is not configured) */ + shouldShowEpg = false; + + /** Item size for virtual scroll - compact when no EPG */ + get itemSize(): number { + return this.shouldShowEpg ? 68 : 48; + } + /** * Channels array * Create local copy of the store for local manipulations without updates in the store @@ -128,7 +147,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { this.channelList$, ]).pipe( map(([favoriteChannelIds, channelList]) => { - console.log('[ChannelList] favorites$ emit - IDs:', favoriteChannelIds.length, 'Channels:', channelList.length); + console.log( + '[ChannelList] favorites$ emit - IDs:', + favoriteChannelIds.length, + 'Channels:', + channelList.length + ); const favorites = favoriteChannelIds .map((favoriteChannelId) => channelList.find( @@ -136,7 +160,11 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ) .filter((channel): channel is Channel => channel !== undefined); // Filter out undefined channels - console.log('[ChannelList] favorites$ result:', favorites.length, 'favorites'); + console.log( + '[ChannelList] favorites$ result:', + favorites.length, + 'favorites' + ); return favorites; }) ); @@ -185,6 +213,25 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } ngOnInit(): void { + // Check if EPG should be shown (only in Electron with configured EPG URL) + const isElectron = !!window['electron']; + if (isElectron) { + this.storage + .get(STORE_KEY.Settings) + .subscribe((settings: unknown) => { + if ( + settings && + Object.keys(settings as Settings).length > 0 + ) { + const epgUrl = (settings as Settings).epgUrl; + this.shouldShowEpg = !!(epgUrl && epgUrl.length > 0); + } + }); + } else { + // PWA mode - don't show EPG + this.shouldShowEpg = false; + } + // Set up EPG refresh interval (every 60 seconds) this.epgRefreshInterval = window.setInterval(() => { this.fetchEpgForChannels(this._channelList); @@ -198,6 +245,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { clearInterval(this.epgRefreshInterval); } + this.destroy$.next(); + this.destroy$.complete(); // Clean up BehaviorSubject this.channelList$.complete(); } @@ -213,19 +262,16 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { // Get channel IDs (prefer tvg-id, fallback to name) const channelIds = channels - .map(channel => channel?.tvg?.id?.trim() || channel?.name?.trim()) - .filter(id => !!id); - - console.log(`[EPG] Fetching EPG for ${channelIds.length} channels`); + .map((channel) => channel?.tvg?.id?.trim() || channel?.name?.trim()) + .filter((id) => !!id); // Batch fetch EPG programs - this.epgService.getCurrentProgramsForChannels(channelIds).subscribe( - (epgMap) => { - console.log(`[EPG] Received EPG data for ${epgMap.size} channels`); - console.log('[EPG] Sample data:', Array.from(epgMap.entries()).slice(0, 3)); + this.epgService + .getCurrentProgramsForChannels(channelIds) + .pipe(takeUntil(this.destroy$)) + .subscribe((epgMap) => { this.channelEpgMap = epgMap; - } - ); + }); } /** diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 838b9d0c8..f64284e0d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -19,6 +19,12 @@ &.active { background-color: rgba(0, 0, 0, 0.08); } + + // Compact mode when EPG is not shown + &.compact { + height: 48px; + padding: 6px 12px; + } } // Dark theme support @@ -57,6 +63,12 @@ border-radius: 4px; object-fit: contain; flex-shrink: 0; + + // Smaller logo in compact mode + .compact & { + width: 32px; + height: 32px; + } } // Channel details (name + EPG) @@ -83,9 +95,9 @@ // EPG title (program name) .epg-title { - font-family: 'Crimson Pro', serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 0.8rem; - font-weight: 600; + font-weight: 500; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 3692c925c..43523658c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -26,6 +26,7 @@ import { EpgProgram } from 'shared-interfaces'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
Date: Sat, 22 Nov 2025 09:45:10 +0100 Subject: [PATCH 08/13] feat(multi-epg): redesign multi-EPG styles with modern dark theme Revamp the Multi-EPG component stylesheet to introduce a polished, broadcast-control-room inspired dark theme and improved layout styling. - Add Google fonts (Outfit, JetBrains Mono) for a clean UI and mono date display. - Introduce color variables, gradients and accents for consistent theming and easier maintenance. - Rework base container (.parent) with subtle grid overlay and updated typography. - Implement a frosted glass navigation bar (#epg-navigation) with blur, glow, and accessible button styles. - Add today-date styling using a monospace font and glowing accent. - Expand channels column width, update background, borders, shadows, and hover transitions for clearer channel separation. - Replace many hard-coded colors with variables and add transitions for smoother interactions. - Add utility classes for navigation buttons and improved disabled/ hover states. These changes improve visual hierarchy, accessibility, and maintainabiity of the EPG UI while setting up a consistent design system for further enhancements. --- .../video-player/video-player.component.ts | 7 + .../multi-epg-container.component.html | 61 +- .../multi-epg-container.component.scss | 537 ++++++++++++++---- .../multi-epg-container.component.ts | 46 +- 4 files changed, 496 insertions(+), 155 deletions(-) diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index a03a0d086..5ce337d7e 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -335,6 +335,13 @@ export class VideoPlayerComponent implements OnInit { selectChannels ) as Observable; + // Pass the active channel's tvg.id for highlighting + this.activeChannel$.pipe(take(1)).subscribe((channel) => { + if (channel) { + componentRef.instance.activeChannelId = (channel as Channel).tvg?.id || null; + } + }); + this.overlayRef.backdropClick().subscribe(() => { this.overlayRef.dispose(); }); diff --git a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.html index fd1814ded..97af434d0 100644 --- a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.html +++ b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.html @@ -1,60 +1,41 @@
+
+ {{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }} +
-
- {{ today | momentDate: 'YYYYMMDD' : 'MMMM Do, dddd' }} -
- - -
-
- +
+ @for (item of channels$ | async; track item.id; let i = $index) { - + } -
- +
+ @for (hour of timeHeader; track hour) { @@ -102,12 +83,20 @@ > {{ hour }}:00 + + } @for (item of channels$ | async; track item.id; let i = $index) { - + @for (program of item.programs; track trackByProgram($index, program)) { rect[fill='#000'] { + fill: $bg-surface !important; + } + + g > text { + font-family: 'JetBrains Mono', monospace !important; + font-size: 11px !important; + font-weight: 500; + fill: $text-secondary !important; + letter-spacing: 0.1em; + } + + // Hour separator vertical lines + .hour-separator { + stroke: $border-subtle; + stroke-width: 1px; + pointer-events: none; + } + + // Program blocks + .program { + cursor: pointer; + + rect { + fill: $bg-elevated; + stroke: $border-subtle; + rx: 4; + ry: 4; + transition: all 0.15s ease; + } + + &:hover rect { + fill: $bg-hover; + stroke: rgba(139, 92, 246, 0.4); + filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2)); + } + } + + // Active channel row highlighting + .active-row .program rect { + fill: rgba(139, 92, 246, 0.15); + stroke: rgba(139, 92, 246, 0.3); + } + + .active-row .program:hover rect { + fill: rgba(139, 92, 246, 0.25); + } + + .program-title { + color: $text-primary; + padding: 6px 10px; + font-size: 11px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + letter-spacing: 0.01em; + line-height: 1.4; + + // Subtle gradient text for depth + display: flex; + align-items: center; + height: 100%; + } + + // Current time line - glowing effect + line[stroke='red'] { + stroke: $accent-primary !important; + stroke-width: 2px; + filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) + drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + + // Animated pulse + animation: timePulse 2s ease-in-out infinite; + } +} + +// Pulse animation for time line +@keyframes timePulse { + 0%, + 100% { + opacity: 1; + filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6)) + drop-shadow(0 0 16px rgba(0, 212, 170, 0.3)); + } + 50% { + opacity: 0.8; + filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8)) + drop-shadow(0 0 24px rgba(0, 212, 170, 0.4)); + } +} + +// Legacy classes cleanup .program-item { cursor: pointer; } rect { - stroke: #676767; + stroke: $border-subtle; cursor: pointer; } .channel > rect { - stroke: #676767; -} - -.program-item:hover > rect { - fill: #333; + stroke: $border-subtle; } .program-name, @@ -23,112 +345,121 @@ rect { text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; - padding: 6px 2px; -} - -#epg-container { - width: calc(100vw - 100px); - height: calc(100vh - 42px); - overflow-x: scroll; - overflow-y: hidden; -} - -.parent { - display: flex; - background-color: black; - color: #fff; -} - -#channels-column { - width: 100px; - flex: none; - border-right: 2px solid #ccc; - - .channel { - cursor: pointer; - - rect { - fill: #2d2d2d; - stroke: #3d3d3d; - } - - &:hover rect { - fill: #3d3d3d; - } - } - - .channel-name { - color: white; - padding: 4px 8px; - font-size: 12px; - display: flex; - align-items: center; - height: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - image { - object-fit: contain; - background: #2d2d2d; - } -} - -/* .channel-name { - margin: 0 5px; - text-align: center; -} */ - -#epg-navigation { - display: flex; - background-color: white; - border-bottom: 2px solid #fff; - align-items: center; -} - -#epg-svg { - .program { - cursor: pointer; - - rect { - fill: #2d2d2d; - stroke: #3d3d3d; - } - - &:hover rect { - fill: #3d3d3d; - } - } - - .program-title { - color: white; - padding: 4px 8px; - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - } -} - -.today-date { - flex: 1; - align-items: center; - line-height: 40px; - height: 40px; - padding: 0 20px; + padding: 6px 8px; } #current-time-line { stroke-width: 2px; - stroke: white; + stroke: $accent-primary; } +// Dark theme override (already dark, but ensure consistency) ::ng-deep { .dark-theme { #epg-navigation { - background-color: black; + background: rgba(18, 18, 26, 0.85); + } + } + + // Light theme adjustments + .light-theme { + #epg-navigation { + background: rgba(255, 255, 255, 0.9); + border-bottom-color: rgba(0, 0, 0, 0.1); + + .today-date { + color: #059669; + text-shadow: none; + } + } + + .parent { + background: #f8fafc; + + &::before { + background-image: + linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px), + linear-gradient( + 90deg, + rgba(0, 0, 0, 0.03) 1px, + transparent 1px + ); + } + } + + #channels-column { + background: #ffffff; + border-right-color: rgba(0, 0, 0, 0.1); + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05); + + .channel rect { + fill: #ffffff; + stroke: rgba(0, 0, 0, 0.08); + } + + .channel:hover rect { + fill: #f1f5f9; + } + + .channel-name { + color: #1e293b; + } + } + + #epg-container { + background: #f8fafc; + } + + #epg-svg { + g > rect[fill='#000'] { + fill: #e2e8f0 !important; + } + + g > text { + fill: #64748b !important; + } + + .program { + rect { + fill: #ffffff; + stroke: rgba(0, 0, 0, 0.08); + } + + &:hover rect { + fill: #f1f5f9; + stroke: rgba(139, 92, 246, 0.4); + } + } + + .program-title { + color: #1e293b; + } + + line[stroke='red'] { + stroke: #059669 !important; + filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4)); + } } } } + +// Responsive adjustments +@media (max-width: 768px) { + #channels-column { + width: 100px; + + .channel-name { + font-size: 10px; + padding: 6px 8px; + } + } + + #epg-container { + width: calc(100vw - 100px); + } + + .today-date { + font-size: 12px; + padding: 0 12px; + } +} diff --git a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts index 3dfcc68d1..46a2d3d85 100644 --- a/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/components/src/lib/multi-epg/multi-epg-container.component.ts @@ -62,12 +62,19 @@ export class MultiEpgContainerComponent if (value) { this.playlistChannelsSubscription = value.subscribe(() => { + // Reset data for new playlist + this.channelsLowerRange = 0; + this.originalEpgData = []; + this.channels$.next([]); + this.isLastPage = false; this.initializeVisibleChannels(); this.requestPrograms(); }); } } + @Input() activeChannelId: string | null = null; + readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); readonly hourWidth$ = new BehaviorSubject(150); readonly barHeight = 50; @@ -85,6 +92,7 @@ export class MultiEpgContainerComponent private playlistChannelsSubscription?: Subscription; isLastPage = false; + isLoading = false; totalChannels = 0; private readonly dialog = inject(MatDialog); @@ -161,6 +169,12 @@ export class MultiEpgContainerComponent return; } + if (this.isLoading || this.isLastPage) { + return; + } + + this.isLoading = true; + try { const response = await window.electron.getEpgChannelsByRange( this.channelsLowerRange, @@ -168,35 +182,35 @@ export class MultiEpgContainerComponent ); if (response && Array.isArray(response)) { - this.originalEpgData = response; + // Append new data to existing data + this.originalEpgData = [...this.originalEpgData, ...response]; this.channels$.next(this.enrichProgramData()); // Update isLastPage based on the number of channels received this.isLastPage = 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; } } - nextChannels(): void { - this.channelsLowerRange += this.visibleChannels; - this.channelsUpperRange = - this.channelsLowerRange + this.visibleChannels; + onScroll(event: Event): void { + const target = event.target as HTMLElement; + const scrollTop = target.scrollTop; + const scrollHeight = target.scrollHeight; + const clientHeight = target.clientHeight; - this.requestPrograms(); - } - - previousChannels(): void { - this.channelsLowerRange = Math.max( - 0, - this.channelsLowerRange - this.visibleChannels - ); - this.channelsUpperRange = - this.channelsLowerRange + this.visibleChannels; - this.requestPrograms(); + // Load more when user scrolls to within 200px of the bottom + if (scrollHeight - scrollTop - clientHeight < 200) { + this.requestPrograms(); + } } private getCachedDate(dateStr: string): Date { From 7da2202119f10eeed9c34268707a4ce4b7736ba3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 23 Nov 2025 18:50:46 +0100 Subject: [PATCH 09/13] ci: add icon paths to electron packager configs Add explicit icon settings to packaged app configuration for macOS, Linux and Windows. Set macOS icon to apps/web/src/assets/icons/favicon.ic, Linux icon directory to apps/web/src/assets/icons and Windows icon to apps/web/src/assets/icons/favicon.ico. This ensures bundled installers and app bundles include the intended application icon across platforms and prevents missing icon assets when creating artifacts. Also add trailing commas to keep JSON formatting consistent. --- package.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6e3e86826..f80c824d2 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "gatekeeperAssess": false, "hardenedRuntime": false, "entitlements": null, - "entitlementsInherit": null + "entitlementsInherit": null, + "icon": "apps/web/src/assets/icons/favicon.icns" }, "linux": { "category": "Video", @@ -46,7 +47,8 @@ "deb", "rpm" ], - "artifactName": "${name}-${version}-${os}-${arch}.${ext}" + "artifactName": "${name}-${version}-${os}-${arch}.${ext}", + "icon": "apps/web/src/assets/icons" }, "win": { "compression": "maximum", @@ -59,7 +61,8 @@ ] } ], - "artifactName": "${name}-${version}-windows-${arch}-setup.${ext}" + "artifactName": "${name}-${version}-windows-${arch}-setup.${ext}", + "icon": "apps/web/src/assets/icons/icon.png" }, "nsis": { "artifactName": "${name}-${version}-windows-${arch}-setup.${ext}" From 13c05c85b0e18fd6ff48fc53c777b2ca12cc7851 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 22 Nov 2025 09:47:12 +0100 Subject: [PATCH 10/13] docs: normalize list indentation and fix formatting Adjust documentation lists and code blocks for consistent indentation and spacing across CLAUDE.md. Convert mixed dash and nested list levels to use uniform two-space indentation for subitems, reflow a few wrapped lines, and add blank lines where needed to separate sections and examples. These changes improve readability and maintain a consistent markdown structure for the project documentation. --- .claude/settings.local.json | 6 +- .mcp.json | 9 +++ .vscode/extensions.json | 3 + CLAUDE.md | 128 +++++++++++++++++++++++------------- 4 files changed, 100 insertions(+), 46 deletions(-) create mode 100644 .mcp.json create mode 100644 .vscode/extensions.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 41cd94774..7d693d23c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,5 +9,9 @@ ], "deny": [], "ask": [] - } + }, + "enabledMcpjsonServers": [ + "nx-mcp" + ], + "enableAllProjectMcpServers": true } diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..ae5a82ef5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "nx-mcp": { + "type": "stdio", + "command": "npx", + "args": ["nx", "mcp"] + } + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..3583c4314 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["ms-playwright.playwright", "esbenp.prettier-vscode"] +} diff --git a/CLAUDE.md b/CLAUDE.md index c42097d00..9420b3598 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,23 +94,25 @@ This is an Nx monorepo with the following structure: - **apps/electron-backend** - Electron main process - **apps/web-e2e** - Playwright end-to-end tests - **libs/** - Shared libraries: - - **m3u-state** - NgRx state management for playlists - - **services** - Abstract DataService and implementations - - **shared/interfaces** - TypeScript interfaces and types - - **shared/m3u-utils** - M3U playlist utilities - - **ui/components** - Reusable UI components - - **ui/pipes** - Angular pipes - - **ui/shared-portals** - Portal-related UI components + - **m3u-state** - NgRx state management for playlists + - **services** - Abstract DataService and implementations + - **shared/interfaces** - TypeScript interfaces and types + - **shared/m3u-utils** - M3U playlist utilities + - **ui/components** - Reusable UI components + - **ui/pipes** - Angular pipes + - **ui/shared-portals** - Portal-related UI components ### Frontend Architecture (Angular) **State Management**: Uses NgRx for playlist state management: + - Store configuration in `apps/web/src/app/app.config.ts` - Playlist state, actions, effects, and reducers in `libs/m3u-state/` - Entity adapter pattern for managing playlists collection - Router store integration for route-based state **Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts` + - Home/playlists overview: `/` - Video player: `/playlists/:id` or `/iptv` - Xtream Codes: `/xtreams/:id` (different routes for Electron vs web) @@ -118,85 +120,96 @@ This is an Nx monorepo with the following structure: - Settings: `/settings` **Service Architecture** (Factory Pattern): + - Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract - Two environment-specific implementations: - - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend - - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version + - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend + - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version - Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject: - ```typescript - if (window.electron) { - return new ElectronService(); - } - return new PwaService(); - ``` + ```typescript + if (window.electron) { + return new ElectronService(); + } + return new PwaService(); + ``` **Data Storage (Environment-Specific)**: + - **Electron**: libSQL/SQLite database via Drizzle ORM - - Location: `~/.iptvnator/databases/iptvnator.db` - - Full-featured relational database with foreign keys and indexes - - Supports local file or remote Turso instance via env vars + - Location: `~/.iptvnator/databases/iptvnator.db` + - Full-featured relational database with foreign keys and indexes + - Supports local file or remote Turso instance via env vars - **PWA (Web)**: IndexedDB via `ngx-indexed-db` - - Browser-based NoSQL storage - - Same schema structure but implemented in IndexedDB - - Limited by browser storage quotas + - Browser-based NoSQL storage + - Same schema structure but implemented in IndexedDB + - Limited by browser storage quotas ### Backend Architecture (Electron) **Main Entry**: `apps/electron-backend/src/main.ts` + - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication **Database**: + - **ORM**: Drizzle ORM with libSQL (local SQLite file or remote Turso) - **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path) - **Schema** (`apps/electron-backend/src/app/database/schema.ts`): - - `playlists` - Playlist metadata (M3U, Xtream, Stalker) - - `categories` - Content categories (live, movies, series) - - `content` - Streams/VOD/series items - - `favorites` - User favorites - - `recentlyViewed` - Watch history + - `playlists` - Playlist metadata (M3U, Xtream, Stalker) + - `categories` - Content categories (live, movies, series) + - `content` - Streams/VOD/series items + - `favorites` - User favorites + - `recentlyViewed` - Watch history - **Connection**: `apps/electron-backend/src/app/database/connection.ts` - - Auto-creates tables on init - - Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`) + - Auto-creates tables on init + - Supports local file or remote via env vars (`LIBSQL_URL`, `LIBSQL_AUTH_TOKEN`) **IPC Communication**: + - **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts` - - Exposes `window.electron` API via `contextBridge` - - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) + - Exposes `window.electron` API via `contextBridge` + - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) - **Event handlers**: `apps/electron-backend/src/app/events/` - - `database.events.ts` - Database CRUD operations - - `playlist.events.ts` - Playlist import/update - - `epg.events.ts` - EPG fetch and parsing (uses worker) - - `xtream.events.ts` - Xtream Codes API - - `stalker.events.ts` - Stalker portal API - - `player.events.ts` - External player (MPV, VLC) integration - - `settings.events.ts` - App settings - - `electron.events.ts` - App version, etc. + - `database.events.ts` - Database CRUD operations + - `playlist.events.ts` - Playlist import/update + - `epg.events.ts` - EPG fetch and parsing (uses worker) + - `xtream.events.ts` - Xtream Codes API + - `stalker.events.ts` - Stalker portal API + - `player.events.ts` - External player (MPV, VLC) integration + - `settings.events.ts` - App settings + - `electron.events.ts` - App version, etc. **Workers**: + - EPG parsing runs in worker thread: `apps/electron-backend/src/app/workers/epg-parser.worker.ts` ### Key Features **Playlist Support**: + - M3U/M3U8 files (local or URL) - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) **Video Players**: + - Built-in HTML5 player with HLS.js or Video.js - External players: MPV, VLC (via IPC to Electron backend) **EPG (Electronic Program Guide)**: + - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup **Favorites and Recently Viewed**: + - Per-playlist favorites and global favorites - Recently viewed tracks watch history **Internationalization**: + - Uses `@ngx-translate` with 16 language files in `apps/web/src/assets/i18n/` ## Development Notes @@ -204,34 +217,39 @@ This is an Nx monorepo with the following structure: ### Environment Detection and Dual-Mode Architecture The app determines whether it's running in Electron or as a PWA by checking: + ```typescript -window.electron // truthy in Electron, undefined in browser +window.electron; // truthy in Electron, undefined in browser ``` **Why Dual Mode?** IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility: + - **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access - **PWA**: Lightweight web version that runs in any browser without installation **Environment-Specific Behavior**: + - `app.config.ts` - `DataFactory()` selects DataService implementation based on environment - `app.routes.ts` - Different routes for Xtream portals (Electron uses Tauri-based routes, PWA uses standard routes) - Storage layer switches automatically: - - Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db` - - PWA → IndexedDB → Browser storage + - Electron → libSQL/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db` + - PWA → IndexedDB → Browser storage - External player support (MPV/VLC) only available in Electron - File system operations only available in Electron (uploading playlists from disk) **Base Href Configuration**: The app uses different base href values depending on the build target: + - **Development & PWA**: `baseHref="/"` (from `index.html`) - - Used by: `npm run serve:frontend`, `npm run build:frontend:pwa` - - For web servers with proper routing + - Used by: `npm run serve:frontend`, `npm run build:frontend:pwa` + - For web servers with proper routing - **Electron Production**: `baseHref="./"` (overridden in build config) - - Used by: `npm run build:backend`, `npm run make:app` - - Required for `file://` protocol in Electron + - Used by: `npm run build:backend`, `npm run make:app` + - Required for `file://` protocol in Electron Build configurations in `apps/web/project.json`: + - `production`: Electron build with `baseHref="./"` - `pwa`: Web deployment with `baseHref="/"` - `development`: Dev mode with `baseHref="/"` from index.html @@ -248,6 +266,7 @@ The factory pattern ensures a single codebase works in both environments without ### Nx Commands Use `nx` CLI for better performance: + ```bash nx run : # Example: nx run web:build @@ -255,6 +274,7 @@ nx run : ``` To run multiple projects: + ```bash nx run-many --target=test --all ``` @@ -262,6 +282,7 @@ nx run-many --target=test --all ### Electron Build Process The Electron backend depends on the web app being built first: + - `electron-backend:build` depends on `web:build` - Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend) - Packaging combines both into distributable @@ -273,18 +294,35 @@ No formal migration system yet. Schema changes are applied via raw SQL in `conne ### Common Patterns **IPC Communication**: + 1. Define handler in appropriate events file (e.g., `database.events.ts`) 2. Register with `ipcMain.handle()` in the event bootstrap function 3. Expose in preload script via `contextBridge.exposeInMainWorld()` 4. Call from Angular via `window.electron.()` **Adding New Playlist Source**: + 1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts` 2. Create event handler in `apps/electron-backend/src/app/events/` 3. Add UI in `apps/web/src/app/home/` 4. Update database schema if needed **State Management**: + - Use NgRx for global application state (playlists) - Use component stores (`@ngrx/component-store`) for feature-specific state - Use NgRx signals for reactive data streams + + + + +# General Guidelines for working with Nx + +- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly +- You have access to the Nx MCP server and its tools, use them to help the user +- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable. +- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies +- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration +- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors + + From 09ad2c3b1be9f88ccacf79ebd6cede8a0ca35ebd Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 23 Nov 2025 18:33:59 +0100 Subject: [PATCH 11/13] refactor(ui): tidy imports, simplify cleanup, and format styles --- apps/web/src/index.html | 4 +++ .../channel-list-container.component.ts | 14 +-------- .../channel-list-item.component.scss | 7 ++--- .../info-overlay/info-overlay.component.scss | 31 ++++++++++++++----- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/apps/web/src/index.html b/apps/web/src/index.html index d2475cdec..0bc670524 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -25,6 +25,10 @@ href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet" /> + (); - /** Whether to show EPG data in channel items (false in PWA mode or when EPG is not configured) */ shouldShowEpg = false; @@ -245,8 +236,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { clearInterval(this.epgRefreshInterval); } - this.destroy$.next(); - this.destroy$.complete(); // Clean up BehaviorSubject this.channelList$.complete(); } @@ -268,7 +257,6 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { // Batch fetch EPG programs this.epgService .getCurrentProgramsForChannels(channelIds) - .pipe(takeUntil(this.destroy$)) .subscribe((epgMap) => { this.channelEpgMap = epgMap; }); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index f64284e0d..89fef133b 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -1,6 +1,3 @@ -// Import refined fonts (same as info-overlay) -@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=DM+Sans:wght@400;500;600&display=swap'); - // Main channel list item container .channel-list-item { display: flex; @@ -95,7 +92,9 @@ // EPG title (program name) .epg-title { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; font-size: 0.8rem; font-weight: 500; opacity: 0.85; diff --git a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss index 44f63c748..7cfe92e31 100644 --- a/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss +++ b/libs/ui/components/src/lib/info-overlay/info-overlay.component.scss @@ -1,6 +1,3 @@ -// Import refined fonts -@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=DM+Sans:wght@400;500;600&display=swap'); - .channel-overlay { position: absolute; bottom: 35px; @@ -23,11 +20,22 @@ inset: 0; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); - mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 20%, rgba(0, 0, 0, 0.8) 60%, black 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 20%, rgba(0, 0, 0, 0.8) 60%, black 100%); + mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 20%, + rgba(0, 0, 0, 0.8) 60%, + black 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 0, 0, 0.3) 20%, + rgba(0, 0, 0, 0.8) 60%, + black 100% + ); z-index: -1; } - } // EPG Layout with data @@ -161,7 +169,12 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%); + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100% + ); animation: shimmer 2s ease-in-out infinite; } } @@ -204,7 +217,9 @@ .hidden { visibility: hidden; opacity: 0; - transition: visibility 0s 2s, opacity 2s linear; + transition: + visibility 0s 2s, + opacity 2s linear; } // No EPG state variations From e279172dfecf5d109dc55f105b475b16754c63de Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 23 Nov 2025 18:43:53 +0100 Subject: [PATCH 12/13] fix(ui): correct fonts, semicolons and preload target - Add missing semicolon in apps/web/src/styles.scss to fix invalid CSS that could break style parsing for .scroll-viewport. - Change preconnect in apps/web/src/index.html from fonts.gstatic.com to fonts.googleapis.com and consolidate multiple Google Fonts links into a single stylesheet URL to reduce redundant requests and ensure Material Icons and selected font families load together. Remove duplicated @import of Outfit in libs/ui/components/src/lib/multi-epg/multi-epg-container.component.scss, switch component font-family to Roboto (with fallback) and update button font to match, ensuring consistent typography and avoiding external import duplication across components. ese changes fix a ntax error, optimize font loading, and standardize typography across the UI. --- apps/web/src/index.html | 12 +---- apps/web/src/styles.scss | 50 ------------------- .../multi-epg-container.component.scss | 7 +-- 3 files changed, 4 insertions(+), 65 deletions(-) diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 0bc670524..1d564b59e 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -20,17 +20,9 @@ - + - -