Files
iptvnator/apps/web/src/app/settings/settings.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

144 lines
5.5 KiB
HTML

@if (isDialog) {
<h2 mat-dialog-title>{{ 'SETTINGS.GENERAL' | translate }}</h2>
}
<div
class="settings-container"
data-test-id="settings-container"
[class.dialog-mode]="isDialog"
appSettingsSectionScroll
>
<form
[formGroup]="settingsForm"
(ngSubmit)="onSubmit()"
class="settings-layout"
novalidate
>
<!-- Page-level "Settings" h1 intentionally removed: the left rail
already labels the page "Settings" (sg-nav-title), and each
section below provides its own h3 + subtitle. Stacking another
h1 in the right pane reproduced the audit's "vertical ladder
of identical labels" complaint. -->
@if (!isDialog) {
<!-- Reserved hook so the page test selector still resolves,
even though there's no visible chrome here. -->
<span
class="visually-hidden"
data-test-id="settings-page-header"
>
{{ 'SETTINGS.TITLE' | translate }}
</span>
}
<div class="settings-content">
<app-settings-general-section
[form]="settingsForm"
[activeSection]="activeSection()"
[languageEnum]="languageEnum"
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
(selectTheme)="selectTheme($event)"
(selectCoverSize)="selectCoverSize($event)"
/>
<app-settings-playback-section
[form]="settingsForm"
[activeSection]="activeSection()"
[players]="players()"
[streamFormatEnum]="streamFormatEnum"
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@if (supportsEpg) {
<app-settings-epg-section
[form]="settingsForm"
[activeSection]="activeSection()"
[epgUrl]="epgUrl"
[isClearingEpgData]="isClearingEpgData()"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="refreshEpg($event)"
(removeEpgSource)="removeEpgSource($event)"
(addEpgSource)="addEpgSource()"
(refreshAllEpg)="refreshAllEpg()"
(clearEpgData)="clearEpgData()"
(selectEpgViewMode)="selectEpgViewMode($event)"
/>
}
<app-settings-dashboard-section
[form]="settingsForm"
[activeSection]="activeSection()"
/>
@if (supportsRemoteControl) {
<app-settings-remote-control-section
[form]="settingsForm"
[activeSection]="activeSection()"
[localIpAddresses]="localIpAddresses()"
[visibleQrCodeIp]="visibleQrCodeIp()"
(toggleQrCode)="toggleQrCode($event)"
/>
}
<app-settings-backup-section
[activeSection]="activeSection()"
[isPwa]="isPwa"
[isRemovingAllPlaylists]="isRemovingAllPlaylists()"
[isExportingData]="isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
<app-settings-reset-section
[activeSection]="activeSection()"
[isRemovingAllPlaylists]="isRemovingAllPlaylists()"
[canRemoveAllPlaylists]="canRemoveAllPlaylists()"
[playlistDeleteSummary]="playlistDeleteSummary()"
[removeAllProgressLabel]="removeAllProgressLabel()"
(removeAll)="removeAll()"
/>
<app-settings-about-section
[activeSection]="activeSection()"
[isDesktop]="isDesktop"
[version]="version"
[updateMessage]="updateMessage"
[appUpdateStatus]="appUpdateStatus()"
(checkForAppUpdate)="checkForAppUpdate()"
(downloadAppUpdate)="downloadAppUpdate()"
(installAppUpdate)="installAppUpdate()"
(openManualAppUpdate)="openManualAppUpdate()"
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
/>
<footer class="action-bar" data-test-id="settings-action-bar">
@if (isDialog) {
<button
mat-button
type="button"
(click)="backToHome()"
data-test-id="back-to-home"
>
<mat-icon>close</mat-icon>
{{ 'CLOSE' | translate }}
</button>
}
<button
mat-flat-button
type="submit"
class="settings-save-button"
[disabled]="settingsForm.pristine || !settingsForm.valid"
data-test-id="save-settings"
>
<mat-icon>save</mat-icon>
{{ 'SETTINGS.SAVE_CHANGES' | translate }}
</button>
</footer>
</div>
</form>
</div>