feat(epg): rework live EPG panel into a horizontal timeline

Replace the vertical EPG list/panel with a single horizontal timeline ribbon
(`app-epg-timeline`) shared across all live surfaces — the M3U player, the
unified live tab, and the Xtream and Stalker layouts. It preloads a multi-day
window and supports scroll, zoom, day navigation, short-programme tiers and
per-state empty/error views; the programme-details dialog is restyled to match
and now shows the channel logo.

Timeline / UI
- Auto-focus the currently-airing programme on channel select — instant
  (`behavior: 'auto'`), centred, and deduped by programme-set identity so the
  30s tick / zoom / re-emits never cause flaky re-jumps.
- Wide-tier cards show a dimmed description preview under the title; title and
  preview clip on whole lines (`flex-shrink: 0` + `-webkit-line-clamp`), never
  mid-line.
- Dynamic bar subtitle: the now-playing / archive programme title instead of a
  static "Timeline" label.
- State-aware toolbar: hide Now + zoom when there is no ribbon, and the date
  stepper when the channel has no EPG on any day (hidden, not disabled).
- Empty-state fills and centres within the panel area instead of a fixed
  min-height that overflowed the compact inline panel and hid the buttons.

EPG current-programme lookup (electron-backend)
- Timezone-aware "airing now" comparison via SQLite `datetime()` (was a lexical
  string compare that broke for offset vs UTC `Z` timestamps).
- Unscoped fallback for resolved candidates whose programmes are tagged with an
  out-of-scope source (multi-source imports sharing XMLTV ids), so the sidebar
  matches the timeline.
- Match channel candidates by the raw (case-sensitive) key as well as `LOWER()`,
  since SQLite `LOWER()`/`COLLATE NOCASE` fold only ASCII — fixes Cyrillic /
  Greek channel names showing "No program information available".
- Overlap-based day membership (`hasProgramsForDateKey`) so a film spanning
  midnight keeps the ribbon on "today" while it airs.

Refactor
- Extract the duplicated channel-list EPG enrichment (progress + side-car map)
  into `channel-list-container/epg-enrichment.util.ts`, shared by the
  all-channels / groups / favorites / recent views; also fixes recent's and
  favorites' unguarded, NaN-prone progress maths.

Tests: ui-epg 75, components 77, electron-backend 432; lint + web build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-06-30 01:40:54 +02:00
1 parent b1119189e2
commit a048e56aca
60 files changed
+5523 -1191

No files matched your search

+137 -4
View File
@@ -21,8 +21,8 @@ The M3U playlist module provides:
│ libs/playlist/m3u/feature-player/src/lib/video-player/ │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │
│ │ Sidebar │ │ Video Player │ │ EPG List │ │
│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │
│ │ Sidebar │ │ Video Player │ │ EPG Timeline │ │
│ │ │ │ (ArtPlayer/Video.js)│ │ (panel below) │ │
│ │ ┌─────────┐ │ │ │ │ │ │
│ │ │Channel │ │ │ │ │ │ │
│ │ │List │ │ │ │ │ │ │
@@ -251,6 +251,122 @@ EPG lookup keys use the same precedence in both program and icon paths:
2. `tvg-name`
3. channel name
All four collection tabs (all-channels, groups, favorites, recent) share a single
EPG-enrichment implementation in `channel-list-container/epg-enrichment.util.ts`,
fed the same `channelEpgMap`/`channelIconMap` from the container — there is no
per-tab EPG logic:
- `calculateEpgProgress(program, now?)` — clamped, **rounded** progress in
`[0, 100]`, guarded against missing/invalid timestamps and zero-length
programmes (never returns `NaN`).
- `resolveChannelEpgProgram(channel, channelEpgMap)` — the current programme for
a channel by its lookup key (used by the per-item recent view).
- `buildChannelEpgMetadataMap(channelEpgMap, now?)` — the side-car
`key → {epgProgram, progressPercentage}` map (used by all-channels/groups/
favorites). Callers read their `progressTick()` signal first so the computed
re-runs on the ~30s tick.
### EPG Timeline Panel
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`.
- **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`),
so the whole ribbon is rendered up front and day navigation is **scroll within
the loaded window** — no per-day lazy fetch. The date stepper / "Now" jump
scroll the ribbon; the day label follows the scroll position.
- **Auto-focus on channel select.** When a channel's EPG (re)loads or the ribbon
(re)mounts, the timeline centres the **currently airing programme** in the
viewport **instantly** (`behavior: 'auto'`, no scroll animation) — selecting a
channel lands on "now" without the user pressing the Now button. The jump is
deduped by programme-set identity (`programsFocusKey`), so the 30s now-tick,
zoom changes, or a host re-emitting the same data never re-jump the viewport;
switching channels (or returning after viewing an empty-day channel) re-centres.
The explicit "Now" button still animates (`behavior: 'smooth'`) since it is a
deliberate user action. See `EpgTimelineComponent.maybeAutoFocus` /
`focusCurrentProgram`.
- **Controlled component.** `app-epg-timeline` is presentation-only: it takes
`programs`, `archivePlaybackAvailable`, `archiveDays`, `activeProgram`,
`isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`,
`summary` and emits `programActivated`, `returnToLive`, `selectedDateChange`,
`openEpgSettings`, `retry`, `collapsedChange`. The host layout owns playback,
persists the collapse state (`liveEpgPanelState` in localStorage), and (for
the M3U player) the `EpgActions.setCurrentEpgProgram` / `setEpgAvailableFlag`
/ `setActiveEpgProgram` dispatches. The timeline owns the **single** panel
bar — collapse chevron + channel name on the left, return-to-live / jump /
date stepper on the right — and the collapsed inline summary; the former
`app-live-epg-panel` wrapper has been removed from the live layouts.
- **Dynamic bar subtitle.** Under the channel name the bar shows the
**now-playing programme title** when expanded and a `summary` exists
(`.epg-timeline__subtitle`) — readable title style, not the uppercase mono
label. During timeshift it switches to the archive programme with a `history`
icon and cyan accent (`.is-arch`). It falls back to the static `sourceLabel`
(`Timeline` / `Xtream` / `Stalker Portal`) only when collapsed or when the
channel has no programme.
- **State-aware toolbar controls.** The right-side controls are **hidden** (not
disabled) when they cannot act, so a channel with no EPG shows a clean bar
instead of dead controls: `showRibbonControls()` gates "Now" + zoom to the
`ribbon` state only (nothing to jump to or zoom otherwise), and
`showDateStepper()` keeps the date stepper for `ribbon` **and** `empty-day`
(the only states where the channel has EPG on some day), hiding it for the
no-EPG-anywhere states and while loading. Return-to-live is a playback control
(`!isLivePlayback()`) and is independent of EPG state.
- **State-driven affordances.** Blocks are coloured past / now / future, with a
red "now" playhead. Catch-up "Watch" appears on past blocks only when
`archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is
schedule-only (dimmed past + a notice, no false buttons). The "i" button opens
the shared `app-epg-item-description` dialog with a state-aware action.
- **Empty / error states.** `emptyReason` selects one of six states
(`loading` skeleton, `empty-day`, `channel-unmapped`, `provider-no-epg`,
`m3u-needs-setup`, `error`) via `app-epg-timeline-empty-state`. Icon tone is
neutral for info, blue for actionable, red for errors; an action button is
shown only when one really exists. The empty-state host `flex: 1`-fills the
area below the toolbar and centres within **that** (compact icon/title/sub),
rather than a fixed `min-height` that used to overflow the compact inline panel
and push the icon/text — and the `empty-day` action buttons — below the visible
edge. `empty-day` itself is decided by `hasProgramsForDateKey`, which is
**overlap-based** (a programme counts for a day when `[start, stop)` intersects
it, end-exclusive) — so a film that starts the previous evening and runs past
midnight still keeps the ribbon on "today" while it airs, matching the sidebar
(which matches by "airing now"); a start-date-only check used to drop to
`empty-day` after midnight even though the programme was on air.
- **Short-programme strategy.** In a proportional ribbon a 5-minute programme
would be an unreadable sliver, so `buildTimelineRenderItems`
(`epg-timeline.utils.ts`) applies a layered fix: (A) a **minimum block width**
(`TIMELINE_MIN_BLOCK_WIDTH_PX`); (B) width-adaptive **content tiers** —
`wide` (title + time, 3-line clamp) → `med` (one-line ellipsis) → `narrow`
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
popover** revealing the full title + time + description for any non-`wide`
block (it flips above the block when the panel is near the screen bottom);
(D) a px-per-minute **zoom** slider (tick density adapts via
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
(<10 min) programmes into one dashed "N short" chip when zoomed out
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
canvas lives in the child `app-epg-timeline-track`; the parent owns the
scroller, toolbar (incl. the zoom slider) and state.
- **Panel height & titles.** Block titles wrap onto as many lines as the card
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
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.
- **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
under the title (`.epg-timeline__block-desc`). Gated to `wide` only, so
narrower cards and the moderate default zoom stay clean; the full description
still lives in the hover popover for every tier. To avoid ugly mid-line cuts,
wide-tier `time`/`title`/`desc` use `flex-shrink: 0` so flexbox can never
shrink them to a fractional height: each self-clips on **whole lines** with an
ellipsis via `-webkit-line-clamp` (title ≤ 2 lines, description ≤ 3) instead of
being cut mid-line by the parent's `overflow: hidden`. At the usual inline
panel height the title + 3-line preview fit without the parent clipping at all.
### Playlist-Declared EPG Sources
Some M3U providers declare XMLTV sources in the playlist header instead of
@@ -321,8 +437,25 @@ These URLs are playlist-scoped by default:
source scope. Batch current-program lookups use the same source-scoped
per-channel TTL cache and order-insensitive in-flight batch deduplication
before reaching IPC; missing exact channel-id matches are resolved with batched
case-insensitive id/display-name candidate queries rather than a per-channel
fallback loop.
id/display-name candidate queries rather than a per-channel fallback loop.
Those candidate queries match the **raw key case-sensitively** as well as via
`LOWER()`: SQLite's `LOWER()`/`COLLATE NOCASE` only fold ASCII, so for non-ASCII
names (Cyrillic, Greek, …) a `LOWER()`-only match would miss channels whose
M3U name and EPG `display_name` share the same casing — the raw exact match
keeps parity with the timeline's single-channel exact-display-name lookup, and
the JS `resolveChannelMetadataCandidate` then folds with full-Unicode
`toLowerCase()`. The "airing now" window is compared with timezone-aware SQLite
`datetime()` on both sides (`EpgQueryService.isAiringAt`), not raw string
comparison: stored EPG timestamps often carry an offset (e.g. `+03:00`) while
`now` is built as UTC (`…Z`), so a lexical compare would be wrong by the offset
and surface a stale (or no) current programme. After the scoped + legacy
candidate queries, any candidate that resolved by id/display-name but still has
no in-scope current programme is retried once **unscoped** (all sources),
mirroring the timeline's own unscoped `getChannelPrograms` lookup. This keeps
the channel-list "now" line consistent with the timeline when a channel's row
and its programmes carry different `source_url` values (shared XMLTV ids across
multiple imports), where the channel resolves in scope but its programmes are
tagged with a source that is not currently enabled.
When upgrading an existing database whose historical programs have no
`source_url`, scoped program and metadata queries try those legacy unscoped
rows only after the requested source scope returns no result, so old EPG data