diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts
index 20f9c60df..c91bc567e 100644
--- a/apps/electron-backend/src/app/events/epg-query.service.ts
+++ b/apps/electron-backend/src/app/events/epg-query.service.ts
@@ -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(
diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss
index e6e8659ad..8b326b5a0 100644
--- a/apps/web/src/styles.scss
+++ b/apps/web/src/styles.scss
@@ -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
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
index 861c06ef1..dee3e0d77 100644
--- a/libs/ui/components/src/lib/window-controls/window-controls.component.ts
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
@@ -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;
diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts
index 6a5544417..4bdb5bff2 100644
--- a/libs/ui/epg/src/index.ts
+++ b/libs/ui/epg/src/index.ts
@@ -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';
diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts
index d405b1273..a6e024588 100644
--- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts
+++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts
@@ -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,
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html
deleted file mode 100644
index 7231f4238..000000000
--- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html
+++ /dev/null
@@ -1,277 +0,0 @@
-
-
-
-
- {{ selectedDayDate() | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
-
-
-
-
-
-
- @if (isSearchExpanded()) {
-
- search
-
- @if (channelFilter()) {
-
- }
-
-
{{ filteredChannels().length }} / {{ channels().length }}
- }
-
-
-
-
-
- @if (isProgramSearchOpen()) {
-
- @if (programSearchResults().length > 0) {
-
- @for (program of programSearchResults(); track program.start) {
-
- }
-
- }
- @if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
-
- search_off
- No programs found
-
- }
- }
-
-
-
-
-
-
-
-
-
-
-
-@if (filteredChannels().length === 0 && channelFilter()) {
-
-
search_off
-
No channels match "{{ channelFilter() }}"
-
-
-}
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss
deleted file mode 100644
index 94bf9af24..000000000
--- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss
+++ /dev/null
@@ -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;
- }
-}
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts
deleted file mode 100644
index 13d709d27..000000000
--- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts
+++ /dev/null
@@ -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;
- let component: MultiEpgContainerComponent;
- let epgBridge: Partial;
- 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);
- });
-});
diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts
deleted file mode 100644
index 39f37a488..000000000
--- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts
+++ /dev/null
@@ -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('epgContainer');
-
- @Input() set playlistChannels(value: Observable) {
- 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([]);
- 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([]);
- readonly isSearchingPrograms = signal(false);
- readonly highlightedProgramKey = signal(null);
- private searchDebounceTimer: ReturnType | 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();
- private interval!: ReturnType;
- private playlistChannelsSubscription?: Subscription;
-
- private readonly dialog = inject(MatDialog);
- private readonly overlayRef = inject(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 {
- 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();
- }
-}
diff --git a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts b/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts
deleted file mode 100644
index db768fed1..000000000
--- a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts
+++ /dev/null
@@ -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(
- 'COMPONENT_OVERLAY_REF'
-);
diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs
index c609d81dc..1a44727aa 100644
--- a/tools/eslint/max-lines-baseline.mjs
+++ b/tools/eslint/max-lines-baseline.mjs
@@ -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',