Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+28
View File
@@ -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"
}
}
}
+6
View File
@@ -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>
@@ -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;
}
}
}
@@ -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'
);
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}