diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index e8efb2483..c09bfa38d 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -753,7 +753,7 @@
}
},
"TOP_MENU": {
- "OPEN_MULTI_EPG": "Open Multi-EPG view"
+ "OPEN_EPG_GUIDE": "Programme guide"
},
"EPG": {
"NEXT_DAY": "Next day",
@@ -803,7 +803,42 @@
"ARCHIVE_PLAYBACK": "Archive playback",
"COLLAPSE_PANEL": "Collapse EPG panel",
"EXPAND_PANEL": "Expand EPG panel",
+ "GUIDE": {
+ "TITLE": "Programme guide",
+ "CLOSE": "Close guide",
+ "CHANNEL": "Channel",
+ "TODAY": "Today",
+ "NOW": "Now",
+ "JUMP_NOW": "Jump to now",
+ "SCOPE": "Channels shown",
+ "ONLY_WITH_EPG": "Only with EPG",
+ "ONLY_WITH_EPG_HINT": "Hide channels without programme data",
+ "DENSITY_COMFORTABLE": "Comfortable rows",
+ "DENSITY_COMPACT": "Compact rows",
+ "ZOOM": "Zoom",
+ "FILTER_CHANNELS": "Filter channels…",
+ "SEARCH_PROGRAMMES": "Search programmes…",
+ "NO_RESULTS": "No programmes found",
+ "NO_PROGRAMME_INFO": "No programme information",
+ "NO_PROGRAMME_INFO_LONG": "No programme information for this channel",
+ "PLAYING": "Playing",
+ "NO_CHANNELS": "No channels match \"{{query}}\"",
+ "CLEAR_FILTER": "Clear filter",
+ "COLLAPSE_PLAYER": "Collapse player",
+ "EXPAND_PLAYER": "Expand player",
+ "KEYS": {
+ "CHANNEL": "channel",
+ "PROGRAMME": "programme",
+ "PLAY": "play",
+ "DETAILS": "details",
+ "NOW": "now",
+ "DAY": "day",
+ "CLOSE": "close"
+ }
+ },
"TIMELINE": {
+ "GUIDE": "Guide",
+ "OPEN_GUIDE": "Open the programme guide",
"TODAY": "Today",
"NOW": "Now",
"JUMP_NOW": "Jump to now",
@@ -1525,7 +1560,7 @@
"ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details",
"OPEN_VIEW_DESCRIPTION": "Navigate to {{view}}",
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Clear all visible {{type}} items in this view",
- "OPEN_MULTI_EPG_DESCRIPTION": "Open the multi-channel EPG view",
+ "OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist",
"GO_TO_VOD_LABEL": "Movies",
"GO_TO_VOD_DESCRIPTION": "Jump to movies section",
"GO_TO_LIVE_LABEL": "Live TV",
diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts
index c657ea779..6a5544417 100644
--- a/libs/ui/epg/src/index.ts
+++ b/libs/ui/epg/src/index.ts
@@ -23,5 +23,10 @@ export {
} from './lib/epg-timeline/epg-summary.util';
export * from './lib/epg-progress-panel/epg-progress-panel.component';
export * from './lib/epg-source-status/epg-source-status.component';
+export * from './lib/epg-guide/epg-guide-source';
+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-guide/epg-guide-now-playing.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html
new file mode 100644
index 000000000..7d3fa03ad
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html
@@ -0,0 +1,53 @@
+
+
+
+ {{ channelName() }}
+ @if (isOnNow()) {
+ {{
+ 'EPG.TIMELINE.ON_NOW' | translate
+ }}
+ }
+
+ @if (program(); as program) {
+
{{ program.title }}
+ @if (!collapsed() && program.desc) {
+
{{ program.desc }}
+ }
+ @if (!collapsed() && progress() !== null) {
+
+ {{ startMs() | date: 'HH:mm' }}
+
+ {{ stopMs() | date: 'HH:mm' }}
+
+ }
+ } @else {
+
+ {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
+
+ }
+
+
+
+
+
+
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss
new file mode 100644
index 000000000..bea8a7abd
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss
@@ -0,0 +1,116 @@
+@use '../epg-theme' as *;
+
+:host {
+ display: block;
+ min-width: 0;
+ height: 100%;
+}
+
+.now-playing {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ height: 100%;
+ min-width: 0;
+ color: $text-primary;
+}
+.now-playing__body {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.now-playing__eyebrow {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ font: 500 10px $font-mono;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: $text-secondary;
+
+ .now-playing__on {
+ color: $live-text;
+ }
+ .now-playing__on::before {
+ content: '';
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: $accent-live;
+ margin-right: 5px;
+ vertical-align: 1px;
+ }
+}
+.now-playing__title {
+ font-size: 16px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.now-playing__title--muted {
+ color: $text-tertiary;
+ font-weight: 400;
+}
+.now-playing__desc {
+ font-size: 12px;
+ color: $text-secondary;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 60ch;
+}
+.now-playing__progress {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font: 11px $font-mono;
+ color: $text-secondary;
+
+ i {
+ flex: 1;
+ max-width: 360px;
+ height: 4px;
+ border-radius: 2px;
+ background: $surface-3;
+ overflow: hidden;
+ display: block;
+ }
+ b {
+ display: block;
+ height: 100%;
+ background: $accent-blue;
+ }
+}
+.now-playing__actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+}
+.now-playing--collapsed {
+ gap: 12px;
+
+ .now-playing__body {
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+ }
+ .now-playing__title {
+ font-size: 13px;
+ }
+ .now-playing__actions {
+ flex-direction: row;
+ }
+}
+kbd {
+ font: 10px $font-mono;
+ border: 1px solid $line-strong;
+ border-radius: 4px;
+ padding: 0 4px;
+ margin-left: 6px;
+ color: $text-secondary;
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts
new file mode 100644
index 000000000..4f572b169
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts
@@ -0,0 +1,82 @@
+import { DatePipe } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ OnDestroy,
+ OnInit,
+ output,
+ signal,
+} from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { TranslatePipe } from '@ngx-translate/core';
+import { getProgramTimeMs } from '../epg-program.utils';
+
+/**
+ * Info block of the docked player strip while the guide is open: channel,
+ * current programme, progress, Close and Collapse. The host renders it next to
+ * the player because the strip itself is host layout.
+ */
+@Component({
+ selector: 'app-epg-guide-now-playing',
+ imports: [DatePipe, MatButtonModule, TranslatePipe],
+ templateUrl: './epg-guide-now-playing.component.html',
+ styleUrl: './epg-guide-now-playing.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy {
+ readonly channelName = input('');
+ readonly program = input(null);
+ readonly offsetMinutes = input(0);
+ readonly collapsed = input(false);
+
+ readonly closeRequested = output();
+ readonly collapsedChange = output();
+
+ private readonly nowMs = signal(Date.now());
+ private timer?: number;
+
+ readonly startMs = computed(() => this.boundary('start'));
+ readonly stopMs = computed(() => this.boundary('stop'));
+ readonly isOnNow = computed(() => {
+ const start = this.startMs();
+ const stop = this.stopMs();
+ const now = this.nowMs();
+ return start !== null && stop !== null && start <= now && now < stop;
+ });
+ readonly progress = computed(() => {
+ const start = this.startMs();
+ const stop = this.stopMs();
+ if (start === null || stop === null || stop <= start) {
+ return null;
+ }
+ const pct = ((this.nowMs() - start) / (stop - start)) * 100;
+ return Math.min(100, Math.max(0, pct));
+ });
+
+ ngOnInit(): void {
+ this.timer = window.setInterval(
+ () => this.nowMs.set(Date.now()),
+ 30_000
+ );
+ }
+
+ ngOnDestroy(): void {
+ window.clearInterval(this.timer);
+ }
+
+ private boundary(edge: 'start' | 'stop'): number | null {
+ const program = this.program();
+ if (!program) {
+ return null;
+ }
+ const ms = getProgramTimeMs(
+ program[edge],
+ edge === 'start' ? program.startTimestamp : program.stopTimestamp,
+ this.offsetMinutes()
+ );
+ return Number.isFinite(ms) ? ms : null;
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.html
new file mode 100644
index 000000000..66ebe2c37
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.html
@@ -0,0 +1,110 @@
+
+
{{ channel().number }}
+
+ @if (channel().logoUrl; as logo) {
+
+ } @else {
+ live_tv
+ }
+
+
+ {{ channel().name }}
+ @if (density() === 'comfortable') {
+
+ @if (active()) {
+ ▶ {{ 'EPG.GUIDE.PLAYING' | translate }}
+ } @else if (nowBlock(); as now) {
+ {{ now.block.program.title }}
+ } @else if (showEmpty()) {
+ {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
+ }
+
+ }
+
+ @if (active()) {
+
equalizer
+ }
+
+
+
+ @if (showEmpty()) {
+
{{
+ 'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate
+ }}
+ } @else if (isLoading()) {
+
+
+
+ }
+ @for (item of blocks(); track item.key; let index = $index) {
+
+ @if (item.block.when === 'now') {
+
+ }
+
+ {{ item.block.startMs | date: 'HH:mm' }}
+ @if (density() === 'comfortable') {
+ – {{ item.block.stopMs | date: 'HH:mm' }}
+ }
+
+ {{
+ item.block.program.title
+ }}
+ @if (item.block.when === 'now') {
+ {{
+ 'EPG.TIMELINE.ON_NOW' | translate
+ }}
+ }
+ @if (item.canCatchUp && item.block.when === 'past') {
+
+ }
+
+ }
+
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss
new file mode 100644
index 000000000..df44c0cb8
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss
@@ -0,0 +1,296 @@
+@use '../epg-theme' as *;
+
+:host {
+ display: flex;
+ height: 100%;
+ box-sizing: border-box;
+ border-bottom: 1px solid $line;
+ background: var(--app-content-bg);
+
+ &:hover {
+ background: $surface-3;
+ }
+ &.is-active {
+ background: var(--app-selection-surface);
+ }
+ &.is-focused {
+ box-shadow: inset 0 0 0 2px $accent-blue;
+ }
+}
+
+.epg-guide-row__channel {
+ position: sticky;
+ left: 0;
+ z-index: 2;
+ flex: 0 0 var(--epg-guide-channel-col, 232px);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 0 12px 0 14px;
+ box-sizing: border-box;
+ background: inherit;
+ border-right: 1px solid $line-strong;
+ cursor: pointer;
+ color: $text-primary;
+
+ :host(.is-active) &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 8px;
+ bottom: 8px;
+ width: 3px;
+ border-radius: 0 3px 3px 0;
+ background: $accent-blue;
+ }
+}
+
+.epg-guide-row__num {
+ width: 22px;
+ text-align: right;
+ font: 11px $font-mono;
+ color: $text-tertiary;
+ font-variant-numeric: tabular-nums;
+}
+
+.epg-guide-row__logo {
+ flex: 0 0 auto;
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ overflow: hidden;
+ display: grid;
+ place-items: center;
+ background: $surface-1;
+ border: 1px solid $line;
+ color: $text-tertiary;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ }
+ :host(.is-compact) & {
+ width: 26px;
+ height: 26px;
+ border-radius: 6px;
+ }
+}
+
+.epg-guide-row__name {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ b {
+ font-weight: 500;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ small {
+ font-size: 10.5px;
+ color: $text-tertiary;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ :host(.is-active) & small {
+ color: $accent-text;
+ }
+}
+
+.epg-guide-row__playing {
+ color: $accent-blue;
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+}
+
+.epg-guide-row__lane {
+ position: relative;
+ flex: 0 0 auto;
+ height: 100%;
+}
+
+.epg-guide-row__empty {
+ position: absolute;
+ left: 14px;
+ top: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ font-style: italic;
+ color: $text-tertiary;
+}
+
+.epg-guide-row__skeleton {
+ position: absolute;
+ top: 9px;
+ bottom: 9px;
+ border-radius: 8px;
+ background: $surface-2;
+ opacity: 0.6;
+}
+
+.epg-guide-row__block {
+ position: absolute;
+ top: 7px;
+ bottom: 7px;
+ box-sizing: border-box;
+ border-radius: 8px;
+ padding: 6px 10px;
+ border: 1px solid $line-strong;
+ background: $surface-1;
+ color: $text-primary;
+ cursor: pointer;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+ min-width: 0;
+ transition:
+ border-color 120ms,
+ transform 120ms;
+
+ &:hover {
+ border-color: $accent-blue;
+ transform: translateY(-1px);
+ z-index: 3;
+ }
+ &.is-focused {
+ outline: 2px solid $accent-blue;
+ outline-offset: 1px;
+ z-index: 3;
+ }
+ &.is-past {
+ background: transparent;
+ border-color: $line;
+
+ .epg-guide-row__title {
+ color: $text-secondary;
+ font-weight: 400;
+ }
+ }
+ &.is-now {
+ background: var(--app-selection-surface);
+ border-color: $accent-blue;
+
+ .epg-guide-row__title {
+ font-weight: 600;
+ }
+ }
+ &.is-playing {
+ background: var(--app-selection-surface-strong);
+ }
+ &[data-tier='narrow'] {
+ padding: 6px 8px;
+
+ .epg-guide-row__time,
+ .epg-guide-row__tag {
+ display: none;
+ }
+ }
+ &[data-tier='micro'] {
+ padding: 0;
+ border-radius: 4px;
+
+ .epg-guide-row__time,
+ .epg-guide-row__title,
+ .epg-guide-row__tag {
+ display: none;
+ }
+ }
+ :host(.is-compact) & {
+ top: 5px;
+ bottom: 5px;
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ border-radius: 6px;
+ padding: 0 9px;
+ }
+}
+
+.epg-guide-row__fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ background: color-mix(in srgb, $accent-blue 14%, transparent);
+ pointer-events: none;
+}
+
+.epg-guide-row__time {
+ position: relative;
+ font: 10.5px $font-mono;
+ color: $text-secondary;
+ white-space: nowrap;
+ font-variant-numeric: tabular-nums;
+}
+
+.epg-guide-row__title {
+ position: relative;
+ font-size: 12.5px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.epg-guide-row__tag {
+ position: absolute;
+ right: 8px;
+ top: 7px;
+ font: 600 9px $font-mono;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: $accent-text;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: $accent-live;
+ margin-right: 4px;
+ vertical-align: 1px;
+ }
+ :host(.is-compact) & {
+ position: static;
+ margin-left: auto;
+ }
+}
+
+.epg-guide-row__watch {
+ position: absolute;
+ right: 6px;
+ bottom: 5px;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: $text-secondary;
+ cursor: pointer;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ }
+ &:hover {
+ color: $accent-text;
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts
new file mode 100644
index 000000000..2127073a6
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts
@@ -0,0 +1,103 @@
+import { DatePipe } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ output,
+} from '@angular/core';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { TranslatePipe } from '@ngx-translate/core';
+import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
+import {
+ buildGuideRowBlocks,
+ EPG_GUIDE_ZOOM_DEFAULT,
+ EpgGuideDayAxis,
+ EpgGuideDensity,
+ guideTrackWidthPx,
+} from './epg-guide-layout.util';
+import { EpgGuideRowStatus } from './epg-guide-programs.service';
+import { EpgGuideChannel } from './epg-guide-source';
+
+/**
+ * One channel row: the sticky channel cell and the lane with positioned
+ * programme cards. Layout is recomputed only when its own inputs change, so a
+ * 2000-row guide re-lays-out one row per programme batch, not the grid.
+ */
+@Component({
+ selector: 'app-epg-guide-row',
+ imports: [DatePipe, MatIconModule, MatTooltipModule, TranslatePipe],
+ templateUrl: './epg-guide-row.component.html',
+ styleUrl: './epg-guide-row.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ class: 'epg-guide-row',
+ '[class.is-active]': 'active()',
+ '[class.is-focused]': 'rowFocused()',
+ '[class.is-compact]': 'density() === "compact"',
+ },
+})
+export class EpgGuideRowComponent {
+ readonly channel = input.required();
+ readonly programs = input([]);
+ readonly status = input('idle');
+ readonly axis = input.required();
+ readonly hourWidthPx = input(EPG_GUIDE_ZOOM_DEFAULT);
+ readonly nowMs = input(0);
+ readonly offsetMinutes = input(0);
+ readonly catchUpAvailable = input(false);
+ readonly active = input(false);
+ readonly density = input('comfortable');
+ readonly rowFocused = input(false);
+ readonly focusedBlock = input(null);
+
+ /** Single click on the channel cell or the on-now card. */
+ readonly channelActivated = output();
+ /** Double click: activate and close the guide. */
+ readonly channelCommitted = output();
+ readonly detailsRequested = output();
+ readonly watchRequested = output();
+
+ readonly blocks = computed(() =>
+ buildGuideRowBlocks(this.programs(), {
+ axis: this.axis(),
+ hourWidthPx: this.hourWidthPx(),
+ nowMs: this.nowMs(),
+ offsetMinutes: this.offsetMinutes(),
+ catchUpAvailable: this.catchUpAvailable(),
+ })
+ );
+ readonly nowBlock = computed(
+ () => this.blocks().find((item) => item.block.when === 'now') ?? null
+ );
+ readonly trackWidthPx = computed(() =>
+ guideTrackWidthPx(this.axis(), this.hourWidthPx())
+ );
+ readonly showEmpty = computed(
+ () =>
+ this.status() === 'none' ||
+ (this.status() === 'loaded' && this.blocks().length === 0)
+ );
+ readonly isLoading = computed(() => this.status() === 'loading');
+
+ onBlockClick(item: TimelineRenderBlock): void {
+ if (item.block.when === 'now') {
+ this.channelActivated.emit();
+ } else {
+ this.detailsRequested.emit(item);
+ }
+ }
+
+ onBlockDoubleClick(item: TimelineRenderBlock): void {
+ if (item.block.when === 'now') {
+ this.channelCommitted.emit();
+ }
+ }
+
+ onWatchClick(event: Event, item: TimelineRenderBlock): void {
+ event.stopPropagation();
+ this.watchRequested.emit(item);
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts
new file mode 100644
index 000000000..a7691e128
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts
@@ -0,0 +1,65 @@
+/**
+ * Pure scroll geometry for the guide viewport, extracted from the shell so the
+ * component stays small and the maths can be unit-tested without a DOM.
+ */
+
+/** Horizontal padding kept to the left of a revealed programme block. */
+const REVEAL_PADDING_PX = 40;
+
+/** Puts the now-line a third into the visible lane, never past the start. */
+export function guideNowScrollLeft(
+ clientWidth: number,
+ nowLeftPx: number,
+ channelColumnPx: number
+): number {
+ const visibleTrack = Math.max(0, clientWidth - channelColumnPx);
+ return Math.max(0, nowLeftPx - visibleTrack / 3);
+}
+
+/** True when the row is (partly) outside the vertical viewport. */
+export function guideRowNeedsReveal(
+ rowIndex: number,
+ rowHeightPx: number,
+ scrollTop: number,
+ clientHeight: number
+): boolean {
+ const rowTop = rowIndex * rowHeightPx;
+ return (
+ rowTop < scrollTop || rowTop + rowHeightPx > scrollTop + clientHeight
+ );
+}
+
+/**
+ * Target scrollLeft that brings a block into the lane, or `null` when it is
+ * already fully visible.
+ */
+export function guideBlockRevealScrollLeft(
+ block: { readonly leftPx: number; readonly widthPx: number },
+ scrollLeft: number,
+ clientWidth: number,
+ channelColumnPx: number
+): number | null {
+ const visibleWidth = Math.max(0, clientWidth - channelColumnPx);
+ const fitsLeft = block.leftPx >= scrollLeft;
+ const fitsRight = block.leftPx + block.widthPx <= scrollLeft + visibleWidth;
+ if (fitsLeft && fitsRight) {
+ return null;
+ }
+ return Math.max(0, block.leftPx - REVEAL_PADDING_PX);
+}
+
+/**
+ * `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in
+ * unit tests), so fall back to assigning `scrollLeft` directly.
+ */
+export function scrollElementLeft(
+ element: HTMLElement,
+ left: number,
+ animate: boolean
+): void {
+ if (typeof element.scrollTo === 'function') {
+ element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
+ return;
+ }
+ element.scrollLeft = left;
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-search.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-search.controller.ts
new file mode 100644
index 000000000..c4e85aaf3
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-search.controller.ts
@@ -0,0 +1,48 @@
+import { signal } from '@angular/core';
+import { EpgGuideSearchHit } from './epg-guide-source';
+
+const SEARCH_DEBOUNCE_MS = 300;
+const SEARCH_MAX_RESULTS = 20;
+const SEARCH_MIN_LENGTH = 2;
+
+/**
+ * Debounced programme search for the guide toolbar. The host's
+ * `searchPrograms` is optional, so `enabled` decides whether the field is
+ * rendered at all; a response is dropped when the query moved on while it was
+ * in flight, and a failing search yields no results rather than an error.
+ */
+export class EpgGuideSearchController {
+ readonly query = signal('');
+ readonly results = signal([]);
+ readonly enabled: boolean;
+
+ private timer?: number;
+
+ constructor(
+ private readonly search?: (
+ query: string
+ ) => Promise
+ ) {
+ this.enabled = typeof search === 'function';
+ }
+
+ setQuery(query: string): void {
+ this.query.set(query);
+ window.clearTimeout(this.timer);
+ const term = query.trim();
+ if (term.length < SEARCH_MIN_LENGTH || !this.search) {
+ this.results.set([]);
+ return;
+ }
+ this.timer = window.setTimeout(async () => {
+ const hits = await this.search?.(term).catch(() => []);
+ if (this.query() === query) {
+ this.results.set((hits ?? []).slice(0, SEARCH_MAX_RESULTS));
+ }
+ }, SEARCH_DEBOUNCE_MS);
+ }
+
+ destroy(): void {
+ window.clearTimeout(this.timer);
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html
new file mode 100644
index 000000000..ef3aa14f5
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html
@@ -0,0 +1,148 @@
+
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss
new file mode 100644
index 000000000..f4f39476a
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss
@@ -0,0 +1,238 @@
+@use '../epg-theme' as *;
+
+:host {
+ display: block;
+}
+
+.guide-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 52px;
+ padding: 0 18px;
+ border-bottom: 1px solid $line;
+ background: var(--app-content-bg);
+ color: $text-primary;
+
+ button,
+ label,
+ input {
+ font: inherit;
+ }
+}
+
+.guide-toolbar__day {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid $line-strong;
+ border-radius: 8px;
+ height: 30px;
+ overflow: hidden;
+
+ button {
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ }
+}
+
+.guide-toolbar__date {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 12px;
+ font-weight: 500;
+ white-space: nowrap;
+ border-left: 1px solid $line-strong;
+ border-right: 1px solid $line-strong;
+ height: 100%;
+
+ small {
+ font: 10px $font-mono;
+ letter-spacing: 0.1em;
+ color: $text-tertiary;
+ }
+}
+
+.guide-toolbar__now,
+.guide-toolbar__scope {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 12px;
+ border-radius: 999px;
+ border: 1px solid $line-strong;
+ background: transparent;
+ color: $text-primary;
+ font-size: 12.5px;
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+
+ &:hover {
+ background: $surface-2;
+ }
+ &:focus-visible {
+ outline: 2px solid $accent-blue;
+ outline-offset: 1px;
+ }
+}
+
+.guide-toolbar__now {
+ border-color: $accent-blue;
+ color: $accent-text;
+ background: var(--app-selection-surface);
+
+ &::before {
+ content: '';
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: $accent-live;
+ box-shadow: 0 0 0 3px color-mix(in srgb, $accent-live 20%, transparent);
+ }
+}
+
+.guide-toolbar__scope mat-icon {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ margin-right: -6px;
+}
+
+.guide-toolbar__toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12.5px;
+ cursor: pointer;
+ white-space: nowrap;
+
+ input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+ i {
+ width: 30px;
+ height: 16px;
+ border-radius: 99px;
+ background: $surface-3;
+ position: relative;
+ transition: background 120ms;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: $text-tertiary;
+ transition:
+ left 120ms,
+ background 120ms;
+ }
+ }
+ &.is-on i {
+ background: $accent-blue;
+
+ &::after {
+ left: 16px;
+ background: var(--app-content-bg);
+ }
+ }
+ &:has(input:disabled) {
+ opacity: 0.5;
+ cursor: default;
+ }
+ &:has(input:focus-visible) i {
+ outline: 2px solid $accent-blue;
+ outline-offset: 2px;
+ }
+}
+
+.guide-toolbar__zoom {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: $text-secondary;
+
+ input[type='range'] {
+ width: 110px;
+ accent-color: $accent-blue;
+ }
+}
+
+.guide-toolbar__spacer {
+ flex: 1;
+ min-width: 8px;
+}
+
+.guide-toolbar__input {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 4px 0 10px;
+ width: 190px;
+ border: 1px solid $line-strong;
+ border-radius: 8px;
+ background: $surface-1;
+ color: $text-tertiary;
+
+ mat-icon {
+ width: 16px;
+ height: 16px;
+ font-size: 16px;
+ }
+ input {
+ flex: 1;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: $text-primary;
+ font-size: 12px;
+ outline: none;
+
+ &::placeholder {
+ color: $text-tertiary;
+ }
+ }
+ button {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+
+ mat-icon {
+ font-size: 14px;
+ }
+ }
+ &:focus-within {
+ border-color: $accent-blue;
+ }
+}
+
+.guide-toolbar__input--search {
+ width: 230px;
+}
+
+.guide-toolbar__count {
+ font: 10.5px $font-mono;
+ color: $text-tertiary;
+ white-space: nowrap;
+}
+
+// CDK menus render outside the component, so the scope menu needs a piercing
+// rule rather than component-scoped styling.
+::ng-deep .guide-toolbar__scope-menu {
+ max-height: 60vh;
+
+ .is-selected {
+ color: var(--app-selection-color);
+ font-weight: 600;
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts
new file mode 100644
index 000000000..3739f3d83
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts
@@ -0,0 +1,133 @@
+import { DatePipe } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+ output,
+} from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { TranslatePipe } from '@ngx-translate/core';
+import { EpgDateNavigationDirection } from '../epg-date';
+import {
+ EPG_GUIDE_ZOOM_MAX,
+ EPG_GUIDE_ZOOM_MIN,
+ EPG_GUIDE_ZOOM_STEP,
+ EpgGuideDensity,
+} from './epg-guide-layout.util';
+import { EpgGuideScope } from './epg-guide-source';
+
+/**
+ * Guide toolbar: day stepper, "Now", scope menu, "Only with EPG", density,
+ * zoom, the channel filter and (when the host supports it) programme search.
+ * Purely presentational — every control reports through an output and the
+ * guide shell owns the state.
+ */
+@Component({
+ selector: 'app-epg-guide-toolbar',
+ imports: [
+ DatePipe,
+ MatButtonModule,
+ MatIconModule,
+ MatMenuModule,
+ MatTooltipModule,
+ TranslatePipe,
+ ],
+ templateUrl: './epg-guide-toolbar.component.html',
+ styleUrl: './epg-guide-toolbar.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class EpgGuideToolbarComponent {
+ readonly dayMs = input.required();
+ readonly isToday = input(false);
+ readonly currentLocale = input('en');
+ readonly scopes = input([]);
+ readonly scopeId = input('');
+ readonly onlyWithEpg = input(false);
+ readonly coverageLoaded = input(false);
+ readonly density = input('comfortable');
+ readonly zoom = input(EPG_GUIDE_ZOOM_MIN);
+ readonly filter = input('');
+ readonly searchEnabled = input(false);
+ readonly searchQuery = input('');
+ readonly shownCount = input(0);
+ readonly totalCount = input(0);
+
+ readonly stepDay = output();
+ readonly jumpNow = output();
+ readonly scopeChange = output();
+ readonly onlyWithEpgChange = output();
+ readonly densityChange = output();
+ readonly zoomChange = output();
+ readonly filterChange = output();
+ readonly searchQueryChange = output();
+
+ readonly zoomMin = EPG_GUIDE_ZOOM_MIN;
+ readonly zoomMax = EPG_GUIDE_ZOOM_MAX;
+ readonly zoomStep = EPG_GUIDE_ZOOM_STEP;
+
+ readonly scopeLabel = computed(
+ () =>
+ this.scopes().find((scope) => scope.id === this.scopeId())?.label ??
+ ''
+ );
+ readonly densityIcon = computed(() =>
+ this.density() === 'comfortable' ? 'density_medium' : 'density_small'
+ );
+ readonly densityLabelKey = computed(() =>
+ this.density() === 'comfortable'
+ ? 'EPG.GUIDE.DENSITY_COMPACT'
+ : 'EPG.GUIDE.DENSITY_COMFORTABLE'
+ );
+
+ toggleDensity(): void {
+ this.densityChange.emit(
+ this.density() === 'comfortable' ? 'compact' : 'comfortable'
+ );
+ }
+
+ onZoomInput(event: Event): void {
+ this.zoomChange.emit((event.target as HTMLInputElement).valueAsNumber);
+ }
+
+ onFilterInput(event: Event): void {
+ this.filterChange.emit((event.target as HTMLInputElement).value);
+ }
+
+ onSearchInput(event: Event): void {
+ this.searchQueryChange.emit((event.target as HTMLInputElement).value);
+ }
+
+ /**
+ * The guide's keyboard controller ignores keys typed into inputs, so Esc
+ * is handled here: it first clears a non-empty field, and only blurs an
+ * already-empty one so the next Esc reaches the guide and closes it.
+ */
+ onFilterEscape(event: Event): void {
+ this.onEscape(event, this.filter(), (value) =>
+ this.filterChange.emit(value)
+ );
+ }
+
+ onSearchEscape(event: Event): void {
+ this.onEscape(event, this.searchQuery(), (value) =>
+ this.searchQueryChange.emit(value)
+ );
+ }
+
+ private onEscape(
+ event: Event,
+ value: string,
+ clear: (value: string) => void
+ ): void {
+ if (value.length > 0) {
+ event.stopPropagation();
+ clear('');
+ return;
+ }
+ (event.target as HTMLElement | null)?.blur();
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts
new file mode 100644
index 000000000..6c0d5a722
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts
@@ -0,0 +1,129 @@
+import { ListRange } from '@angular/cdk/collections';
+import { DestroyRef } from '@angular/core';
+import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
+import { EpgGuideFocus } from './epg-guide-keyboard.controller';
+import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
+import {
+ guideBlockRevealScrollLeft,
+ guideNowScrollLeft,
+ guideRowNeedsReveal,
+ scrollElementLeft,
+} from './epg-guide-scroll.util';
+import { EpgGuideChannel } from './epg-guide-source';
+
+/** Rows kept above the keyboard focus when it is scrolled into view. */
+const FOCUS_ROW_MARGIN = 2;
+/** Rows kept above the playing channel when the guide jumps to now. */
+const ACTIVE_ROW_MARGIN = 3;
+/** Rows requested before the viewport has reported a rendered range. */
+const INITIAL_LOAD_ROWS = 30;
+
+export interface EpgGuideViewportHost {
+ viewport(): CdkVirtualScrollViewport | undefined;
+ rows(): readonly EpgGuideChannel[];
+ rowHeightPx(): number;
+ channelColumnPx(): number;
+ blocksFor(row: number): TimelineRenderBlock[];
+ activeRow(): number;
+ ensureLoaded(channels: readonly EpgGuideChannel[]): void;
+}
+
+/**
+ * Scrolling and lazy loading for the guide's virtual viewport: which rows the
+ * programme cache is asked about, where "now" and the keyboard focus are
+ * scrolled to. Split out of the shell component, which owns state and
+ * rendering only.
+ */
+export class EpgGuideViewportController {
+ private renderedRange: ListRange | null = null;
+
+ constructor(private readonly host: EpgGuideViewportHost) {}
+
+ /** Reload as the viewport renders new rows, until the host is destroyed. */
+ watch(viewport: CdkVirtualScrollViewport, destroyRef: DestroyRef): void {
+ viewport.renderedRangeStream
+ .pipe(takeUntilDestroyed(destroyRef))
+ .subscribe((range) => {
+ this.renderedRange = range;
+ this.loadRenderedRange();
+ });
+ }
+
+ /**
+ * Request programmes for the rendered rows plus a buffer. Before the
+ * viewport has measured itself (it reports nothing in a zero-sized host,
+ * e.g. jsdom) the first screenful is requested instead, so the guide is
+ * never blank waiting for a range that will not arrive.
+ */
+ loadRenderedRange(): void {
+ const rows = this.host.rows();
+ const range = this.renderedRange ?? {
+ start: 0,
+ end: Math.min(rows.length, INITIAL_LOAD_ROWS),
+ };
+ const start = Math.max(0, range.start - EPG_GUIDE_ROW_BUFFER);
+ const end = Math.min(rows.length, range.end + EPG_GUIDE_ROW_BUFFER);
+ this.host.ensureLoaded(rows.slice(start, end));
+ }
+
+ /** Put the now-line into view, and the playing channel's row with it. */
+ scrollToNow(nowLeftPx: number | null, animate: boolean): void {
+ const viewport = this.host.viewport();
+ if (!viewport || nowLeftPx === null) {
+ return;
+ }
+ const element = viewport.elementRef.nativeElement;
+ scrollElementLeft(
+ element,
+ guideNowScrollLeft(
+ element.clientWidth,
+ nowLeftPx,
+ this.host.channelColumnPx()
+ ),
+ animate
+ );
+ const activeRow = this.host.activeRow();
+ if (activeRow >= 0) {
+ viewport.scrollToIndex(
+ Math.max(0, activeRow - ACTIVE_ROW_MARGIN),
+ animate ? 'smooth' : 'auto'
+ );
+ }
+ }
+
+ /** Keep the keyboard focus target inside the viewport, both axes. */
+ revealFocus(focused: EpgGuideFocus | null): void {
+ const viewport = this.host.viewport();
+ if (!focused || !viewport) {
+ return;
+ }
+ const element = viewport.elementRef.nativeElement;
+ if (
+ guideRowNeedsReveal(
+ focused.row,
+ this.host.rowHeightPx(),
+ viewport.measureScrollOffset('top'),
+ element.clientHeight
+ )
+ ) {
+ viewport.scrollToIndex(Math.max(0, focused.row - FOCUS_ROW_MARGIN));
+ }
+ if (focused.block === null) {
+ return;
+ }
+ const block = this.host.blocksFor(focused.row)[focused.block];
+ const left =
+ block &&
+ guideBlockRevealScrollLeft(
+ block,
+ element.scrollLeft,
+ element.clientWidth,
+ this.host.channelColumnPx()
+ );
+ if (typeof left === 'number') {
+ scrollElementLeft(element, left, true);
+ }
+ }
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html
new file mode 100644
index 000000000..84166634c
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html
@@ -0,0 +1,164 @@
+
+
+@if (searchEnabled && searchQuery().trim().length >= 2) {
+
+ @for (
+ hit of searchResults();
+ track hit.program.channel + hit.program.start
+ ) {
+
+ } @empty {
+
+ {{ 'EPG.GUIDE.NO_RESULTS' | translate }}
+
+ }
+
+}
+
+
+
+
+ {{ 'EPG.GUIDE.CHANNEL' | translate }}
+
+
+
+ @for (tick of ticks(); track tick.ms) {
+
+ {{ tick.ms | date: 'HH:mm' }}
+
+ }
+ @if (nowLeftPx(); as nowX) {
+
+ {{ nowMs() | date: 'HH:mm' }}
+
+ }
+
+
+
+
+
+
+ @if (rows().length === 0) {
+
+
search_off
+
+ {{
+ 'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() }
+ }}
+
+ @if (filter()) {
+
+ }
+
+ }
+
+
+ @if (nowLeftPx(); as nowX) {
+
+ }
+
+
+
+ ↑↓{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}
+ ←→{{ 'EPG.GUIDE.KEYS.PROGRAMME' | translate }}
+ Enter{{ 'EPG.GUIDE.KEYS.PLAY' | translate }}
+ I{{ 'EPG.GUIDE.KEYS.DETAILS' | translate }}
+ N{{ 'EPG.GUIDE.KEYS.NOW' | translate }}
+ PgUpPgDn{{ 'EPG.GUIDE.KEYS.DAY' | translate }}
+ Esc{{ 'EPG.GUIDE.KEYS.CLOSE' | translate }}
+
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss
new file mode 100644
index 000000000..1b9d65dbe
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss
@@ -0,0 +1,199 @@
+@use '../epg-theme' as *;
+
+:host {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ background: var(--app-content-bg);
+ color: $text-primary;
+ position: relative;
+}
+
+.epg-guide__grid {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.epg-guide__ruler {
+ display: flex;
+ height: 34px;
+ flex: 0 0 auto;
+ border-bottom: 1px solid $line-strong;
+ background: var(--app-content-bg);
+ z-index: 4;
+}
+
+.epg-guide__corner {
+ flex: 0 0 var(--epg-guide-channel-col);
+ display: flex;
+ align-items: center;
+ padding: 0 14px;
+ font: 500 10px $font-mono;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: $text-tertiary;
+ border-right: 1px solid $line-strong;
+ box-sizing: border-box;
+}
+
+.epg-guide__ruler-clip {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+.epg-guide__ruler-track {
+ position: relative;
+ height: 100%;
+ will-change: transform;
+}
+
+.epg-guide__tick {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ padding-left: 8px;
+ display: flex;
+ align-items: center;
+ border-left: 1px solid $line-strong;
+ font: 11px $font-mono;
+ color: $text-secondary;
+ font-variant-numeric: tabular-nums;
+
+ &.is-half {
+ border-left-color: $line;
+ color: $text-tertiary;
+ font-size: 10px;
+ }
+}
+
+.epg-guide__now-badge {
+ position: absolute;
+ top: 6px;
+ transform: translateX(-50%);
+ background: $accent-live;
+ color: #1a0d0d;
+ font: 600 10.5px $font-mono;
+ padding: 3px 7px;
+ border-radius: 5px;
+ z-index: 5;
+ font-variant-numeric: tabular-nums;
+}
+
+.epg-guide__viewport {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+
+ ::ng-deep .cdk-virtual-scroll-content-wrapper {
+ min-width: 100%;
+ }
+}
+
+.epg-guide__now-line {
+ position: absolute;
+ top: 34px;
+ bottom: 0;
+ width: 2px;
+ background: $accent-live;
+ box-shadow: 0 0 10px color-mix(in srgb, $accent-live 60%, transparent);
+ pointer-events: none;
+ z-index: 3;
+}
+
+.epg-guide__no-rows {
+ position: sticky;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 48px 16px;
+ color: $text-tertiary;
+
+ mat-icon {
+ font-size: 32px;
+ width: 32px;
+ height: 32px;
+ }
+}
+
+.epg-guide__keys {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ height: 34px;
+ padding: 0 18px;
+ border-top: 1px solid $line;
+ font-size: 11px;
+ color: $text-tertiary;
+ flex: 0 0 auto;
+
+ kbd {
+ font: 10px $font-mono;
+ color: $text-secondary;
+ border: 1px solid $line-strong;
+ border-radius: 4px;
+ padding: 1px 5px;
+ margin-right: 5px;
+ }
+}
+
+.epg-guide__search-results {
+ position: absolute;
+ top: 52px;
+ right: 18px;
+ width: 420px;
+ max-height: 50vh;
+ overflow: auto;
+ z-index: 20;
+ background: $surface-1;
+ border: 1px solid $line-strong;
+ border-radius: 10px;
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
+ padding: 6px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.epg-guide__search-result {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ text-align: left;
+ padding: 8px 10px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: $text-primary;
+ cursor: pointer;
+ font: inherit;
+
+ &:hover,
+ &:focus-visible {
+ background: $surface-3;
+ outline: none;
+ }
+}
+
+.epg-guide__search-title {
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.epg-guide__search-meta {
+ font: 10.5px $font-mono;
+ color: $text-tertiary;
+}
+
+.epg-guide__search-empty {
+ padding: 14px;
+ text-align: center;
+ color: $text-tertiary;
+ font-size: 12.5px;
+}
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts
new file mode 100644
index 000000000..9cd5f6827
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts
@@ -0,0 +1,245 @@
+import { signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatDialog } from '@angular/material/dialog';
+import { By } from '@angular/platform-browser';
+import { SettingsStore } from '@iptvnator/services';
+import { EpgProgram } from '@iptvnator/shared/interfaces';
+import { TranslateService } from '@ngx-translate/core';
+import { BehaviorSubject, of } from 'rxjs';
+import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
+import { EpgGuideComponent } from './epg-guide.component';
+import {
+ EPG_GUIDE_DENSITY_KEY,
+ EPG_GUIDE_ONLY_WITH_EPG_KEY,
+} from './epg-guide-preferences';
+import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
+import {
+ EPG_GUIDE_SOURCE,
+ EpgGuideChannel,
+ EpgGuideSource,
+} from './epg-guide-source';
+
+function channel(
+ id: string,
+ epgKey: string | null = id,
+ number = 1
+): EpgGuideChannel {
+ return { id, number, name: `Channel ${id}`, logoUrl: null, epgKey };
+}
+
+function nowProgram(channelId: string): EpgProgram {
+ const start = new Date(Date.now() - 10 * 60_000);
+ const stop = new Date(Date.now() + 20 * 60_000);
+ return {
+ start: start.toISOString(),
+ stop: stop.toISOString(),
+ channel: channelId,
+ title: `${channelId} now`,
+ desc: null,
+ category: null,
+ };
+}
+
+async function settle(fixture: ComponentFixture): Promise {
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+}
+
+describe('EpgGuideComponent', () => {
+ let fixture: ComponentFixture;
+ let component: EpgGuideComponent;
+ const channels = signal([]);
+ const activeChannelId = signal(null);
+ const activate = jest.fn();
+ const setScope = jest.fn();
+ const dialogOpen = jest.fn(() => of(undefined));
+
+ beforeEach(() => {
+ localStorage.clear();
+ activate.mockReset();
+ setScope.mockReset();
+ dialogOpen.mockClear();
+ channels.set([
+ channel('a', 'a', 1),
+ channel('b', null, 2),
+ channel('c', 'c', 3),
+ ]);
+ activeChannelId.set('a');
+ const source: EpgGuideSource = {
+ channels,
+ scopes: signal([{ id: 'all', label: 'All channels', kind: 'all' }]),
+ scopeId: signal('all'),
+ setScope,
+ loadPrograms: async (window) =>
+ new Map(
+ window.channels.map((item) => [
+ item.id,
+ item.id === 'a' ? [nowProgram('a')] : [],
+ ])
+ ),
+ loadCoverage: async () => new Set(['a']),
+ activeChannelId,
+ activate,
+ };
+ TestBed.configureTestingModule({
+ imports: [EpgGuideComponent],
+ providers: [
+ { provide: EPG_GUIDE_SOURCE, useValue: source },
+ {
+ provide: EpgProgrammeDialogService,
+ useValue: { open: dialogOpen },
+ },
+ { provide: MatDialog, useValue: { openDialogs: [] } },
+ {
+ provide: SettingsStore,
+ useValue: { resolvedEpgOffsetMinutes: signal(0) },
+ },
+ {
+ provide: TranslateService,
+ useValue: {
+ currentLang: 'en',
+ defaultLang: 'en',
+ onLangChange: new BehaviorSubject(null),
+ onTranslationChange: new BehaviorSubject(null),
+ onDefaultLangChange: new BehaviorSubject(null),
+ instant: (key: string) => key,
+ get: (key: string) => of(key),
+ stream: (key: string) => of(key),
+ },
+ },
+ ],
+ });
+ fixture = TestBed.createComponent(EpgGuideComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('lists the scope channels in order and marks the active one', async () => {
+ await settle(fixture);
+ expect(component.rows().map((row) => row.id)).toEqual(['a', 'b', 'c']);
+ expect(component.activeRowIndex()).toBe(0);
+ });
+
+ it('emits activate on click and activate + close on double click / Enter', async () => {
+ await settle(fixture);
+ const close = jest.fn();
+ component.close.subscribe(close);
+ component.activateRow(component.rows()[2]);
+ expect(activate).toHaveBeenCalledWith('c');
+ expect(close).not.toHaveBeenCalled();
+ component.commitRow(component.rows()[2]);
+ expect(close).toHaveBeenCalledTimes(1);
+ component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' }));
+ expect(activate).toHaveBeenLastCalledWith('a');
+ expect(close).toHaveBeenCalledTimes(2);
+ });
+
+ it('hides only uncovered rows when "Only with EPG" is on, after coverage arrived', async () => {
+ await settle(fixture);
+ component.setOnlyWithEpg(true);
+ await settle(fixture);
+ expect(component.rows().map((row) => row.id)).toEqual(['a']);
+ expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1');
+ });
+
+ it('filters by channel name and clears', async () => {
+ await settle(fixture);
+ component.setFilter('channel c');
+ expect(component.rows().map((row) => row.id)).toEqual(['c']);
+ component.setFilter('');
+ expect(component.rows()).toHaveLength(3);
+ });
+
+ it('persists density and zoom and restores them', async () => {
+ await settle(fixture);
+ component.setDensity('compact');
+ component.setZoom(9_999);
+ await settle(fixture);
+ expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact');
+ expect(component.zoom()).toBe(480);
+ expect(component.rowHeightPx()).toBe(44);
+ });
+
+ it('opens the programme dialog for a non-live card and activates on "live"', async () => {
+ await settle(fixture);
+ dialogOpen.mockReturnValueOnce(of('live'));
+ const row = component.rows()[0];
+ const item = {
+ kind: 'block' as const,
+ key: 'k',
+ block: {
+ program: nowProgram('a'),
+ key: 'k',
+ startMs: 0,
+ stopMs: 1,
+ when: 'past' as const,
+ offsetMin: 0,
+ durationMin: 1,
+ },
+ leftPx: 0,
+ widthPx: 10,
+ tier: 'wide' as const,
+ nowFillPercent: 0,
+ canCatchUp: false,
+ };
+ component.openDetails(row, item);
+ expect(dialogOpen).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channelName: 'Channel a',
+ primaryAction: null,
+ archiveUnavailableNote: true,
+ })
+ );
+ expect(activate).toHaveBeenCalledWith('a');
+ });
+
+ it('closes on Escape and steps days with PageUp/PageDown', async () => {
+ await settle(fixture);
+ const close = jest.fn();
+ component.close.subscribe(close);
+ const before = component.dayKey();
+ component.onKeydown(new KeyboardEvent('keydown', { key: 'PageDown' }));
+ expect(component.dayKey()).not.toBe(before);
+ component.onKeydown(new KeyboardEvent('keydown', { key: 'Escape' }));
+ expect(close).toHaveBeenCalled();
+ expect(
+ fixture.debugElement.query(By.css('app-epg-guide-toolbar'))
+ ).toBeTruthy();
+ });
+
+ it('clears the channel filter on Escape inside the toolbar field, then lets Escape through', async () => {
+ await settle(fixture);
+ component.setFilter('channel c');
+ await settle(fixture);
+ const toolbar = fixture.debugElement.query(
+ By.directive(EpgGuideToolbarComponent)
+ );
+ const input: HTMLInputElement = toolbar.query(
+ By.css('.guide-toolbar__input input')
+ ).nativeElement;
+ const cleared = jest.fn();
+ toolbar.componentInstance.filterChange.subscribe(cleared);
+ const blur = jest.spyOn(input, 'blur');
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+ expect(cleared).toHaveBeenCalledWith('');
+ expect(blur).not.toHaveBeenCalled();
+
+ component.setFilter('');
+ await settle(fixture);
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ })
+ );
+ expect(blur).toHaveBeenCalled();
+ });
+});
diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts
new file mode 100644
index 000000000..51882f536
--- /dev/null
+++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts
@@ -0,0 +1,378 @@
+import {
+ CdkVirtualScrollViewport,
+ ScrollingModule,
+} from '@angular/cdk/scrolling';
+import { DatePipe } from '@angular/common';
+import {
+ afterNextRender,
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ effect,
+ HostListener,
+ inject,
+ OnDestroy,
+ OnInit,
+ output,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialog } from '@angular/material/dialog';
+import { MatIconModule } from '@angular/material/icon';
+import { normalizeDateLocale } from '@iptvnator/pipes';
+import { SettingsStore } from '@iptvnator/services';
+import { EpgProgram, epgProviderClockMs } from '@iptvnator/shared/interfaces';
+import { TranslatePipe, TranslateService } from '@ngx-translate/core';
+import { startWith } from 'rxjs';
+import {
+ EpgDateNavigationDirection,
+ getTodayEpgDateKey,
+ shiftEpgDateKey,
+} from '../epg-date';
+import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
+import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
+import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
+import { EpgGuideKeyboardController } from './epg-guide-keyboard.controller';
+import {
+ buildGuideDayAxis,
+ buildGuideRowBlocks,
+ buildGuideTicks,
+ EPG_GUIDE_CHANNEL_COLUMN_PX,
+ EPG_GUIDE_ROW_HEIGHT_PX,
+ EpgGuideDensity,
+ guideNowLeftPx,
+ guideTrackWidthPx,
+} from './epg-guide-layout.util';
+import {
+ clampGuideZoom,
+ persistEpgGuidePreferences,
+ restoreEpgGuidePreferences,
+} from './epg-guide-preferences';
+import {
+ EpgGuideProgramsService,
+ EpgGuideRowStatus,
+} from './epg-guide-programs.service';
+import { EpgGuideRowComponent } from './epg-guide-row.component';
+import { EpgGuideSearchController } from './epg-guide-search.controller';
+import {
+ EPG_GUIDE_SOURCE,
+ EpgGuideChannel,
+ EpgGuideSearchHit,
+} from './epg-guide-source';
+import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
+import { EpgGuideViewportController } from './epg-guide-viewport.controller';
+
+/**
+ * Multi-channel programme guide. Reads everything from `EPG_GUIDE_SOURCE`;
+ * owns the selected day, zoom, density, filters and keyboard navigation.
+ * Rows are virtualised; the channel column and the ruler are sticky.
+ */
+@Component({
+ selector: 'app-epg-guide',
+ imports: [
+ DatePipe,
+ MatButtonModule,
+ MatIconModule,
+ ScrollingModule,
+ TranslatePipe,
+ EpgGuideRowComponent,
+ EpgGuideToolbarComponent,
+ ],
+ providers: [EpgGuideProgramsService],
+ templateUrl: './epg-guide.component.html',
+ styleUrl: './epg-guide.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class EpgGuideComponent implements OnInit, OnDestroy {
+ private readonly source = inject(EPG_GUIDE_SOURCE);
+ private readonly programsService = inject(EpgGuideProgramsService);
+ private readonly settingsStore = inject(SettingsStore);
+ private readonly programmeDialog = inject(EpgProgrammeDialogService);
+ private readonly dialog = inject(MatDialog);
+ private readonly translate = inject(TranslateService);
+ private readonly destroyRef = inject(DestroyRef);
+
+ readonly close = output();
+ readonly channelActivated = output();
+
+ readonly viewport = viewChild('viewport');
+
+ private readonly preferences = restoreEpgGuidePreferences();
+ readonly dayKey = signal(getTodayEpgDateKey());
+ readonly zoom = signal(this.preferences.zoom);
+ readonly density = signal(this.preferences.density);
+ readonly onlyWithEpg = signal(this.preferences.onlyWithEpg);
+ readonly filter = signal('');
+ readonly nowMs = signal(Date.now());
+ readonly scrollLeft = signal(0);
+
+ 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 offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
+ readonly scopes = this.source.scopes;
+ readonly scopeId = this.source.scopeId;
+ readonly activeChannelId = this.source.activeChannelId;
+ readonly coverageLoaded = this.programsService.coverageLoaded;
+ private readonly search = new EpgGuideSearchController(
+ this.source.searchPrograms?.bind(this.source)
+ );
+ readonly searchEnabled = this.search.enabled;
+ readonly searchQuery = this.search.query;
+ readonly searchResults = this.search.results;
+ readonly catchUpAvailable = this.source.catchUp !== undefined;
+ readonly channelColumnPx = EPG_GUIDE_CHANNEL_COLUMN_PX;
+
+ readonly axis = computed(() => buildGuideDayAxis(this.dayKey()));
+ readonly isToday = computed(() => {
+ this.nowMs();
+ return this.dayKey() === getTodayEpgDateKey();
+ });
+ readonly ticks = computed(() => buildGuideTicks(this.axis(), this.zoom()));
+ readonly trackWidthPx = computed(() =>
+ guideTrackWidthPx(this.axis(), this.zoom())
+ );
+ readonly nowLeftPx = computed(() =>
+ guideNowLeftPx(this.axis(), this.nowMs(), this.zoom())
+ );
+ readonly rowHeightPx = computed(
+ () => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]
+ );
+ readonly totalCount = computed(() => this.source.channels().length);
+ readonly rows = computed(() => {
+ const needle = this.filter().trim().toLowerCase();
+ const onlyWithEpg = this.onlyWithEpg();
+ return this.source
+ .channels()
+ .filter(
+ (channel) =>
+ (!needle || channel.name.toLowerCase().includes(needle)) &&
+ (!onlyWithEpg || this.programsService.isCovered(channel.id))
+ );
+ });
+ readonly activeRowIndex = computed(() => {
+ const activeId = this.activeChannelId();
+ return activeId === null
+ ? -1
+ : this.rows().findIndex((channel) => channel.id === activeId);
+ });
+
+ private readonly keyboard = new EpgGuideKeyboardController({
+ rowCount: () => this.rows().length,
+ blockCount: (row) => this.blocksFor(row).length,
+ activeRow: () => this.activeRowIndex(),
+ isBlocked: () => this.dialog.openDialogs.length > 0,
+ play: (row) => this.commitRow(this.rows()[row]),
+ details: (row, block) =>
+ this.openDetails(this.rows()[row], this.blocksFor(row)[block]),
+ jumpNow: () => this.jumpNow(),
+ stepDay: (direction) => this.stepDay(direction),
+ close: () => this.close.emit(),
+ });
+ readonly focus = this.keyboard.focus;
+
+ private readonly viewportController = new EpgGuideViewportController({
+ viewport: () => this.viewport(),
+ rows: () => this.rows(),
+ rowHeightPx: () => this.rowHeightPx(),
+ channelColumnPx: () => this.channelColumnPx,
+ blocksFor: (row) => this.blocksFor(row),
+ activeRow: () => this.activeRowIndex(),
+ ensureLoaded: (channels) => this.programsService.ensureLoaded(channels),
+ });
+
+ private minuteTimer?: number;
+
+ constructor() {
+ effect(() => {
+ const axis = this.axis();
+ const offset = this.offsetMinutes();
+ this.programsService.setWindow(
+ epgProviderClockMs(axis.startMs, offset),
+ epgProviderClockMs(axis.endMs, offset)
+ );
+ untracked(() => this.viewportController.loadRenderedRange());
+ });
+ effect(() => {
+ this.rows();
+ untracked(() => this.viewportController.loadRenderedRange());
+ });
+ effect(() =>
+ persistEpgGuidePreferences({
+ density: this.density(),
+ zoom: this.zoom(),
+ onlyWithEpg: this.onlyWithEpg(),
+ })
+ );
+ effect(() => {
+ const viewport = this.viewport();
+ if (!viewport) {
+ return;
+ }
+ untracked(() =>
+ this.viewportController.watch(viewport, this.destroyRef)
+ );
+ });
+ afterNextRender(() => this.jumpNow(false));
+ }
+
+ ngOnInit(): void {
+ this.minuteTimer = window.setInterval(
+ () => this.nowMs.set(Date.now()),
+ 60_000
+ );
+ }
+
+ ngOnDestroy(): void {
+ window.clearInterval(this.minuteTimer);
+ this.search.destroy();
+ }
+
+ @HostListener('document:keydown', ['$event'])
+ onKeydown(event: KeyboardEvent): void {
+ if (this.keyboard.handle(event)) {
+ event.preventDefault();
+ this.viewportController.revealFocus(this.focus());
+ }
+ }
+
+ trackRow(_index: number, channel: EpgGuideChannel): string {
+ return `${channel.number}:${channel.id}`;
+ }
+
+ programsFor(channelId: string): readonly EpgProgram[] {
+ return this.programsService.programsFor(channelId);
+ }
+
+ statusFor(channelId: string): EpgGuideRowStatus {
+ return this.programsService.statusFor(channelId);
+ }
+
+ focusedBlockFor(row: number): number | null {
+ const focused = this.focus();
+ return focused?.row === row ? focused.block : null;
+ }
+
+ onViewportScroll(event: Event): void {
+ this.scrollLeft.set((event.target as HTMLElement).scrollLeft);
+ }
+
+ stepDay(direction: EpgDateNavigationDirection): void {
+ this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction));
+ this.focus.set(null);
+ }
+
+ jumpNow(animate = true): void {
+ this.dayKey.set(getTodayEpgDateKey());
+ this.nowMs.set(Date.now());
+ this.viewportController.scrollToNow(this.nowLeftPx(), animate);
+ }
+
+ setScope(scopeId: string): void {
+ this.source.setScope(scopeId);
+ this.focus.set(null);
+ }
+
+ setOnlyWithEpg(value: boolean): void {
+ this.onlyWithEpg.set(value);
+ }
+
+ setDensity(value: EpgGuideDensity): void {
+ this.density.set(value);
+ }
+
+ setZoom(value: number): void {
+ this.zoom.set(clampGuideZoom(value));
+ }
+
+ setFilter(value: string): void {
+ this.filter.set(value);
+ this.focus.set(null);
+ }
+
+ onSearchQueryChange(query: string): void {
+ this.search.setQuery(query);
+ }
+
+ openSearchResult(hit: EpgGuideSearchHit): void {
+ this.programmeDialog.open({ ...hit.program }).subscribe();
+ }
+
+ activateRow(channel: EpgGuideChannel | undefined): void {
+ if (!channel) {
+ return;
+ }
+ this.source.activate(channel.id);
+ this.channelActivated.emit(channel.id);
+ }
+
+ commitRow(channel: EpgGuideChannel | undefined): void {
+ if (!channel) {
+ return;
+ }
+ this.activateRow(channel);
+ this.close.emit();
+ }
+
+ openDetails(
+ channel: EpgGuideChannel | undefined,
+ item: TimelineRenderBlock | undefined
+ ): void {
+ if (!channel || !item) {
+ return;
+ }
+ const when = item.block.when;
+ this.programmeDialog
+ .open({
+ ...item.block.program,
+ channelName: channel.name,
+ channelLogo: channel.logoUrl,
+ primaryAction:
+ when === 'now'
+ ? 'live'
+ : item.canCatchUp
+ ? 'timeshift'
+ : null,
+ archiveUnavailableNote: when === 'past' && !item.canCatchUp,
+ })
+ .subscribe((result: EpgItemDialogAction | undefined) => {
+ if (result === 'live') {
+ this.activateRow(channel);
+ } else if (result === 'timeshift') {
+ this.source.catchUp?.watch(channel, item.block.program);
+ }
+ });
+ }
+
+ watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void {
+ this.source.catchUp?.watch(channel, item.block.program);
+ }
+
+ private blocksFor(row: number): TimelineRenderBlock[] {
+ const channel = this.rows()[row];
+ if (!channel) {
+ return [];
+ }
+ return buildGuideRowBlocks(
+ this.programsService.programsFor(channel.id),
+ {
+ axis: this.axis(),
+ hourWidthPx: this.zoom(),
+ nowMs: this.nowMs(),
+ offsetMinutes: this.offsetMinutes(),
+ catchUpAvailable: this.catchUpAvailable,
+ }
+ );
+ }
+}