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>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-07-02 00:29:52 +02:00
1 parent 401012eac4
commit c4890748e0
57 files changed
+2981 -90

No files matched your search

+43 -9
View File
@@ -266,16 +266,33 @@ per-tab EPG logic:
favorites). Callers read their `progressTick()` signal first so the computed
re-runs on the ~30s tick.
### EPG Timeline Panel
### EPG Panel (Timeline & List views)
The programme guide under the player is a horizontal **timeline ribbon**
(`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`) shared by all four live
surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker
live-stream layouts. It replaces the former vertical `app-epg-list` /
`app-epg-view`.
The programme guide under the player renders in one of **two interchangeable
views**, chosen by the **`epgViewMode`** setting (`'timeline'` default, or
`'list'`; Settings → EPG → *Guide view*):
The component stays presentation-focused; the reusable, view-agnostic pieces
(so a future list view can share them) are split out and re-exported from
- **Timeline** — a horizontal **ribbon** (`app-epg-timeline`,
`libs/ui/epg/src/lib/epg-timeline/`).
- **List** — a vertical, single-day **programme list** (`app-epg-list-view`,
`libs/ui/epg/src/lib/epg-list-view/`) with a prev/today/next stepper.
Both are shared by all four live surfaces: the M3U video player, the unified live
tab, and the Xtream and Stalker live-stream layouts (replacing the former
vertical `app-epg-list` / `app-epg-view`). `EpgListViewComponent` mirrors
`EpgTimelineComponent`'s input/output contract **1:1**, so each host swaps them
with a plain `@if (epgViewMode() === 'list') { <app-epg-list-view … /> } @else {
<app-epg-timeline … /> }` — identical bindings in both branches. Hosts read
`epgViewMode` from `SettingsStore` (a signal), so flipping the setting swaps the
panel live. The setting flows end-to-end (`Settings.epgViewMode` →
`DEFAULT_SETTINGS` → `SettingsStore`/`StorageMap` → the segmented control in
`settings-epg-section`) and needs no backend change. The control is
**Electron-only in practice** — the EPG settings section (and the form control)
is gated behind `supportsEpg`, which is false in PWA; there the stored value
simply stays at the `'timeline'` default.
Both components stay presentation-focused; the reusable, view-agnostic pieces
(shared by the timeline and the list) are split out and re-exported from
`@iptvnator/ui/epg`:
- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts`
@@ -290,6 +307,19 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
scrolling + channel-select auto-focus); timeline-specific, kept out of the
component so it stays under the line ceiling.
The **list view** (`epg-list-view/`) composes those same shared modules — it does
**not** duplicate classification or gating logic. It reuses `classifyTimelineWhen`
/ `hasProgramsForDateKey` / `nearestDateKeyWithPrograms`, `epg-archive.util`,
`epg-summary.util`, the `epg-date` helpers, `EpgProgrammeDialogService`, and the
shared `app-epg-timeline-empty-state` — and drops all ribbon geometry, zoom, and
horizontal scroll. It filters the loaded window to the selected day (overlap-based,
matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
`buildEpgListRows` (`epg-list-view.utils.ts`); renders each row through the dumb
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
states, the collapsed inline summary, the date stepper, catch-up/timeshift
activation, and the details dialog behave identically to the timeline.
- **One channel, preloaded window.** The panel always shows a single channel.
Each provider returns a multi-day window in roughly one call (M3U
`GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`),
@@ -370,7 +400,11 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)`
in `_portal-layout.scss`) so the player stays dominant; with an external
player the guide keeps `flex: 1` and fills the whole content area.
player the guide keeps `flex: 1` and fills the whole content area. In **list
mode** the hosts also set `.epg--list`, which raises only the inline clamp
(`--epg-inline-height: clamp(280px, 46vh, 430px)`) — vertical rows need more
height than the ribbon; the timeline height is unchanged, and the collapsed
56px clamp still wins because the modifier sets just the CSS variable.
- **Wide-tier description preview.** When a block is the `wide` tier (rendered
width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme
has a `desc`, a dimmed (`--text-secondary`) preview of the description renders