From e9359750bf9ef3017d2fd8793aa6edf23c27420a Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 13:19:54 +0200 Subject: [PATCH] fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize Co-Authored-By: Claude Fable 5.1 --- libs/ui/epg/src/lib/_epg-theme.scss | 5 + .../epg-guide-keyboard.controller.spec.ts | 44 +++- .../epg-guide-keyboard.controller.ts | 33 ++- .../epg-guide-programs.service.spec.ts | 17 +- .../epg-guide/epg-guide-programs.service.ts | 25 ++- .../epg-guide/epg-guide-row.component.html | 2 + .../epg-guide/epg-guide-row.component.scss | 1 - .../epg-guide/epg-guide-row.component.spec.ts | 179 +++++++++++++++ .../epg-guide/epg-guide-scroll.util.spec.ts | 97 ++++++++ .../epg-guide/epg-guide-search.controller.ts | 27 ++- .../epg-guide-viewport.controller.spec.ts | 210 ++++++++++++++++++ .../epg-guide-viewport.controller.ts | 33 ++- .../lib/epg-guide/epg-guide.component.html | 27 ++- .../lib/epg-guide/epg-guide.component.scss | 32 ++- .../lib/epg-guide/epg-guide.component.spec.ts | 48 ++++ .../src/lib/epg-guide/epg-guide.component.ts | 58 ++++- 16 files changed, 782 insertions(+), 56 deletions(-) create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts diff --git a/libs/ui/epg/src/lib/_epg-theme.scss b/libs/ui/epg/src/lib/_epg-theme.scss index 454f6634a..d35a719a9 100644 --- a/libs/ui/epg/src/lib/_epg-theme.scss +++ b/libs/ui/epg/src/lib/_epg-theme.scss @@ -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); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts index c8542b04a..91fe6da80 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts @@ -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(); + }); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts index 1c414bdcd..898b45049 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts @@ -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; diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts index aed83f7a3..f3dfb1871 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts @@ -33,7 +33,10 @@ async function flush(): Promise { describe('EpgGuideProgramsService', () => { const channels = signal([]); - const loadPrograms = jest.fn>, [unknown]>(); + const loadPrograms = jest.fn< + Promise>, + [unknown] + >(); const loadCoverage = jest.fn>, [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); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts index 4fe9e7335..06c53eb95 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts @@ -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>( - new Map() - ); + private readonly programs = signal< + ReadonlyMap + >(new Map()); private readonly statuses = signal>( 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( 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 index 66ebe2c37..18061167b 100644 --- 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 @@ -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'" 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 index df44c0cb8..91472af62 100644 --- 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 @@ -2,7 +2,6 @@ :host { display: flex; - height: 100%; box-sizing: border-box; border-bottom: 1px solid $line; background: var(--app-content-bg); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.spec.ts new file mode 100644 index 000000000..5ae1bbe27 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.spec.ts @@ -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; + 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); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts new file mode 100644 index 000000000..bc1463c88 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts @@ -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); + }); +}); 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 index c4e85aaf3..e584b52c4 100644 --- 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 @@ -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([]); 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 { + try { + return (await this.search?.(term)) ?? []; + } catch { + return []; + } + } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts new file mode 100644 index 000000000..9fabff884 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts @@ -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; + scrolled$: Subject; + 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(); + const scrolled$ = new Subject(); + 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(); + }); +}); 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 index 6c0d5a722..6c56f4d06 100644 --- 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 @@ -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()); } /** 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 index 84166634c..02df077e4 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html @@ -23,11 +23,11 @@ (searchQueryChange)="onSearchQueryChange($event)" /> -@if (searchEnabled && searchQuery().trim().length >= 2) { +@if (searchPanelVisible()) {
@for ( hit of searchResults(); - track hit.program.channel + hit.program.start + track hit.program.channel + hit.program.start + $index ) {