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 <mat-divider> 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 <p> 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.
This commit is contained in:
4gray committed 2025-11-24 22:29:42 +01:00
1 parent 7918c4849d
commit 008f267808
4 files changed
+317 -71

No files matched your search

@@ -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;
}
}
@@ -23,7 +23,7 @@
</div>
</div>
</div>
<mat-divider />
<div class="header-divider"></div>
<div id="date-navigator">
<button
class="previous-day"
@@ -46,35 +46,40 @@
</button>
</div>
</div>
<mat-divider />
@if (timeshiftUntil) {
<!-- program list -->
<mat-selection-list
id="program-list"
[multiple]="false"
hideSingleSelectionIndicator="true"
>
<div id="program-list">
@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
) {
<mat-list-option>
<div matListItemLine>
<div class="program-item">
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="program"
/>
</div>
<p matListItemTitle class="program-title">
<div class="program-title">
{{ program?.title?.[0]?.value }}
</p>
</mat-list-option>
</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="calculateProgress(program)"
></div>
</div>
</div>
}
</div>
} @else {
<mat-list-option
[value]="program"
<div
class="program-item clickable"
[class.active]="
timeNow >= program.start && timeNow <= program.stop
"
@@ -87,25 +92,34 @@
)
"
>
<div matListItemLine>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="program"
/>
</div>
<p
matListItemLine
<div
class="program-title"
[innerHTML]="program.title?.[0]?.value"
></p>
</mat-list-option>
></div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="calculateProgress(program)"
></div>
</div>
</div>
}
</div>
}
<mat-divider />
}
} @else {
<p class="epg-not-available">
<div class="epg-not-available">
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
</p>
</div>
}
</mat-selection-list>
</div>
}
@@ -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;
}
@@ -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;
}
}