Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "ui-epg",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/ui/epg/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "domain:playback", "type:ui"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"command": [
|
||||
"node",
|
||||
"./tools/testing/run-web-esm-lib-tests.mjs",
|
||||
"libs/ui/epg/src"
|
||||
],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--experimental-vm-modules"
|
||||
},
|
||||
"forwardAllArgs": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './lib/epg-list/epg-list.component';
|
||||
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-progress-panel/epg-progress-panel.component';
|
||||
export * from './lib/epg-source-status/epg-source-status.component';
|
||||
export * from './lib/multi-epg/multi-epg-container.component';
|
||||
export * from './lib/multi-epg/overlay-ref.token';
|
||||
@@ -0,0 +1,78 @@
|
||||
<div class="program-dialog">
|
||||
<!-- Header with channel and time -->
|
||||
<header class="program-header">
|
||||
<div class="time-block">
|
||||
<div class="time-display">
|
||||
<span class="time-start">{{
|
||||
(epgProgram.startTimestamp
|
||||
? epgProgram.startTimestamp * 1000
|
||||
: epgProgram.start
|
||||
) | date: 'HH:mm'
|
||||
}}</span>
|
||||
<span class="time-separator">—</span>
|
||||
<span class="time-end">{{
|
||||
(epgProgram.stopTimestamp
|
||||
? epgProgram.stopTimestamp * 1000
|
||||
: epgProgram.stop
|
||||
) | date: 'HH:mm'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="date-display">{{
|
||||
(epgProgram.startTimestamp
|
||||
? epgProgram.startTimestamp * 1000
|
||||
: epgProgram.start
|
||||
) | date: 'EEEE, MMMM d' : undefined : currentLocale()
|
||||
}}</div>
|
||||
</div>
|
||||
@if (channelName) {
|
||||
<div class="channel-badge">
|
||||
<span class="channel-icon">📺</span>
|
||||
<span class="channel-name">{{ channelName }}</span>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Title section -->
|
||||
<div class="program-title-section">
|
||||
<h1 class="program-title">{{ epgProgram.title }}</h1>
|
||||
@if (epgProgram.category) {
|
||||
<span class="category-tag">{{ epgProgram.category }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="program-content">
|
||||
@if (epgProgram.desc) {
|
||||
<p class="program-description">{{ epgProgram.desc }}</p>
|
||||
}
|
||||
|
||||
<!-- Meta info grid -->
|
||||
<div class="meta-grid">
|
||||
@if (epgProgram.episodeNum) {
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }}</span>
|
||||
<span class="meta-value">{{ epgProgram.episodeNum }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (epgProgram.rating) {
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}</span>
|
||||
<span class="meta-value rating">{{ epgProgram.rating }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (duration) {
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }}</span>
|
||||
<span class="meta-value">{{ duration }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="program-footer">
|
||||
<button class="close-button" mat-dialog-close>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
// Broadcast control room palette
|
||||
$bg-deep: #0a0a0f;
|
||||
$bg-surface: #12121a;
|
||||
$bg-elevated: #1a1a24;
|
||||
$accent-cyan: #00d4aa;
|
||||
$accent-purple: #8b5cf6;
|
||||
$text-primary: #f8fafc;
|
||||
$text-secondary: rgba(248, 250, 252, 0.7);
|
||||
$text-muted: rgba(248, 250, 252, 0.4);
|
||||
$border-subtle: rgba(255, 255, 255, 0.08);
|
||||
|
||||
.program-dialog {
|
||||
background: linear-gradient(180deg, $bg-surface 0%, $bg-deep 100%);
|
||||
color: $text-primary;
|
||||
min-width: 380px;
|
||||
max-width: 800px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
// Ambient glow effect
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: radial-gradient(
|
||||
ellipse at top center,
|
||||
rgba($accent-cyan, 0.12) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Header with time and channel
|
||||
.program-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24px 24px 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
|
||||
.time-start {
|
||||
color: $accent-cyan;
|
||||
text-shadow: 0 0 20px rgba($accent-cyan, 0.4);
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
color: $text-muted;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.time-end {
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.date-display {
|
||||
font-size: 13px;
|
||||
color: $text-muted;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgba($accent-purple, 0.15);
|
||||
border: 1px solid rgba($accent-purple, 0.3);
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
|
||||
.channel-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Title section
|
||||
.program-title-section {
|
||||
padding: 0 24px 20px;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: $text-primary;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
background: rgba($accent-cyan, 0.1);
|
||||
border: 1px solid rgba($accent-cyan, 0.25);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: $accent-cyan;
|
||||
}
|
||||
|
||||
// Content section
|
||||
.program-content {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.program-description {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
// Meta grid
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
border: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
|
||||
&.rating {
|
||||
color: $accent-purple;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
.program-footer {
|
||||
padding: 16px 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 10px 24px;
|
||||
background: transparent;
|
||||
border: 1px solid rgba($accent-cyan, 0.4);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $accent-cyan;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-cyan, 0.1);
|
||||
border-color: $accent-cyan;
|
||||
box-shadow: 0 0 20px rgba($accent-cyan, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Override Material dialog styling
|
||||
::ng-deep .mat-mdc-dialog-container {
|
||||
--mdc-dialog-container-color: transparent;
|
||||
--mdc-dialog-container-shape: 16px;
|
||||
|
||||
.mdc-dialog__surface {
|
||||
background: transparent !important;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme adjustments
|
||||
::ng-deep .light-theme {
|
||||
.program-dialog {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
color: #1e293b;
|
||||
|
||||
&::before {
|
||||
background: radial-gradient(
|
||||
ellipse at top center,
|
||||
rgba(#059669, 0.08) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.time-display {
|
||||
.time-start {
|
||||
color: #059669;
|
||||
text-shadow: none;
|
||||
}
|
||||
.time-end {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.date-display {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
background: rgba(#7c3aed, 0.08);
|
||||
border-color: rgba(#7c3aed, 0.2);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.program-title-section {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
background: rgba(#059669, 0.08);
|
||||
border-color: rgba(#059669, 0.2);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.program-description {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1e293b;
|
||||
|
||||
&.rating {
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
border-color: rgba(#059669, 0.4);
|
||||
color: #059669;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#059669, 0.08);
|
||||
border-color: #059669;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { EpgItemDescriptionComponent } from './epg-item-description.component';
|
||||
|
||||
describe('EpgItemDescriptionComponent', () => {
|
||||
let component: EpgItemDescriptionComponent;
|
||||
let fixture: ComponentFixture<EpgItemDescriptionComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EpgItemDescriptionComponent,
|
||||
MockModule(MatDialogModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
start: '2026-04-05T11:30:00.000Z',
|
||||
stop: '2026-04-05T12:30:00.000Z',
|
||||
title: 'TV Show 1',
|
||||
desc: 'Highly interesting show about pets',
|
||||
category: 'Fun',
|
||||
} as unknown as EpgProgram,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EpgItemDescriptionComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.epgProgram = TestBed.inject(MAT_DIALOG_DATA);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render epg details in the dialog', () => {
|
||||
fixture.detectChanges();
|
||||
const titleElement = fixture.debugElement.query(
|
||||
By.css('.program-title')
|
||||
);
|
||||
expect(titleElement.nativeElement.textContent.trim()).toContain(
|
||||
'TV Show 1'
|
||||
);
|
||||
|
||||
const categoryElement = fixture.debugElement.query(
|
||||
By.css('.category-tag')
|
||||
);
|
||||
expect(categoryElement.nativeElement.textContent.trim()).toContain(
|
||||
'Fun'
|
||||
);
|
||||
|
||||
const descElement = fixture.debugElement.query(
|
||||
By.css('.program-description')
|
||||
);
|
||||
expect(descElement.nativeElement.textContent.trim()).toContain(
|
||||
'Highly interesting show about pets'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import { startWith } from 'rxjs';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
|
||||
type EpgItemDialogData = EpgProgram & {
|
||||
channelName?: string | null;
|
||||
channel_name?: string | null;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-item-description',
|
||||
templateUrl: './epg-item-description.component.html',
|
||||
styleUrls: ['./epg-item-description.component.scss'],
|
||||
imports: [DatePipe, MatButtonModule, MatDialogModule, TranslatePipe],
|
||||
})
|
||||
export class EpgItemDescriptionComponent {
|
||||
dialogData = inject<EpgItemDialogData>(MAT_DIALOG_DATA);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
epgProgram: EpgProgram;
|
||||
channelName: string | null = null;
|
||||
duration: string | null = null;
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.epgProgram = this.dialogData;
|
||||
// Check multiple possible field names for channel name
|
||||
this.channelName = this.dialogData.channelName
|
||||
|| this.dialogData.channel_name
|
||||
|| this.dialogData.display_name
|
||||
|| null;
|
||||
this.duration = this.calculateDuration();
|
||||
}
|
||||
|
||||
private calculateDuration(): string | null {
|
||||
if (!this.epgProgram.start || !this.epgProgram.stop) return null;
|
||||
try {
|
||||
const start = new Date(this.epgProgram.start);
|
||||
const stop = new Date(this.epgProgram.stop);
|
||||
const mins = differenceInMinutes(stop, start);
|
||||
if (mins < 60) {
|
||||
return `${mins} min`;
|
||||
}
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainingMins = mins % 60;
|
||||
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<span class="time"
|
||||
>{{ (item.startTimestamp ? item.startTimestamp * 1000 : item.start) | date: 'HH:mm' }}
|
||||
-
|
||||
{{ (item.stopTimestamp ? item.stopTimestamp * 1000 : item.stop) | date: 'HH:mm' }}</span
|
||||
>
|
||||
@if (isLive) {
|
||||
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
|
||||
>live</span
|
||||
>
|
||||
}
|
||||
|
||||
@if (showArchiveBadge) {
|
||||
<span
|
||||
class="archive-playback badge"
|
||||
[matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
|
||||
>
|
||||
<mat-icon class="archive-playback__icon" aria-hidden="true"
|
||||
>replay</mat-icon
|
||||
>
|
||||
<span class="archive-playback__label"
|
||||
>{{ 'XTREAM.PLAY' | translate }}</span
|
||||
>
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item?.desc) {
|
||||
<span
|
||||
(click)="$event.stopPropagation(); showDescription(item)"
|
||||
matListItemMeta
|
||||
class="description-icon"
|
||||
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
|
||||
><mat-icon>info</mat-icon></span
|
||||
>
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 0.76rem;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 42px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.live {
|
||||
color: #ff8f8f;
|
||||
background: rgba(255, 95, 95, 0.12);
|
||||
border-color: rgba(255, 95, 95, 0.28);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.archive-playback {
|
||||
color: color-mix(in srgb, var(--app-selection-color) 92%, white);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--app-selection-color) 16%, transparent),
|
||||
color-mix(in srgb, var(--app-selection-color) 9%, transparent)
|
||||
),
|
||||
color-mix(in srgb, var(--app-selection-surface) 42%, transparent);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 78%,
|
||||
transparent
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 6px 14px -12px var(--app-selection-glow);
|
||||
padding-inline: 7px 10px;
|
||||
}
|
||||
|
||||
.archive-playback__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.archive-playback__label {
|
||||
font-size: 0.69rem;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description-icon {
|
||||
margin-inline-start: auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
|
||||
import { EpgListItemComponent } from './epg-list-item.component';
|
||||
|
||||
const EPG_PROGRAM_ITEM: EpgProgram = {
|
||||
start: '2026-04-05T11:30:00.000Z',
|
||||
stop: '2026-04-05T12:30:00.000Z',
|
||||
channel: '12345',
|
||||
title: 'NOW on PBS',
|
||||
desc: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
|
||||
category: 'Newsmagazine',
|
||||
episodeNum: '427',
|
||||
rating: 'TV-G',
|
||||
iconUrl:
|
||||
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
|
||||
};
|
||||
|
||||
describe('EpgListItemComponent', () => {
|
||||
let component: EpgListItemComponent;
|
||||
let fixture: ComponentFixture<EpgListItemComponent>;
|
||||
let dialog: MatDialog;
|
||||
let translate: TranslateService;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
EpgListItemComponent,
|
||||
MockModule(MatDialogModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatTooltipModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EpgListItemComponent);
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation(
|
||||
'en',
|
||||
{
|
||||
EPG: {
|
||||
TIMESHIFT_AVAILABLE: 'Archive playback is available',
|
||||
},
|
||||
XTREAM: {
|
||||
PLAY: 'Play',
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
translate.use('en');
|
||||
component = fixture.componentInstance;
|
||||
component.item = EPG_PROGRAM_ITEM;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should trigger the function to open the details dialog', () => {
|
||||
jest.spyOn(dialog, 'open');
|
||||
component.showDescription({} as EpgProgram);
|
||||
expect(dialog.open).toHaveBeenCalledTimes(1);
|
||||
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
|
||||
data: {},
|
||||
width: '800px',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an archive playback chip with replay icon and label', () => {
|
||||
fixture = TestBed.createComponent(EpgListItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = EPG_PROGRAM_ITEM;
|
||||
component.showArchiveBadge = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const chip = fixture.nativeElement.querySelector('.archive-playback');
|
||||
const icon = fixture.nativeElement.querySelector(
|
||||
'.archive-playback__icon'
|
||||
);
|
||||
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip.textContent).toContain('Play');
|
||||
expect(icon.textContent.trim()).toBe('replay');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, inject, Input } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
|
||||
|
||||
@Component({
|
||||
imports: [DatePipe, MatIcon, MatTooltip, TranslatePipe],
|
||||
selector: 'app-epg-list-item',
|
||||
templateUrl: './epg-list-item.component.html',
|
||||
styleUrls: ['./epg-list-item.component.scss'],
|
||||
})
|
||||
export class EpgListItemComponent {
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** EPG Program to render */
|
||||
@Input() item!: EpgProgram;
|
||||
|
||||
/** Whether the program is currently live */
|
||||
@Input() isLive = false;
|
||||
|
||||
/** Whether archive playback is available for the program */
|
||||
@Input() showArchiveBadge = false;
|
||||
|
||||
/**
|
||||
* Opens the dialog with details about the selected program
|
||||
* @param program selected epg program
|
||||
*/
|
||||
showDescription(program: EpgProgram): void {
|
||||
this.dialog.open(EpgItemDescriptionComponent, {
|
||||
width: '800px',
|
||||
data: program,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
@let items = filteredItems();
|
||||
@let timeshiftLimit = timeshiftUntil();
|
||||
|
||||
<!-- channel info panel -->
|
||||
<div id="channel-header" color="primary">
|
||||
<div id="date-navigator">
|
||||
<button
|
||||
class="previous-day"
|
||||
mat-icon-button
|
||||
(click)="changeDate('prev')"
|
||||
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="selected-date">
|
||||
{{
|
||||
selectedDate()
|
||||
| date: 'MMMM d, EEEE' : undefined : currentLocale()
|
||||
}}</span
|
||||
>
|
||||
<button
|
||||
class="next-day"
|
||||
mat-icon-button
|
||||
(click)="changeDate('next')"
|
||||
[matTooltip]="'EPG.NEXT_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (timeshiftLimit) {
|
||||
<!-- program list -->
|
||||
<div id="program-list" #programList>
|
||||
@if (items.length > 0) {
|
||||
@for (program of items; track $index) {
|
||||
@if (!canActivateProgram(program)) {
|
||||
<div
|
||||
class="program-item"
|
||||
[class.current-program]="isProgramPlaying(program)"
|
||||
>
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[item]="program"
|
||||
[isLive]="isProgramPlaying(program)"
|
||||
[showArchiveBadge]="
|
||||
canPlayArchivedProgram(program)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="program-title">
|
||||
{{ program?.title }}
|
||||
</div>
|
||||
<div class="epg-item__description">
|
||||
{{ program?.desc }}
|
||||
</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 {
|
||||
<div
|
||||
class="program-item clickable"
|
||||
[class.current-program]="isProgramPlaying(program)"
|
||||
[class.active]="isProgramPlaying(program)"
|
||||
(click)="activateProgram(program)"
|
||||
>
|
||||
<div class="program-meta">
|
||||
<app-epg-list-item
|
||||
[item]="program"
|
||||
[isLive]="isProgramPlaying(program)"
|
||||
[showArchiveBadge]="
|
||||
canPlayArchivedProgram(program)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="program-title"
|
||||
[innerHTML]="program.title"
|
||||
></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 {
|
||||
<div class="epg-not-available">
|
||||
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#date-navigator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px 10px;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
border-radius: 10px;
|
||||
opacity: 0.84;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover:not([disabled]) {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-date {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#program-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 10px 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.program-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)),
|
||||
rgba(127, 127, 127, 0.045);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
0 10px 20px -20px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0)),
|
||||
rgba(127, 127, 127, 0.065);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
border-color: var(--app-selection-border);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 12px 20px -18px var(--app-selection-glow);
|
||||
|
||||
.program-title {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.program-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.program-title {
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 560;
|
||||
letter-spacing: 0.005em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.epg-item__description {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.74;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.program-progress {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.epg-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(127, 127, 127, 0.28);
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 1px 1px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.epg-progress-fill {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
border-radius: inherit;
|
||||
background-color: var(--app-selection-color);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.18),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
|
||||
0 0 10px -2px var(--app-selection-glow);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.epg-not-available {
|
||||
margin: auto 10px;
|
||||
padding: 22px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
font-size: 0.88rem;
|
||||
opacity: 0.66;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
|
||||
import { EpgListComponent } from './epg-list.component';
|
||||
|
||||
let requestAnimationFrameQueue: FrameRequestCallback[] = [];
|
||||
|
||||
registerLocaleData(localeDe, 'de');
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list-item',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubEpgListItemComponent {
|
||||
readonly item = input<EpgProgram>();
|
||||
readonly isLive = input(false);
|
||||
readonly showArchiveBadge = input(false);
|
||||
}
|
||||
|
||||
describe('EpgListComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListComponent>;
|
||||
let component: EpgListComponent;
|
||||
let store: { select: jest.Mock; dispatch: jest.Mock };
|
||||
let epgService: { currentEpgPrograms$: BehaviorSubject<EpgProgram[]> };
|
||||
let translateService: {
|
||||
currentLang: string;
|
||||
defaultLang: string;
|
||||
onLangChange: BehaviorSubject<unknown>;
|
||||
};
|
||||
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
|
||||
|
||||
const fixedNow = new Date('2026-04-05T12:00:00.000Z');
|
||||
const controlledChannel: Channel = {
|
||||
id: 'channel-101',
|
||||
name: 'Channel 101',
|
||||
url: 'https://example.com/live.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'channel-101',
|
||||
name: 'Channel 101',
|
||||
url: '',
|
||||
logo: 'channel-101.png',
|
||||
rec: '3',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
|
||||
originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
requestAnimationFrameQueue = [];
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
requestAnimationFrameQueue.push(callback);
|
||||
return requestAnimationFrameQueue.length;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
|
||||
store = {
|
||||
select: jest.fn().mockReturnValue(of(null)),
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
epgService = {
|
||||
currentEpgPrograms$: new BehaviorSubject<EpgProgram[]>([]),
|
||||
};
|
||||
translateService = {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject<unknown>(null),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EpgListComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: EpgService, useValue: epgService },
|
||||
{ provide: TranslateService, useValue: translateService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(EpgListComponent, {
|
||||
remove: {
|
||||
imports: [EpgListItemComponent, TranslatePipe],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubEpgListItemComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EpgListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controlledChannel', controlledChannel);
|
||||
fixture.componentRef.setInput('controlledArchiveDays', 3);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('auto-scrolls once on the initial current-day load', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 172 });
|
||||
});
|
||||
|
||||
it('does not re-trigger auto-scroll on timer-driven updates', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
jest.advanceTimersByTime(30_000);
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps older same-day rows reachable after the initial scroll', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
const programList = fixture.nativeElement.querySelector(
|
||||
'#program-list'
|
||||
) as HTMLElement;
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
programList.scrollTop = 0;
|
||||
jest.advanceTimersByTime(30_000);
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(programList.scrollTop).toBe(0);
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-arm auto-scroll when changing dates for the same selection', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
const scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
scrollToSpy.mockClear();
|
||||
component.changeDate('prev');
|
||||
fixture.detectChanges();
|
||||
component.changeDate('next');
|
||||
fixture.detectChanges();
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-arms auto-scroll when the effective selection changes', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
|
||||
fixture.detectChanges();
|
||||
let scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
scrollToSpy.mockClear();
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'controlledPrograms',
|
||||
buildPrograms({
|
||||
currentStart: '2026-04-05T11:45:00.000Z',
|
||||
currentStop: '2026-04-05T12:45:00.000Z',
|
||||
futureStart: '2026-04-05T12:45:00.000Z',
|
||||
futureStop: '2026-04-05T13:45:00.000Z',
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
scrollToSpy = configureScrollableProgramList(fixture);
|
||||
flushAnimationFrames();
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows archived rows as clickable only when archive playback is supported', () => {
|
||||
const programs = buildPrograms();
|
||||
fixture.componentRef.setInput('controlledPrograms', programs);
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canActivateProgram(programs[0])).toBe(false);
|
||||
expect(component.canActivateProgram(programs[1])).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.program-item.clickable')
|
||||
.length
|
||||
).toBe(1);
|
||||
|
||||
fixture.componentRef.setInput('archivePlaybackAvailable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canActivateProgram(programs[0])).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.program-item.clickable')
|
||||
.length
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('updates the selected-day header when the app language changes', () => {
|
||||
fixture.componentRef.setInput('controlledPrograms', buildPrograms());
|
||||
fixture.detectChanges();
|
||||
|
||||
translateService.currentLang = 'de';
|
||||
translateService.onLangChange.next({ lang: 'de' });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.selected-date')
|
||||
.textContent.trim()
|
||||
).toContain('Sonntag');
|
||||
});
|
||||
});
|
||||
|
||||
function buildPrograms(overrides?: {
|
||||
currentStart?: string;
|
||||
currentStop?: string;
|
||||
futureStart?: string;
|
||||
futureStop?: string;
|
||||
}): EpgProgram[] {
|
||||
const pastStart = '2026-04-05T09:00:00.000Z';
|
||||
const pastStop = '2026-04-05T10:00:00.000Z';
|
||||
const currentStart =
|
||||
overrides?.currentStart ?? '2026-04-05T11:30:00.000Z';
|
||||
const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z';
|
||||
const futureStart =
|
||||
overrides?.futureStart ?? '2026-04-05T12:30:00.000Z';
|
||||
const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z';
|
||||
|
||||
return [
|
||||
buildProgram('past', 'Past Show', pastStart, pastStop),
|
||||
buildProgram('current', 'Current Show', currentStart, currentStop),
|
||||
buildProgram('future', 'Future Show', futureStart, futureStop),
|
||||
];
|
||||
}
|
||||
|
||||
function buildProgram(
|
||||
channelSuffix: string,
|
||||
title: string,
|
||||
start: string,
|
||||
stop: string
|
||||
): EpgProgram {
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
channel: `channel-${channelSuffix}`,
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: Math.floor(Date.parse(start) / 1000),
|
||||
stopTimestamp: Math.floor(Date.parse(stop) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function configureScrollableProgramList(
|
||||
fixture: ComponentFixture<EpgListComponent>
|
||||
): jest.Mock {
|
||||
const programList = fixture.nativeElement.querySelector(
|
||||
'#program-list'
|
||||
) as HTMLElement;
|
||||
const currentProgram = fixture.nativeElement.querySelector(
|
||||
'.program-item.current-program'
|
||||
) as HTMLElement;
|
||||
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(programList, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = value;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(programList, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 120,
|
||||
});
|
||||
Object.defineProperty(currentProgram, 'offsetTop', {
|
||||
configurable: true,
|
||||
value: 240,
|
||||
});
|
||||
Object.defineProperty(currentProgram, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 36,
|
||||
});
|
||||
|
||||
const scrollToSpy = jest.fn(({ top }: { top: number }) => {
|
||||
scrollTop = top;
|
||||
});
|
||||
programList.scrollTo = scrollToSpy as typeof programList.scrollTo;
|
||||
|
||||
return scrollToSpy;
|
||||
}
|
||||
|
||||
function flushAnimationFrames(): void {
|
||||
while (requestAnimationFrameQueue.length > 0) {
|
||||
const callback = requestAnimationFrameQueue.shift();
|
||||
callback?.(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { EpgActions, selectActive } from 'm3u-state';
|
||||
import { addDays, format, subDays } from 'date-fns';
|
||||
import { startWith } from 'rxjs';
|
||||
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
|
||||
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export interface EpgProgramActivationEvent {
|
||||
program: EpgProgram;
|
||||
type: 'live' | 'timeshift';
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DatePipe,
|
||||
EpgListItemComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-epg-list',
|
||||
templateUrl: './epg-list.component.html',
|
||||
styleUrls: ['./epg-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgListComponent {
|
||||
readonly controlledChannel = input<Channel | null>(null);
|
||||
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
||||
readonly controlledArchiveDays = input<number | null>(null);
|
||||
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
||||
readonly programActivated = output<EpgProgramActivationEvent>();
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private readonly activeChannel = toSignal(this.store.select(selectActive), {
|
||||
initialValue: null,
|
||||
});
|
||||
private readonly servicePrograms = toSignal(
|
||||
this.epgService.currentEpgPrograms$,
|
||||
{ initialValue: [] as EpgProgram[] }
|
||||
);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
|
||||
readonly selectedDate = signal(format(new Date(), DATE_FORMAT));
|
||||
readonly timeNow = signal(new Date().toISOString());
|
||||
readonly currentTimeMs = computed(() => Date.parse(this.timeNow()));
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
readonly isControlled = computed(
|
||||
() =>
|
||||
this.controlledChannel() !== null ||
|
||||
this.controlledPrograms() !== null
|
||||
);
|
||||
readonly displayChannel = computed(
|
||||
() => this.controlledChannel() ?? this.activeChannel()
|
||||
);
|
||||
readonly channel = computed<EpgChannel | null>(() => {
|
||||
const channel = this.displayChannel();
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: channel.tvg?.id || '',
|
||||
displayName: channel.name ? [{ lang: '', value: channel.name }] : [],
|
||||
url: channel.url ? [channel.url] : [],
|
||||
icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [],
|
||||
};
|
||||
});
|
||||
readonly items = computed(
|
||||
() => this.controlledPrograms() ?? this.servicePrograms() ?? []
|
||||
);
|
||||
readonly archiveDays = computed(() => {
|
||||
const controlledArchiveDays = this.controlledArchiveDays();
|
||||
if (controlledArchiveDays !== null) {
|
||||
return Math.max(0, controlledArchiveDays);
|
||||
}
|
||||
|
||||
const channel = this.displayChannel();
|
||||
const value =
|
||||
channel?.tvg?.rec || channel?.timeshift || channel?.catchup?.days;
|
||||
return Math.max(0, Number(value ?? 0) || 0);
|
||||
});
|
||||
readonly timeshiftUntil = computed(() =>
|
||||
subDays(new Date(), this.archiveDays()).toISOString()
|
||||
);
|
||||
readonly archivePlaybackEnabled = computed(
|
||||
() => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0)
|
||||
);
|
||||
readonly filteredItems = computed(() =>
|
||||
[...this.items()]
|
||||
.filter(
|
||||
(item) =>
|
||||
getProgramDateKey(item.start, item.startTimestamp) ===
|
||||
this.selectedDate()
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
getProgramTimeMs(left.start, left.startTimestamp) -
|
||||
getProgramTimeMs(right.start, right.startTimestamp)
|
||||
)
|
||||
);
|
||||
|
||||
private scrollScheduled = false;
|
||||
private activeScrollContextKey: string | null = null;
|
||||
private lastAutoScrolledContextKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
}, 30_000);
|
||||
|
||||
onCleanup(() => clearInterval(intervalId));
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const programs = this.items();
|
||||
const channel = this.displayChannel();
|
||||
this.currentTimeMs();
|
||||
|
||||
if (!this.isControlled()) {
|
||||
this.store.dispatch(
|
||||
EpgActions.setEpgAvailableFlag({
|
||||
value: programs.length > 0,
|
||||
})
|
||||
);
|
||||
|
||||
if (programs.length > 0) {
|
||||
const currentProgram = this.findCurrentProgram(programs);
|
||||
if (currentProgram) {
|
||||
this.store.dispatch(
|
||||
EpgActions.setCurrentEpgProgram({
|
||||
program: currentProgram,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel && !this.isControlled()) {
|
||||
this.store.dispatch(EpgActions.resetActiveEpgProgram());
|
||||
}
|
||||
|
||||
this.updateScrollContext(channel, programs);
|
||||
this.scheduleScrollToCurrentProgram();
|
||||
});
|
||||
}
|
||||
|
||||
changeDate(direction: 'next' | 'prev'): void {
|
||||
const selectedDate = new Date(`${this.selectedDate()}T00:00:00`);
|
||||
this.selectedDate.set(
|
||||
format(
|
||||
direction === 'next'
|
||||
? addDays(selectedDate, 1)
|
||||
: subDays(selectedDate, 1),
|
||||
DATE_FORMAT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
activateProgram(program: EpgProgram): void {
|
||||
const isLive = this.isProgramPlaying(program);
|
||||
const isTimeshift = this.canPlayArchivedProgram(program);
|
||||
|
||||
if (!isLive && !isTimeshift) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isControlled()) {
|
||||
this.programActivated.emit({
|
||||
program,
|
||||
type: isLive ? 'live' : 'timeshift',
|
||||
});
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLive) {
|
||||
this.store.dispatch(EpgActions.returnToLivePlayback());
|
||||
} else {
|
||||
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
|
||||
}
|
||||
|
||||
this.timeNow.set(new Date().toISOString());
|
||||
}
|
||||
|
||||
canActivateProgram(program: EpgProgram): boolean {
|
||||
return (
|
||||
this.isProgramPlaying(program) || this.canPlayArchivedProgram(program)
|
||||
);
|
||||
}
|
||||
|
||||
canPlayArchivedProgram(program: EpgProgram): boolean {
|
||||
return this.archivePlaybackEnabled() && this.isProgramArchived(program);
|
||||
}
|
||||
|
||||
calculateProgress(program: EpgProgram): number {
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const total = stop - start;
|
||||
const elapsed = now - start;
|
||||
|
||||
return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0;
|
||||
}
|
||||
|
||||
isProgramPlaying(program: EpgProgram): boolean {
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
return now >= start && now <= stop;
|
||||
}
|
||||
|
||||
isProgramArchived(program: EpgProgram): boolean {
|
||||
if (this.archiveDays() <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = this.currentTimeMs();
|
||||
const start = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const archiveLimit = Date.parse(this.timeshiftUntil());
|
||||
|
||||
return stop < now && start >= archiveLimit;
|
||||
}
|
||||
|
||||
private findCurrentProgram(programs: EpgProgram[]): EpgProgram | undefined {
|
||||
const now = this.currentTimeMs();
|
||||
return programs.find((program) => {
|
||||
const start = getProgramTimeMs(
|
||||
program.start,
|
||||
program.startTimestamp
|
||||
);
|
||||
const stop = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
return now >= start && now <= stop;
|
||||
});
|
||||
}
|
||||
|
||||
private updateScrollContext(
|
||||
channel: Channel | null,
|
||||
programs: EpgProgram[]
|
||||
): void {
|
||||
const nextContextKey = buildScrollContextKey(channel, programs);
|
||||
if (nextContextKey === this.activeScrollContextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeScrollContextKey = nextContextKey;
|
||||
this.scrollScheduled = false;
|
||||
}
|
||||
|
||||
private scheduleScrollToCurrentProgram(): void {
|
||||
if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollContextKey = this.activeScrollContextKey;
|
||||
if (
|
||||
!scrollContextKey ||
|
||||
this.lastAutoScrolledContextKey === scrollContextKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scrollScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollScheduled = false;
|
||||
this.scrollCurrentProgramIntoView(scrollContextKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private scrollCurrentProgramIntoView(scrollContextKey: string): void {
|
||||
const container = this.programList()?.nativeElement;
|
||||
const currentProgram = container?.querySelector<HTMLElement>(
|
||||
'.program-item.current-program'
|
||||
);
|
||||
|
||||
if (!container || !currentProgram) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastAutoScrolledContextKey = scrollContextKey;
|
||||
|
||||
const viewTop = container.scrollTop;
|
||||
const viewBottom = viewTop + container.clientHeight;
|
||||
const elementTop = currentProgram.offsetTop;
|
||||
const elementBottom = elementTop + currentProgram.offsetHeight;
|
||||
|
||||
if (elementTop < viewTop) {
|
||||
container.scrollTo({ top: Math.max(elementTop - 16, 0) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (elementBottom > viewBottom) {
|
||||
container.scrollTo({
|
||||
top: elementBottom - container.clientHeight + 16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildScrollContextKey(
|
||||
channel: Channel | null,
|
||||
programs: EpgProgram[]
|
||||
): string | null {
|
||||
if (!channel && programs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelKey =
|
||||
channel?.tvg?.id || channel?.name || channel?.url || 'unknown-channel';
|
||||
const programKey = programs
|
||||
.map(
|
||||
(program) =>
|
||||
`${getProgramTimeMs(program.start, program.startTimestamp)}-${getProgramTimeMs(program.stop, program.stopTimestamp)}`
|
||||
)
|
||||
.join('|');
|
||||
|
||||
return `${channelKey}:${programKey}`;
|
||||
}
|
||||
|
||||
function getProgramTimeMs(
|
||||
isoValue: string,
|
||||
timestampValue?: number | null
|
||||
): number {
|
||||
if (Number.isFinite(timestampValue) && Number(timestampValue) > 0) {
|
||||
return Number(timestampValue) * 1000;
|
||||
}
|
||||
|
||||
return Date.parse(isoValue);
|
||||
}
|
||||
|
||||
function getProgramDateKey(
|
||||
isoValue: string,
|
||||
timestampValue?: number | null
|
||||
): string {
|
||||
const programTimeMs = getProgramTimeMs(isoValue, timestampValue);
|
||||
|
||||
if (!Number.isFinite(programTimeMs)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return format(new Date(programTimeMs), DATE_FORMAT);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
@if (isVisible()) {
|
||||
<div
|
||||
class="epg-progress-panel"
|
||||
[class.epg-progress-panel--minimized]="minimized()"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<span class="header-title">
|
||||
<mat-icon>live_tv</mat-icon>
|
||||
@if (!minimized()) {
|
||||
{{ 'EPG.IMPORT_PROGRESS' | translate }}
|
||||
} @else {
|
||||
<span class="minimized-summary">
|
||||
{{ loadingCount() }} loading
|
||||
@if (queuedCount() > 0) {
|
||||
, {{ queuedCount() }} queued
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="minimize-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[attr.aria-label]="
|
||||
minimized() ? 'Expand' : 'Minimize'
|
||||
"
|
||||
>
|
||||
<mat-icon>
|
||||
{{ minimized() ? 'unfold_more' : 'unfold_less' }}
|
||||
</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="dismiss-all-btn"
|
||||
(click)="dismissAll()"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!minimized()) {
|
||||
<div class="imports-list">
|
||||
@for (item of activeImports; track item.url) {
|
||||
<div
|
||||
class="import-item"
|
||||
[class]="getStatusClass(item.status)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<mat-icon
|
||||
[class.spinning]="
|
||||
item.status === 'loading'
|
||||
"
|
||||
>
|
||||
{{ getStatusIcon(item.status) }}
|
||||
</mat-icon>
|
||||
<span class="item-url" [title]="item.url">
|
||||
{{ getDisplayUrl(item.url) }}
|
||||
</span>
|
||||
@if (item.status === 'error') {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="retry-btn"
|
||||
(click)="retry(item.url)"
|
||||
[attr.aria-label]="'EPG.RETRY' | translate"
|
||||
[matTooltip]="'EPG.RETRY' | translate"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
class="dismiss-btn"
|
||||
(click)="dismiss(item.url)"
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (item.status === 'loading') {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<div class="item-stats">
|
||||
@if (item.stats) {
|
||||
<span
|
||||
class="stat-badge"
|
||||
matTooltip="{{
|
||||
'EPG.TOTAL_CHANNELS' | translate
|
||||
}}"
|
||||
>
|
||||
<mat-icon>tv</mat-icon>
|
||||
{{ item.stats.totalChannels }}
|
||||
</span>
|
||||
<span
|
||||
class="stat-badge"
|
||||
matTooltip="{{
|
||||
'EPG.TOTAL_PROGRAMS' | translate
|
||||
}}"
|
||||
>
|
||||
<mat-icon>event</mat-icon>
|
||||
{{ item.stats.totalPrograms }}
|
||||
</span>
|
||||
} @else if (item.error) {
|
||||
<span class="error-text">{{
|
||||
item.error
|
||||
}}</span>
|
||||
} @else if (item.status === 'loading') {
|
||||
<span
|
||||
>{{ 'EPG.LOADING' | translate }}...</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (queuedImports.length > 0) {
|
||||
<div class="queue-section">
|
||||
<div class="queue-header">
|
||||
<mat-icon>queue</mat-icon>
|
||||
<span
|
||||
>{{ 'EPG.QUEUED' | translate }} ({{
|
||||
queuedImports.length
|
||||
}})</span
|
||||
>
|
||||
</div>
|
||||
<div class="queue-list">
|
||||
@for (
|
||||
item of queuedImports;
|
||||
track item.url;
|
||||
let i = $index
|
||||
) {
|
||||
<div class="queue-item">
|
||||
<span class="queue-position">{{
|
||||
i + 1
|
||||
}}</span>
|
||||
<span
|
||||
class="queue-url"
|
||||
[title]="item.url"
|
||||
>
|
||||
{{ getDisplayUrl(item.url) }}
|
||||
</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="dismiss-btn"
|
||||
(click)="dismiss(item.url)"
|
||||
[attr.aria-label]="
|
||||
'CLOSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
:host {
|
||||
// Theme-aware tokens — map to app design tokens
|
||||
--epg-panel-bg: var(--app-widget-bg);
|
||||
--epg-shadow: var(--app-widget-shadow);
|
||||
--epg-text-primary: var(--app-heading-color);
|
||||
--epg-text-secondary: var(--app-body-color);
|
||||
--epg-text-tertiary: var(--app-muted-color);
|
||||
--epg-accent: var(--app-selection-color);
|
||||
--epg-accent-glow: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color) 40%,
|
||||
transparent
|
||||
);
|
||||
--epg-item-hover: var(--app-hover-overlay);
|
||||
--epg-separator: var(--app-separator);
|
||||
|
||||
// Status colors — semantic, not theme-dependent
|
||||
--epg-success: #22c55e;
|
||||
--epg-error: #ef4444;
|
||||
--epg-queued: #a78bfa;
|
||||
}
|
||||
|
||||
.epg-progress-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 76px;
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 96px);
|
||||
background: var(--epg-panel-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--epg-separator);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18),
|
||||
0 16px 48px rgba(0, 0, 0, 0.12);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
animation: slideInFromLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes slideInFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-bottom: 1px solid var(--epg-separator);
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--epg-text-primary);
|
||||
text-transform: uppercase;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--epg-accent);
|
||||
}
|
||||
|
||||
.minimized-summary {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--epg-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.minimize-btn,
|
||||
.dismiss-all-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
line-height: 28px;
|
||||
border-radius: 8px;
|
||||
color: var(--epg-text-tertiary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.epg-progress-panel--minimized {
|
||||
width: auto;
|
||||
min-width: 180px;
|
||||
max-width: 300px;
|
||||
|
||||
.panel-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.imports-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.import-item {
|
||||
padding: 6px 10px;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid var(--epg-separator);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--epg-item-hover);
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-url {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--epg-text-secondary);
|
||||
}
|
||||
|
||||
.dismiss-btn,
|
||||
.retry-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
line-height: 24px;
|
||||
border-radius: 6px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
color: var(--epg-text-tertiary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry is a deliberate action — keep it discoverable, not hover-only,
|
||||
// and use the accent color so it reads as primary.
|
||||
.retry-btn {
|
||||
opacity: 1;
|
||||
color: var(--epg-accent);
|
||||
|
||||
&:hover {
|
||||
color: color-mix(
|
||||
in srgb,
|
||||
var(--epg-accent) 80%,
|
||||
var(--epg-text-primary)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .dismiss-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.status-loading .item-header mat-icon {
|
||||
color: var(--epg-accent);
|
||||
}
|
||||
|
||||
&.status-complete .item-header mat-icon {
|
||||
color: var(--epg-success);
|
||||
}
|
||||
|
||||
&.status-error .item-header mat-icon {
|
||||
color: var(--epg-error);
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.item-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 24px;
|
||||
min-height: 20px;
|
||||
|
||||
.stat-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--epg-text-tertiary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 0.72rem;
|
||||
color: var(--epg-error);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
margin: 2px 0 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid var(--epg-separator);
|
||||
|
||||
// When queue is the only content (no active imports above),
|
||||
// the panel-header border-bottom already provides separation —
|
||||
// suppress this border to avoid a visible double rule.
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--epg-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--epg-queued);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--epg-text-tertiary);
|
||||
|
||||
.queue-position {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--epg-queued);
|
||||
}
|
||||
|
||||
.queue-url {
|
||||
flex: 1;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
line-height: 22px;
|
||||
color: var(--epg-text-tertiary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.epg-progress-panel {
|
||||
left: 76px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.epg-progress-panel {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
EpgImportProgress,
|
||||
EpgProgressService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-progress-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltip,
|
||||
MatProgressBar,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-progress-panel.component.html',
|
||||
styleUrl: './epg-progress-panel.component.scss',
|
||||
})
|
||||
export class EpgProgressPanelComponent {
|
||||
private readonly epgProgress = inject(EpgProgressService);
|
||||
|
||||
readonly imports = this.epgProgress.imports;
|
||||
readonly isVisible = this.epgProgress.isVisible;
|
||||
readonly queuedCount = this.epgProgress.queuedCount;
|
||||
readonly activeCount = this.epgProgress.activeCount;
|
||||
readonly minimized = signal(false);
|
||||
|
||||
readonly loadingCount = computed(
|
||||
() => this.imports().filter((i) => i.status === 'loading').length
|
||||
);
|
||||
|
||||
toggleMinimize(): void {
|
||||
this.minimized.update((v) => !v);
|
||||
}
|
||||
|
||||
get activeImports() {
|
||||
return this.imports().filter((item) => item.status !== 'queued');
|
||||
}
|
||||
|
||||
get queuedImports() {
|
||||
return this.imports()
|
||||
.filter((item) => item.status === 'queued')
|
||||
.sort(
|
||||
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
getStatusIcon(status: EpgImportProgress['status']): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'schedule';
|
||||
case 'loading':
|
||||
return 'sync';
|
||||
case 'complete':
|
||||
return 'check_circle';
|
||||
case 'error':
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClass(status: EpgImportProgress['status']): string {
|
||||
return `status-${status}`;
|
||||
}
|
||||
|
||||
getDisplayUrl(url: string): string {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return urlObject.hostname + urlObject.pathname.split('/').pop();
|
||||
} catch {
|
||||
return url.length > 40 ? `${url.substring(0, 40)}...` : url;
|
||||
}
|
||||
}
|
||||
|
||||
dismiss(url: string): void {
|
||||
this.epgProgress.dismiss(url);
|
||||
}
|
||||
|
||||
dismissAll(): void {
|
||||
this.epgProgress.dismissAll();
|
||||
}
|
||||
|
||||
retry(url: string): void {
|
||||
this.epgProgress.retry(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@switch (status()) {
|
||||
@case ('loading') {
|
||||
<span class="status status--loading">
|
||||
<mat-progress-spinner diameter="14" mode="indeterminate" />
|
||||
<span class="status__label">{{ 'EPG.LOADING' | translate }}</span>
|
||||
</span>
|
||||
}
|
||||
@case ('queued') {
|
||||
<span class="status status--queued">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span class="status__label">{{ 'EPG.QUEUED' | translate }}</span>
|
||||
</span>
|
||||
}
|
||||
@case ('fresh') {
|
||||
<span class="status status--fresh">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span class="status__label">{{
|
||||
'SETTINGS.EPG_STATUS_FRESH' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
@case ('stale') {
|
||||
<span class="status status--stale">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<span class="status__label">{{
|
||||
'SETTINGS.EPG_STATUS_STALE' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
@case ('error') {
|
||||
<span
|
||||
class="status status--error"
|
||||
[matTooltip]="errorMessage() ?? ''"
|
||||
>
|
||||
<mat-icon>error</mat-icon>
|
||||
<span class="status__label">{{
|
||||
'SETTINGS.EPG_STATUS_ERROR' | translate
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
:host {
|
||||
// Theme-aware tokens
|
||||
--badge-fresh: #22c55e;
|
||||
--badge-stale: #f59e0b;
|
||||
--badge-error: #ef4444;
|
||||
--badge-queued: #a78bfa;
|
||||
--badge-accent: var(--app-selection-color);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--badge-color, var(--app-body-color)) 14%,
|
||||
transparent
|
||||
);
|
||||
color: var(--badge-color, var(--app-body-color));
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--badge-color, var(--app-body-color)) 28%,
|
||||
transparent
|
||||
);
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
mat-progress-spinner {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
--badge-color: var(--badge-accent);
|
||||
}
|
||||
|
||||
&--queued {
|
||||
--badge-color: var(--badge-queued);
|
||||
}
|
||||
|
||||
&--fresh {
|
||||
--badge-color: var(--badge-fresh);
|
||||
}
|
||||
|
||||
&--stale {
|
||||
--badge-color: var(--badge-stale);
|
||||
}
|
||||
|
||||
&--error {
|
||||
--badge-color: var(--badge-error);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgProgressService } from '@iptvnator/epg/data-access';
|
||||
|
||||
type BadgeStatus =
|
||||
| 'loading'
|
||||
| 'queued'
|
||||
| 'fresh'
|
||||
| 'stale'
|
||||
| 'error'
|
||||
| 'unknown';
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-source-status',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-source-status.component.html',
|
||||
styleUrl: './epg-source-status.component.scss',
|
||||
})
|
||||
export class EpgSourceStatusComponent implements OnInit {
|
||||
readonly url = input.required<string>();
|
||||
|
||||
private readonly epgProgress = inject(EpgProgressService);
|
||||
|
||||
private readonly freshnessLoaded = signal(false);
|
||||
private readonly isFresh = signal(false);
|
||||
|
||||
readonly status = computed<BadgeStatus>(() => {
|
||||
const progress = this.epgProgress
|
||||
.imports()
|
||||
.find((item) => item.url === this.url());
|
||||
|
||||
if (progress) {
|
||||
if (progress.status === 'loading') return 'loading';
|
||||
if (progress.status === 'queued') return 'queued';
|
||||
if (progress.status === 'error') return 'error';
|
||||
if (progress.status === 'complete') return 'fresh';
|
||||
}
|
||||
|
||||
if (!this.freshnessLoaded()) return 'unknown';
|
||||
return this.isFresh() ? 'fresh' : 'stale';
|
||||
});
|
||||
|
||||
readonly errorMessage = computed(() =>
|
||||
this.epgProgress
|
||||
.imports()
|
||||
.find((item) => item.url === this.url())?.error
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Optimistically mark as fresh when a live fetch completes for our URL,
|
||||
// so the badge updates without waiting for another IPC round-trip.
|
||||
effect(() => {
|
||||
const progress = this.epgProgress
|
||||
.imports()
|
||||
.find((item) => item.url === this.url());
|
||||
if (progress?.status === 'complete') {
|
||||
this.freshnessLoaded.set(true);
|
||||
this.isFresh.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!window.electron?.checkEpgFreshness) {
|
||||
return;
|
||||
}
|
||||
const url = this.url();
|
||||
if (!url) return;
|
||||
|
||||
const result = await window.electron.checkEpgFreshness([url], 12);
|
||||
this.isFresh.set(result.freshUrls.includes(url));
|
||||
this.freshnessLoaded.set(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<div id="epg-navigation">
|
||||
<button
|
||||
[matTooltip]="'CLOSE' | translate"
|
||||
mat-icon-button
|
||||
(click)="close()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
|
||||
mat-icon-button
|
||||
(click)="switchDay('prev')"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<div class="today-date">
|
||||
{{ selectedDayDate() | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
|
||||
</div>
|
||||
<button
|
||||
[matTooltip]="'EPG.NEXT_DAY' | translate"
|
||||
mat-icon-button
|
||||
(click)="switchDay('next')"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="zoomOut()" [disabled]="hourWidth() <= 50" matTooltip="Zoom out">
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth() >= 800" matTooltip="Zoom in">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
|
||||
<div class="search-container" [class.expanded]="isSearchExpanded()">
|
||||
@if (isSearchExpanded()) {
|
||||
<div class="channel-search">
|
||||
<mat-icon class="search-icon">search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="channelFilter()"
|
||||
(input)="onFilterInput($event)"
|
||||
placeholder="Filter channels..."
|
||||
autocomplete="off"
|
||||
#searchInput
|
||||
/>
|
||||
@if (channelFilter()) {
|
||||
<button class="clear-btn" (click)="clearFilter()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="filter-count">{{ filteredChannels().length }} / {{ channels().length }}</span>
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleSearch()"
|
||||
[matTooltip]="isSearchExpanded() ? 'Close filter' : 'Filter channels'"
|
||||
>
|
||||
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Program Search -->
|
||||
<div class="program-search-container" [class.open]="isProgramSearchOpen()">
|
||||
@if (isProgramSearchOpen()) {
|
||||
<div class="program-search-input">
|
||||
<mat-icon class="search-icon">manage_search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="programSearchQuery()"
|
||||
(input)="onProgramSearchInput($event)"
|
||||
placeholder="Search programs..."
|
||||
autocomplete="off"
|
||||
#programSearchInput
|
||||
/>
|
||||
@if (programSearchQuery()) {
|
||||
<button class="clear-btn" (click)="clearProgramSearch()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (isSearchingPrograms()) {
|
||||
<div class="search-spinner"></div>
|
||||
}
|
||||
</div>
|
||||
@if (programSearchResults().length > 0) {
|
||||
<div class="program-search-results">
|
||||
@for (program of programSearchResults(); track $index) {
|
||||
<button class="search-result-item" (click)="showProgramDetails(program)">
|
||||
<div class="result-info">
|
||||
<span class="result-title">{{ program.title }}</span>
|
||||
<span class="result-meta">
|
||||
<span class="result-time">
|
||||
{{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'HH:mm' }}
|
||||
-
|
||||
{{ (program.stopTimestamp ? program.stopTimestamp * 1000 : program.stop) | date: 'HH:mm' }}
|
||||
</span>
|
||||
<span class="result-date">
|
||||
{{ (program.startTimestamp ? program.startTimestamp * 1000 : program.start) | date: 'MMM d' : undefined : currentLocale() }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<mat-icon class="result-arrow">arrow_forward</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
|
||||
<div class="program-search-empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<span>No programs found</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleProgramSearch()"
|
||||
[matTooltip]="isProgramSearchOpen() ? 'Close program search' : 'Search programs'"
|
||||
>
|
||||
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="parent" #epgContainer (scroll)="onScroll($event)">
|
||||
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
|
||||
@for (item of filteredChannels(); track item.id; let i = $index) {
|
||||
<g>
|
||||
<!-- channel name -->
|
||||
<g class="channel" [class.active]="item.id === activeChannelId">
|
||||
<rect
|
||||
width="100"
|
||||
[attr.height]="barHeight"
|
||||
[attr.y]="barHeight * i + barHeight"
|
||||
stroke="black"
|
||||
/>
|
||||
<foreignObject
|
||||
width="100"
|
||||
[attr.height]="barHeight"
|
||||
[attr.y]="barHeight * i + barHeight"
|
||||
>
|
||||
<div class="channel-name">
|
||||
@if (getChannelIcon(item)) {
|
||||
<img
|
||||
[src]="getChannelIcon(item)"
|
||||
[alt]="getChannelName(item)"
|
||||
style="height: 20px; width: 20px; margin-right: 8px;"
|
||||
>
|
||||
}
|
||||
<span>{{ getChannelName(item) }}</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
<div id="epg-container" [style.height.px]="filteredChannels().length * barHeight + barHeight + 50">
|
||||
<svg [attr.width]="hourWidth() * 24" [attr.height]="filteredChannels().length * barHeight + barHeight + 50" id="epg-svg">
|
||||
<!-- time headline -->
|
||||
@for (hour of timeHeader; track hour) {
|
||||
<g>
|
||||
<rect
|
||||
[matTooltip]="hour + ':00'"
|
||||
[attr.width]="hourWidth()"
|
||||
[attr.height]="barHeight"
|
||||
[attr.x]="hour * hourWidth()"
|
||||
fill="#000"
|
||||
></rect>
|
||||
<text
|
||||
[attr.x]="hour * hourWidth()"
|
||||
y="10"
|
||||
font-size="14"
|
||||
fill="white"
|
||||
transform="translate(20,20)"
|
||||
>
|
||||
{{ hour }}:00
|
||||
</text>
|
||||
<!-- vertical hour separator line -->
|
||||
<line
|
||||
[attr.x1]="hour * hourWidth()"
|
||||
[attr.y1]="barHeight"
|
||||
[attr.x2]="hour * hourWidth()"
|
||||
[attr.y2]="filteredChannels().length * barHeight + barHeight"
|
||||
class="hour-separator"
|
||||
></line>
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- epg channels with programs -->
|
||||
@for (item of filteredChannels(); track item.id; let i = $index) {
|
||||
<g [class.active-row]="item.id === activeChannelId">
|
||||
@for (program of item.programs; track trackByProgram($index, program)) {
|
||||
<g
|
||||
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
|
||||
class="program"
|
||||
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
|
||||
(click)="showDescription(program)"
|
||||
>
|
||||
<rect
|
||||
[attr.width]="program.width"
|
||||
[attr.height]="barHeight"
|
||||
[matTooltip]="program.title"
|
||||
></rect>
|
||||
<foreignObject
|
||||
[attr.width]="program.width"
|
||||
[attr.height]="barHeight"
|
||||
>
|
||||
<div class="program-title">{{ program.title }}</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
}
|
||||
</g>
|
||||
}
|
||||
|
||||
<!-- current time line -->
|
||||
<line
|
||||
[attr.x1]="currentTimeLine()"
|
||||
y1="0"
|
||||
[attr.x2]="currentTimeLine()"
|
||||
[attr.y2]="filteredChannels().length * barHeight + barHeight"
|
||||
stroke="red"
|
||||
stroke-width="2"
|
||||
></line>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when no channels match filter -->
|
||||
@if (filteredChannels().length === 0 && channelFilter()) {
|
||||
<div class="no-results">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>No channels match "{{ channelFilter() }}"</p>
|
||||
<button mat-button (click)="clearFilter()">Clear filter</button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
// Color palette - Broadcast control room aesthetic
|
||||
$bg-deep: #0a0a0f;
|
||||
$bg-surface: #12121a;
|
||||
$bg-elevated: #1a1a24;
|
||||
$bg-hover: #24243a;
|
||||
$border-subtle: rgba(255, 255, 255, 0.06);
|
||||
$border-medium: rgba(255, 255, 255, 0.12);
|
||||
|
||||
// Accent colors - TV broadcast vibes
|
||||
$accent-primary: #00d4aa; // Cyan/teal for time
|
||||
$accent-secondary: #8b5cf6; // Purple for programs
|
||||
$accent-tertiary: #f472b6; // Pink for highlights
|
||||
$accent-warning: #fbbf24;
|
||||
|
||||
// Text colors
|
||||
$text-primary: #f8fafc;
|
||||
$text-secondary: rgba(248, 250, 252, 0.7);
|
||||
$text-muted: rgba(248, 250, 252, 0.4);
|
||||
|
||||
// Gradients
|
||||
$gradient-program: linear-gradient(
|
||||
135deg,
|
||||
rgba(139, 92, 246, 0.15) 0%,
|
||||
rgba(139, 92, 246, 0.05) 100%
|
||||
);
|
||||
$gradient-program-hover: linear-gradient(
|
||||
135deg,
|
||||
rgba(139, 92, 246, 0.3) 0%,
|
||||
rgba(139, 92, 246, 0.15) 100%
|
||||
);
|
||||
$gradient-header: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 212, 170, 0.08) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
// Base container
|
||||
.parent {
|
||||
display: flex;
|
||||
background: $bg-deep;
|
||||
color: $text-primary;
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 56px);
|
||||
|
||||
// Custom scrollbar for vertical scroll
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $bg-surface;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation toolbar - Frosted glass effect
|
||||
#epg-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights
|
||||
background: rgba(18, 18, 26, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid $border-medium;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
// Subtle glow at bottom
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(0, 212, 170, 0.3) 20%,
|
||||
rgba(139, 92, 246, 0.3) 80%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Button styling - exclude from drag region
|
||||
button {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:not([disabled]):hover {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search container - exclude from drag region
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.channel-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 200px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
color: $text-primary;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: $text-muted;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.channel-search {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
width: 200px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Program search container
|
||||
.program-search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
.program-search-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $accent-secondary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
color: $text-primary;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: $text-muted;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid $border-subtle;
|
||||
border-top-color: $accent-secondary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.program-search-results {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 320px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background: $bg-elevated;
|
||||
border: 1px solid $border-medium;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $bg-hover;
|
||||
|
||||
.result-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.result-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.result-time {
|
||||
color: $accent-primary;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.result-date {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.result-arrow {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: $accent-secondary;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.program-search-empty {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 16px;
|
||||
width: 220px;
|
||||
padding: 24px 16px;
|
||||
background: $bg-elevated;
|
||||
border: 1px solid $border-medium;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $text-muted;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
.program-search-input {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlightPulse {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
|
||||
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
// No results state
|
||||
.no-results {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
// Today's date display
|
||||
.today-date {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $accent-primary;
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
|
||||
// Glowing text effect
|
||||
text-shadow: 0 0 20px rgba(0, 212, 170, 0.3);
|
||||
}
|
||||
|
||||
// Channel column
|
||||
#channels-column {
|
||||
width: 140px;
|
||||
flex: none;
|
||||
border-right: 1px solid $border-medium;
|
||||
background: $bg-surface;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: calc(100vh - 6px);
|
||||
|
||||
// Subtle shadow for depth
|
||||
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.channel {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
rect {
|
||||
fill: $bg-surface;
|
||||
stroke: $border-subtle;
|
||||
transition: fill 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover rect {
|
||||
fill: $bg-elevated;
|
||||
}
|
||||
|
||||
&.active rect {
|
||||
fill: rgba(139, 92, 246, 0.2);
|
||||
stroke: rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
&.active .channel-name {
|
||||
color: $accent-secondary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
color: $text-primary;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
img {
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
image {
|
||||
object-fit: contain;
|
||||
background: $bg-elevated;
|
||||
}
|
||||
}
|
||||
|
||||
// EPG container
|
||||
#epg-container {
|
||||
width: calc(100vw - 140px);
|
||||
overflow-x: scroll;
|
||||
overflow-y: clip;
|
||||
background: $bg-deep;
|
||||
min-height: calc(100vh - 6px);
|
||||
|
||||
// Custom scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: $bg-surface;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(139, 92, 246, 0.4);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(139, 92, 246, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EPG SVG container
|
||||
#epg-svg {
|
||||
// Time header styling
|
||||
g > 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));
|
||||
}
|
||||
|
||||
// Highlighted program (from search)
|
||||
&.highlighted rect {
|
||||
fill: rgba(0, 212, 170, 0.25);
|
||||
stroke: $accent-primary;
|
||||
stroke-width: 2px;
|
||||
animation: highlightPulse 1s ease-in-out 3;
|
||||
}
|
||||
|
||||
&.highlighted .program-title {
|
||||
color: $accent-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 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: $border-subtle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel > rect {
|
||||
stroke: $border-subtle;
|
||||
}
|
||||
|
||||
.program-name,
|
||||
.channel-name {
|
||||
-webkit-line-clamp: 2;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
#current-time-line {
|
||||
stroke-width: 2px;
|
||||
stroke: $accent-primary;
|
||||
}
|
||||
|
||||
// Dark theme override (already dark, but ensure consistency)
|
||||
::ng-deep {
|
||||
.dark-theme {
|
||||
#epg-navigation {
|
||||
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) {
|
||||
#epg-navigation {
|
||||
padding-left: 70px; // Slightly reduced for smaller screens
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { OverlayRef } from '@angular/cdk/overlay';
|
||||
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
|
||||
import { Observable, Subscription, startWith } from 'rxjs';
|
||||
import {
|
||||
Channel,
|
||||
EpgChannel,
|
||||
EpgChannelWithPrograms,
|
||||
EpgProgram,
|
||||
} from 'shared-interfaces';
|
||||
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
|
||||
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
|
||||
|
||||
interface MultiEpgChannel extends EpgChannelWithPrograms {
|
||||
iconUrl?: string | null;
|
||||
}
|
||||
|
||||
interface EnrichedProgram extends EpgProgram {
|
||||
startDate: Date;
|
||||
stopDate: Date;
|
||||
startPosition: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface EnrichedChannel extends MultiEpgChannel {
|
||||
programs: EnrichedProgram[];
|
||||
}
|
||||
|
||||
interface ProgramSearchResult extends EpgProgram {
|
||||
channel_id?: string;
|
||||
channelName?: string | null;
|
||||
channel_name?: string | null;
|
||||
display_name?: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-multi-epg-container',
|
||||
templateUrl: './multi-epg-container.component.html',
|
||||
styleUrls: ['./multi-epg-container.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MultiEpgContainerComponent
|
||||
implements OnInit, AfterViewInit, OnDestroy
|
||||
{
|
||||
readonly epgContainer = viewChild.required<ElementRef>('epgContainer');
|
||||
|
||||
@Input() set playlistChannels(value: Observable<Channel[]>) {
|
||||
if (this.playlistChannelsSubscription) {
|
||||
this.playlistChannelsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
if (value) {
|
||||
this.playlistChannelsSubscription = value.subscribe(() => {
|
||||
this.channelsLowerRange = 0;
|
||||
this.originalEpgData.set([]);
|
||||
this.isLastPage.set(false);
|
||||
this.initializeVisibleChannels();
|
||||
this.requestPrograms();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Input() activeChannelId: string | null = null;
|
||||
|
||||
// Signals
|
||||
readonly hourWidth = signal(150);
|
||||
readonly today = signal(format(new Date(), 'yyyyMMdd'));
|
||||
readonly originalEpgData = signal<MultiEpgChannel[]>([]);
|
||||
readonly isLoading = signal(false);
|
||||
readonly isLastPage = signal(false);
|
||||
readonly channelFilter = signal('');
|
||||
readonly isSearchExpanded = signal(false);
|
||||
|
||||
// Program search signals
|
||||
readonly isProgramSearchOpen = signal(false);
|
||||
readonly programSearchQuery = signal('');
|
||||
readonly programSearchResults = signal<ProgramSearchResult[]>([]);
|
||||
readonly isSearchingPrograms = signal(false);
|
||||
readonly highlightedProgramKey = signal<string | null>(null);
|
||||
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
readonly selectedDayDate = computed(() =>
|
||||
parse(this.today(), 'yyyyMMdd', new Date())
|
||||
);
|
||||
|
||||
// Computed signal for enriched channels - automatically updates when dependencies change
|
||||
readonly channels = computed(() => this.enrichProgramData());
|
||||
|
||||
// Computed signal for filtered channels based on search term
|
||||
readonly filteredChannels = computed(() => {
|
||||
const filter = this.channelFilter().toLowerCase().trim();
|
||||
const allChannels = this.channels();
|
||||
|
||||
if (!filter) {
|
||||
return allChannels;
|
||||
}
|
||||
|
||||
return allChannels.filter((channel) => {
|
||||
const name = this.getChannelName(channel).toLowerCase();
|
||||
return name.includes(filter);
|
||||
});
|
||||
});
|
||||
|
||||
// Computed signal for current time line position
|
||||
readonly currentTimeLine = computed(() => {
|
||||
const now = new Date();
|
||||
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
|
||||
});
|
||||
|
||||
// Constants
|
||||
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
|
||||
readonly barHeight = 50;
|
||||
|
||||
// Pagination state
|
||||
visibleChannels = 20;
|
||||
channelsLowerRange = 0;
|
||||
channelsUpperRange = this.visibleChannels;
|
||||
totalChannels = 0;
|
||||
|
||||
private dateCache = new Map<string, Date>();
|
||||
private interval!: ReturnType<typeof setInterval>;
|
||||
private playlistChannelsSubscription?: Subscription;
|
||||
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
|
||||
|
||||
ngOnInit() {
|
||||
// Update current time line every minute
|
||||
this.interval = setInterval(() => {
|
||||
// Force recomputation by updating hourWidth to same value
|
||||
// This triggers the computed signal to recalculate
|
||||
this.hourWidth.update((v) => v);
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initializeVisibleChannels();
|
||||
this.scrollToCurrentTime();
|
||||
}
|
||||
|
||||
private scrollToCurrentTime(): void {
|
||||
const timeNow = new Date();
|
||||
const scrollPosition =
|
||||
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const container = document.getElementById('epg-container');
|
||||
if (container) {
|
||||
container.scrollTo(
|
||||
scrollPosition < 1000 ? 0 : scrollPosition - 150,
|
||||
0
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initializeVisibleChannels(): void {
|
||||
const epgContainer = this.epgContainer();
|
||||
if (epgContainer) {
|
||||
const containerHeight = epgContainer.nativeElement.offsetHeight;
|
||||
const calculatedVisibleChannels = Math.floor(
|
||||
(containerHeight - this.barHeight) / this.barHeight
|
||||
);
|
||||
|
||||
this.visibleChannels = Math.max(
|
||||
10,
|
||||
Math.min(calculatedVisibleChannels, 20)
|
||||
);
|
||||
this.channelsUpperRange = this.visibleChannels;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
clearInterval(this.interval);
|
||||
|
||||
if (this.playlistChannelsSubscription) {
|
||||
this.playlistChannelsSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
}
|
||||
|
||||
trackByIndex(index: number): number {
|
||||
return index;
|
||||
}
|
||||
|
||||
trackByProgram(_: number, program: EnrichedProgram): string {
|
||||
return `${program.start}|${program?.title?.toString() ?? ''}`;
|
||||
}
|
||||
|
||||
async requestPrograms(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
console.warn('Multi-EPG not available: Electron not detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isLoading() || this.isLastPage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
try {
|
||||
const response = await window.electron.getEpgChannelsByRange(
|
||||
this.channelsLowerRange,
|
||||
this.visibleChannels
|
||||
);
|
||||
|
||||
if (response && Array.isArray(response)) {
|
||||
// Append new data to existing data
|
||||
this.originalEpgData.update((data) => [
|
||||
...data,
|
||||
...(response as MultiEpgChannel[]),
|
||||
]);
|
||||
|
||||
// Update isLastPage based on the number of channels received
|
||||
this.isLastPage.set(response.length < this.visibleChannels);
|
||||
|
||||
// Update range for next fetch
|
||||
this.channelsLowerRange += response.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching EPG data:', error);
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onScroll(event: Event): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const scrollTop = target.scrollTop;
|
||||
const scrollHeight = target.scrollHeight;
|
||||
const clientHeight = target.clientHeight;
|
||||
|
||||
// Load more when user scrolls to within 200px of the bottom
|
||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||
this.requestPrograms();
|
||||
}
|
||||
}
|
||||
|
||||
private getCachedDate(dateStr: string): Date {
|
||||
let date = this.dateCache.get(dateStr);
|
||||
if (!date) {
|
||||
date = new Date(dateStr);
|
||||
this.dateCache.set(dateStr, date);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private enrichProgramData(): EnrichedChannel[] {
|
||||
const hourWidthValue = this.hourWidth();
|
||||
const todayValue = this.today();
|
||||
const data = this.originalEpgData();
|
||||
|
||||
return data.map((channel) => {
|
||||
const filteredPrograms = (channel.programs || [])
|
||||
.filter((item: EpgProgram) => {
|
||||
const itemDate = format(
|
||||
this.getCachedDate(item.start),
|
||||
'yyyyMMdd'
|
||||
);
|
||||
return itemDate === todayValue;
|
||||
})
|
||||
.map((program: EpgProgram) => {
|
||||
const startDate = this.getCachedDate(program.start);
|
||||
const stopDate = this.getCachedDate(program.stop);
|
||||
const startPosition =
|
||||
(startDate.getHours() + startDate.getMinutes() / 60) *
|
||||
hourWidthValue;
|
||||
const duration = differenceInMinutes(stopDate, startDate);
|
||||
const width = (duration * hourWidthValue) / 60;
|
||||
|
||||
return {
|
||||
...program,
|
||||
startDate,
|
||||
stopDate,
|
||||
startPosition,
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...channel,
|
||||
programs: filteredPrograms,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name from EpgChannel
|
||||
*/
|
||||
getChannelName(channel: EpgChannel): string {
|
||||
if (typeof channel.displayName === 'string') {
|
||||
return channel.displayName;
|
||||
}
|
||||
if (
|
||||
Array.isArray(channel.displayName) &&
|
||||
channel.displayName.length > 0
|
||||
) {
|
||||
return channel.displayName[0].value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon from EpgChannel
|
||||
*/
|
||||
getChannelIcon(channel: EpgChannel | MultiEpgChannel): string {
|
||||
if ('iconUrl' in channel && channel.iconUrl) {
|
||||
return channel.iconUrl;
|
||||
}
|
||||
if (channel.icon && channel.icon.length > 0) {
|
||||
return channel.icon[0].src;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
zoomIn(): void {
|
||||
if (this.hourWidth() >= 800) return;
|
||||
this.hourWidth.update((v) => v + 50);
|
||||
}
|
||||
|
||||
zoomOut(): void {
|
||||
if (this.hourWidth() <= 50) return;
|
||||
this.hourWidth.update((v) => v - 50);
|
||||
}
|
||||
|
||||
toggleSearch(): void {
|
||||
this.isSearchExpanded.update((v) => !v);
|
||||
if (!this.isSearchExpanded()) {
|
||||
this.channelFilter.set('');
|
||||
}
|
||||
}
|
||||
|
||||
clearFilter(): void {
|
||||
this.channelFilter.set('');
|
||||
}
|
||||
|
||||
onFilterInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.channelFilter.set(input.value);
|
||||
}
|
||||
|
||||
switchDay(direction: 'prev' | 'next'): void {
|
||||
const currentDate = parse(this.today(), 'yyyyMMdd', new Date());
|
||||
this.today.set(
|
||||
direction === 'prev'
|
||||
? format(subDays(currentDate, 1), 'yyyyMMdd')
|
||||
: format(addDays(currentDate, 1), 'yyyyMMdd')
|
||||
);
|
||||
}
|
||||
|
||||
showDescription(program: EpgProgram): void {
|
||||
this.dialog.open(EpgItemDescriptionComponent, {
|
||||
width: '600px',
|
||||
data: program,
|
||||
});
|
||||
}
|
||||
|
||||
// Program search methods
|
||||
toggleProgramSearch(): void {
|
||||
this.isProgramSearchOpen.update((v) => !v);
|
||||
if (!this.isProgramSearchOpen()) {
|
||||
this.programSearchQuery.set('');
|
||||
this.programSearchResults.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
onProgramSearchInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const query = input.value.trim();
|
||||
this.programSearchQuery.set(query);
|
||||
|
||||
// Clear previous debounce timer
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
|
||||
if (query.length < 2) {
|
||||
this.programSearchResults.set([]);
|
||||
this.isSearchingPrograms.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 500ms - show spinner only when search actually starts
|
||||
this.searchDebounceTimer = setTimeout(async () => {
|
||||
this.isSearchingPrograms.set(true);
|
||||
try {
|
||||
const results = await window.electron.searchEpgPrograms(
|
||||
query,
|
||||
20
|
||||
);
|
||||
this.programSearchResults.set(
|
||||
(results as ProgramSearchResult[]) || []
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error searching programs:', error);
|
||||
this.programSearchResults.set([]);
|
||||
} finally {
|
||||
this.isSearchingPrograms.set(false);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
clearProgramSearch(): void {
|
||||
this.programSearchQuery.set('');
|
||||
this.programSearchResults.set([]);
|
||||
}
|
||||
|
||||
showProgramDetails(program: ProgramSearchResult): void {
|
||||
// Get channel id (DB returns channel_id, EPG uses channel)
|
||||
const channelId = program.channel_id || program.channel;
|
||||
|
||||
// Find channel name
|
||||
const channel = this.channels().find((ch) => ch.id === channelId);
|
||||
const channelName = channel ? this.getChannelName(channel) : null;
|
||||
|
||||
// Open the program details dialog with channel name
|
||||
this.dialog.open(EpgItemDescriptionComponent, {
|
||||
width: '800px',
|
||||
data: { ...program, channelName },
|
||||
});
|
||||
}
|
||||
|
||||
getProgramKey(program: ProgramSearchResult): string {
|
||||
const channelId = program.channel_id || program.channel;
|
||||
return `${channelId}|${program.start}`;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.overlayRef.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { OverlayRef } from '@angular/cdk/overlay';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Injection token for providing OverlayRef to dynamically created overlay components
|
||||
*/
|
||||
export const COMPONENT_OVERLAY_REF = new InjectionToken<OverlayRef>(
|
||||
'COMPONENT_OVERLAY_REF'
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user