mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
44ad33beb9
commit
e9359750bf
16 files changed
+782
-56
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user