fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:19:54 +02:00
1 parent 44ad33beb9
commit e9359750bf
16 files changed
+782 -56

No files matched your search

+5
View File
@@ -21,4 +21,9 @@ $live-text: color-mix(
var(--app-on-surface)
);
$accent-live: var(--app-live-color);
// Text/icons ON a filled `$accent-live` surface. The content background is the
// live colour's counterpart in both themes — near-black behind the light
// theme's red and near-white behind the dark theme's pink — so it clears 4.5:1
// either way, where a fixed dark literal only worked in the light theme.
$on-live: var(--app-content-bg);
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
@@ -83,12 +83,52 @@ describe('EpgGuideKeyboardController', () => {
it('ignores typing, modifier chords, blocked state and unknown keys', () => {
const input = document.createElement('input');
expect(controller.handle(key('ArrowDown', { target: input }))).toBe(false);
expect(controller.handle(key('ArrowDown', { ctrlKey: true }))).toBe(false);
expect(controller.handle(key('ArrowDown', { target: input }))).toBe(
false
);
expect(controller.handle(key('ArrowDown', { ctrlKey: true }))).toBe(
false
);
host.isBlocked.mockReturnValue(true);
expect(controller.handle(key('Escape'))).toBe(false);
host.isBlocked.mockReturnValue(false);
expect(controller.handle(key('x'))).toBe(false);
expect(host.close).not.toHaveBeenCalled();
});
it('ignores keys aimed at a control the guide does not own', () => {
const button = document.createElement('button');
expect(controller.handle(key('Enter', { target: button }))).toBe(false);
expect(host.play).not.toHaveBeenCalled();
const icon = document.createElement('mat-icon');
button.appendChild(icon);
expect(controller.handle(key('Enter', { target: icon }))).toBe(false);
expect(host.play).not.toHaveBeenCalled();
});
it('still handles keys on its own grid surfaces', () => {
const card = document.createElement('div');
card.setAttribute('role', 'button');
card.setAttribute('data-epg-guide-grid', '');
expect(controller.handle(key('Enter', { target: card }))).toBe(true);
expect(host.play).toHaveBeenCalledWith(2);
const label = document.createElement('span');
card.appendChild(label);
expect(controller.handle(key('ArrowDown', { target: label }))).toBe(
true
);
expect(controller.focus()).toEqual({ row: 3, block: null });
});
it('leaves the catch-up button inside a card to the button', () => {
const card = document.createElement('div');
card.setAttribute('role', 'button');
card.setAttribute('data-epg-guide-grid', '');
const watch = document.createElement('button');
card.appendChild(watch);
expect(controller.handle(key('Enter', { target: watch }))).toBe(false);
expect(host.play).not.toHaveBeenCalled();
});
});
@@ -21,13 +21,35 @@ export interface EpgGuideKeyboardHost {
close(): void;
}
function isEditableTarget(target: EventTarget | null): boolean {
/**
* Controls the guide must not steal keys from: form fields, buttons, menu and
* listbox items, links and editable content — anywhere the browser or a
* Material overlay already gives the key a meaning.
*/
const INTERACTIVE_SELECTOR =
'input, textarea, select, button, [role="button"], [role="menuitem"],' +
' [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"],' +
' a[href], [contenteditable=""], [contenteditable="true"]';
/**
* The guide's own grid surfaces (channel cells and programme cards) carry
* `role="button"` for assistive technology, so they match the selector above
* even though the guide owns their keys. They opt back in with
* `data-epg-guide-grid`; a real control nested inside one (the catch-up
* button) is the closest match and still wins.
*/
const GUIDE_GRID_ATTRIBUTE = 'data-epg-guide-grid';
function isForeignInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const interactive = target.closest(INTERACTIVE_SELECTOR);
return (
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
interactive !== null && !interactive.hasAttribute(GUIDE_GRID_ATTRIBUTE)
);
}
@@ -52,7 +74,7 @@ export class EpgGuideKeyboardController {
event.metaKey ||
event.ctrlKey ||
event.altKey ||
isEditableTarget(event.target) ||
isForeignInteractiveTarget(event.target) ||
this.host.isBlocked()
) {
return false;
@@ -124,7 +146,8 @@ export class EpgGuideKeyboardController {
this.focus.set({ row, block: null });
return true;
}
const current = this.focus()?.row === row ? this.focus()?.block ?? null : null;
const current =
this.focus()?.row === row ? (this.focus()?.block ?? null) : null;
const start = current ?? (delta > 0 ? -1 : blocks);
this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) });
return true;
@@ -33,7 +33,10 @@ async function flush(): Promise<void> {
describe('EpgGuideProgramsService', () => {
const channels = signal<EpgGuideChannel[]>([]);
const loadPrograms = jest.fn<Promise<Map<string, EpgProgram[]>>, [unknown]>();
const loadPrograms = jest.fn<
Promise<Map<string, EpgProgram[]>>,
[unknown]
>();
const loadCoverage = jest.fn<Promise<Set<string>>, [unknown]>();
let service: EpgGuideProgramsService;
@@ -70,7 +73,9 @@ describe('EpgGuideProgramsService', () => {
service.ensureLoaded(channels());
await flush();
expect(service.statusFor('c')).toBe('none');
const requested = loadPrograms.mock.calls[0][0] as { channels: EpgGuideChannel[] };
const requested = loadPrograms.mock.calls[0][0] as {
channels: EpgGuideChannel[];
};
expect(requested.channels.map((item) => item.id)).toEqual(['a', 'b']);
});
@@ -80,8 +85,12 @@ describe('EpgGuideProgramsService', () => {
);
channels.set(many);
loadPrograms.mockImplementation(async (window) => {
const { channels: requested } = window as { channels: EpgGuideChannel[] };
return new Map(requested.map((item) => [item.id, [programFor(item.id)]]));
const { channels: requested } = window as {
channels: EpgGuideChannel[];
};
return new Map(
requested.map((item) => [item.id, [programFor(item.id)]])
);
});
service.setWindow(1_000, 2_000);
service.ensureLoaded(many);
@@ -24,7 +24,9 @@ const debugEpgGuidePrograms = createDevLogger('EpgGuideProgramsService');
/** A channel is only ever requested when it carries a real, non-blank key. */
function hasEpgKey(channel: EpgGuideChannel): boolean {
return typeof channel.epgKey === 'string' && channel.epgKey.trim().length > 0;
return (
typeof channel.epgKey === 'string' && channel.epgKey.trim().length > 0
);
}
/**
@@ -38,9 +40,9 @@ function hasEpgKey(channel: EpgGuideChannel): boolean {
export class EpgGuideProgramsService {
private readonly source = inject(EPG_GUIDE_SOURCE);
private readonly programs = signal<ReadonlyMap<string, readonly EpgProgram[]>>(
new Map()
);
private readonly programs = signal<
ReadonlyMap<string, readonly EpgProgram[]>
>(new Map());
private readonly statuses = signal<ReadonlyMap<string, EpgGuideRowStatus>>(
new Map()
);
@@ -106,14 +108,19 @@ export class EpgGuideProgramsService {
return;
}
const pending = channels.filter(
(channel) => this.statusFor(channel.id) === 'idle' && hasEpgKey(channel)
(channel) =>
this.statusFor(channel.id) === 'idle' && hasEpgKey(channel)
);
if (pending.length === 0) {
return;
}
this.patchStatuses(pending.map((channel) => [channel.id, 'loading']));
const generation = this.generation;
for (let start = 0; start < pending.length; start += EPG_GUIDE_LOAD_CHUNK) {
for (
let start = 0;
start < pending.length;
start += EPG_GUIDE_LOAD_CHUNK
) {
const chunk = pending.slice(start, start + EPG_GUIDE_LOAD_CHUNK);
this.loadChunk({ channels: chunk, ...range }, generation);
}
@@ -155,7 +162,11 @@ export class EpgGuideProgramsService {
const generation = this.generation;
const keyed = this.source.channels().filter(hasEpgKey);
const chunks: EpgGuideChannel[][] = [];
for (let start = 0; start < keyed.length; start += EPG_GUIDE_COVERAGE_CHUNK) {
for (
let start = 0;
start < keyed.length;
start += EPG_GUIDE_COVERAGE_CHUNK
) {
chunks.push(keyed.slice(start, start + EPG_GUIDE_COVERAGE_CHUNK));
}
Promise.all(
@@ -2,6 +2,7 @@
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()"
@@ -58,6 +59,7 @@
class="epg-guide-row__block"
role="button"
tabindex="-1"
data-epg-guide-grid
[attr.data-tier]="item.tier"
[class.is-past]="item.block.when === 'past'"
[class.is-now]="item.block.when === 'now'"
@@ -2,7 +2,6 @@
:host {
display: flex;
height: 100%;
box-sizing: border-box;
border-bottom: 1px solid $line;
background: var(--app-content-bg);
@@ -0,0 +1,179 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, of } from 'rxjs';
import { buildGuideDayAxis, EpgGuideDayAxis } from './epg-guide-layout.util';
import { EpgGuideRowStatus } from './epg-guide-programs.service';
import { EpgGuideRowComponent } from './epg-guide-row.component';
import { EpgGuideChannel } from './epg-guide-source';
const MINUTE_MS = 60_000;
const CHANNEL: EpgGuideChannel = {
id: 'a',
number: 1,
name: 'Channel a',
logoUrl: null,
epgKey: 'a',
};
function todayDateKey(): string {
const now = new Date();
const pad = (value: number) => `${value}`.padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)}`;
}
describe('EpgGuideRowComponent', () => {
let fixture: ComponentFixture<EpgGuideRowComponent>;
let component: EpgGuideRowComponent;
let axis: EpgGuideDayAxis;
/** Fixed "now" in the middle of the rendered day, so no case is clock-dependent. */
let nowMs: number;
function program(
title: string,
fromNowMin: number,
toNowMin: number
): EpgProgram {
return {
start: new Date(nowMs + fromNowMin * MINUTE_MS).toISOString(),
stop: new Date(nowMs + toNowMin * MINUTE_MS).toISOString(),
channel: 'a',
title,
desc: null,
category: null,
};
}
function render(
programs: readonly EpgProgram[],
overrides: {
status?: EpgGuideRowStatus;
catchUpAvailable?: boolean;
} = {}
): void {
fixture.componentRef.setInput('channel', CHANNEL);
fixture.componentRef.setInput('axis', axis);
fixture.componentRef.setInput('programs', programs);
fixture.componentRef.setInput('status', overrides.status ?? 'loaded');
fixture.componentRef.setInput('nowMs', nowMs);
fixture.componentRef.setInput('hourWidthPx', 240);
fixture.componentRef.setInput(
'catchUpAvailable',
overrides.catchUpAvailable ?? false
);
fixture.detectChanges();
}
beforeEach(() => {
axis = buildGuideDayAxis(todayDateKey());
nowMs = axis.startMs + 12 * 60 * MINUTE_MS;
TestBed.configureTestingModule({
imports: [EpgGuideRowComponent],
providers: [
{
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(EpgGuideRowComponent);
component = fixture.componentInstance;
});
it('activates the channel when the on-now card is clicked', () => {
render([program('on now', -20, 40)]);
const activated = jest.fn();
const details = jest.fn();
component.channelActivated.subscribe(activated);
component.detailsRequested.subscribe(details);
const card = fixture.debugElement.query(
By.css('.epg-guide-row__block')
);
expect(card.nativeElement.classList).toContain('is-now');
card.nativeElement.click();
expect(activated).toHaveBeenCalled();
expect(details).not.toHaveBeenCalled();
});
it('opens details for a past card instead of switching channel', () => {
render([program('earlier', -180, -120)]);
const activated = jest.fn();
const details = jest.fn();
component.channelActivated.subscribe(activated);
component.detailsRequested.subscribe(details);
fixture.debugElement
.query(By.css('.epg-guide-row__block'))
.nativeElement.click();
expect(details).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'block' })
);
expect(activated).not.toHaveBeenCalled();
});
it('reports a catch-up click as watch only, not as details', () => {
render([program('earlier', -180, -120)], { catchUpAvailable: true });
const details = jest.fn();
const watch = jest.fn();
component.detailsRequested.subscribe(details);
component.watchRequested.subscribe(watch);
const button = fixture.debugElement.query(
By.css('.epg-guide-row__watch')
);
expect(button).toBeTruthy();
button.nativeElement.click();
expect(watch).toHaveBeenCalledWith(
expect.objectContaining({ canCatchUp: true })
);
expect(details).not.toHaveBeenCalled();
});
it('renders the empty note when the host says there is no EPG', () => {
render([], { status: 'none' });
expect(
fixture.debugElement.query(By.css('.epg-guide-row__empty'))
).toBeTruthy();
expect(
fixture.debugElement.queryAll(By.css('.epg-guide-row__skeleton'))
).toHaveLength(0);
});
it('renders skeletons while programmes are loading', () => {
render([], { status: 'loading' });
expect(
fixture.debugElement.queryAll(By.css('.epg-guide-row__skeleton'))
.length
).toBeGreaterThan(0);
expect(
fixture.debugElement.query(By.css('.epg-guide-row__empty'))
).toBeNull();
});
it('marks its channel cell and cards as guide-owned keyboard surfaces', () => {
render([program('on now', -20, 40)]);
expect(
fixture.debugElement.queryAll(By.css('[data-epg-guide-grid]'))
).toHaveLength(2);
});
});
@@ -0,0 +1,97 @@
import {
guideBlockRevealScrollLeft,
guideNowScrollLeft,
guideRowNeedsReveal,
scrollElementLeft,
} from './epg-guide-scroll.util';
function block(leftPx: number, widthPx: number) {
return { leftPx, widthPx };
}
describe('guideNowScrollLeft', () => {
it('puts the now-line a third into the visible lane', () => {
// 1100 - 200 = 900 visible, a third of it is 300.
expect(guideNowScrollLeft(1100, 900, 200)).toBe(600);
});
it('never scrolls before the start of the track', () => {
expect(guideNowScrollLeft(1000, 100, 200)).toBe(0);
});
it('survives a viewport narrower than the channel column', () => {
expect(guideNowScrollLeft(100, 500, 200)).toBe(500);
});
});
describe('guideRowNeedsReveal', () => {
it('is false for a row fully inside the viewport', () => {
expect(guideRowNeedsReveal(3, 60, 100, 400)).toBe(false);
});
it('is true for a row above the viewport', () => {
expect(guideRowNeedsReveal(1, 60, 100, 400)).toBe(true);
});
it('is true for a row only partly below the bottom edge', () => {
// Row 8 spans 480–540, the viewport ends at 100 + 400 = 500.
expect(guideRowNeedsReveal(8, 60, 100, 400)).toBe(true);
// Row 7 spans 420–480 and still fits.
expect(guideRowNeedsReveal(7, 60, 100, 400)).toBe(false);
});
it('treats a row flush with either edge as visible', () => {
expect(guideRowNeedsReveal(2, 50, 100, 400)).toBe(false);
expect(guideRowNeedsReveal(9, 50, 100, 400)).toBe(false);
});
});
describe('guideBlockRevealScrollLeft', () => {
it('returns null while the block is fully visible', () => {
expect(guideBlockRevealScrollLeft(block(300, 100), 200, 800, 200)).toBe(
null
);
});
it('scrolls back with padding for a block left of the lane', () => {
expect(guideBlockRevealScrollLeft(block(300, 100), 500, 800, 200)).toBe(
260
);
});
it('scrolls forward for a block that overflows the right edge', () => {
// Visible lane is 600 wide, so 100..900 ends past 0 + 600.
expect(guideBlockRevealScrollLeft(block(100, 800), 0, 800, 200)).toBe(
60
);
});
it('clamps the target to the start of the track', () => {
expect(guideBlockRevealScrollLeft(block(10, 50), 200, 800, 200)).toBe(
0
);
});
});
describe('scrollElementLeft', () => {
it('uses scrollTo when the element implements it', () => {
const scrollTo = jest.fn();
const element = { scrollTo, scrollLeft: 0 } as unknown as HTMLElement;
scrollElementLeft(element, 120, true);
expect(scrollTo).toHaveBeenCalledWith({
left: 120,
behavior: 'smooth',
});
scrollElementLeft(element, 10, false);
expect(scrollTo).toHaveBeenLastCalledWith({
left: 10,
behavior: 'auto',
});
});
it('falls back to assigning scrollLeft (jsdom has no scrollTo)', () => {
const element = { scrollLeft: 0 } as unknown as HTMLElement;
scrollElementLeft(element, 42, true);
expect(element.scrollLeft).toBe(42);
});
});
@@ -1,9 +1,10 @@
import { signal } from '@angular/core';
import { computed, 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;
/** Shortest query that runs a search — and shows the results panel. */
export const EPG_GUIDE_SEARCH_MIN_LENGTH = 2;
/**
* Debounced programme search for the guide toolbar. The host's
@@ -15,6 +16,12 @@ export class EpgGuideSearchController {
readonly query = signal('');
readonly results = signal<EpgGuideSearchHit[]>([]);
readonly enabled: boolean;
/** True while the query is long enough for the results panel to show. */
readonly panelVisible = computed(
() =>
this.enabled &&
this.query().trim().length >= EPG_GUIDE_SEARCH_MIN_LENGTH
);
private timer?: number;
@@ -30,14 +37,16 @@ export class EpgGuideSearchController {
this.query.set(query);
window.clearTimeout(this.timer);
const term = query.trim();
if (term.length < SEARCH_MIN_LENGTH || !this.search) {
if (term.length < EPG_GUIDE_SEARCH_MIN_LENGTH || !this.search) {
this.results.set([]);
return;
}
this.timer = window.setTimeout(async () => {
const hits = await this.search?.(term).catch(() => []);
// The host's search may throw synchronously (before it ever
// returns a promise), which `.catch()` alone would not contain.
const hits = await this.runSearch(term);
if (this.query() === query) {
this.results.set((hits ?? []).slice(0, SEARCH_MAX_RESULTS));
this.results.set(hits.slice(0, SEARCH_MAX_RESULTS));
}
}, SEARCH_DEBOUNCE_MS);
}
@@ -45,4 +54,12 @@ export class EpgGuideSearchController {
destroy(): void {
window.clearTimeout(this.timer);
}
private async runSearch(term: string): Promise<EpgGuideSearchHit[]> {
try {
return (await this.search?.(term)) ?? [];
} catch {
return [];
}
}
}
@@ -0,0 +1,210 @@
import { ListRange } from '@angular/cdk/collections';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DestroyRef } from '@angular/core';
import { Subject } from 'rxjs';
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
import { EpgGuideChannel } from './epg-guide-source';
import {
EpgGuideViewportController,
EpgGuideViewportHost,
} from './epg-guide-viewport.controller';
const CHANNEL_COLUMN_PX = 200;
function channels(count: number): EpgGuideChannel[] {
return Array.from({ length: count }, (_unused, index) => ({
id: `c${index}`,
number: index + 1,
name: `Channel ${index}`,
logoUrl: null,
epgKey: `c${index}`,
}));
}
function block(leftPx: number, widthPx: number): TimelineRenderBlock {
return {
kind: 'block',
key: `b${leftPx}`,
block: {
program: {
start: '',
stop: '',
channel: 'c0',
title: 't',
desc: null,
category: null,
},
key: `b${leftPx}`,
startMs: 0,
stopMs: 1,
when: 'now',
offsetMin: 0,
durationMin: 1,
},
leftPx,
widthPx,
tier: 'wide',
nowFillPercent: 0,
canCatchUp: false,
};
}
interface Harness {
controller: EpgGuideViewportController;
host: EpgGuideViewportHost;
viewport: CdkVirtualScrollViewport;
element: HTMLElement;
renderedRange$: Subject<ListRange>;
scrolled$: Subject<Event>;
destroy: () => void;
destroyRef: DestroyRef;
ensureLoaded: jest.Mock;
setScrollLeft: jest.Mock;
scrollToIndex: jest.Mock;
checkViewportSize: jest.Mock;
scrollTo: jest.Mock;
}
function harness(rowCount = 100): Harness {
const renderedRange$ = new Subject<ListRange>();
const scrolled$ = new Subject<Event>();
const destroyHandlers: Array<() => void> = [];
const destroyRef = {
onDestroy: (handler: () => void) => {
destroyHandlers.push(handler);
return () => undefined;
},
} as unknown as DestroyRef;
const scrollTo = jest.fn();
// A real element (a ResizeObserver, where one exists, only accepts one),
// with the layout properties jsdom always reports as 0 shadowed.
const element = document.createElement('div');
Object.defineProperty(element, 'clientWidth', { value: 1000 });
Object.defineProperty(element, 'clientHeight', { value: 400 });
Object.defineProperty(element, 'scrollLeft', { value: 0, writable: true });
element.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
const scrollToIndex = jest.fn();
const checkViewportSize = jest.fn();
const viewport = {
elementRef: { nativeElement: element },
renderedRangeStream: renderedRange$.asObservable(),
elementScrolled: () => scrolled$.asObservable(),
measureScrollOffset: jest.fn(() => 0),
scrollToIndex,
checkViewportSize,
} as unknown as CdkVirtualScrollViewport;
const ensureLoaded = jest.fn();
const setScrollLeft = jest.fn();
const rows = channels(rowCount);
const host: EpgGuideViewportHost = {
viewport: () => viewport,
rows: () => rows,
rowHeightPx: () => 60,
channelColumnPx: () => CHANNEL_COLUMN_PX,
blocksFor: () => [block(0, 100), block(2000, 300)],
activeRow: () => 40,
ensureLoaded,
setScrollLeft,
};
return {
controller: new EpgGuideViewportController(host),
host,
viewport,
element,
renderedRange$,
scrolled$,
destroy: () => destroyHandlers.forEach((handler) => handler()),
destroyRef,
ensureLoaded,
setScrollLeft,
scrollToIndex,
checkViewportSize,
scrollTo,
};
}
describe('EpgGuideViewportController', () => {
it('requests the first screenful before the viewport reports a range', () => {
const { controller, ensureLoaded } = harness();
controller.loadRenderedRange();
// 30 initial rows, extended by the buffer, clamped to the row count.
expect(ensureLoaded.mock.calls[0][0]).toHaveLength(
30 + EPG_GUIDE_ROW_BUFFER
);
expect(ensureLoaded.mock.calls[0][0][0].id).toBe('c0');
});
it('buffers the rendered range on both sides and clamps at the ends', () => {
const test = harness(100);
test.controller.watch(test.viewport, test.destroyRef);
test.renderedRange$.next({ start: 50, end: 60 });
const middle = test.ensureLoaded.mock.calls.at(-1)?.[0];
expect(middle[0].id).toBe(`c${50 - EPG_GUIDE_ROW_BUFFER}`);
expect(middle).toHaveLength(10 + 2 * EPG_GUIDE_ROW_BUFFER);
test.renderedRange$.next({ start: 0, end: 95 });
const edges = test.ensureLoaded.mock.calls.at(-1)?.[0];
expect(edges[0].id).toBe('c0');
expect(edges).toHaveLength(100);
});
it('mirrors the viewport scroll offset to the host', () => {
const test = harness();
test.controller.watch(test.viewport, test.destroyRef);
test.element.scrollLeft = 640;
test.scrolled$.next(new Event('scroll'));
expect(test.setScrollLeft).toHaveBeenCalledWith(640);
});
it('stops mirroring and loading once the host is destroyed', () => {
const test = harness();
test.controller.watch(test.viewport, test.destroyRef);
test.destroy();
test.ensureLoaded.mockClear();
test.scrolled$.next(new Event('scroll'));
test.renderedRange$.next({ start: 10, end: 20 });
expect(test.setScrollLeft).not.toHaveBeenCalled();
expect(test.ensureLoaded).not.toHaveBeenCalled();
});
it('scrolls the lane and the playing row to now, and does nothing off-day', () => {
const test = harness();
test.controller.scrollToNow(900, false);
// 1000 - 200 visible, a third of it kept to the left of the line.
expect(test.scrollTo).toHaveBeenCalledWith({
left: 900 - 800 / 3,
behavior: 'auto',
});
expect(test.scrollToIndex).toHaveBeenCalledWith(37, 'auto');
test.scrollTo.mockClear();
test.scrollToIndex.mockClear();
test.controller.scrollToNow(null, false);
expect(test.scrollTo).not.toHaveBeenCalled();
expect(test.scrollToIndex).not.toHaveBeenCalled();
});
it('reveals the focused row and block, and ignores a null focus', () => {
const test = harness();
test.controller.revealFocus({ row: 40, block: 1 });
expect(test.scrollToIndex).toHaveBeenCalledWith(38);
expect(test.scrollTo).toHaveBeenCalledWith({
left: 2000 - 40,
behavior: 'smooth',
});
test.scrollTo.mockClear();
test.controller.revealFocus({ row: 40, block: 0 });
expect(test.scrollTo).not.toHaveBeenCalled();
test.scrollToIndex.mockClear();
test.controller.revealFocus(null);
expect(test.scrollToIndex).not.toHaveBeenCalled();
});
});
@@ -28,6 +28,8 @@ export interface EpgGuideViewportHost {
blocksFor(row: number): TimelineRenderBlock[];
activeRow(): number;
ensureLoaded(channels: readonly EpgGuideChannel[]): void;
/** Reports the viewport's horizontal offset; drives the ruler and now-line. */
setScrollLeft(left: number): void;
}
/**
@@ -41,7 +43,12 @@ export class EpgGuideViewportController {
constructor(private readonly host: EpgGuideViewportHost) {}
/** Reload as the viewport renders new rows, until the host is destroyed. */
/**
* Reload as the viewport renders new rows, mirror its horizontal offset and
* re-measure it when the host resizes — all until the host is destroyed.
* `elementScrolled()` runs outside the zone, and the signal write it feeds
* schedules change detection itself, so scrolling costs no zone churn.
*/
watch(viewport: CdkVirtualScrollViewport, destroyRef: DestroyRef): void {
viewport.renderedRangeStream
.pipe(takeUntilDestroyed(destroyRef))
@@ -49,6 +56,30 @@ export class EpgGuideViewportController {
this.renderedRange = range;
this.loadRenderedRange();
});
const element = viewport.elementRef.nativeElement;
viewport
.elementScrolled()
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.host.setScrollLeft(element.scrollLeft));
this.observeSize(viewport, element, destroyRef);
}
/**
* The CDK only re-measures on window resize, so a viewport that changes
* size with its container (a drawer opening, a split pane) keeps a stale
* height and renders the wrong range. `ResizeObserver` is absent in jsdom.
*/
private observeSize(
viewport: CdkVirtualScrollViewport,
element: HTMLElement,
destroyRef: DestroyRef
): void {
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() => viewport.checkViewportSize());
observer.observe(element);
destroyRef.onDestroy(() => observer.disconnect());
}
/**
@@ -23,11 +23,11 @@
(searchQueryChange)="onSearchQueryChange($event)"
/>
@if (searchEnabled && searchQuery().trim().length >= 2) {
@if (searchPanelVisible()) {
<div class="epg-guide__search-results" role="listbox">
@for (
hit of searchResults();
track hit.program.channel + hit.program.start
track hit.program.channel + hit.program.start + $index
) {
<button
type="button"
@@ -78,8 +78,11 @@
{{ tick.ms | date: 'HH:mm' }}
</div>
}
@if (nowLeftPx(); as nowX) {
<div class="epg-guide__now-badge" [style.left.px]="nowX">
@if (nowLeftPx() !== null) {
<div
class="epg-guide__now-badge"
[style.left.px]="nowLeftPx()"
>
{{ nowMs() | date: 'HH:mm' }}
</div>
}
@@ -93,7 +96,6 @@
[itemSize]="rowHeightPx()"
[minBufferPx]="rowHeightPx() * 6"
[maxBufferPx]="rowHeightPx() * 12"
(scroll)="onViewportScroll($event)"
>
<app-epg-guide-row
*cdkVirtualFor="
@@ -136,12 +138,15 @@
}
</cdk-virtual-scroll-viewport>
@if (nowLeftPx(); as nowX) {
<div
class="epg-guide__now-line"
[style.left.px]="channelColumnPx + nowX - scrollLeft()"
></div>
}
<!-- Clipped to the lane so the line never paints over the sticky column. -->
<div class="epg-guide__now-clip">
@if (nowLineLeftPx() !== null) {
<div
class="epg-guide__now-line"
[style.left.px]="nowLineLeftPx()"
></div>
}
</div>
</div>
<div class="epg-guide__keys" aria-hidden="true">
@@ -1,5 +1,11 @@
@use '../epg-theme' as *;
// Ruler strip height; the now-line clip layer starts below it.
$ruler-height: 34px;
// Matches `.guide-toolbar`'s min-height in epg-guide-toolbar.component.scss —
// the search results panel hangs directly under the toolbar.
$toolbar-height: 52px;
:host {
display: flex;
flex-direction: column;
@@ -20,7 +26,7 @@
.epg-guide__ruler {
display: flex;
height: 34px;
height: $ruler-height;
flex: 0 0 auto;
border-bottom: 1px solid $line-strong;
background: var(--app-content-bg);
@@ -76,7 +82,7 @@
top: 6px;
transform: translateX(-50%);
background: $accent-live;
color: #1a0d0d;
color: $on-live;
font: 600 10.5px $font-mono;
padding: 3px 7px;
border-radius: 5px;
@@ -88,21 +94,26 @@
flex: 1;
min-height: 0;
overflow: auto;
}
::ng-deep .cdk-virtual-scroll-content-wrapper {
min-width: 100%;
}
.epg-guide__now-clip {
position: absolute;
left: var(--epg-guide-channel-col);
right: 0;
top: $ruler-height;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: 3;
}
.epg-guide__now-line {
position: absolute;
top: 34px;
top: 0;
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 {
@@ -145,7 +156,7 @@
.epg-guide__search-results {
position: absolute;
top: 52px;
top: $toolbar-height;
right: 18px;
width: 420px;
max-height: 50vh;
@@ -154,7 +165,8 @@
background: $surface-1;
border: 1px solid $line-strong;
border-radius: 10px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
box-shadow: 0 16px 40px
color-mix(in srgb, var(--app-on-surface) 25%, transparent);
padding: 6px;
display: flex;
flex-direction: column;
@@ -207,6 +207,54 @@ describe('EpgGuideComponent', () => {
).toBeTruthy();
});
it('ignores guide keys aimed at a toolbar control', async () => {
await settle(fixture);
const close = jest.fn();
component.close.subscribe(close);
activate.mockClear();
const button: HTMLButtonElement = fixture.debugElement.query(
By.css('app-epg-guide-toolbar button')
).nativeElement;
const event = new KeyboardEvent('keydown', {
key: 'Enter',
cancelable: true,
});
Object.defineProperty(event, 'target', { value: button });
component.onKeydown(event);
expect(activate).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it('opens a search hit with its own channel and focuses that row', async () => {
await settle(fixture);
// Revealing the row scrolls the CDK viewport; jsdom has no scrollTo.
const viewportEl: HTMLElement = fixture.debugElement.query(
By.css('cdk-virtual-scroll-viewport')
).nativeElement;
viewportEl.scrollTo = jest.fn() as unknown as HTMLElement['scrollTo'];
component.openSearchResult({
channelId: 'c',
program: nowProgram('c'),
});
expect(dialogOpen).toHaveBeenCalledWith(
expect.objectContaining({ channelName: 'Channel c' })
);
expect(component.focus()).toEqual({ row: 2, block: null });
dialogOpen.mockClear();
component.openSearchResult({
channelId: null,
program: nowProgram('x'),
});
expect(dialogOpen).toHaveBeenCalledWith(
expect.not.objectContaining({ channelName: expect.anything() })
);
});
it('clears the channel filter on Escape inside the toolbar field, then lets Escape through', async () => {
await settle(fixture);
component.setFilter('channel c');
@@ -131,6 +131,7 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
readonly searchEnabled = this.search.enabled;
readonly searchQuery = this.search.query;
readonly searchResults = this.search.results;
readonly searchPanelVisible = this.search.panelVisible;
readonly catchUpAvailable = this.source.catchUp !== undefined;
readonly channelColumnPx = EPG_GUIDE_CHANNEL_COLUMN_PX;
@@ -146,6 +147,20 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
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()]
);
@@ -190,6 +205,7 @@ 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),
});
private minuteTimer?: number;
@@ -198,11 +214,13 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
effect(() => {
const axis = this.axis();
const offset = this.offsetMinutes();
this.programsService.setWindow(
epgProviderClockMs(axis.startMs, offset),
epgProviderClockMs(axis.endMs, offset)
);
untracked(() => this.viewportController.loadRenderedRange());
untracked(() => {
this.programsService.setWindow(
epgProviderClockMs(axis.startMs, offset),
epgProviderClockMs(axis.endMs, offset)
);
this.viewportController.loadRenderedRange();
});
});
effect(() => {
this.rows();
@@ -264,10 +282,6 @@ export class EpgGuideComponent implements OnInit, OnDestroy {
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);
@@ -305,8 +319,32 @@ 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 {
this.programmeDialog.open({ ...hit.program }).subscribe();
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();
}
activateRow(channel: EpgGuideChannel | undefined): void {