From 304f0952f7117f5811460182debb13986cac0b63 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 10:32:51 +0200 Subject: [PATCH] docs(epg): add programme guide redesign spec for the M3U host Co-Authored-By: Claude Fable 5.1 --- .../specs/2026-09-06-epg-guide-m3u-design.md | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md 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 new file mode 100644 index 000000000..18425a4da --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md @@ -0,0 +1,314 @@ +# Programme Guide (Multi-EPG) Redesign — M3U Host + +Sub-project 1 of the guide rework tracked by issue #171. It rebuilds the +multi-channel EPG grid as a host-agnostic component, fills it with the +playlist's own channels, makes channel rows switch playback, and restyles it in +the visual language of the EPG timeline under the player. Sub-project 2 (Xtream +and Stalker hosts) is out of scope here but shapes the data contract; see +"Deferred to sub-project 2". + +Decisions were taken against a visual companion: +https://claude.ai/code/artifact/0cac36f6-9027-46a7-bd0c-b264416e6acf +(Q1 layout → B "player docked on top", Q2 grid styling → as drawn, Q3 density +→ comfortable by default with a toggle). + +## Goal + +- The guide shows the channels of the current playlist scope (all channels, a + group, or favorites) in playlist order, never the whole XMLTV channel table. +- Clicking a channel row switches live playback without closing the guide; + double-click or Enter switches and closes. The player stays visible while + the guide is open. +- The grid reuses the timeline's tokens, typography, now-line, card tiers and + behaviour so both guides read as one product. +- Rows for channels without EPG data stay in place (numbers match the sidebar) + and can be hidden with an "Only with EPG" toggle. +- The old SVG overlay, its IPC and its capability are removed, not kept beside + the new implementation. + +## Current Problem + +`MultiEpgContainerComponent` (`libs/ui/epg/src/lib/multi-epg/`) is a +full-window CDK overlay opened from `VideoPlayerComponent.openMultiEpgView()`. +It receives `playlistChannels` but only uses the emission as a reload trigger: +its data comes from `EPG_GET_CHANNELS_BY_RANGE`, which pages through the +entire `epg_channels` table alphabetically and issues one programme query per +channel. A large XMLTV therefore fills the grid with hundreds of channels the +playlist does not carry. Channel rows are not clickable, only programmes are, +and the SVG + `foreignObject` layout cannot virtualise rows. The overlay +covers the player, so a native-view Embedded MPV surface, which paints above +the DOM, would hide the grid, and channel switching from an overlay is blind +anyway. + +## Considered Approaches + +### 1. Host-owned layout mode + injected data source (selected) + +The guide becomes `app-epg-guide` in `libs/ui/epg`, ignorant of playlists and +portals. A host provides `EPG_GUIDE_SOURCE`. The M3U host keeps a `guideOpen` +signal; while it is on, the template drops the sidebar and the timeline, +renders the guide, and CSS reflows the player container into a docked strip +on top. The player component keeps its DOM position, so nothing remounts and +native-view Embedded MPV syncs bounds like on any resize. This is the same +pattern as the fullscreen channel panel: the host owns layout, the shared +component owns content. + +### 2. Keep the CDK overlay and cut a "hole" for the player + +Two independent layers would have to agree pixel-for-pixel on resize and +zoom, the overlay lives in another stacking context (window controls, +dialogs), and the scope switcher in the guide toolbar would have to reach +sidebar state through the overlay. Rejected as fragile. + +### 3. Child route `/workspace/playlists/:id/guide` + +The M3U child routes only drive the sidebar view; the guide needs the whole +width, so the parent template would still switch layout on route state. That +is approach 1 plus routing, and the only gain is a URL nobody asked for. + +## Data Contract: `EPG_GUIDE_SOURCE` + +Defined in `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` (types and the +injection token only, no Angular services). + +```ts +interface EpgGuideChannel { + id: string; // host-stable id (M3U: channel.id) + number: number; // 1-based position within the current scope + name: string; + logoUrl: string | null; + epgKey: string | null; // programme lookup key; null = host knows there is no binding +} + +interface EpgGuideScope { + id: string; + label: string; + kind: 'all' | 'group' | 'favorites'; +} + +interface EpgGuideWindow { + channels: EpgGuideChannel[]; + fromMs: number; // provider-clock instants (display offset already removed) + toMs: number; +} + +interface EpgGuideSource { + readonly channels: Signal; // scope-resolved, playlist order, radio/movies excluded by host + readonly scopes: Signal; + readonly scopeId: Signal; + setScope(id: string): void; + loadPrograms(window: EpgGuideWindow): Promise>; // keyed by channel.id + loadCoverage(window: EpgGuideWindow): Promise>; // channel.ids with ≥1 programme + readonly activeChannelId: Signal; + activate(channelId: string): void; // switch playback; the guide stays open + searchPrograms?(query: string): Promise; // toolbar search hidden when absent + catchUp?: { + canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean; + watch(channel: EpgGuideChannel, program: EpgProgram): void; + }; +} +``` + +Rules: + +- Programmes are the shared `EpgProgram` (XMLTV shape). Portal adapters convert + `EpgItem` with the existing `toEpgProgram` from + `unified-live-epg-summary.util`. +- A channel with `epgKey === null` renders "No programme information" without + a request. +- `loadCoverage` exists so the "Only with EPG" toggle has a complete answer + before rows scroll into view; hiding rows as they load would look like data + loss. For M3U it is one SQL `EXISTS`; Stalker's bulk cache answers for free. +- Time windows are provider-clock instants: the guide converts the selected + day with `epgProviderClockMs` before requesting and renders with + `epgDisplayTimeMs`, exactly one offset form per comparison (contract in + `epg-display-offset.util.ts`). The guide reads + `SettingsStore.resolvedEpgOffsetMinutes` itself, as the programme dialog + does, because it has several openers. +- The contract does not carry the player, "now playing", or layout. Those are + host concerns. + +## Guide Component + +Location: `libs/ui/epg/src/lib/epg-guide/`. Every production file stays under +300 lines. + +- `epg-guide.component` — shell. Injects the source, owns the selected day, + zoom, density, channel filter, "Only with EPG" state. Outputs `close` and + `channelActivated`. The time axis is 00:00–24:00 of the selected day; the + now-line and its time badge tick once a minute; "Now" returns to today and + scrolls the current time into view. Single click on a row or on an + "ON NOW" card calls `activate`; double-click or Enter calls `activate` then + emits `close`; click on a past or future card opens the existing programme + dialog via `EpgProgrammeDialogService`. +- `epg-guide-toolbar.component` — ‹ date ›, Now, scope select, "Only with + EPG" toggle, density toggle (comfortable 60 px / compact 44 px), zoom range + (120–480 px per hour), channel filter, programme search (only when the + source implements `searchPrograms`). Density, zoom and the toggle persist in + `localStorage` under `epg-guide:density`, `epg-guide:zoom`, + `epg-guide:only-with-epg`; the scope is not persisted. +- `epg-guide-row.component` (OnPush) — channel cell (number, logo, name; + second line: current programme title, "No programme information", or + "▶ Playing" plus an equaliser icon for the active row) and the lane with + cards. Card tiers reuse the timeline's rules: wide = mono time + title, + narrow (< ~70 px) = title only, micro (< ~20 px) = bar with tooltip. Past + cards have no fill and a thin border; "ON NOW" cards carry a progress wash + and tag; the active channel's row uses the selection surface and a left + accent bar. +- `epg-guide-layout.util` — day window, ticks, now-x, card left/width/tier + computed through `epg-timeline-render.util` so grid and timeline agree. +- `epg-guide-programs.service` (component-provided) — cache keyed by + (channel id, day), batched loads for visible rows plus a 10-row buffer, + 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. +- `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 + is host layout. + +Rendering: CDK virtual scroll over fixed-height rows inside one viewport that +scrolls both ways; the channel column is `position: sticky; left: 0`, the +ruler `sticky; top: 0`. Styles come from `_epg-theme.scss`, the timeline's +token set; card radius is 8 px (timeline uses 11 px on 96 px cards). + +## M3U Host + +- `VideoPlayerComponent` gains `guideOpen` and `guideDockCollapsed` signals + (the latter persisted under `epg-guide:dock-collapsed`). While + `guideOpen()` is true the sidebar and the `.epg` block are not rendered; + `` takes their place; `.content-container` gets `is-guide` + and CSS turns `.video-player` into a 128 px strip (16:9 video on the left, + `app-epg-guide-now-playing` beside it; 40 px single line when collapsed). + `app-web-player-view` keeps its DOM position, so HTML5/hls.js, Video.js, + ArtPlayer and both Embedded MPV engines keep playing; native-view syncs + bounds as on any resize. +- The guide is unavailable for radio and recognised movies (no + `app-web-player-view` host there) and in the PWA (`supportsEpg` is false). + Entering player fullscreen closes the guide; the guide and the fullscreen + channel panel are mutually exclusive. +- `M3uEpgGuideSourceService` in + `libs/playlist/m3u/feature-player/src/lib/epg-guide/`, provided by the host + component as `EPG_GUIDE_SOURCE`. Channels come from NgRx (`selectChannels`, + groups, favorites); scopes are "All channels", each group, "Favorites"; the + 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. +- Entry points, all toggling the same signal: + 1. Workspace header action (`m3u-epg-guide`, icon `grid_view`, label + "Programme guide"), highlighted while open. + 2. Command palette entry (same action). + 3. A "Guide" button in the timeline toolbar next to Now and zoom: new + `openGuide` output on `app-epg-timeline`, rendered only when the host is + subscribed. + 4. Key `G` on the M3U player page, handled by the host's existing document + listener (the PageUp/PageDown zapping one) with the same exclusions: + typing, dialogs, player fullscreen. Not added to workspace-level + shortcuts, so portals without a guide never swallow G. +- Closing: Esc, the strip's Close button, any entry point again, + double-click on a channel, leaving the route. + +## Backend + +Two new IPC handlers registered in `epg.events.ts`, implemented in a new +`epg-guide-query.service.ts` (`epg-query.service.ts` is already past 900 +lines). + +- `EPG_GET_PROGRAMS_FOR_CHANNELS { channelIds, fromMs, toMs, sourceUrls? }` → + `Record`. Id resolution matches + `GET_CURRENT_PROGRAMS_BATCH`: manual mappings via `queryByResolvedChannelIds`, + then exact id in scoped sources, legacy rows, then case-insensitive + `id`/`display_name` candidates. One SQL per batch: + `channel_id IN (...) AND start < to AND stop > from` on + `idx_epg_programs_time_range`; programmes crossing the day boundary are + included. Batches are limited to 100 ids; the renderer splits. +- `EPG_GET_PROGRAM_COVERAGE { channelIds, fromMs, toMs, sourceUrls? }` → + `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. +- Bridge: `getEpgProgramsForChannels` and `getEpgProgramCoverage` in + `ElectronBridgeApi`, `main.preload.ts`, `EpgRuntimeBridgeService`; + capability `supportsEpgGuide` in `RuntimeCapabilitiesService` replaces + `supportsEpgChannelBrowser` inside the composite `supportsEpg`. Both methods + get rows in `main.preload.spec-data.ts`. +- The display offset never reaches SQL (see the contract rules above). + +## Removed + +- `libs/ui/epg/src/lib/multi-epg/` (component, spec, `overlay-ref.token.ts`), + its export, its `max-lines-baseline.mjs` entry, `openMultiEpgView` and the + `COMPONENT_OVERLAY_REF` wiring in `VideoPlayerComponent`. +- `EPG_GET_CHANNELS_BY_RANGE`, `EpgQueryService.getChannelsByRange`, + `getEpgChannelsByRange` in preload and contract, `supportsEpgChannelBrowser`. +- Comments describing the "multi-EPG overlay" in + `window-controls.component.ts`, `epg-item-description.component.ts`, + `docs/architecture/workspace-shell.md`: the guide is ordinary host layout, + so the window controls no longer have an overlay to stay above. +- i18n keys `TOP_MENU.OPEN_MULTI_EPG` and + `WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION`, replaced by + `EPG.GUIDE.*` (toolbar, density, empty states, key hints, player strip) + added to all 19 locales with the `i18n-fill` skill. + +## Testing + +- Unit, `ui-epg`: layout util (ticks, now-x, boundary-crossing cards, tiers); + programs service (visible-row batching, per-day cache, coverage, reset on + scope change); keyboard controller (navigation, input/dialog exclusions); + guide component (click/double-click/Enter → `activate`/`close`, toggle hides + only uncovered rows, density/zoom/toggle persistence). +- Unit, `playlist-m3u-feature-player`: source adapter (scopes from groups and + favorites, `epgKey` chain, radio/movie exclusion, `sourceUrls` forwarded, + `activate` dispatches); `video-player.component.spec` (guide mode hides + sidebar and timeline, the player instance is unchanged, fullscreen closes + the guide, G and Esc). +- Unit, `electron-backend`: `epg-guide-query.service.spec` on in-memory SQLite + (day window with boundary overlap, manual mapping and case-insensitive + `display_name` resolution, scoped sources, batch limit, coverage); + `main.preload.spec-data.ts` rows for both methods. +- E2E, `electron-backend-e2e`: new `epg-guide.e2e.ts` on the fixture used by + `epg.e2e.ts` (imports XMLTV, renders the timeline): open the guide from the + timeline button, rows equal the playlist's channels in order, the toggle + hides the row without EPG, clicking a channel changes the active channel, + Esc closes, and the `