mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(epg): add vertical list view for the live EPG panel
Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).
- New EpgListViewComponent (app-epg-list-view) mirrors
EpgTimelineComponent's input/output contract 1:1, so all four live
hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
hasProgramsForDateKey, epg-archive.util catch-up gating,
epg-summary.util collapsed-summary maths, epg-date helpers,
EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
duplicated logic.
- Rows show time range, title, optional description, live progress on
the on-air row, catch-up "Watch" on past rows when archive playback
is available, and a details dialog; keyboard activation guards
nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
collapse/expand remounts, and shows a sticky in-flow "On now" strip
(never overlaying rows) when the current programme is scrolled away;
all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
modifier (--epg-inline-height clamp); timeline and collapsed heights
are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
SettingsStore/StorageMap → segmented control in the EPG section);
Electron-only UI, PWA stays on the timeline default. i18n keys added
to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
persistence spec, swap tests in all four host specs, and Electron
E2E for the settings round-trip and the rendered list view. Docs
updated (m3u-playlist-module.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): address list-view review findings from Codex and Greptile
- Reset the list view to today when a new channel's programme set
arrives while the user is parked on another day (timeline parity):
the scroll controller now keys by the full programme-set identity
(programsFocusKey) and commits today before focusing, instead of
silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
on SettingsStore; the four live hosts consume the derived signal
instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
registerEpgListViewEffects(), bringing the component back under the
300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
coverage: return-to-today on channel switch, day navigation left
alone, no-takeover when today has no data, empty-set no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): drop malformed programmes from the list-view day filter
Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
7.2 KiB
HTML
175 lines
7.2 KiB
HTML
<section
|
|
class="settings-group"
|
|
[class.settings-group--active]="activeSection() === 'epg'"
|
|
id="epg"
|
|
[formGroup]="form()"
|
|
>
|
|
<header class="settings-group__header">
|
|
<div class="settings-group__header-icon">
|
|
<mat-icon>list_alt</mat-icon>
|
|
</div>
|
|
<div class="settings-group__header-text">
|
|
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
|
|
<p>{{ 'SETTINGS.EPG_SOURCES_DESCRIPTION' | translate }}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="setting-item setting-item--full-control">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
|
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<div
|
|
class="theme-switcher"
|
|
role="radiogroup"
|
|
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
|
data-test-id="select-epg-view-mode"
|
|
>
|
|
@for (option of epgViewModeOptions(); track option.value) {
|
|
<button
|
|
type="button"
|
|
class="theme-switcher__option"
|
|
[class.theme-switcher__option--selected]="
|
|
form().value.epgViewMode === option.value
|
|
"
|
|
[attr.aria-checked]="
|
|
form().value.epgViewMode === option.value
|
|
"
|
|
[attr.data-test-id]="'epg-view-mode-' + option.value"
|
|
role="radio"
|
|
(click)="selectEpgViewMode.emit(option.value)"
|
|
>
|
|
<mat-icon>{{ option.icon }}</mat-icon>
|
|
<span>{{ option.labelKey | translate }}</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item setting-item--full-control">
|
|
<div class="setting-item__control">
|
|
<ng-container formArrayName="epgUrl">
|
|
@if (epgUrl().length === 0) {
|
|
<div
|
|
class="epg-sources-empty"
|
|
data-test-id="epg-sources-empty"
|
|
>
|
|
<mat-icon class="epg-sources-empty__icon"
|
|
>event_busy</mat-icon
|
|
>
|
|
<h4 class="epg-sources-empty__title">
|
|
{{ 'SETTINGS.EPG_EMPTY_TITLE' | translate }}
|
|
</h4>
|
|
<p class="epg-sources-empty__hint">
|
|
{{ 'SETTINGS.EPG_EMPTY_HINT' | translate }}
|
|
</p>
|
|
</div>
|
|
} @else {
|
|
<div class="epg-sources">
|
|
@for (_ of epgUrl().controls; track _; let i = $index) {
|
|
<div class="epg-source-row">
|
|
<mat-form-field
|
|
appearance="outline"
|
|
subscriptSizing="dynamic"
|
|
class="w-full"
|
|
>
|
|
<mat-label>{{
|
|
'SETTINGS.EPG_URL_PLACEHOLDER'
|
|
| translate
|
|
}}</mat-label>
|
|
<input
|
|
matInput
|
|
type="url"
|
|
[formControlName]="i"
|
|
#epgField
|
|
/>
|
|
<mat-error>{{
|
|
'SETTINGS.EPG_URL_ERROR' | translate
|
|
}}</mat-error>
|
|
</mat-form-field>
|
|
@if (epgField.value) {
|
|
<app-epg-source-status
|
|
[url]="epgField.value"
|
|
/>
|
|
}
|
|
<button
|
|
mat-icon-button
|
|
[matTooltip]="
|
|
'SETTINGS.REFRESH_EPG' | translate
|
|
"
|
|
type="button"
|
|
[disabled]="epgField.value === ''"
|
|
(click)="refreshEpg.emit(epgUrl().value[i])"
|
|
>
|
|
<mat-icon>refresh</mat-icon>
|
|
</button>
|
|
<button
|
|
mat-icon-button
|
|
[matTooltip]="
|
|
'SETTINGS.REMOVE_EPG_SOURCE' | translate
|
|
"
|
|
type="button"
|
|
(click)="removeEpgSource.emit(i)"
|
|
>
|
|
<mat-icon>remove</mat-icon>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</ng-container>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}</h4>
|
|
<p>{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
formControlName="preferUploadedEpgOverXtream"
|
|
data-test-id="toggle-prefer-uploaded-epg"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="settings-group__footer">
|
|
<div class="inline-actions">
|
|
<button mat-button type="button" (click)="addEpgSource.emit()">
|
|
<mat-icon>add</mat-icon>
|
|
{{ 'SETTINGS.ADD_EPG_SOURCE' | translate }}
|
|
</button>
|
|
<button
|
|
mat-button
|
|
type="button"
|
|
[disabled]="epgUrl().length === 0 || isClearingEpgData()"
|
|
(click)="refreshAllEpg.emit()"
|
|
>
|
|
<mat-icon>refresh</mat-icon>
|
|
{{ 'SETTINGS.REFRESH_ALL_EPG' | translate }}
|
|
</button>
|
|
<button
|
|
mat-button
|
|
color="warn"
|
|
type="button"
|
|
[disabled]="isClearingEpgData()"
|
|
(click)="clearEpgData.emit()"
|
|
>
|
|
@if (isClearingEpgData()) {
|
|
<mat-progress-spinner
|
|
class="button-spinner"
|
|
diameter="16"
|
|
mode="indeterminate"
|
|
/>
|
|
{{ 'SETTINGS.CLEARING_EPG_DATA' | translate }}
|
|
} @else {
|
|
{{ 'SETTINGS.CLEAR_EPG_DATA' | translate }}
|
|
}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</section>
|