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