refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:25:37 +02:00
1 parent e9359750bf
commit 5c2cf3f345
11 files changed
+5 -1999

No files matched your search

@@ -400,54 +400,6 @@ export class EpgQueryService {
}
}
async getChannelsByRange(
skip: number,
limit: number
): Promise<
Array<{
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}>
> {
try {
const db = await getDatabase();
const channels = await db
.select({
id: schema.epgChannels.id,
displayName: schema.epgChannels.displayName,
iconUrl: schema.epgChannels.iconUrl,
})
.from(schema.epgChannels)
.orderBy(schema.epgChannels.displayName)
.offset(skip)
.limit(limit);
return Promise.all(
channels.map(async (channel) => {
const programs = await db
.select()
.from(schema.epgPrograms)
.where(eq(schema.epgPrograms.channelId, channel.id))
.orderBy(schema.epgPrograms.start);
return {
...channel,
programs: programs.map(toEpgProgramFromRow),
};
})
);
} catch (error) {
epgLogger.error(
this.loggerLabel,
'Error getting channels by range:',
error
);
return [];
}
}
private normalizeChannelLookupKeys(channelIds: string[]): string[] {
return Array.from(
new Set(
+1 -5
View File
@@ -98,15 +98,11 @@ app-root.macos-platform .current-playlist {
// Windows/Linux frameless title bar: reserve room in top-aligned drag
// regions for the renderer-drawn window controls (3 × 46px buttons).
// Body-level class so it also reaches cdk-overlay content (multi-EPG).
// Body-level class so it also reaches cdk-overlay content (dialogs, menus).
body.frameless-platform .workspace-header {
padding-right: 150px;
}
body.frameless-platform #epg-navigation {
padding-right: 150px;
}
// ─── Base ─────────────────────────────────────────────────────────────────────
// Native UI (scrollbars, form controls, popups) must follow the app theme,
// not the OS preference. Chromium colors unstyled/native scrollbars from the
@@ -15,7 +15,7 @@ import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
* native traffic lights instead.
*
* Rendered once in the app root so the buttons stay reachable above
* full-window content such as the multi-EPG overlay and dialog backdrops —
* full-window content such as dialog backdrops —
* mirroring how the macOS traffic lights float above everything. CDK
* overlays render as popovers in the browser top layer (above any
* z-index), so the host is itself a manual popover and re-enters the top
@@ -91,7 +91,7 @@ export class WindowControlsComponent implements AfterViewInit {
/**
* Re-enter the top layer after any other popover opens: top-layer
* elements paint in insertion order, so hiding and re-showing puts the
* controls back above freshly opened CDK overlays (dialogs, multi-EPG).
* controls back above freshly opened CDK overlays (dialogs, menus).
*/
private readonly onDocumentToggle = (event: Event): void => {
const newState = (event as { newState?: string }).newState;
-2
View File
@@ -28,5 +28,3 @@ export * from './lib/epg-guide/epg-guide-layout.util';
export * from './lib/epg-guide/epg-guide-preferences';
export * from './lib/epg-guide/epg-guide.component';
export * from './lib/epg-guide/epg-guide-now-playing.component';
export * from './lib/multi-epg/multi-epg-container.component';
export * from './lib/multi-epg/overlay-ref.token';
@@ -79,8 +79,8 @@ export class EpgItemDescriptionComponent {
this.archiveUnavailableNote =
this.dialogData.archiveUnavailableNote ?? false;
// Opened imperatively from six surfaces (timeline, list, channel rows,
// the multi-EPG grid and its search), so the dialog reads the display
// offset itself instead of trusting every opener to forward it.
// the programme guide grid and its search), so the dialog reads the
// display offset itself instead of trusting every opener to forward it.
const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes();
this.startMs = getProgramTimeMs(
this.epgProgram.start,
@@ -1,277 +0,0 @@
<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]="'EPG.ZOOM_OUT' | translate"
[attr.aria-label]="'EPG.ZOOM_OUT' | translate"
>
<mat-icon>remove</mat-icon>
</button>
<button
mat-icon-button
(click)="zoomIn()"
[disabled]="hourWidth() >= 800"
[matTooltip]="'EPG.ZOOM_IN' | translate"
[attr.aria-label]="'EPG.ZOOM_IN' | translate"
>
<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() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
| translate
"
[attr.aria-label]="
(isSearchExpanded() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
| translate
"
>
<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 program.start) {
<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">
{{ programDisplayMs(program, 'start') | date: 'HH:mm' }}
-
{{ programDisplayMs(program, 'stop') | date: 'HH:mm' }}
</span>
<span class="result-date">
{{ programDisplayMs(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()
? 'EPG.CLOSE_PROGRAM_SEARCH'
: 'EPG.SEARCH_PROGRAMS'
) | translate
"
[attr.aria-label]="
(isProgramSearchOpen()
? 'EPG.CLOSE_PROGRAM_SEARCH'
: 'EPG.SEARCH_PROGRAMS'
) | translate
"
>
<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 (column widened to 180px per UX audit) -->
<g class="channel" [class.active]="item.id === activeChannelId">
<rect
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
@if (getChannelIcon(item)) {
<img
[src]="getChannelIcon(item)"
[alt]="getChannelName(item)"
>
}
<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)"
[class.is-now]="isProgramAiringNow(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 + label badge.
The badge sits on top of the time-header row so users can read the
current time at a glance — addresses the UX-audit complaint that
the now-line was a "decorative gradient" without a time anchor. -->
<line
[attr.x1]="currentTimeLine()"
y1="0"
[attr.x2]="currentTimeLine()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
id="current-time-line"
></line>
<g class="now-label" [attr.transform]="'translate(' + currentTimeLine() + ', ' + (barHeight - 14) + ')'">
<rect
x="-26"
y="-9"
width="52"
height="18"
rx="9"
ry="9"
></rect>
<text x="0" y="4" text-anchor="middle">{{ currentTimeLabel() }}</text>
</g>
</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>
}
@@ -1,916 +0,0 @@
// Color palette — every value resolves through the app's theme tokens so
// Multi-EPG looks like part of IPTVnator instead of a separate broadcast
// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid
// switch to near-black while the rest of the app sat on --app-content-bg,
// which the UX audit called out as the screen "visually detaching" from
// the rest of the app.
$bg-deep: var(--app-content-bg);
$bg-surface: var(--app-widget-bg);
$bg-elevated: var(--app-widget-header-bg);
$bg-hover: var(--app-card-hover-bg);
$border-subtle: var(--app-widget-header-border);
$border-medium: var(--app-separator);
// Accent colors — unified with the rest of the app:
// cyan stays on the "now" indicator only; blue is the primary; the old
// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit.
$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator
$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line
$accent-secondary: var(--app-selection-color); // Blue — programs
$accent-tertiary: var(--app-selection-color); // Blue — highlights
$accent-warning: #fbbf24;
// Text colors — derived from app heading/body so light and dark themes
// stay in sync without a separate `.light-theme` override at the bottom
// (which was duplicating responsibility with the same token chain).
$text-primary: var(--app-heading-color);
$text-secondary: var(--app-body-color);
$text-muted: var(--app-muted-color);
// Gradients
$gradient-program: linear-gradient(
135deg,
rgba(79, 142, 255, 0.15) 0%,
rgba(79, 142, 255, 0.05) 100%
);
$gradient-program-hover: linear-gradient(
135deg,
rgba(79, 142, 255, 0.3) 0%,
rgba(79, 142, 255, 0.15) 100%
);
$gradient-header: linear-gradient(
180deg,
rgba(92, 214, 255, 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(47, 123, 255, 0.4);
border-radius: 4px;
&:hover {
background: rgba(47, 123, 255, 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(92, 214, 255, 0.3) 20%,
rgba(47, 123, 255, 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(47, 123, 255, 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(47, 123, 255, 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(47, 123, 255, 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(47, 123, 255, 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(92, 214, 255, 0.6));
}
50% {
filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9))
drop-shadow(0 0 40px rgba(92, 214, 255, 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(92, 214, 255, 0.3);
}
// Channel column — bumped from 140px to 180px per the UX audit so long
// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being
// truncated to 5 characters.
#channels-column {
width: 180px;
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(47, 123, 255, 0.2);
stroke: rgba(47, 123, 255, 0.4);
}
&.active .channel-name {
color: $accent-secondary;
font-weight: 600;
}
}
.channel-name {
color: $text-primary;
padding: 8px 12px;
font-size: 12.5px;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
height: 100%;
overflow: hidden;
letter-spacing: 0.01em;
line-height: 1.2;
img {
// Smaller logo (24px) per audit recommendation so the channel
// name has room to breathe in the 180px column.
width: 24px !important;
height: 24px !important;
margin-right: 0 !important;
flex: 0 0 auto;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
padding: 2px;
object-fit: contain;
}
span {
// Allow up to two lines so "13th Street Universal HD" and
// similar long names wrap instead of disappearing into an
// ellipsis after 5 characters.
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
}
image {
object-fit: contain;
background: $bg-elevated;
}
}
// EPG container
#epg-container {
width: calc(100vw - 180px);
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(47, 123, 255, 0.4);
border-radius: 4px;
&:hover {
background: rgba(47, 123, 255, 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(47, 123, 255, 0.4);
filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2));
}
// Highlighted program (from search)
&.highlighted rect {
fill: rgba(92, 214, 255, 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(47, 123, 255, 0.15);
stroke: rgba(47, 123, 255, 0.3);
}
.active-row .program:hover rect {
fill: rgba(47, 123, 255, 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(92, 214, 255, 0.6))
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
// Animated pulse
animation: timePulse 2s ease-in-out infinite;
}
// Now-line time badge — a small filled pill sitting on top of the time
// header that reads "HH:MM". Keeps the cyan visual language of the line
// itself so the relationship is unmistakable.
.now-label {
pointer-events: none;
rect {
fill: $accent-primary;
stroke: none;
filter: drop-shadow(0 0 6px $accent-now-glow);
}
text {
fill: var(--app-shell-bg, #0d0f12);
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo,
monospace;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
}
}
// Currently-airing program — a thin cyan border on every program block
// whose time window contains the now-line. Audit recommended this to
// make "what's airing right now" scannable without finding the line
// first then tracing it down each row.
.program.is-now {
rect {
stroke: $accent-primary;
stroke-width: 1.5px;
}
}
}
// Pulse animation for time line
@keyframes timePulse {
0%,
100% {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6))
drop-shadow(0 0 16px rgba(92, 214, 255, 0.3));
}
50% {
opacity: 0.8;
filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8))
drop-shadow(0 0 24px rgba(92, 214, 255, 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(47, 123, 255, 0.4);
}
}
.program-title {
color: #1e293b;
}
line[stroke='red'] {
stroke: $accent-primary !important;
filter: drop-shadow(0 0 6px $accent-now-glow);
}
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
#epg-navigation {
padding-left: 70px; // Slightly reduced for smaller screens
}
#channels-column {
width: 140px;
.channel-name {
font-size: 11.5px;
padding: 6px 8px;
gap: 8px;
img {
width: 20px !important;
height: 20px !important;
}
}
}
#epg-container {
width: calc(100vw - 140px);
}
.today-date {
font-size: 12px;
padding: 0 12px;
}
}
@@ -1,180 +0,0 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OverlayRef } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
MultiEpgContainerComponent,
isSelectedEpgDayToday,
} from './multi-epg-container.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
describe('isSelectedEpgDayToday', () => {
it('returns true only when the selected EPG day is the actual current day', () => {
const now = new Date('2026-05-21T20:00:00.000Z');
expect(isSelectedEpgDayToday('20260521', now)).toBe(true);
expect(isSelectedEpgDayToday('20260520', now)).toBe(false);
expect(isSelectedEpgDayToday('20260522', now)).toBe(false);
});
});
function programAt(start: Date, minutes: number, title: string): EpgProgram {
return {
start: start.toISOString(),
stop: new Date(start.getTime() + minutes * 60_000).toISOString(),
channel: 'c1',
title,
desc: null,
category: null,
};
}
describe('MultiEpgContainerComponent runtime gates', () => {
let fixture: ComponentFixture<MultiEpgContainerComponent>;
let component: MultiEpgContainerComponent;
let epgBridge: Partial<EpgRuntimeBridgeService>;
const epgOffsetMinutes = signal(0);
beforeEach(async () => {
epgOffsetMinutes.set(0);
epgBridge = {
searchPrograms: jest.fn().mockResolvedValue([]),
supportsGuide: false,
supportsProgramSearch: false,
};
await TestBed.configureTestingModule({
imports: [MultiEpgContainerComponent],
providers: [
{ provide: MatDialog, useValue: { open: jest.fn() } },
{
provide: COMPONENT_OVERLAY_REF,
useValue: { detach: jest.fn() },
},
{
provide: OverlayRef,
useValue: { detach: jest.fn() },
},
{
provide: EpgRuntimeBridgeService,
useValue: epgBridge,
},
{
provide: SettingsStore,
useValue: { resolvedEpgOffsetMinutes: epgOffsetMinutes },
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: of(null),
},
},
],
})
.overrideComponent(MultiEpgContainerComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(MultiEpgContainerComponent);
component = fixture.componentInstance;
});
afterEach(() => {
fixture.destroy();
jest.restoreAllMocks();
jest.useRealTimers();
});
it('lays programmes out in display time when an EPG offset is set', () => {
const morning = new Date();
morning.setHours(10, 0, 0, 0);
const late = new Date();
late.setHours(23, 30, 0, 0);
component.originalEpgData.set([
{
id: 'c1',
displayName: 'Channel 1',
programs: [
programAt(morning, 60, 'Morning'),
programAt(late, 60, 'Late'),
],
} as unknown as ReturnType<
typeof component.originalEpgData
>[number],
]);
epgOffsetMinutes.set(90);
// 23:30 + 90 min lands on the next day and leaves today's column;
// 10:00 + 90 min is drawn under 11:30.
const programs = component.channels()[0].programs;
expect(programs.map((program) => program.title)).toEqual(['Morning']);
expect(programs[0].startPosition).toBe(11.5 * component.hourWidth());
expect(programs[0].width).toBe(component.hourWidth());
});
it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => {
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
await component.requestPrograms();
expect(component.isLoading()).toBe(false);
});
// This legacy grid's `EPG_GET_CHANNELS_BY_RANGE` read was removed in
// Task 2 (it never carried real data past this point regardless — see
// the temporary `Promise.resolve([])` in `requestPrograms()`); the whole
// component is deleted in Task 10.
it.skip('requests EPG channel ranges through the EPG runtime bridge', async () => {
epgBridge.supportsGuide = true;
await component.requestPrograms();
expect(component.isLoading()).toBe(false);
});
it('does not search EPG programs when the EPG bridge cannot search programs', () => {
jest.useFakeTimers();
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(600);
expect(epgBridge.searchPrograms).not.toHaveBeenCalled();
expect(component.isSearchingPrograms()).toBe(false);
expect(component.programSearchResults()).toEqual([]);
});
it('searches EPG programs through the EPG runtime bridge', async () => {
jest.useFakeTimers();
const results = [
{
channelId: 'channel-1',
start: '2026-05-22T10:00:00.000Z',
stop: '2026-05-22T11:00:00.000Z',
title: 'News',
},
];
epgBridge.searchPrograms = jest.fn().mockResolvedValue(results);
epgBridge.supportsProgramSearch = true;
component.onProgramSearchInput({
target: { value: 'news' },
} as unknown as Event);
jest.advanceTimersByTime(500);
await Promise.resolve();
expect(epgBridge.searchPrograms).toHaveBeenCalledWith('news', 20);
expect(component.programSearchResults()).toEqual(results);
expect(component.isSearchingPrograms()).toBe(false);
});
});
@@ -1,557 +0,0 @@
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,
epgDisplayTimeMs,
EpgProgram,
} from '@iptvnator/shared/interfaces';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { SettingsStore } from '@iptvnator/services';
import { getProgramTimeMs } from '../epg-program.utils';
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
// TODO(Task 10): this legacy multi-EPG view is being replaced by the
// host-agnostic `app-epg-guide` grid; `EPG_GET_CHANNELS_BY_RANGE` is gone,
// so this component is temporarily patched to keep typecheck green.
interface MultiEpgChannel {
id: string;
displayName: string;
iconUrl: string | null;
programs: EpgProgram[];
}
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;
}
export function isSelectedEpgDayToday(
selectedDay: string,
now: Date = new Date()
): boolean {
return selectedDay === format(now, 'yyyyMMdd');
}
@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();
});
// "16:32"-style label rendered as a badge above the now-line. Recomputes
// on the same 60-second tick that drives `currentTimeLine` (both depend
// on hourWidth's update pulse from `ngOnInit`).
readonly currentTimeLabel = computed(() => {
// Subscribe to the same tick source as currentTimeLine.
this.hourWidth();
const now = new Date();
return `${now.getHours().toString().padStart(2, '0')}:${now
.getMinutes()
.toString()
.padStart(2, '0')}`;
});
// 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);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
/**
* Attached to an overlay without host inputs, so the grid reads the EPG
* display offset itself (`epg-display-offset.util.ts`, display form).
*/
private readonly epgOffsetMinutes =
this.settingsStore.resolvedEpgOffsetMinutes;
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() ?? ''}`;
}
/**
* Returns true when the now-line falls within this program's rendered
* span — used to add a `.is-now` class so the cyan border lights up
* every airing program at once. The template reads `currentTimeLine()`
* (a signal) here so the highlight refreshes on the same 60-second tick
* as the line itself.
*/
isProgramAiringNow(program: EnrichedProgram): boolean {
const nowX = this.currentTimeLine();
if (!isSelectedEpgDayToday(this.today())) {
return false;
}
return (
nowX >= program.startPosition &&
nowX <= program.startPosition + program.width
);
}
async requestPrograms(): Promise<void> {
if (!this.epgBridge.supportsGuide) {
console.warn('Multi-EPG not available in this runtime');
return;
}
if (this.isLoading() || this.isLastPage()) {
return;
}
this.isLoading.set(true);
try {
const response = await Promise.resolve(
[] as MultiEpgChannel[]
);
if (response && Array.isArray(response)) {
// Append new data to existing data
this.originalEpgData.update((data) => [...data, ...response]);
// 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;
}
/** The cached raw date shifted into display time. */
private getDisplayDate(dateStr: string, offsetMinutes: number): Date {
const raw = this.getCachedDate(dateStr);
return offsetMinutes === 0
? raw
: new Date(epgDisplayTimeMs(raw.getTime(), offsetMinutes));
}
/** Display-time epoch ms of a search-result boundary for the date pipe. */
programDisplayMs(program: EpgProgram, side: 'start' | 'stop'): number {
return side === 'start'
? getProgramTimeMs(
program.start,
program.startTimestamp,
this.epgOffsetMinutes()
)
: getProgramTimeMs(
program.stop,
program.stopTimestamp,
this.epgOffsetMinutes()
);
}
private enrichProgramData(): EnrichedChannel[] {
const hourWidthValue = this.hourWidth();
const todayValue = this.today();
const data = this.originalEpgData();
// Day filter and x-position both work in display time, so a shifted
// programme lands in the right day column under the right hour.
const offsetMinutes = this.epgOffsetMinutes();
return data.map((channel) => {
const filteredPrograms = (channel.programs || [])
.filter((item: EpgProgram) => {
const itemDate = format(
this.getDisplayDate(item.start, offsetMinutes),
'yyyyMMdd'
);
return itemDate === todayValue;
})
.map((program: EpgProgram) => {
const startDate = this.getDisplayDate(
program.start,
offsetMinutes
);
const stopDate = this.getDisplayDate(
program.stop,
offsetMinutes
);
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 | MultiEpgChannel): 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 (
'icon' in channel &&
Array.isArray(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;
}
if (!this.epgBridge.supportsProgramSearch) {
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 this.epgBridge.searchPrograms(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();
}
}
@@ -1,9 +0,0 @@
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'
);
-1
View File
@@ -60,7 +60,6 @@ export const maxLinesBaseline = [
'libs/shared/interfaces/src/lib/electron-api.interface.ts',
'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts',
'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts',
'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts',
'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts',
'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',