Files
iptvnator/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html
T
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00

278 lines
9.0 KiB
HTML

<div id="epg-navigation">
<button
[matTooltip]="'CLOSE' | translate"
mat-icon-button
(click)="close()"
>
<mat-icon>close</mat-icon>
</button>
<button
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
mat-icon-button
(click)="switchDay('prev')"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ selectedDayDate() | date: 'MMMM d, EEEE' : undefined : currentLocale() }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
mat-icon-button
(click)="switchDay('next')"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button
mat-icon-button
(click)="zoomOut()"
[disabled]="hourWidth() <= 50"
[matTooltip]="'EPG.ZOOM_OUT' | translate"
[attr.aria-label]="'EPG.ZOOM_OUT' | translate"
>
<mat-icon>remove</mat-icon>
</button>
<button
mat-icon-button
(click)="zoomIn()"
[disabled]="hourWidth() >= 800"
[matTooltip]="'EPG.ZOOM_IN' | translate"
[attr.aria-label]="'EPG.ZOOM_IN' | translate"
>
<mat-icon>add</mat-icon>
</button>
<div class="search-container" [class.expanded]="isSearchExpanded()">
@if (isSearchExpanded()) {
<div class="channel-search">
<mat-icon class="search-icon">search</mat-icon>
<input
type="text"
[value]="channelFilter()"
(input)="onFilterInput($event)"
placeholder="Filter channels..."
autocomplete="off"
#searchInput
/>
@if (channelFilter()) {
<button class="clear-btn" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
</button>
}
</div>
<span class="filter-count">{{ filteredChannels().length }} / {{ channels().length }}</span>
}
<button
mat-icon-button
(click)="toggleSearch()"
[matTooltip]="
(isSearchExpanded() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
| translate
"
[attr.aria-label]="
(isSearchExpanded() ? 'EPG.CLOSE_FILTER' : 'EPG.FILTER_CHANNELS')
| translate
"
>
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
</button>
</div>
<!-- Program Search -->
<div class="program-search-container" [class.open]="isProgramSearchOpen()">
@if (isProgramSearchOpen()) {
<div class="program-search-input">
<mat-icon class="search-icon">manage_search</mat-icon>
<input
type="text"
[value]="programSearchQuery()"
(input)="onProgramSearchInput($event)"
placeholder="Search programs..."
autocomplete="off"
#programSearchInput
/>
@if (programSearchQuery()) {
<button class="clear-btn" (click)="clearProgramSearch()">
<mat-icon>clear</mat-icon>
</button>
}
@if (isSearchingPrograms()) {
<div class="search-spinner"></div>
}
</div>
@if (programSearchResults().length > 0) {
<div class="program-search-results">
@for (program of programSearchResults(); track program.start) {
<button class="search-result-item" (click)="showProgramDetails(program)">
<div class="result-info">
<span class="result-title">{{ program.title }}</span>
<span class="result-meta">
<span class="result-time">
{{ programDisplayMs(program, 'start') | date: 'HH:mm' }}
-
{{ programDisplayMs(program, 'stop') | date: 'HH:mm' }}
</span>
<span class="result-date">
{{ programDisplayMs(program, 'start') | date: 'MMM d' : undefined : currentLocale() }}
</span>
</span>
</div>
<mat-icon class="result-arrow">arrow_forward</mat-icon>
</button>
}
</div>
}
@if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
<div class="program-search-empty">
<mat-icon>search_off</mat-icon>
<span>No programs found</span>
</div>
}
}
<button
mat-icon-button
(click)="toggleProgramSearch()"
[matTooltip]="
(isProgramSearchOpen()
? 'EPG.CLOSE_PROGRAM_SEARCH'
: 'EPG.SEARCH_PROGRAMS'
) | translate
"
[attr.aria-label]="
(isProgramSearchOpen()
? 'EPG.CLOSE_PROGRAM_SEARCH'
: 'EPG.SEARCH_PROGRAMS'
) | translate
"
>
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
</button>
</div>
</div>
<div class="parent" #epgContainer (scroll)="onScroll($event)">
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
@for (item of filteredChannels(); track item.id; let i = $index) {
<g>
<!-- channel name (column widened to 180px per UX audit) -->
<g class="channel" [class.active]="item.id === activeChannelId">
<rect
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="180"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
@if (getChannelIcon(item)) {
<img
[src]="getChannelIcon(item)"
[alt]="getChannelName(item)"
>
}
<span>{{ getChannelName(item) }}</span>
</div>
</foreignObject>
</g>
</g>
}
</svg>
<div id="epg-container" [style.height.px]="filteredChannels().length * barHeight + barHeight + 50">
<svg [attr.width]="hourWidth() * 24" [attr.height]="filteredChannels().length * barHeight + barHeight + 50" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth()"
[attr.height]="barHeight"
[attr.x]="hour * hourWidth()"
fill="#000"
></rect>
<text
[attr.x]="hour * hourWidth()"
y="10"
font-size="14"
fill="white"
transform="translate(20,20)"
>
{{ hour }}:00
</text>
<!-- vertical hour separator line -->
<line
[attr.x1]="hour * hourWidth()"
[attr.y1]="barHeight"
[attr.x2]="hour * hourWidth()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
class="hour-separator"
></line>
</g>
}
<!-- epg channels with programs -->
@for (item of filteredChannels(); track item.id; let i = $index) {
<g [class.active-row]="item.id === activeChannelId">
@for (program of item.programs; track trackByProgram($index, program)) {
<g
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
[class.is-now]="isProgramAiringNow(program)"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
}
</g>
}
<!-- current time line + label badge.
The badge sits on top of the time-header row so users can read the
current time at a glance — addresses the UX-audit complaint that
the now-line was a "decorative gradient" without a time anchor. -->
<line
[attr.x1]="currentTimeLine()"
y1="0"
[attr.x2]="currentTimeLine()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
id="current-time-line"
></line>
<g class="now-label" [attr.transform]="'translate(' + currentTimeLine() + ', ' + (barHeight - 14) + ')'">
<rect
x="-26"
y="-9"
width="52"
height="18"
rx="9"
ry="9"
></rect>
<text x="0" y="4" text-anchor="middle">{{ currentTimeLabel() }}</text>
</g>
</svg>
</div>
</div>
<!-- Empty state when no channels match filter -->
@if (filteredChannels().length === 0 && channelFilter()) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>No channels match "{{ channelFilter() }}"</p>
<button mat-button (click)="clearFilter()">Clear filter</button>
</div>
}