From e5688ccd28e1a7b12a6bbd6c7858f4690cfce9b4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 13:26:08 +0200 Subject: [PATCH] docs(epg): document the programme guide and its release note Co-Authored-By: Claude Fable 5.1 --- .changes/epg-programme-guide.md | 13 ++++ CLAUDE.md | 5 +- docs/architecture/m3u-playlist-module.md | 62 ++++++++++++++++++- docs/architecture/pwa-self-hosted.md | 4 +- docs/architecture/workspace-shell.md | 7 +-- .../specs/2026-09-06-epg-guide-m3u-design.md | 34 +++++++--- 6 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 .changes/epg-programme-guide.md diff --git a/.changes/epg-programme-guide.md b/.changes/epg-programme-guide.md new file mode 100644 index 000000000..03da9d351 --- /dev/null +++ b/.changes/epg-programme-guide.md @@ -0,0 +1,13 @@ +--- +type: feature +area: epg +issues: [171] +highlight: Programme guide, rebuilt +--- + +The programme guide now shows your playlist's own channels in their order, +with the current group or favorites one click away. Click a channel to +switch playback while the player stays on screen above the grid; +double-click switches and closes. Open it from the new Guide button, the +header, the command palette, or the G key. Hide channels without EPG and +pick comfortable or compact rows. diff --git a/CLAUDE.md b/CLAUDE.md index c63701691..c675a2041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -362,7 +362,7 @@ This is an Nx monorepo with the following structure: - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/.svg`) - **shared/testing** - Shared test helpers - **ui/components** - Reusable UI components (incl. channel list) - - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs) + - **ui/epg** - EPG UI (timeline ribbon, programme guide grid via `EPG_GUIDE_SOURCE`, progress panel, program dialogs) - **ui/playback** - Player UI (video/audio players) - **ui/pipes** - Angular pipes - **ui/remote-control** - Remote-control UI pieces @@ -1471,7 +1471,8 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the - XMLTV format support - Background parsing in worker thread - Stored in database for quick lookup -- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and multi-EPG overlay read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") +- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset") +- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the timeline's Guide button or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide"). - Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/` **TMDB Metadata Enrichment** (opt-in): diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index a8aecd194..3eea63052 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -703,7 +703,7 @@ comparison; applying both doubles the shift. header's range and progress) receives the offset as the `offsetMinutes` input from each host; `app-channel-list-item`, the dashboard time range and the recording detail shift their labels the same way. The programme dialog - and the multi-EPG overlay are opened imperatively, so they read + and the programme guide are opened imperatively, so they read `SettingsStore.resolvedEpgOffsetMinutes` themselves. - **Clock form** — every "currently airing" decision, so the row picked as "now" is the one the UI renders as "now": the batched @@ -1068,7 +1068,7 @@ class EpgService { - EPG sidebar - Collapsible inline EPG panel for internal players, persisted through the shared `live-epg-panel-state` preference -- Multi-EPG modal view +- Programme guide (multi-channel grid) in guide mode — see "Programme guide" below - Channel info overlay - External player support (MPV, VLC) in Electron - M3U archive/catch-up playback for supported replay schemes @@ -1110,6 +1110,64 @@ class EpgService { for a programme the user clicked, both hosts surface a `EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing. +### Programme guide + +`app-epg-guide` (`libs/ui/epg/src/lib/epg-guide/`) is a host-agnostic +multi-channel grid. It reads everything through the `EPG_GUIDE_SOURCE` +injection token (`epg-guide-source.ts`): the scope-resolved channel list +(`EpgGuideChannel { id, number, name, logoUrl, epgKey }`), the available scopes +(all / group / favorites), `loadPrograms(window)` and `loadCoverage(window)` +for a provider-clock time window, the active channel and `activate(id)`. An +optional `searchPrograms(query)` returns `EpgGuideSearchHit { channelId, +program }`, where `channelId` is `null` when the host cannot say which row a +hit belongs to. The M3U adapter is `M3uEpgGuideSourceService` +(`libs/playlist/m3u/feature-player/src/lib/epg-guide/`): channels come from +NgRx, `epgKey` uses the `tvg.id → tvg.name → name` chain, and the two bridge +reads `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` resolve keys +in the main process (manual mappings first, then the metadata lookup shared +with the sidebar) and return programmes keyed by the requested key. Queries +are unscoped, like the timeline. + +Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen` +hides the sidebar and the timeline, renders the guide, and CSS reflows the +untouched `app-web-player-view` into a 128 px docked strip +(`.content-container.is-guide`) beside `app-epg-guide-now-playing`; the strip +collapses to one line (`epg-guide:dock-collapsed`). Nothing remounts, so +playback and native-view Embedded MPV bounds survive. Entry points: the +workspace header action (`m3u-epg-guide`), the command palette, the Guide +button in the timeline toolbar (`EpgTimelineComponent.openGuide`) and the `G` +key on the player page. Player fullscreen, radio, recognised movies and the +PWA close or withhold it. Inside the guide: single click on a row or an +"on now" card switches the channel and keeps the guide open, double-click or +Enter switches and closes, other cards open the programme dialog; ↑/↓ ←/→ +navigate, I details, N now, PgUp/PgDn day, Esc close — the keyboard controller +ignores keys on any element matching its interactive-target selector +(inputs, buttons, menu/option items, links, editable content) unless that +element carries `data-epg-guide-grid`, which the guide's own channel cells and +programme cards set so their `role="button"` does not shadow the grid's own +keys; a real control nested inside one (the catch-up button) still wins. +Density (`epg-guide:density`, comfortable 60 px / compact 44 px), zoom +(`epg-guide:zoom`, 120–480 px per hour, default 240) and the "Only with EPG" +toggle (`epg-guide:only-with-epg`) persist in localStorage; coverage is loaded +for the whole scope so the toggle never hides rows as they scroll in. The +guide reads the EPG display offset itself: the day axis is display time, the +request window is converted with `epgProviderClockMs`. + +**Backend contract**: `EpgGuideQueryService` +(`apps/electron-backend/src/app/events/epg-guide-query.service.ts`) answers +both IPCs from one `guideWindowCondition()` predicate — when the request +carries `sourceUrls` (portal hosts only; the M3U host never does), a row +qualifies if it belongs to one of those sources OR carries no source at all +(legacy pre-per-source-tracking data), never if it belongs to a *different* +source. Both reads cap the requested channel-key batch +(`EPG_GUIDE_MAX_CHANNELS_PER_REQUEST` = 100 for programmes, +`EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST` = 2000 for the cheaper coverage +probe) and respond with exactly the trimmed, de-duplicated, cap-respecting +requested keys: a key cut by the cap is absent from the answer, never present +with an empty list, so a caller can tell "queried, nothing found" apart from +"not queried at all". An invalid window (bad instants, `fromMs >= toMs`, no +usable keys) returns `{}`/`[]` rather than throwing. + ### External Player Request Headers The built-in web players inherit the playlist-level custom diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index eed5d6c0b..6c236e9d9 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -113,8 +113,8 @@ The self-hosted build is the browser PWA, not the Electron desktop app. Keep these limitations explicit in UI, troubleshooting, and release notes: - EPG/XMLTV is not supported in the PWA yet. Do not render live EPG panels, - multi-EPG shortcuts, or EPG-fetching flows in browser/PWA mode, and do not use - EPG as the readiness signal for Docker. + programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and + do not use EPG as the readiness signal for Docker. - The PWA does not use the Electron SQLite database or DB worker. Playlist metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently viewed items, playback positions, and cached collection snapshots use diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 63cc17262..590c96cc0 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -266,8 +266,7 @@ The Electron window hides the native title bar on all desktop platforms The controls are mounted once in `app-root` (not inside the workspace header) as a `position: fixed` top-right overlay so they stay clickable -above full-window content such as the multi-EPG cdk overlay and Material -dialog backdrops — the same behavior as the macOS traffic lights. Because +above full-window content such as Material dialog backdrops — the same behavior as the macOS traffic lights. Because CDK overlays render as popovers in the browser top layer (above any z-index), the component host is itself a `popover="manual"` element: it enters the top layer on init and re-enters it (hide + show) whenever @@ -402,8 +401,8 @@ Layout integration: `AppComponent`, same mechanism as `dark-theme`) — body-level so rules also reach cdk-overlay content rendered outside `app-root`. 2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in - top-aligned drag regions (`.workspace-header`, multi-EPG - `#epg-navigation`) for the 3 × 46px button strip. + the top-aligned drag region (`.workspace-header`) for the 3 × 46px + button strip. 3. Button colors follow the theme via CSS variables (`--app-on-surface`, `--app-hover-overlay`); the close button uses the Windows-style red hover (`#e81123`). No theme IPC is involved. diff --git a/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md index 73c98946f..400eda707 100644 --- a/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md +++ b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md @@ -164,8 +164,14 @@ Location: `libs/ui/epg/src/lib/epg-guide/`. Every production file stays under per-channel status `idle | loading | loaded`, coverage per day, reset on scope or source change. Batches are capped at 100 channels per IPC. - `epg-guide-keyboard.controller` — ↑/↓ channel, ←/→ programme, Enter play, - I details, N now, PgUp/PgDn day, Esc close. Ignores events from inputs and - while a dialog is open. + I details, N now, PgUp/PgDn day, Esc close. Ignores events whose target + matches its interactive-element selector (inputs, buttons, menu/option + items, links, editable content) and while a dialog is open. The guide's own + channel cells and programme cards carry `role="button"` for assistive tech, + which would otherwise match that selector too — they opt back in with + `data-epg-guide-grid` so the controller still owns their keys; a real + control nested inside one (the catch-up button) is the closest match and + still wins over the grid attribute. - `epg-guide-now-playing.component` — the right part of the docked player strip: channel, programme title, description, progress, Close (Esc) and Collapse. Rendered by the host beside the player, because the strip itself @@ -198,12 +204,12 @@ token set; card radius is 8 px (timeline uses 11 px on 96 px cards). initial scope follows `activeView()` and the sidebar's selected group. `epgKey` uses the `stream-resolver` chain `tvg.id → tvg.name → name`; radio and movie entries are excluded. `loadPrograms`/`loadCoverage` call the new - bridge methods with the playlist's `sourceUrls`, as - `EpgService.getCurrentProgramsForChannels` does, so playlist-scoped EPG wins. - `activate` dispatches `ChannelActions.setActiveChannel`; `activeChannelId` - mirrors `activeChannel()`. `searchPrograms` uses `EPG_SEARCH_PROGRAMS` and - filters results to the scope's resolved channel ids. `catchUp` is not wired - in this sub-project. + bridge methods unscoped (every imported EPG source), exactly like the + timeline's `getChannelPrograms`; `sourceUrls` stays in the IPC contract for + portal hosts. `activate` dispatches `ChannelActions.setActiveChannel`; + `activeChannelId` mirrors `activeChannel()`. `searchPrograms` uses + `EPG_SEARCH_PROGRAMS` unfiltered (result click opens the programme dialog, + as the previous overlay did). `catchUp` is not wired in this sub-project. - Entry points, all toggling the same signal: 1. Workspace header action (`m3u-epg-guide`, icon `grid_view`, label "Programme guide"), highlighted while open. @@ -236,6 +242,18 @@ lines). `string[]` of requested ids with at least one programme in the window. Same resolution, then `SELECT DISTINCT channel_id … WHERE start < to AND stop > from`. 2000 keys cost one resolution and one query, not 2000. +- Both responses key exactly the trimmed, de-duplicated, cap-respecting + requested ids — never the raw request. An id cut by the per-request cap + (100 for programmes, 2000 for coverage) is absent from the answer, never + present with an empty list, so a caller can tell "queried, nothing found" + apart from "not queried at all"; an invalid window (bad instants, + `fromMs >= toMs`, no usable ids) returns `{}`/`[]`. +- When a request carries `sourceUrls`, `guideWindowCondition()` folds the + source scoping into the same query instead of a second scoped/unscoped + pass: a row qualifies if it belongs to one of the requested sources OR + carries no source at all (legacy rows predating per-source EPG tracking), + never if it belongs to a *different* source. The M3U host never sets + `sourceUrls`, so this only matters for portal hosts. - Bridge: `getEpgProgramsForChannels` and `getEpgProgramCoverage` in `ElectronBridgeApi`, `main.preload.ts`, `EpgRuntimeBridgeService`; capability `supportsEpgGuide` in `RuntimeCapabilitiesService` replaces