Files
iptvnator/apps/web/src/app/settings/settings-epg-section.component.html
T
4grayandClaude Opus 4.8 0b526b119a feat(epg): add vertical list view for the live EPG panel (#1115)
* 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>
2026-07-04 10:05:08 +02:00

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>