From 44e421103e0c9e2c7afcddcaf42026495c052714 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 12:53:16 +0200 Subject: [PATCH] feat(epg): add the programme guide grid components Co-Authored-By: Claude Fable 5.1 --- apps/web/src/assets/i18n/en.json | 39 +- libs/ui/epg/src/index.ts | 5 + .../epg-guide-now-playing.component.html | 53 +++ .../epg-guide-now-playing.component.scss | 116 ++++++ .../epg-guide-now-playing.component.ts | 82 ++++ .../epg-guide/epg-guide-row.component.html | 110 +++++ .../epg-guide/epg-guide-row.component.scss | 296 ++++++++++++++ .../lib/epg-guide/epg-guide-row.component.ts | 103 +++++ .../lib/epg-guide/epg-guide-scroll.util.ts | 65 +++ .../epg-guide/epg-guide-search.controller.ts | 48 +++ .../epg-guide-toolbar.component.html | 148 +++++++ .../epg-guide-toolbar.component.scss | 238 +++++++++++ .../epg-guide/epg-guide-toolbar.component.ts | 133 ++++++ .../epg-guide-viewport.controller.ts | 129 ++++++ .../lib/epg-guide/epg-guide.component.html | 164 ++++++++ .../lib/epg-guide/epg-guide.component.scss | 199 +++++++++ .../lib/epg-guide/epg-guide.component.spec.ts | 245 ++++++++++++ .../src/lib/epg-guide/epg-guide.component.ts | 378 ++++++++++++++++++ 18 files changed, 2549 insertions(+), 2 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-search.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts 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 }} + + + {{ 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 @@ +
+
+ + + {{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }} + @if (isToday()) { + {{ 'EPG.GUIDE.TODAY' | translate }} + } + + +
+ + + + + + @for (scope of scopes(); track scope.id) { + + } + + + + + + + + + + + + + @if (searchEnabled()) { + + } +
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) { +
+ } +
+ + 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, + } + ); + } +}