mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user