mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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>
176 lines
6.0 KiB
HTML
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>↑</kbd><kbd>↓</kbd
|
|
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
|
|
>
|
|
<span
|
|
><kbd>←</kbd><kbd>→</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>
|