mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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>
278 lines
9.0 KiB
HTML
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>
|
|
}
|