refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 14:34:39 +02:00
1 parent cb599c7bb4
commit e00e5d6476
13 files changed
+1012 -231

No files matched your search

@@ -0,0 +1,205 @@
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
import {
EpgGuideDialogController,
EpgGuideDialogHost,
} from './epg-guide-dialog.controller';
import { EpgGuideCatchUp, EpgGuideChannel } from './epg-guide-source';
const MINUTE_MS = 60_000;
const START_MS = Date.UTC(2026, 0, 15, 20, 0);
const CHANNELS: EpgGuideChannel[] = [
{ id: 'a', number: 1, name: 'Channel a', logoUrl: null, epgKey: 'a' },
{ id: 'b', number: 2, name: 'Channel b', logoUrl: 'logo.png', epgKey: 'b' },
];
function program(overrides: Partial<EpgProgram> = {}): EpgProgram {
return {
start: new Date(START_MS).toISOString(),
stop: new Date(START_MS + 30 * MINUTE_MS).toISOString(),
channel: 'a',
title: 'Programme',
desc: null,
category: null,
...overrides,
};
}
function block(
when: 'past' | 'now' | 'future',
canCatchUp = false
): TimelineRenderBlock {
return {
kind: 'block',
key: 'k',
block: {
program: program(),
key: 'k',
startMs: START_MS,
stopMs: START_MS + 30 * MINUTE_MS,
when,
offsetMin: 0,
durationMin: 30,
},
leftPx: 0,
widthPx: 100,
tier: 'wide',
nowFillPercent: 0,
canCatchUp,
};
}
interface Harness {
controller: EpgGuideDialogController;
open: jest.Mock;
focusRow: jest.Mock;
activate: jest.Mock;
watch: jest.Mock;
offsetMinutes: number;
}
function harness(offsetMinutes = 0): Harness {
const open = jest.fn(() => of(undefined));
const focusRow = jest.fn();
const activate = jest.fn();
const watch = jest.fn();
const catchUp: EpgGuideCatchUp = { canWatch: () => true, watch };
const state = { offsetMinutes };
const host: EpgGuideDialogHost = {
rows: () => CHANNELS,
offsetMinutes: () => state.offsetMinutes,
focusRow,
activate,
catchUp: () => catchUp,
};
return {
controller: new EpgGuideDialogController(
{ open } as unknown as EpgProgrammeDialogService,
host
),
open,
focusRow,
activate,
watch,
offsetMinutes,
};
}
describe('EpgGuideDialogController', () => {
it('labels a card dialog with its channel and offers the live action', () => {
const test = harness();
test.open.mockReturnValueOnce(of('live'));
test.controller.openDetails(CHANNELS[1], block('now'));
expect(test.open).toHaveBeenCalledWith(
expect.objectContaining({
channelName: 'Channel b',
channelLogo: 'logo.png',
primaryAction: 'live',
archiveUnavailableNote: false,
})
);
expect(test.activate).toHaveBeenCalledWith(CHANNELS[1]);
});
it('offers timeshift for a recorded past card and plays it back', () => {
const test = harness();
test.open.mockReturnValueOnce(of('timeshift'));
const item = block('past', true);
test.controller.openDetails(CHANNELS[0], item);
expect(test.open).toHaveBeenCalledWith(
expect.objectContaining({
primaryAction: 'timeshift',
archiveUnavailableNote: false,
})
);
expect(test.watch).toHaveBeenCalledWith(
CHANNELS[0],
item.block.program
);
});
it('notes a past card that cannot be replayed, and ignores a missing one', () => {
const test = harness();
test.controller.openDetails(CHANNELS[0], block('past'));
expect(test.open).toHaveBeenCalledWith(
expect.objectContaining({
primaryAction: null,
archiveUnavailableNote: true,
})
);
test.open.mockClear();
test.controller.openDetails(CHANNELS[0], undefined);
test.controller.openDetails(undefined, block('now'));
expect(test.open).not.toHaveBeenCalled();
});
it('sends a catch-up request straight to the host', () => {
const test = harness();
const item = block('past', true);
test.controller.watch(CHANNELS[0], item);
expect(test.watch).toHaveBeenCalledWith(
CHANNELS[0],
item.block.program
);
expect(test.open).not.toHaveBeenCalled();
});
it('focuses the row a search hit belongs to, and copes without one', () => {
const test = harness();
test.controller.openSearchResult({
channelId: 'b',
program: program({ channel: 'b' }),
});
expect(test.focusRow).toHaveBeenCalledWith(1);
expect(test.open).toHaveBeenCalledWith(
expect.objectContaining({ channelName: 'Channel b' })
);
test.focusRow.mockClear();
test.open.mockClear();
test.controller.openSearchResult({
channelId: null,
program: program({ channel: 'x' }),
});
expect(test.focusRow).not.toHaveBeenCalled();
expect(test.open).toHaveBeenCalledWith(
expect.not.objectContaining({ channelName: expect.anything() })
);
});
it('shifts a search hit start by the EPG display offset', () => {
const test = harness(60);
expect(
test.controller.searchHitStartMs({
channelId: 'a',
program: program(),
})
).toBe(START_MS + 60 * MINUTE_MS);
// A unix timestamp wins over the ISO string, offset included.
expect(
test.controller.searchHitStartMs({
channelId: 'a',
program: program({
start: 'not a date',
startTimestamp: START_MS / 1000,
}),
})
).toBe(START_MS + 60 * MINUTE_MS);
});
});
@@ -0,0 +1,108 @@
import { getProgramTimeMs } from '../epg-program.utils';
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 {
EpgGuideCatchUp,
EpgGuideChannel,
EpgGuideSearchHit,
} from './epg-guide-source';
export interface EpgGuideDialogHost {
/** The rows currently rendered, in display order. */
rows(): readonly EpgGuideChannel[];
/** EPG display offset in minutes; search hits carry provider instants. */
offsetMinutes(): number;
/** Move the keyboard focus to a row and scroll it into view. */
focusRow(rowIndex: number): void;
/** Switch playback to the channel (a no-op when it already plays). */
activate(channel: EpgGuideChannel): void;
catchUp(): EpgGuideCatchUp | undefined;
}
/**
* Every programme-dialog entry point of the guide: a card's details, a
* catch-up request and a search hit. Split out of the shell component, which
* owns rows, focus and playback and supplies them through the host.
*/
export class EpgGuideDialogController {
constructor(
private readonly programmeDialog: EpgProgrammeDialogService,
private readonly host: EpgGuideDialogHost
) {}
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.host.activate(channel);
} else if (result === 'timeshift') {
this.host.catchUp()?.watch(channel, item.block.program);
}
});
}
watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void {
this.host.catchUp()?.watch(channel, item.block.program);
}
/**
* Open a search hit. When the host resolved the hit's row, focus and reveal
* it and label the dialog with that channel; an unresolved hit still opens,
* just without a channel.
*/
openSearchResult(hit: EpgGuideSearchHit): void {
const rows = this.host.rows();
const rowIndex =
hit.channelId === null
? -1
: rows.findIndex((row) => row.id === hit.channelId);
const channel = rowIndex < 0 ? null : rows[rowIndex];
if (channel) {
this.host.focusRow(rowIndex);
}
this.programmeDialog
.open(
channel
? {
...hit.program,
channelName: channel.name,
channelLogo: channel.logoUrl,
}
: { ...hit.program }
)
.subscribe();
}
/**
* A hit's start as the guide displays it. Results come straight from the
* host's EPG store, so they carry provider instants and must go through
* the same display-offset shift as every other time in the guide.
*/
searchHitStartMs(hit: EpgGuideSearchHit): number {
return getProgramTimeMs(
hit.program.start,
hit.program.startTimestamp,
this.host.offsetMinutes()
);
}
}
@@ -1,41 +1,47 @@
<div
class="epg-guide-row__channel"
role="button"
tabindex="-1"
data-epg-guide-grid
[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>
<div class="epg-guide-row__channel-cell" role="gridcell">
<div
class="epg-guide-row__channel"
role="button"
data-epg-guide-grid
[attr.tabindex]="channelTabIndex()"
[attr.aria-label]="channel().name"
[attr.aria-pressed]="active()"
(click)="onChannelClick()"
(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>
}
</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>
<div class="epg-guide-row__lane" [style.width.px]="trackWidthPx()">
<div
class="epg-guide-row__lane"
role="gridcell"
[style.width.px]="trackWidthPx()"
>
@if (showEmpty()) {
<span class="epg-guide-row__empty">{{
'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate
@@ -58,8 +64,8 @@
<div
class="epg-guide-row__block"
role="button"
tabindex="-1"
data-epg-guide-grid
[attr.tabindex]="blockTabIndex(index)"
[attr.data-tier]="item.tier"
[class.is-past]="item.block.when === 'past'"
[class.is-now]="item.block.when === 'now'"
@@ -74,7 +80,7 @@
: ''
"
[attr.aria-label]="item.block.program.title"
(click)="onBlockClick(item)"
(click)="onBlockClick(item, index)"
(dblclick)="onBlockDoubleClick(item)"
>
@if (item.block.when === 'now') {
@@ -17,21 +17,36 @@
}
}
.epg-guide-row__channel {
// The grid cell holding the channel column; the button inside it is the
// keyboard/roving-focus target.
.epg-guide-row__channel-cell {
position: sticky;
left: 0;
z-index: 2;
flex: 0 0 var(--epg-guide-channel-col, 232px);
display: flex;
box-sizing: border-box;
background: inherit;
border-right: 1px solid $line-strong;
}
.epg-guide-row__channel {
position: relative;
flex: 1;
min-width: 0;
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;
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: -2px;
}
:host(.is-active) &::before {
content: '';
position: absolute;
@@ -167,7 +182,8 @@
transform: translateY(-1px);
z-index: 3;
}
&.is-focused {
&.is-focused,
&:focus-visible {
outline: 2px solid $accent-blue;
outline-offset: 1px;
z-index: 3;
@@ -53,6 +53,9 @@ describe('EpgGuideRowComponent', () => {
overrides: {
status?: EpgGuideRowStatus;
catchUpAvailable?: boolean;
rowFocused?: boolean;
focusedBlock?: number | null;
tabbable?: boolean;
} = {}
): void {
fixture.componentRef.setInput('channel', CHANNEL);
@@ -65,9 +68,26 @@ describe('EpgGuideRowComponent', () => {
'catchUpAvailable',
overrides.catchUpAvailable ?? false
);
fixture.componentRef.setInput(
'rowFocused',
overrides.rowFocused ?? false
);
fixture.componentRef.setInput(
'focusedBlock',
overrides.focusedBlock ?? null
);
fixture.componentRef.setInput('tabbable', overrides.tabbable ?? false);
fixture.detectChanges();
}
function tabIndexes(): (string | null)[] {
return fixture.debugElement
.queryAll(By.css('[data-epg-guide-grid]'))
.map((element) =>
(element.nativeElement as HTMLElement).getAttribute('tabindex')
);
}
beforeEach(() => {
axis = buildGuideDayAxis(todayDateKey());
nowMs = axis.startMs + 12 * 60 * MINUTE_MS;
@@ -169,6 +189,53 @@ describe('EpgGuideRowComponent', () => {
).toBeNull();
});
it('moves the roving tabindex between the channel cell and the focused card', () => {
// [channel cell, card] — a row nobody points at is fully untabbable.
render([program('on now', -20, 40)]);
expect(tabIndexes()).toEqual(['-1', '-1']);
// The shell's fallback row: Tab lands on the channel cell.
render([program('on now', -20, 40)], { tabbable: true });
expect(tabIndexes()).toEqual(['0', '-1']);
// Keyboard focus on the row itself keeps it on the channel cell...
render([program('on now', -20, 40)], { rowFocused: true });
expect(tabIndexes()).toEqual(['0', '-1']);
// ...and moves to the card once a programme is focused.
render([program('on now', -20, 40)], {
rowFocused: true,
focusedBlock: 0,
});
expect(tabIndexes()).toEqual(['-1', '0']);
});
it('reports the clicked cell so the roving focus follows the mouse', () => {
render([program('on now', -20, 40)]);
const focused: (number | null)[] = [];
component.focusRequested.subscribe((block) => focused.push(block));
fixture.debugElement
.query(By.css('.epg-guide-row__channel'))
.nativeElement.click();
fixture.debugElement
.query(By.css('.epg-guide-row__block'))
.nativeElement.click();
expect(focused).toEqual([null, 0]);
});
it('exposes the grid roles the guide viewport announces', () => {
render([program('on now', -20, 40)]);
expect(fixture.nativeElement.getAttribute('role')).toBe('row');
expect(
fixture.debugElement
.queryAll(By.css('[role="gridcell"]'))
.map((cell) => (cell.nativeElement as HTMLElement).className)
).toEqual(['epg-guide-row__channel-cell', 'epg-guide-row__lane']);
});
it('marks its channel cell and cards as guide-owned keyboard surfaces', () => {
render([program('on now', -20, 40)]);
@@ -34,6 +34,7 @@ import { EpgGuideChannel } from './epg-guide-source';
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'epg-guide-row',
role: 'row',
'[class.is-active]': 'active()',
'[class.is-focused]': 'rowFocused()',
'[class.is-compact]': 'density() === "compact"',
@@ -52,6 +53,12 @@ export class EpgGuideRowComponent {
readonly density = input<EpgGuideDensity>('comfortable');
readonly rowFocused = input(false);
readonly focusedBlock = input<number | null>(null);
/**
* True when this row owns the grid's single `tabindex="0"` — the shell
* hands it to the focused row, else the playing one, else the first, so
* Tab always reaches the grid.
*/
readonly tabbable = input(false);
/** Single click on the channel cell or the on-now card. */
readonly channelActivated = output<void>();
@@ -59,6 +66,12 @@ export class EpgGuideRowComponent {
readonly channelCommitted = output<void>();
readonly detailsRequested = output<TimelineRenderBlock>();
readonly watchRequested = output<TimelineRenderBlock>();
/**
* A pointer landed on a grid cell: the block's index, or `null` for the
* channel cell. The shell moves its keyboard focus there so the roving
* tabindex follows the mouse.
*/
readonly focusRequested = output<number | null>();
readonly blocks = computed(() =>
buildGuideRowBlocks(this.programs(), {
@@ -82,7 +95,25 @@ export class EpgGuideRowComponent {
);
readonly isLoading = computed(() => this.status() === 'loading');
onBlockClick(item: TimelineRenderBlock): void {
/** This row holds the roving `tabindex="0"` while it is focused or picked. */
private readonly roving = computed(
() => this.tabbable() || this.rowFocused()
);
readonly channelTabIndex = computed(() =>
this.roving() && this.focusedBlock() === null ? 0 : -1
);
blockTabIndex(index: number): number {
return this.roving() && this.focusedBlock() === index ? 0 : -1;
}
onChannelClick(): void {
this.focusRequested.emit(null);
this.channelActivated.emit();
}
onBlockClick(item: TimelineRenderBlock, index: number): void {
this.focusRequested.emit(index);
if (item.block.when === 'now') {
this.channelActivated.emit();
} else {
@@ -0,0 +1,137 @@
import { TestBed } from '@angular/core/testing';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { getTodayEpgDateKey } from '../epg-date';
import {
EPG_GUIDE_DENSITY_KEY,
EPG_GUIDE_ONLY_WITH_EPG_KEY,
EPG_GUIDE_ZOOM_KEY,
} from './epg-guide-preferences';
import { EpgGuideChannel } from './epg-guide-source';
import { EpgGuideViewState } from './epg-guide-view-state';
const MINUTE_MS = 60_000;
function channel(id: string, name = `Channel ${id}`): EpgGuideChannel {
return { id, number: 1, name, logoUrl: null, epgKey: id };
}
function create(): EpgGuideViewState {
return TestBed.runInInjectionContext(() => new EpgGuideViewState());
}
describe('EpgGuideViewState', () => {
let state: EpgGuideViewState;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({});
});
afterEach(() => {
state?.destroy();
});
it('starts from the persisted preferences and writes changes back', () => {
localStorage.setItem(EPG_GUIDE_DENSITY_KEY, 'compact');
localStorage.setItem(EPG_GUIDE_ZOOM_KEY, '300');
localStorage.setItem(EPG_GUIDE_ONLY_WITH_EPG_KEY, '1');
state = create();
expect(state.density()).toBe('compact');
expect(state.zoom()).toBe(300);
expect(state.onlyWithEpg()).toBe(true);
expect(state.rowHeightPx()).toBe(44);
// Out-of-range zoom is clamped before it is stored.
state.setZoom(9_999);
state.density.set('comfortable');
TestBed.tick();
expect(state.zoom()).toBe(480);
expect(localStorage.getItem(EPG_GUIDE_ZOOM_KEY)).toBe('480');
expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('comfortable');
});
it('filters rows by name and by EPG coverage', () => {
state = create();
const rows = [channel('a', 'News HD'), channel('b', 'Sports')];
const covered = new Set(['b']);
const isCovered = (id: string) => covered.has(id);
expect(state.filterRows(rows, isCovered)).toEqual(rows);
state.filter.set(' NEWS ');
expect(state.filterRows(rows, isCovered).map((row) => row.id)).toEqual([
'a',
]);
state.filter.set('');
state.onlyWithEpg.set(true);
expect(state.filterRows(rows, isCovered).map((row) => row.id)).toEqual([
'b',
]);
});
it('steps the day and returns to today', () => {
state = create();
const today = state.dayKey();
state.stepDay('next');
expect(state.dayKey()).not.toBe(today);
expect(state.axis().dayKey).toBe(state.dayKey());
state.goToToday();
expect(state.dayKey()).toBe(getTodayEpgDateKey());
});
it('hides the now-line once it scrolls behind the channel column', () => {
state = create();
const nowX = state.nowLeftPx();
expect(nowX).not.toBeNull();
state.scrollLeft.set(Number(nowX) - 10);
expect(state.nowLineLeftPx()).toBe(10);
state.scrollLeft.set(Number(nowX) + 10);
expect(state.nowLineLeftPx()).toBeNull();
});
it('lays out a row against the current day, zoom and clock', () => {
state = create();
const startMs = state.axis().startMs + 10 * 60 * MINUTE_MS;
state.nowMs.set(startMs + 5 * MINUTE_MS);
const program: EpgProgram = {
start: new Date(startMs).toISOString(),
stop: new Date(startMs + 30 * MINUTE_MS).toISOString(),
channel: 'a',
title: 'On now',
desc: null,
category: null,
};
const blocks = state.blocksFor([program], 0, false);
expect(blocks).toHaveLength(1);
expect(blocks[0].block.when).toBe('now');
// 10 hours into the day at the default 240px/hour zoom.
expect(blocks[0].leftPx).toBeCloseTo(2400, 0);
});
it('stops its clock when destroyed', () => {
jest.useFakeTimers();
try {
state = create();
const first = state.nowMs();
jest.advanceTimersByTime(60_000);
expect(state.nowMs()).toBeGreaterThan(first);
const last = state.nowMs();
state.destroy();
jest.advanceTimersByTime(120_000);
expect(state.nowMs()).toBe(last);
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,148 @@
import { computed, effect, signal } from '@angular/core';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgDateNavigationDirection,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '../epg-date';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
import {
buildGuideDayAxis,
buildGuideRowBlocks,
buildGuideTicks,
EPG_GUIDE_ROW_HEIGHT_PX,
EpgGuideDensity,
guideNowLeftPx,
guideTrackWidthPx,
} from './epg-guide-layout.util';
import {
clampGuideZoom,
persistEpgGuidePreferences,
restoreEpgGuidePreferences,
} from './epg-guide-preferences';
import { EpgGuideChannel } from './epg-guide-source';
/** How often the guide re-reads the clock; drives "on now" and the now-line. */
const CLOCK_TICK_MS = 60_000;
/**
* Everything the guide renders itself from: the selected day, the persisted
* view preferences (zoom, density, "only with EPG"), the channel filter, the
* clock and the derived day geometry. It owns its own minute timer and the
* preference-persisting effect, so it must be constructed inside an injection
* context and released with `destroy()`.
*
* Split out of the shell component, which keeps ownership of the data source,
* keyboard focus and the viewport.
*/
export class EpgGuideViewState {
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());
/** The viewport's horizontal offset; the ruler and now-line follow it. */
readonly scrollLeft = signal(0);
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())
);
/**
* The now-line's x inside the scrolling lane, or `null` when it is not on
* the selected day or has scrolled behind the sticky channel column — the
* clip layer starts at the column's right edge, so a negative offset would
* otherwise be painted under it.
*/
readonly nowLineLeftPx = computed(() => {
const nowX = this.nowLeftPx();
if (nowX === null) {
return null;
}
const left = nowX - this.scrollLeft();
return left >= 0 ? left : null;
});
readonly rowHeightPx = computed(
() => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]
);
private readonly clock = window.setInterval(
() => this.nowMs.set(Date.now()),
CLOCK_TICK_MS
);
constructor() {
effect(() =>
persistEpgGuidePreferences({
density: this.density(),
zoom: this.zoom(),
onlyWithEpg: this.onlyWithEpg(),
})
);
}
/**
* Apply the channel filter and the "only with EPG" toggle. `isCovered` is
* called synchronously, so a signal it reads is tracked by the caller's
* own computed.
*/
filterRows(
channels: readonly EpgGuideChannel[],
isCovered: (channelId: string) => boolean
): EpgGuideChannel[] {
const needle = this.filter().trim().toLowerCase();
const onlyWithEpg = this.onlyWithEpg();
return channels.filter(
(channel) =>
(!needle || channel.name.toLowerCase().includes(needle)) &&
(!onlyWithEpg || isCovered(channel.id))
);
}
setZoom(value: number): void {
this.zoom.set(clampGuideZoom(value));
}
stepDay(direction: EpgDateNavigationDirection): void {
this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction));
}
/** Select today and re-read the clock; the caller does the scrolling. */
goToToday(): void {
this.dayKey.set(getTodayEpgDateKey());
this.nowMs.set(Date.now());
}
/**
* Row-block geometry for the current day, zoom and clock — the same layout
* the row component renders, needed by keyboard focus and scroll reveal.
*/
blocksFor(
programs: readonly EpgProgram[],
offsetMinutes: number,
catchUpAvailable: boolean
): TimelineRenderBlock[] {
return buildGuideRowBlocks(programs, {
axis: this.axis(),
hourWidthPx: this.zoom(),
nowMs: this.nowMs(),
offsetMinutes,
catchUpAvailable,
});
}
destroy(): void {
window.clearInterval(this.clock);
}
}
@@ -190,6 +190,33 @@ describe('EpgGuideViewportController', () => {
expect(test.scrollToIndex).not.toHaveBeenCalled();
});
it('gives the DOM focus to the cell holding the roving tabindex', () => {
const test = harness();
const passive = document.createElement('div');
passive.setAttribute('data-epg-guide-grid', '');
passive.tabIndex = -1;
const roving = document.createElement('div');
roving.setAttribute('data-epg-guide-grid', '');
roving.tabIndex = 0;
test.element.append(passive, roving);
document.body.append(test.element);
test.controller.focusRovingTarget();
expect(document.activeElement).toBe(roving);
// Nothing tabbable (a row scrolled out of the rendered range): the
// current focus is left alone rather than reset.
roving.remove();
const outside = document.createElement('button');
document.body.append(outside);
outside.focus();
test.controller.focusRovingTarget();
expect(document.activeElement).toBe(outside);
outside.remove();
test.element.remove();
});
it('reveals the focused row and block, and ignores a null focus', () => {
const test = harness();
test.controller.revealFocus({ row: 40, block: 1 });
@@ -124,6 +124,22 @@ export class EpgGuideViewportController {
}
}
/**
* Give the DOM focus to whichever grid cell currently carries the roving
* `tabindex="0"`, so assistive technology follows the guide's keyboard
* navigation. Scrolling is `revealFocus`'s job, hence `preventScroll`.
* Call it after the render that moved the tabindex.
*/
focusRovingTarget(): void {
const element = this.host.viewport()?.elementRef.nativeElement;
const target = element?.querySelector<HTMLElement>(
'[data-epg-guide-grid][tabindex="0"]'
);
if (typeof target?.focus === 'function') {
target.focus({ preventScroll: true });
}
}
/** Keep the keyboard focus target inside the viewport, both axes. */
revealFocus(focused: EpgGuideFocus | null): void {
const viewport = this.host.viewport();
@@ -1,6 +1,6 @@
<app-epg-guide-toolbar
[dayMs]="axis().startMs"
[isToday]="isToday()"
[dayMs]="view.axis().startMs"
[isToday]="view.isToday()"
[currentLocale]="currentLocale()"
[scopes]="scopes()"
[scopeId]="scopeId()"
@@ -41,7 +41,7 @@
}}</span>
<span class="epg-guide__search-meta">
{{
hit.program.start
searchHitStartMs(hit)
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
}}
· {{ hit.program.channel }}
@@ -66,10 +66,10 @@
<div class="epg-guide__ruler-clip">
<div
class="epg-guide__ruler-track"
[style.width.px]="trackWidthPx()"
[style.transform]="'translateX(' + -scrollLeft() + 'px)'"
[style.width.px]="view.trackWidthPx()"
[style.transform]="'translateX(' + -view.scrollLeft() + 'px)'"
>
@for (tick of ticks(); track tick.ms) {
@for (tick of view.ticks(); track tick.ms) {
<div
class="epg-guide__tick"
[class.is-half]="tick.kind === 'half'"
@@ -78,12 +78,12 @@
{{ tick.ms | date: 'HH:mm' }}
</div>
}
@if (nowLeftPx() !== null) {
@if (view.nowLeftPx() !== null) {
<div
class="epg-guide__now-badge"
[style.left.px]="nowLeftPx()"
[style.left.px]="view.nowLeftPx()"
>
{{ nowMs() | date: 'HH:mm' }}
{{ view.nowMs() | date: 'HH:mm' }}
</div>
}
</div>
@@ -93,6 +93,9 @@
<cdk-virtual-scroll-viewport
#viewport
class="epg-guide__viewport"
role="grid"
[attr.aria-label]="'EPG.GUIDE.TITLE' | translate"
[attr.aria-rowcount]="rows().length"
[itemSize]="rowHeightPx()"
[minBufferPx]="rowHeightPx() * 6"
[maxBufferPx]="rowHeightPx() * 12"
@@ -103,19 +106,22 @@
trackBy: trackRow;
let index = index
"
[attr.aria-rowindex]="index + 1"
[channel]="channel"
[programs]="programsFor(channel.id)"
[status]="statusFor(channel.id)"
[axis]="axis()"
[axis]="view.axis()"
[hourWidthPx]="zoom()"
[nowMs]="nowMs()"
[nowMs]="view.nowMs()"
[offsetMinutes]="offsetMinutes()"
[catchUpAvailable]="catchUpAvailable"
[active]="channel.id === activeChannelId()"
[density]="density()"
[rowFocused]="focus()?.row === index"
[focusedBlock]="focusedBlockFor(index)"
[tabbable]="tabbableRow() === index"
[style.height.px]="rowHeightPx()"
(focusRequested)="focusCell(index, $event)"
(channelActivated)="activateRow(channel)"
(channelCommitted)="commitRow(channel)"
(detailsRequested)="openDetails(channel, $event)"
@@ -140,16 +146,16 @@
<!-- Clipped to the lane so the line never paints over the sticky column. -->
<div class="epg-guide__now-clip">
@if (nowLineLeftPx() !== null) {
@if (view.nowLineLeftPx() !== null) {
<div
class="epg-guide__now-line"
[style.left.px]="nowLineLeftPx()"
[style.left.px]="view.nowLineLeftPx()"
></div>
}
</div>
</div>
<div class="epg-guide__keys" aria-hidden="true">
<div class="epg-guide__keys">
<span
><kbd>&uarr;</kbd><kbd>&darr;</kbd
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
@@ -5,7 +5,7 @@ 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 { of, Subject } from 'rxjs';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { EpgGuideComponent } from './epg-guide.component';
import {
@@ -16,6 +16,7 @@ import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
import {
EPG_GUIDE_SOURCE,
EpgGuideChannel,
EpgGuideSearchHit,
EpgGuideSource,
} from './epg-guide-source';
@@ -51,12 +52,16 @@ describe('EpgGuideComponent', () => {
let component: EpgGuideComponent;
const channels = signal<EpgGuideChannel[]>([]);
const activeChannelId = signal<string | null>(null);
const offsetMinutes = signal(0);
const searchHits = signal<EpgGuideSearchHit[]>([]);
const activate = jest.fn();
const setScope = jest.fn();
const dialogOpen = jest.fn(() => of(undefined));
beforeEach(() => {
localStorage.clear();
offsetMinutes.set(0);
searchHits.set([]);
activate.mockReset();
setScope.mockReset();
dialogOpen.mockClear();
@@ -81,6 +86,7 @@ describe('EpgGuideComponent', () => {
loadCoverage: async () => new Set(['a']),
activeChannelId,
activate,
searchPrograms: async () => searchHits(),
};
TestBed.configureTestingModule({
imports: [EpgGuideComponent],
@@ -93,16 +99,19 @@ describe('EpgGuideComponent', () => {
{ provide: MatDialog, useValue: { openDialogs: [] } },
{
provide: SettingsStore,
useValue: { resolvedEpgOffsetMinutes: signal(0) },
useValue: { resolvedEpgOffsetMinutes: offsetMinutes },
},
{
provide: TranslateService,
useValue: {
currentLang: 'en',
defaultLang: 'en',
onLangChange: new BehaviorSubject(null),
onTranslationChange: new BehaviorSubject(null),
onDefaultLangChange: new BehaviorSubject(null),
// Plain subjects: `TranslatePipe` reads `event.lang`
// off every emission, so a replayed `null` would throw
// inside its subscriber on the first render.
onLangChange: new Subject(),
onTranslationChange: new Subject(),
onDefaultLangChange: new Subject(),
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
@@ -297,4 +306,80 @@ describe('EpgGuideComponent', () => {
);
expect(blur).toHaveBeenCalled();
});
it('keeps one tabbable grid cell and follows the keyboard with DOM focus', async () => {
await settle(fixture);
const viewportEl: HTMLElement = fixture.debugElement.query(
By.css('cdk-virtual-scroll-viewport')
).nativeElement;
viewportEl.scrollTo = jest.fn() as unknown as HTMLElement['scrollTo'];
const cells = (): HTMLElement[] =>
fixture.debugElement
.queryAll(By.css('.epg-guide-row__channel'))
.map((cell) => cell.nativeElement as HTMLElement);
// Nothing focused yet: the playing row is the grid's Tab stop.
expect(cells().map((cell) => cell.getAttribute('tabindex'))).toEqual([
'0',
'-1',
'-1',
]);
component.onKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
await settle(fixture);
expect(component.focus()).toEqual({ row: 1, block: null });
expect(cells().map((cell) => cell.getAttribute('tabindex'))).toEqual([
'-1',
'0',
'-1',
]);
expect(document.activeElement).toBe(cells()[1]);
// A filter that drops the focused row must not leave the grid without
// a Tab stop: it falls back to the playing row.
component.setOnlyWithEpg(true);
await settle(fixture);
expect(component.rows().map((row) => row.id)).toEqual(['a']);
expect(cells().map((cell) => cell.getAttribute('tabindex'))).toEqual([
'0',
]);
});
it('moves the roving focus to a clicked programme card', async () => {
await settle(fixture);
const card = fixture.debugElement.query(
By.css('app-epg-guide-row .epg-guide-row__block')
);
expect(card).toBeTruthy();
card.nativeElement.click();
await settle(fixture);
expect(component.focus()).toEqual({ row: 0, block: 0 });
expect(card.nativeElement.getAttribute('tabindex')).toBe('0');
});
it('renders search hits at the display-offset time', async () => {
offsetMinutes.set(60);
const program = nowProgram('a');
searchHits.set([{ channelId: 'a', program }]);
await settle(fixture);
expect(component.searchHitStartMs({ channelId: 'a', program })).toBe(
Date.parse(program.start) + 60 * 60_000
);
component.onSearchQueryChange('now');
// The controller debounces for 300ms before it asks the host.
await new Promise((resolve) => setTimeout(resolve, 400));
await settle(fixture);
const shifted = new Date(Date.parse(program.start) + 60 * 60_000);
const pad = (value: number) => `${value}`.padStart(2, '0');
expect(
fixture.debugElement.query(By.css('.epg-guide__search-meta'))
.nativeElement.textContent
).toContain(`${pad(shifted.getHours())}:${pad(shifted.getMinutes())}`);
});
});
@@ -12,10 +12,9 @@ import {
effect,
HostListener,
inject,
Injector,
OnDestroy,
OnInit,
output,
signal,
untracked,
viewChild,
} from '@angular/core';
@@ -28,30 +27,15 @@ 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 { EpgDateNavigationDirection } from '../epg-date';
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
import { EpgGuideDialogController } from './epg-guide-dialog.controller';
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,
@@ -64,12 +48,15 @@ import {
EpgGuideSearchHit,
} from './epg-guide-source';
import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component';
import { EpgGuideViewState } from './epg-guide-view-state';
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.
* owns the rows, the keyboard focus and the viewport, and delegates the view
* state (day, zoom, density, filters, clock) and the programme dialogs to
* their own controllers. Rows are virtualised; the channel column and the
* ruler are sticky.
*/
@Component({
selector: 'app-epg-guide',
@@ -87,14 +74,14 @@ import { EpgGuideViewportController } from './epg-guide-viewport.controller';
styleUrl: './epg-guide.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EpgGuideComponent implements OnInit, OnDestroy {
export class EpgGuideComponent implements 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);
private readonly injector = inject(Injector);
readonly close = output<void>();
/**
@@ -106,14 +93,14 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
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);
/** Day, zoom, density, filters, clock and the derived day geometry. */
readonly view = new EpgGuideViewState();
readonly dayKey = this.view.dayKey;
readonly zoom = this.view.zoom;
readonly density = this.view.density;
readonly onlyWithEpg = this.view.onlyWithEpg;
readonly filter = this.view.filter;
readonly rowHeightPx = this.view.rowHeightPx;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -140,47 +127,12 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
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())
);
/**
* The now-line's x inside the scrolling lane, or `null` when it is not on
* the selected day or has scrolled behind the sticky channel column — the
* clip layer starts at the column's right edge, so a negative offset would
* otherwise be painted under it.
*/
readonly nowLineLeftPx = computed(() => {
const nowX = this.nowLeftPx();
if (nowX === null) {
return null;
}
const left = nowX - this.scrollLeft();
return left >= 0 ? left : null;
});
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 rows = computed(() =>
this.view.filterRows(this.source.channels(), (channelId) =>
this.programsService.isCovered(channelId)
)
);
readonly activeRowIndex = computed(() => {
const activeId = this.activeChannelId();
return activeId === null
@@ -201,6 +153,17 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
close: () => this.close.emit(),
});
readonly focus = this.keyboard.focus;
/**
* The row that carries the grid's single `tabindex="0"`: the focused one,
* else the playing one, else the first — so Tab always reaches the grid,
* and lands where the keyboard left off. A focus left behind by a filter
* that dropped its row falls back too, or nothing would be tabbable.
*/
readonly tabbableRow = computed(() => {
const fallback = Math.max(0, this.activeRowIndex());
const row = this.focus()?.row ?? fallback;
return row < this.rows().length ? row : fallback;
});
private readonly viewportController = new EpgGuideViewportController({
viewport: () => this.viewport(),
@@ -210,14 +173,23 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
blocksFor: (row) => this.blocksFor(row),
activeRow: () => this.activeRowIndex(),
ensureLoaded: (channels) => this.programsService.ensureLoaded(channels),
setScrollLeft: (left) => this.scrollLeft.set(left),
setScrollLeft: (left) => this.view.scrollLeft.set(left),
});
private minuteTimer?: number;
private readonly dialogs = new EpgGuideDialogController(
inject(EpgProgrammeDialogService),
{
rows: () => this.rows(),
offsetMinutes: () => this.offsetMinutes(),
focusRow: (rowIndex) => this.revealRow(rowIndex),
activate: (channel) => this.activateRow(channel),
catchUp: () => this.source.catchUp,
}
);
constructor() {
effect(() => {
const axis = this.axis();
const axis = this.view.axis();
const offset = this.offsetMinutes();
untracked(() => {
this.programsService.setWindow(
@@ -231,13 +203,6 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
this.rows();
untracked(() => this.viewportController.loadRenderedRange());
});
effect(() =>
persistEpgGuidePreferences({
density: this.density(),
zoom: this.zoom(),
onlyWithEpg: this.onlyWithEpg(),
})
);
effect(() => {
const viewport = this.viewport();
if (!viewport) {
@@ -250,24 +215,28 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
afterNextRender(() => this.jumpNow(false));
}
ngOnInit(): void {
this.minuteTimer = window.setInterval(
() => this.nowMs.set(Date.now()),
60_000
);
}
ngOnDestroy(): void {
window.clearInterval(this.minuteTimer);
this.view.destroy();
this.search.destroy();
}
/**
* The grid's keys are handled at the document, so the focused cell needs no
* listener of its own — but it must own the DOM focus, or a screen reader
* would still announce whatever the user tabbed from. The roving
* `tabindex="0"` moves with the signal, so the element to focus only exists
* after the next render.
*/
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
if (this.keyboard.handle(event)) {
event.preventDefault();
this.viewportController.revealFocus(this.focus());
if (!this.keyboard.handle(event)) {
return;
}
event.preventDefault();
this.viewportController.revealFocus(this.focus());
afterNextRender(() => this.viewportController.focusRovingTarget(), {
injector: this.injector,
});
}
trackRow(_index: number, channel: EpgGuideChannel): string {
@@ -287,15 +256,19 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
return focused?.row === row ? focused.block : null;
}
/** Pointer focus: the roving tabindex follows the mouse, without scrolling. */
focusCell(row: number, block: number | null): void {
this.focus.set({ row, block });
}
stepDay(direction: EpgDateNavigationDirection): void {
this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction));
this.view.stepDay(direction);
this.focus.set(null);
}
jumpNow(animate = true): void {
this.dayKey.set(getTodayEpgDateKey());
this.nowMs.set(Date.now());
this.viewportController.scrollToNow(this.nowLeftPx(), animate);
this.view.goToToday();
this.viewportController.scrollToNow(this.view.nowLeftPx(), animate);
}
setScope(scopeId: string): void {
@@ -304,19 +277,19 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
}
setOnlyWithEpg(value: boolean): void {
this.onlyWithEpg.set(value);
this.view.onlyWithEpg.set(value);
}
setDensity(value: EpgGuideDensity): void {
this.density.set(value);
this.view.density.set(value);
}
setZoom(value: number): void {
this.zoom.set(clampGuideZoom(value));
this.view.setZoom(value);
}
setFilter(value: string): void {
this.filter.set(value);
this.view.filter.set(value);
this.focus.set(null);
}
@@ -324,32 +297,23 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
this.search.setQuery(query);
}
/**
* Open a search hit. When the host resolved the hit's row, focus and reveal
* it and label the dialog with that channel; an unresolved hit still opens,
* just without a channel.
*/
openSearchResult(hit: EpgGuideSearchHit): void {
const rowIndex =
hit.channelId === null
? -1
: this.rows().findIndex((row) => row.id === hit.channelId);
const channel = rowIndex < 0 ? null : this.rows()[rowIndex];
if (channel) {
this.focus.set({ row: rowIndex, block: null });
this.viewportController.revealFocus(this.focus());
}
this.programmeDialog
.open(
channel
? {
...hit.program,
channelName: channel.name,
channelLogo: channel.logoUrl,
}
: { ...hit.program }
)
.subscribe();
this.dialogs.openSearchResult(hit);
}
searchHitStartMs(hit: EpgGuideSearchHit): number {
return this.dialogs.searchHitStartMs(hit);
}
openDetails(
channel: EpgGuideChannel | undefined,
item: TimelineRenderBlock | undefined
): void {
this.dialogs.openDetails(channel, item);
}
watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void {
this.dialogs.watch(channel, item);
}
/**
@@ -373,54 +337,19 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
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 revealRow(rowIndex: number): void {
this.focus.set({ row: rowIndex, block: null });
this.viewportController.revealFocus(this.focus());
}
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,
}
);
return channel
? this.view.blocksFor(
this.programsService.programsFor(channel.id),
this.offsetMinutes(),
this.catchUpAvailable
)
: [];
}
}