mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user