Files
iptvnator/libs/ui/epg/src/lib/epg-guide/epg-guide.component.html
T
4grayandClaude Fable 5.1 e00e5d6476 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>
2026-09-06 14:34:39 +02:00

176 lines
6.0 KiB
HTML

<app-epg-guide-toolbar
[dayMs]="view.axis().startMs"
[isToday]="view.isToday()"
[currentLocale]="currentLocale()"
[scopes]="scopes()"
[scopeId]="scopeId()"
[onlyWithEpg]="onlyWithEpg()"
[coverageLoaded]="coverageLoaded()"
[density]="density()"
[zoom]="zoom()"
[filter]="filter()"
[searchEnabled]="searchEnabled"
[searchQuery]="searchQuery()"
[shownCount]="rows().length"
[totalCount]="totalCount()"
(stepDay)="stepDay($event)"
(jumpNow)="jumpNow()"
(scopeChange)="setScope($event)"
(onlyWithEpgChange)="setOnlyWithEpg($event)"
(densityChange)="setDensity($event)"
(zoomChange)="setZoom($event)"
(filterChange)="setFilter($event)"
(searchQueryChange)="onSearchQueryChange($event)"
/>
@if (searchPanelVisible()) {
<div class="epg-guide__search-results" role="listbox">
@for (
hit of searchResults();
track hit.program.channel + hit.program.start + $index
) {
<button
type="button"
class="epg-guide__search-result"
role="option"
[attr.aria-selected]="false"
(click)="openSearchResult(hit)"
>
<span class="epg-guide__search-title">{{
hit.program.title
}}</span>
<span class="epg-guide__search-meta">
{{
searchHitStartMs(hit)
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
}}
· {{ hit.program.channel }}
</span>
</button>
} @empty {
<div class="epg-guide__search-empty">
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
</div>
}
</div>
}
<div
class="epg-guide__grid"
[style.--epg-guide-channel-col.px]="channelColumnPx"
>
<div class="epg-guide__ruler">
<div class="epg-guide__corner">
{{ 'EPG.GUIDE.CHANNEL' | translate }}
</div>
<div class="epg-guide__ruler-clip">
<div
class="epg-guide__ruler-track"
[style.width.px]="view.trackWidthPx()"
[style.transform]="'translateX(' + -view.scrollLeft() + 'px)'"
>
@for (tick of view.ticks(); track tick.ms) {
<div
class="epg-guide__tick"
[class.is-half]="tick.kind === 'half'"
[style.left.px]="tick.leftPx"
>
{{ tick.ms | date: 'HH:mm' }}
</div>
}
@if (view.nowLeftPx() !== null) {
<div
class="epg-guide__now-badge"
[style.left.px]="view.nowLeftPx()"
>
{{ view.nowMs() | date: 'HH:mm' }}
</div>
}
</div>
</div>
</div>
<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"
>
<app-epg-guide-row
*cdkVirtualFor="
let channel of rows();
trackBy: trackRow;
let index = index
"
[attr.aria-rowindex]="index + 1"
[channel]="channel"
[programs]="programsFor(channel.id)"
[status]="statusFor(channel.id)"
[axis]="view.axis()"
[hourWidthPx]="zoom()"
[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)"
(watchRequested)="watch(channel, $event)"
/>
@if (rows().length === 0) {
<div class="epg-guide__no-rows">
<mat-icon>search_off</mat-icon>
<p>
{{
'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() }
}}
</p>
@if (filter()) {
<button type="button" mat-button (click)="setFilter('')">
{{ 'EPG.GUIDE.CLEAR_FILTER' | translate }}
</button>
}
</div>
}
</cdk-virtual-scroll-viewport>
<!-- Clipped to the lane so the line never paints over the sticky column. -->
<div class="epg-guide__now-clip">
@if (view.nowLineLeftPx() !== null) {
<div
class="epg-guide__now-line"
[style.left.px]="view.nowLineLeftPx()"
></div>
}
</div>
</div>
<div class="epg-guide__keys">
<span
><kbd>&uarr;</kbd><kbd>&darr;</kbd
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
>
<span
><kbd>&larr;</kbd><kbd>&rarr;</kbd
>{{ 'EPG.GUIDE.KEYS.PROGRAMME' | translate }}</span
>
<span><kbd>Enter</kbd>{{ 'EPG.GUIDE.KEYS.PLAY' | translate }}</span>
<span><kbd>I</kbd>{{ 'EPG.GUIDE.KEYS.DETAILS' | translate }}</span>
<span><kbd>N</kbd>{{ 'EPG.GUIDE.KEYS.NOW' | translate }}</span>
<span
><kbd>PgUp</kbd><kbd>PgDn</kbd
>{{ 'EPG.GUIDE.KEYS.DAY' | translate }}</span
>
<span><kbd>Esc</kbd>{{ 'EPG.GUIDE.KEYS.CLOSE' | translate }}</span>
</div>