feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 12:53:16 +02:00
1 parent 5f86631786
commit 44e421103e
18 files changed
+2549 -2

No files matched your search

+37 -2
View File
@@ -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",
+5
View File
@@ -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';
@@ -0,0 +1,53 @@
<div class="now-playing" [class.now-playing--collapsed]="collapsed()">
<div class="now-playing__body">
<div class="now-playing__eyebrow">
<span>{{ channelName() }}</span>
@if (isOnNow()) {
<span class="now-playing__on">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
}
</div>
@if (program(); as program) {
<div class="now-playing__title">{{ program.title }}</div>
@if (!collapsed() && program.desc) {
<div class="now-playing__desc">{{ program.desc }}</div>
}
@if (!collapsed() && progress() !== null) {
<div class="now-playing__progress">
<span>{{ startMs() | date: 'HH:mm' }}</span>
<i aria-hidden="true"
><b [style.width.%]="progress()"></b
></i>
<span>{{ stopMs() | date: 'HH:mm' }}</span>
</div>
}
} @else {
<div class="now-playing__title now-playing__title--muted">
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
</div>
}
</div>
<div class="now-playing__actions">
<button
type="button"
mat-stroked-button
(click)="closeRequested.emit()"
>
{{ 'EPG.GUIDE.CLOSE' | translate }} <kbd>Esc</kbd>
</button>
<button
type="button"
mat-button
(click)="collapsedChange.emit(!collapsed())"
[attr.aria-expanded]="!collapsed()"
>
{{
(collapsed()
? 'EPG.GUIDE.EXPAND_PLAYER'
: 'EPG.GUIDE.COLLAPSE_PLAYER'
) | translate
}}
</button>
</div>
</div>
@@ -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;
}
@@ -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<EpgProgram | null>(null);
readonly offsetMinutes = input(0);
readonly collapsed = input(false);
readonly closeRequested = output<void>();
readonly collapsedChange = output<boolean>();
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;
}
}
@@ -0,0 +1,110 @@
<div
class="epg-guide-row__channel"
role="button"
tabindex="-1"
[attr.aria-label]="channel().name"
[attr.aria-pressed]="active()"
(click)="channelActivated.emit()"
(dblclick)="channelCommitted.emit()"
>
<span class="epg-guide-row__num">{{ channel().number }}</span>
<span class="epg-guide-row__logo">
@if (channel().logoUrl; as logo) {
<img [src]="logo" alt="" loading="lazy" />
} @else {
<mat-icon>live_tv</mat-icon>
}
</span>
<span class="epg-guide-row__name">
<b>{{ channel().name }}</b>
@if (density() === 'comfortable') {
<small>
@if (active()) {
&#9654; {{ 'EPG.GUIDE.PLAYING' | translate }}
} @else if (nowBlock(); as now) {
{{ now.block.program.title }}
} @else if (showEmpty()) {
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
}
</small>
}
</span>
@if (active()) {
<mat-icon class="epg-guide-row__playing">equalizer</mat-icon>
}
</div>
<div class="epg-guide-row__lane" [style.width.px]="trackWidthPx()">
@if (showEmpty()) {
<span class="epg-guide-row__empty">{{
'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate
}}</span>
} @else if (isLoading()) {
<span
class="epg-guide-row__skeleton"
style="left: 24px; width: 180px"
></span>
<span
class="epg-guide-row__skeleton"
style="left: 220px; width: 260px"
></span>
<span
class="epg-guide-row__skeleton"
style="left: 500px; width: 200px"
></span>
}
@for (item of blocks(); track item.key; let index = $index) {
<div
class="epg-guide-row__block"
role="button"
tabindex="-1"
[attr.data-tier]="item.tier"
[class.is-past]="item.block.when === 'past'"
[class.is-now]="item.block.when === 'now'"
[class.is-future]="item.block.when === 'future'"
[class.is-playing]="active() && item.block.when === 'now'"
[class.is-focused]="focusedBlock() === index"
[style.left.px]="item.leftPx"
[style.width.px]="item.widthPx"
[matTooltip]="
item.tier === 'micro' || item.tier === 'narrow'
? item.block.program.title
: ''
"
[attr.aria-label]="item.block.program.title"
(click)="onBlockClick(item)"
(dblclick)="onBlockDoubleClick(item)"
>
@if (item.block.when === 'now') {
<span
class="epg-guide-row__fill"
[style.width.%]="item.nowFillPercent"
></span>
}
<span class="epg-guide-row__time">
{{ item.block.startMs | date: 'HH:mm' }}
@if (density() === 'comfortable') {
&ndash; {{ item.block.stopMs | date: 'HH:mm' }}
}
</span>
<span class="epg-guide-row__title">{{
item.block.program.title
}}</span>
@if (item.block.when === 'now') {
<span class="epg-guide-row__tag">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
}
@if (item.canCatchUp && item.block.when === 'past') {
<button
type="button"
class="epg-guide-row__watch"
(click)="onWatchClick($event, item)"
[attr.aria-label]="'EPG.TIMELINE.WATCH' | translate"
>
<mat-icon>replay</mat-icon>
</button>
}
</div>
}
</div>
@@ -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;
}
}
@@ -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<EpgGuideChannel>();
readonly programs = input<readonly EpgProgram[]>([]);
readonly status = input<EpgGuideRowStatus>('idle');
readonly axis = input.required<EpgGuideDayAxis>();
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<EpgGuideDensity>('comfortable');
readonly rowFocused = input(false);
readonly focusedBlock = input<number | null>(null);
/** Single click on the channel cell or the on-now card. */
readonly channelActivated = output<void>();
/** Double click: activate and close the guide. */
readonly channelCommitted = output<void>();
readonly detailsRequested = output<TimelineRenderBlock>();
readonly watchRequested = output<TimelineRenderBlock>();
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);
}
}
@@ -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;
}
@@ -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<EpgGuideSearchHit[]>([]);
readonly enabled: boolean;
private timer?: number;
constructor(
private readonly search?: (
query: string
) => Promise<EpgGuideSearchHit[]>
) {
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);
}
}
@@ -0,0 +1,148 @@
<div class="guide-toolbar">
<div class="guide-toolbar__day">
<button
type="button"
mat-icon-button
(click)="stepDay.emit('prev')"
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="guide-toolbar__date">
{{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }}
@if (isToday()) {
<small>{{ 'EPG.GUIDE.TODAY' | translate }}</small>
}
</span>
<button
type="button"
mat-icon-button
(click)="stepDay.emit('next')"
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<button
type="button"
class="guide-toolbar__now"
(click)="jumpNow.emit()"
[matTooltip]="'EPG.GUIDE.JUMP_NOW' | translate"
>
{{ 'EPG.GUIDE.NOW' | translate }}
</button>
<button
type="button"
class="guide-toolbar__scope"
[matMenuTriggerFor]="scopeMenu"
[attr.aria-label]="'EPG.GUIDE.SCOPE' | translate"
>
{{ scopeLabel() }} <mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #scopeMenu="matMenu" class="guide-toolbar__scope-menu">
@for (scope of scopes(); track scope.id) {
<button
mat-menu-item
[class.is-selected]="scope.id === scopeId()"
(click)="scopeChange.emit(scope.id)"
>
{{ scope.label }}
</button>
}
</mat-menu>
<label
class="guide-toolbar__toggle"
[class.is-on]="onlyWithEpg()"
[matTooltip]="'EPG.GUIDE.ONLY_WITH_EPG_HINT' | translate"
>
<input
type="checkbox"
[checked]="onlyWithEpg()"
[disabled]="!coverageLoaded()"
(change)="onlyWithEpgChange.emit(!onlyWithEpg())"
/>
<i aria-hidden="true"></i>
<span>{{ 'EPG.GUIDE.ONLY_WITH_EPG' | translate }}</span>
</label>
<button
type="button"
mat-icon-button
class="guide-toolbar__density"
(click)="toggleDensity()"
[matTooltip]="densityLabelKey() | translate"
[attr.aria-label]="densityLabelKey() | translate"
>
<mat-icon>{{ densityIcon() }}</mat-icon>
</button>
<label
class="guide-toolbar__zoom"
[matTooltip]="'EPG.GUIDE.ZOOM' | translate"
>
<mat-icon>zoom_in</mat-icon>
<input
type="range"
[min]="zoomMin"
[max]="zoomMax"
[step]="zoomStep"
[value]="zoom()"
(input)="onZoomInput($event)"
[attr.aria-label]="'EPG.GUIDE.ZOOM' | translate"
/>
</label>
<span class="guide-toolbar__spacer"></span>
<label class="guide-toolbar__input">
<mat-icon>search</mat-icon>
<input
type="text"
[value]="filter()"
(input)="onFilterInput($event)"
(keydown.escape)="onFilterEscape($event)"
[placeholder]="'EPG.GUIDE.FILTER_CHANNELS' | translate"
autocomplete="off"
/>
@if (filter()) {
<span class="guide-toolbar__count"
>{{ shownCount() }} / {{ totalCount() }}</span
>
<button
type="button"
mat-icon-button
(click)="filterChange.emit('')"
[attr.aria-label]="'EPG.GUIDE.CLEAR_FILTER' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
@if (searchEnabled()) {
<label class="guide-toolbar__input guide-toolbar__input--search">
<mat-icon>manage_search</mat-icon>
<input
type="text"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.escape)="onSearchEscape($event)"
[placeholder]="'EPG.GUIDE.SEARCH_PROGRAMMES' | translate"
autocomplete="off"
/>
@if (searchQuery()) {
<button
type="button"
mat-icon-button
(click)="searchQueryChange.emit('')"
[attr.aria-label]="'CLEAR' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
}
</div>
@@ -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;
}
}
@@ -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<number>();
readonly isToday = input(false);
readonly currentLocale = input('en');
readonly scopes = input<EpgGuideScope[]>([]);
readonly scopeId = input('');
readonly onlyWithEpg = input(false);
readonly coverageLoaded = input(false);
readonly density = input<EpgGuideDensity>('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<EpgDateNavigationDirection>();
readonly jumpNow = output<void>();
readonly scopeChange = output<string>();
readonly onlyWithEpgChange = output<boolean>();
readonly densityChange = output<EpgGuideDensity>();
readonly zoomChange = output<number>();
readonly filterChange = output<string>();
readonly searchQueryChange = output<string>();
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();
}
}
@@ -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);
}
}
}
@@ -0,0 +1,164 @@
<app-epg-guide-toolbar
[dayMs]="axis().startMs"
[isToday]="isToday()"
[currentLocale]="currentLocale()"
[scopes]="scopes()"
[scopeId]="scopeId()"
[onlyWithEpg]="onlyWithEpg()"
[coverageLoaded]="coverageLoaded()"
[density]="density()"
[zoom]="zoom()"
[filter]="filter()"
[searchEnabled]="searchEnabled"
[searchQuery]="searchQuery()"
[shownCount]="rows().length"
[totalCount]="totalCount()"
(stepDay)="stepDay($event)"
(jumpNow)="jumpNow()"
(scopeChange)="setScope($event)"
(onlyWithEpgChange)="setOnlyWithEpg($event)"
(densityChange)="setDensity($event)"
(zoomChange)="setZoom($event)"
(filterChange)="setFilter($event)"
(searchQueryChange)="onSearchQueryChange($event)"
/>
@if (searchEnabled && searchQuery().trim().length >= 2) {
<div class="epg-guide__search-results" role="listbox">
@for (
hit of searchResults();
track hit.program.channel + hit.program.start
) {
<button
type="button"
class="epg-guide__search-result"
role="option"
[attr.aria-selected]="false"
(click)="openSearchResult(hit)"
>
<span class="epg-guide__search-title">{{
hit.program.title
}}</span>
<span class="epg-guide__search-meta">
{{
hit.program.start
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
}}
· {{ hit.program.channel }}
</span>
</button>
} @empty {
<div class="epg-guide__search-empty">
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
</div>
}
</div>
}
<div
class="epg-guide__grid"
[style.--epg-guide-channel-col.px]="channelColumnPx"
>
<div class="epg-guide__ruler">
<div class="epg-guide__corner">
{{ 'EPG.GUIDE.CHANNEL' | translate }}
</div>
<div class="epg-guide__ruler-clip">
<div
class="epg-guide__ruler-track"
[style.width.px]="trackWidthPx()"
[style.transform]="'translateX(' + -scrollLeft() + 'px)'"
>
@for (tick of ticks(); track tick.ms) {
<div
class="epg-guide__tick"
[class.is-half]="tick.kind === 'half'"
[style.left.px]="tick.leftPx"
>
{{ tick.ms | date: 'HH:mm' }}
</div>
}
@if (nowLeftPx(); as nowX) {
<div class="epg-guide__now-badge" [style.left.px]="nowX">
{{ nowMs() | date: 'HH:mm' }}
</div>
}
</div>
</div>
</div>
<cdk-virtual-scroll-viewport
#viewport
class="epg-guide__viewport"
[itemSize]="rowHeightPx()"
[minBufferPx]="rowHeightPx() * 6"
[maxBufferPx]="rowHeightPx() * 12"
(scroll)="onViewportScroll($event)"
>
<app-epg-guide-row
*cdkVirtualFor="
let channel of rows();
trackBy: trackRow;
let index = index
"
[channel]="channel"
[programs]="programsFor(channel.id)"
[status]="statusFor(channel.id)"
[axis]="axis()"
[hourWidthPx]="zoom()"
[nowMs]="nowMs()"
[offsetMinutes]="offsetMinutes()"
[catchUpAvailable]="catchUpAvailable"
[active]="channel.id === activeChannelId()"
[density]="density()"
[rowFocused]="focus()?.row === index"
[focusedBlock]="focusedBlockFor(index)"
[style.height.px]="rowHeightPx()"
(channelActivated)="activateRow(channel)"
(channelCommitted)="commitRow(channel)"
(detailsRequested)="openDetails(channel, $event)"
(watchRequested)="watch(channel, $event)"
/>
@if (rows().length === 0) {
<div class="epg-guide__no-rows">
<mat-icon>search_off</mat-icon>
<p>
{{
'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() }
}}
</p>
@if (filter()) {
<button type="button" mat-button (click)="setFilter('')">
{{ 'EPG.GUIDE.CLEAR_FILTER' | translate }}
</button>
}
</div>
}
</cdk-virtual-scroll-viewport>
@if (nowLeftPx(); as nowX) {
<div
class="epg-guide__now-line"
[style.left.px]="channelColumnPx + nowX - scrollLeft()"
></div>
}
</div>
<div class="epg-guide__keys" aria-hidden="true">
<span
><kbd>&uarr;</kbd><kbd>&darr;</kbd
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
>
<span
><kbd>&larr;</kbd><kbd>&rarr;</kbd
>{{ 'EPG.GUIDE.KEYS.PROGRAMME' | translate }}</span
>
<span><kbd>Enter</kbd>{{ 'EPG.GUIDE.KEYS.PLAY' | translate }}</span>
<span><kbd>I</kbd>{{ 'EPG.GUIDE.KEYS.DETAILS' | translate }}</span>
<span><kbd>N</kbd>{{ 'EPG.GUIDE.KEYS.NOW' | translate }}</span>
<span
><kbd>PgUp</kbd><kbd>PgDn</kbd
>{{ 'EPG.GUIDE.KEYS.DAY' | translate }}</span
>
<span><kbd>Esc</kbd>{{ 'EPG.GUIDE.KEYS.CLOSE' | translate }}</span>
</div>
@@ -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;
}
@@ -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<unknown>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
describe('EpgGuideComponent', () => {
let fixture: ComponentFixture<EpgGuideComponent>;
let component: EpgGuideComponent;
const channels = signal<EpgGuideChannel[]>([]);
const activeChannelId = signal<string | null>(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();
});
});
@@ -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<void>();
readonly channelActivated = output<string>();
readonly viewport = viewChild<CdkVirtualScrollViewport>('viewport');
private readonly preferences = restoreEpgGuidePreferences();
readonly dayKey = signal(getTodayEpgDateKey());
readonly zoom = signal(this.preferences.zoom);
readonly density = signal<EpgGuideDensity>(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,
}
);
}
}