From 008f267808ce39a279312d498a7a4f8f31fb0edd Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 24 Nov 2025 22:29:42 +0100 Subject: [PATCH] feat(ui/epg-list): replace mat-list with custom markup and add progress Refactor EPG list template to remove Material selection list and dividers, replacing them with plain markup and CSS-friendly elements. Key changes: - Replace with .header-divider for consistent styling. - Replace mat-selection-list/mat-list-option with plain divs (.program-item, .program-meta, .program-title) to allow richer DOM structure and avoid Material selection semantics. - Use track $index in ngFor to stabilize rendering and avoid using program start/stop concatenation as key. - Render live progress bar inside program items when a program is playing, using calculateProgress(program) to set width. - Convert empty-state

to .epg-not-available div for layout consistency. - Add clickable class and active class binding to interactive items. Code changes and cleanup: - Update component imports to use ChangeDetectionStrategy and ChangeDetectorRef and remove unused Material imports (MatDivider, MatListModule) from the component file. - Adjust template bindings: use [innerHTML] and class bindings instead of matList directives. Why: - Remove heavy dependency on Material selection list to gain finer control over DOM and styles, enable custom progress UI and clickable behavior, and simplify template for better performance and maintainability. --- .../epg-list-item.component.scss | 104 ++++++++-- .../src/lib/epg-list/epg-list.component.html | 62 +++--- .../src/lib/epg-list/epg-list.component.scss | 181 +++++++++++++++--- .../src/lib/epg-list/epg-list.component.ts | 41 +++- 4 files changed, 317 insertions(+), 71 deletions(-) diff --git a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss index 62700d796..fb8d24679 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss +++ b/libs/ui/components/src/lib/epg-list/epg-list-item/epg-list-item.component.scss @@ -1,30 +1,106 @@ +:host { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + .time { - color: #666; - border-bottom: 1px solid #ccc; - font-size: 0.8em; - display: inline-block; - margin-bottom: 5px; - margin-left: 5px; + font-family: 'DM Sans', sans-serif; + font-size: 0.7rem; + opacity: 0.6; + letter-spacing: 0.02em; + line-height: 1; + white-space: nowrap; } .badge { - border-radius: 2px; - padding: 2px 6px; - color: #fff; + border-radius: 4px; + padding: 3px 8px; + color: white; text-transform: uppercase; - margin-left: 6px; - font-size: 0.8em; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .live { - background-color: red; + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + animation: livePulse 2s ease-in-out infinite; + box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + + &::after { + content: '●'; + margin-left: 4px; + animation: blink 1.5s ease-in-out infinite; + } } .rec { - background-color: grey; + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + box-shadow: 0 0 12px rgba(139, 92, 246, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + + &::after { + content: '◉'; + margin-left: 4px; + } } .description-icon { - float: right; + margin-left: auto; cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.04); + } + + &:active { + transform: scale(0.95); + } +} + +// Dark theme support +::ng-deep .dark-theme { + .description-icon:hover { + background: rgba(255, 255, 255, 0.04); + } +} + +// Animations +@keyframes livePulse { + 0%, 100% { + box-shadow: 0 0 12px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + } + 50% { + box-shadow: 0 0 20px rgba(239, 68, 68, 0.8), 0 2px 12px rgba(0, 0, 0, 0.4); + } +} + +@keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.html b/libs/ui/components/src/lib/epg-list/epg-list.component.html index 260047d49..58cfeb869 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.html @@ -23,7 +23,7 @@ - +

- @if (timeshiftUntil) { - +
@if (items?.length > 0) { - @for (program of items; track program.start + program.stop) { + @for (program of items; track $index) { @if ( program.start < timeshiftUntil || program.start >= timeNow ) { - -
+
+
-

+

{{ program?.title?.[0]?.value }} -

- +
+ @if (isProgramPlaying(program)) { +
+
+
+
+
+ } +
} @else { - -
+
-

- + >
+ @if (isProgramPlaying(program)) { +
+
+
+
+
+ } +
} - } } @else { -

+

{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }} -

+
} - +
} diff --git a/libs/ui/components/src/lib/epg-list/epg-list.component.scss b/libs/ui/components/src/lib/epg-list/epg-list.component.scss index 7841ab869..af1a42077 100644 --- a/libs/ui/components/src/lib/epg-list/epg-list.component.scss +++ b/libs/ui/components/src/lib/epg-list/epg-list.component.scss @@ -10,14 +10,20 @@ overflow-x: hidden; } -.active { - border-bottom: 1px dashed #666; +// Header divider +.header-divider { + height: 1px; + background: rgba(0, 0, 0, 0.12); } -.program-title { - font-size: 14px; +// Dark theme support +::ng-deep .dark-theme { + .header-divider { + background: rgba(255, 255, 255, 0.12); + } } +// Channel header #channel-header { flex-direction: column; box-sizing: border-box; @@ -27,20 +33,27 @@ flex-direction: row; box-sizing: border-box; display: flex; - padding: 10px; + padding: 12px; + gap: 12px; } .channel-icon { overflow: hidden; - margin-right: 10px; place-content: center; align-items: center; flex-direction: row; box-sizing: border-box; display: flex; - flex: 1 1 60px; - max-width: 60px; - min-width: 60px; + flex: 0 0 48px; + width: 48px; + height: 48px; + border-radius: 4px; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } } .channel-details { @@ -50,41 +63,153 @@ align-self: center; display: flex; flex-direction: column; + gap: 2px; + min-width: 0; } .channel-name { - font-weight: 800; + 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; } .program-name { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 0.8rem; + font-weight: 500; + opacity: 0.85; overflow: hidden; text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - font-size: 0.9em; + white-space: nowrap; + line-height: 1.2; } } -#program-list { - flex: 1 1 1e-9px; - box-sizing: border-box; -} - -.epg-not-available { - padding: 0 10px; -} - -mat-selection-list { - overflow: auto; -} - +// Date navigator #date-navigator { display: flex; justify-content: space-between; align-items: center; + padding: 8px; + gap: 8px; + + button { + transition: all 0.2s ease; + + &[disabled] { + opacity: 0.3; + } + } .selected-date { - font-size: 0.9em; + flex: 1; + font-family: 'DM Sans', sans-serif; + font-size: 0.85rem; + font-weight: 500; + text-align: center; + opacity: 0.8; } } + +// Program list styling +#program-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +// Program list items +.program-item { + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + transition: background-color 0.2s ease; + position: relative; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: default; + + &.clickable { + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } + + &.active { + background-color: rgba(0, 0, 0, 0.08); + } + + .program-meta { + display: flex; + align-items: center; + gap: 8px; + } + + .program-title { + font-family: 'DM Sans', sans-serif; + font-size: 0.9rem; + font-weight: 500; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .program-progress { + margin-top: 4px; + } +} + +// Dark theme support +::ng-deep .dark-theme { + .program-item { + border-bottom-color: rgba(255, 255, 255, 0.08); + + &.clickable:hover { + background-color: rgba(255, 255, 255, 0.04); + } + + &.active { + background-color: rgba(255, 255, 255, 0.08); + } + } +} + +// Progress bar (matching channel list) +.epg-progress-track { + position: relative; + width: 100%; + height: 3px; + background: rgba(128, 128, 128, 0.2); + border-radius: 2px; + overflow: hidden; +} + +.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); +} + +// Empty state styling +.epg-not-available { + padding: 24px; + text-align: center; + font-family: 'DM Sans', sans-serif; + font-size: 0.875rem; + opacity: 0.6; + font-style: italic; +} 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 206998108..434d5716f 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 @@ -1,10 +1,8 @@ import { AsyncPipe } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; -import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; import { MatTooltip } from '@angular/material/tooltip'; import { MomentDatePipe } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; @@ -34,10 +32,8 @@ const DATE_FORMAT = 'YYYY-MM-DD'; AsyncPipe, EpgListItemComponent, FormsModule, - MatDivider, MatIcon, MatIconButton, - MatListModule, MatTooltip, MomentDatePipe, TranslatePipe, @@ -45,10 +41,12 @@ const DATE_FORMAT = 'YYYY-MM-DD'; selector: 'app-epg-list', templateUrl: './epg-list.component.html', styleUrls: ['./epg-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class EpgListComponent implements OnInit { private readonly store = inject(Store); private readonly epgService = inject(EpgService); + private readonly cdr = inject(ChangeDetectorRef); /** Channel info in EPG format */ channel!: EpgChannel; @@ -169,6 +167,9 @@ export class EpgListComponent implements OnInit { // Clear the current EPG program when no programs available this.store.dispatch(resetActiveEpgProgram()); } + + // Trigger change detection for OnPush strategy + this.cdr.markForCheck(); } /** @@ -224,6 +225,8 @@ export class EpgListComponent implements OnInit { this.store.dispatch( setCurrentEpgProgram({ program: this.playingNow }) ); + // Trigger change detection for OnPush strategy + this.cdr.markForCheck(); }); } @@ -245,5 +248,33 @@ export class EpgListComponent implements OnInit { this.store.dispatch(setActiveEpgProgram({ program })); } this.playingNow = program; + // Trigger change detection for OnPush strategy + this.cdr.markForCheck(); + } + + /** + * Calculates the progress percentage for the EPG program + */ + calculateProgress(program: EpgProgram): number { + const now = new Date().getTime(); + const start = new Date(program.start).getTime(); + const stop = new Date(program.stop).getTime(); + + const total = stop - start; + const elapsed = now - start; + + const progress = Math.min(100, Math.max(0, (elapsed / total) * 100)); + + return progress; + } + + /** + * Check if program is currently playing + */ + isProgramPlaying(program: EpgProgram): boolean { + const isPlaying = + this.timeNow >= program.start && this.timeNow <= program.stop; + + return isPlaying; } }