From 7d1503fd316a30bd11b8297a7f7b4c7ddd5a16e3 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:47:25 +0200 Subject: [PATCH] feat(epg): rebuild the programme guide for M3U playlists (#1560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(epg): add programme guide redesign spec for the M3U host Co-Authored-By: Claude Fable 5.1 * docs(epg): add programme guide implementation plan Co-Authored-By: Claude Fable 5.1 * feat(epg): add window-scoped guide programme queries Co-Authored-By: Claude Fable 5.1 * fix(epg): harden guide query scoping, caps and row mapping - Scoped guide programme/coverage queries now include legacy (unsourced) rows via source_url IN (...) OR IS NULL OR '', mirroring EpgQueryService's legacy fallback. - getProgramsForChannels/getProgramCoverage build their result from the normalized, capped window.channelIds instead of the raw request, so a key cut by the cap is absent rather than [] — an invalid window now returns {}. Truncation logs counts only. - Split the 100-channel guide cap from a new 2000-key coverage cap, and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a parameter and moved (with guideWindowOverlapSqlText) into epg-guide-window.util.ts. - Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram) into epg-program-row.util.ts, used by both EpgQueryService and EpgGuideQueryService so invalid start/stop rows are dropped identically in both. - Added a real-SQLite-backed test for the overlap predicate's exact text, plus per-key array copies in the response. Co-Authored-By: Claude Fable 5.1 * fix(epg): render the guide predicate in tests and document its scope Correct the guide query's JSDoc: it runs one query accepting the union of requested-source and unsourced legacy rows, unlike EpgQueryService's two-query scoped-then-legacy fallback. Replace the hand-maintained plain-SQL twin of the Drizzle overlap predicate with a rendered copy of the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a source-scoping case, and drop the now-redundant operator-sequence test. warnIfTruncated reuses uniqueTrimmedStrings and names which read (programme/coverage) was truncated. Rename epg-query.service.ts's local EpgProgramRow to EpgProgramSelectRow so it isn't confused with the shared EpgProgramRow type, and document getProgramCoverage like its sibling. Co-Authored-By: Claude Fable 5.1 * feat(epg): expose guide programme and coverage reads over the bridge Co-Authored-By: Claude Fable 5.1 * docs(epg): separate coverage chunk size in the guide plan Co-Authored-By: Claude Fable 5.1 * feat(epg): add guide source contract, day layout maths and preferences Co-Authored-By: Claude Fable 5.1 * fix(epg): key guide IPC answers by trimmed, present keys only Co-Authored-By: Claude Fable 5.1 * docs(epg): guide search hits carry a row id Co-Authored-By: Claude Fable 5.1 * fix(epg): make guide geometry DST-safe and tighten the contract Co-Authored-By: Claude Fable 5.1 * feat(epg): cache guide programmes per day with batched loading Co-Authored-By: Claude Fable 5.1 * feat(epg): add guide keyboard navigation controller Co-Authored-By: Claude Fable 5.1 * fix(epg): make guide programme cache robust to first-run effects and coverage failures Co-Authored-By: Claude Fable 5.1 * feat(epg): add the programme guide grid components Co-Authored-By: Claude Fable 5.1 * feat(epg): add a Guide button to the timeline toolbar Co-Authored-By: Claude Fable 5.1 * feat(m3u): adapt the playlist channel list to the guide contract Co-Authored-By: Claude Fable 5.1 * fix(m3u): guard the guide's initial group scope and track language changes Co-Authored-By: Claude Fable 5.1 * feat(m3u): open the programme guide in place with a docked player Co-Authored-By: Claude Fable 5.1 * fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize Co-Authored-By: Claude Fable 5.1 * refactor(epg): remove the multi-EPG overlay and the channel-range IPC Co-Authored-By: Claude Fable 5.1 * docs(epg): document the programme guide and its release note Co-Authored-By: Claude Fable 5.1 * i18n(epg): translate the programme guide Co-Authored-By: Claude Fable 5.1 * fix(m3u): let the guide own the keyboard and gate its entry points While the programme guide is open the docked player carries `data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours alongside `[inert]` — the arrows moved the player's volume instead of the guide's row focus. The external-player strip loses its Collapse toggle (nothing to reveal, no preference to write), the header action and its palette command report `disabled` when the guide cannot open, the docked strip derives its programme from the active channel's own schedule instead of the retained NgRx value, switching playlists closes the guide, and the collapsed strip can reach 48 px on phones. Co-Authored-By: Claude Fable 5.1 * fix(m3u): keep the sidebar mounted while the guide is open Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the guide destroyed `app-channel-list-container`, whose `ngOnDestroy` dispatches `resetActiveChannel()`. That cleared the active channel, which unmounted the block hosting `app-epg-guide` and tripped the `!canOpenGuide()` effect into closing the guide again: the guide never appeared and the page dropped to "Please select a channel". The sidebar now stays mounted and is hidden with `.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read by assistive technology while the guide owns the layout. Hiding also preserves the channel list's scroll position across guide toggles. Co-Authored-By: Claude Fable 5.1 * test(e2e): cover the programme guide flow Imports a two-channel playlist with XMLTV, opens the guide from the timeline toolbar and asserts the row list, the "Only with EPG" filter, a channel switch that keeps the guide open, the hidden-but-mounted sidebar, and that the player element survives both the mode and channel switches. Co-Authored-By: Claude Fable 5.1 * chore(epg): tidy guide docs, palette gating and the unbound output Co-Authored-By: Claude Fable 5.1 * fix(epg): match guide favorites by channel URL and skip re-activating the playing row Favorites are persisted by channel URL (FavoritesActions.updateFavorites), so the Favorites scope compared the wrong key; the id stays as a legacy fallback. A double-click arrives as click, click, dblclick and each activate restarts playback, so the guide now leaves the already-playing row alone and the commit path only closes. Co-Authored-By: Claude Fable 5.1 * perf(epg): let the guide window predicate use the programme time index Co-Authored-By: Claude Fable 5.1 * fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture Co-Authored-By: Claude Fable 5.1 * refactor(epg): split the guide shell, add a roving focus model and offset-aware search times The shell component now owns rows, focus and the viewport only: the day, zoom, density, filters, clock and day geometry move to EpgGuideViewState, and every programme-dialog entry point to EpgGuideDialogController. Keyboard navigation is reachable by assistive technology: exactly one grid cell carries tabindex="0" (the focused cell, else the playing row's channel cell, else the first row's), the guide moves DOM focus with it after each handled key, a click hands the roving index to the clicked cell, and the viewport, rows and cells expose grid/row/gridcell roles. Search results were formatting raw provider instants, so they ignored the EPG display offset; they go through getProgramTimeMs like every other time the guide renders. Co-Authored-By: Claude Fable 5.1 * fix(m3u): make guide row ids collision-proof and gate the G shortcut Co-Authored-By: Claude Fable 5.1 * fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar Co-Authored-By: Claude Fable 5.1 * docs(epg): describe guide row ids as scope-local Co-Authored-By: Claude Fable 5.1 * fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown Search hits carry scope-local row ids, so a scope change drops them. Two playlist entries can share an id but not a stream, so the active row is matched by id + url before falling back to the id. A failed coverage query now rejects instead of answering an empty set, which the guide already treats as "coverage unknown" (every row stays visible). Co-Authored-By: Claude Fable 5.1 * fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers The store spreads the selected channel, so the active row is matched by id, url, group and name before widening; G no longer closes the guide from a dialog or menu; guide answers use null-prototype records so a key named __proto__ stays an own property. Co-Authored-By: Claude Fable 5.1 * fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row EpgQueryService.getChannelMetadata swallowed database errors into {}, so the guide's coverage read could publish an empty set after a transient failure; the guide now uses the strict resolveChannelMetadata (getChannelMetadata is the fail-soft wrapper around it). The active guide row is matched on the whole channel entry (all fields except the reducer-rewritten epgParams) before widening to url and id, so copies that differ only in playback headers or logo are told apart. Co-Authored-By: Claude Fable 5.1 * fix(epg): let the guide return catch-up to live and normalise programme-search rows The guide source contract gains an optional livePlayback signal: while the host plays a catch-up URL, the active row may be activated again, which is how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw snake_case rows to the EpgProgram shape the bridge promises (plus the joined channel name), so search hits resolve their channel and keep descriptions. Co-Authored-By: Claude Fable 5.1 * fix(epg): name search hits, keep guide coverage strict on mapping failures - Search results and the unresolved programme dialog show the channel's display name (playlist row name, else the XMLTV display name the search joined in) instead of the raw XMLTV id. - The guide coverage read resolves manual mappings through a strict variant that rejects on database failure, so a mapped channel can never be reported as uncovered and hidden by "Only with EPG". - Architecture doc describes the tiered active-row resolution. Co-Authored-By: Claude Fable 5.1 * fix(epg): offer the Guide action in the list view too The EPG list view mirrors the timeline's input/output contract, but the Guide action was bound only in the timeline branch, so Settings → EPG → Guide view = List lost the in-panel entry point. The list toolbar now carries the same icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U host binds it in both branches. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .changes/epg-programme-guide.md | 13 + CLAUDE.md | 5 +- .../electron-backend-e2e/src/epg-guide.e2e.ts | 182 + apps/electron-backend-e2e/src/epg.e2e.ts | 4 +- .../src/app/api/main.preload.spec-data.ts | 14 +- .../src/app/api/main.preload.ts | 7 +- .../app/events/database/epg-db.events.spec.ts | 31 +- .../src/app/events/database/epg-db.events.ts | 43 +- .../events/epg-guide-query.service.spec.ts | 685 +++ .../src/app/events/epg-guide-query.service.ts | 298 + .../src/app/events/epg-guide-window.util.ts | 124 + .../src/app/events/epg-guide.events.spec.ts | 196 + .../src/app/events/epg-mapping.service.ts | 16 +- .../src/app/events/epg-program-row.util.ts | 47 + .../src/app/events/epg-query.service.ts | 301 +- .../src/app/events/epg.events.ts | 104 +- .../test-stubs/settings-test-harness.stub.ts | 3 +- apps/web/src/assets/i18n/ar.json | 38 +- apps/web/src/assets/i18n/ary.json | 38 +- apps/web/src/assets/i18n/by.json | 38 +- apps/web/src/assets/i18n/de.json | 38 +- apps/web/src/assets/i18n/el.json | 38 +- apps/web/src/assets/i18n/en.json | 38 +- apps/web/src/assets/i18n/es.json | 38 +- apps/web/src/assets/i18n/fr.json | 38 +- apps/web/src/assets/i18n/hu.json | 38 +- apps/web/src/assets/i18n/it.json | 38 +- apps/web/src/assets/i18n/ja.json | 38 +- apps/web/src/assets/i18n/ko.json | 38 +- apps/web/src/assets/i18n/nl.json | 38 +- apps/web/src/assets/i18n/pl.json | 38 +- apps/web/src/assets/i18n/pt.json | 38 +- apps/web/src/assets/i18n/ru.json | 38 +- apps/web/src/assets/i18n/tr.json | 38 +- apps/web/src/assets/i18n/zh.json | 38 +- apps/web/src/assets/i18n/zhtw.json | 38 +- apps/web/src/styles.scss | 6 +- docs/architecture/m3u-playlist-module.md | 119 +- docs/architecture/pwa-self-hosted.md | 4 +- docs/architecture/workspace-shell.md | 7 +- .../plans/2026-09-06-epg-guide-m3u.md | 4961 +++++++++++++++++ .../specs/2026-09-06-epg-guide-m3u-design.md | 337 ++ .../lib/epg-runtime-bridge.service.spec.ts | 45 +- .../src/lib/epg-runtime-bridge.service.ts | 51 +- .../channel-entry-identity.util.spec.ts | 50 + .../epg-guide/channel-entry-identity.util.ts | 32 + .../m3u-epg-guide-source.service.spec.ts | 352 ++ .../epg-guide/m3u-epg-guide-source.service.ts | 325 ++ .../video-player-guide-gating.spec.ts | 232 + .../video-player-movie-gate.spec.ts | 7 +- .../video-player/video-player-sidebar.spec.ts | 62 +- ...-player.component.fullscreen-panel.spec.ts | 7 +- .../video-player/video-player.component.html | 56 +- .../video-player/video-player.component.scss | 67 + .../video-player.component.spec.ts | 533 +- .../video-player/video-player.component.ts | 251 +- .../video-player/video-player.spec-harness.ts | 175 + .../video-player/video-player.spec-stubs.ts | 119 +- .../lib/workspace-header-context.service.ts | 8 + .../src/lib/workspace-view-command.service.ts | 42 +- .../lib/runtime-capabilities.service.spec.ts | 29 +- .../src/lib/runtime-capabilities.service.ts | 10 +- .../src/lib/electron-api.interface.ts | 34 +- .../shared/interfaces/src/lib/ipc-commands.ts | 3 - .../channel-list-container.component.html | 1 + .../channel-list-container.component.ts | 2 + .../groups-view/groups-view.component.spec.ts | 17 + .../groups-view/groups-view.component.ts | 15 + .../window-controls.component.ts | 4 +- libs/ui/epg/src/index.ts | 7 +- libs/ui/epg/src/lib/_epg-theme.scss | 5 + .../epg-guide-dialog.controller.spec.ts | 205 + .../epg-guide/epg-guide-dialog.controller.ts | 111 + .../epg-guide-keyboard.controller.spec.ts | 150 + .../epg-guide-keyboard.controller.ts | 187 + .../epg-guide/epg-guide-layout.util.spec.ts | 157 + .../lib/epg-guide/epg-guide-layout.util.ts | 165 + .../epg-guide-now-playing.component.html | 55 + .../epg-guide-now-playing.component.scss | 116 + .../epg-guide-now-playing.component.ts | 89 + .../epg-guide/epg-guide-preferences.spec.ts | 72 + .../lib/epg-guide/epg-guide-preferences.ts | 99 + .../epg-guide-programs.service.spec.ts | 202 + .../epg-guide/epg-guide-programs.service.ts | 225 + .../epg-guide/epg-guide-row.component.html | 118 + .../epg-guide/epg-guide-row.component.scss | 311 ++ .../epg-guide/epg-guide-row.component.spec.ts | 246 + .../lib/epg-guide/epg-guide-row.component.ts | 134 + .../epg-guide/epg-guide-scroll.util.spec.ts | 97 + .../lib/epg-guide/epg-guide-scroll.util.ts | 65 + .../epg-guide/epg-guide-search.controller.ts | 65 + .../epg/src/lib/epg-guide/epg-guide-source.ts | 88 + .../epg-guide-toolbar.component.html | 148 + .../epg-guide-toolbar.component.scss | 285 + .../epg-guide/epg-guide-toolbar.component.ts | 133 + .../epg-guide/epg-guide-view-state.spec.ts | 137 + .../src/lib/epg-guide/epg-guide-view-state.ts | 148 + .../epg-guide-viewport.controller.spec.ts | 237 + .../epg-guide-viewport.controller.ts | 176 + .../lib/epg-guide/epg-guide.component.html | 179 + .../lib/epg-guide/epg-guide.component.scss | 217 + .../lib/epg-guide/epg-guide.component.spec.ts | 478 ++ .../src/lib/epg-guide/epg-guide.component.ts | 435 ++ .../epg-item-description.component.ts | 4 +- .../epg-list-view.component.html | 22 +- .../epg-list-view.component.scss | 24 + .../epg-list-view.component.spec.ts | 26 +- .../epg-list-view/epg-list-view.component.ts | 3 + .../epg-timeline/epg-timeline.component.html | 11 + .../epg-timeline/epg-timeline.component.scss | 7 + .../epg-timeline.component.spec.ts | 22 + .../epg-timeline/epg-timeline.component.ts | 3 + .../multi-epg-container.component.html | 277 - .../multi-epg-container.component.scss | 916 --- .../multi-epg-container.component.spec.ts | 187 - .../multi-epg-container.component.ts | 551 -- .../src/lib/multi-epg/overlay-ref.token.ts | 9 - .../controls-shortcuts.spec.ts | 28 + .../lib/player-controls/controls-shortcuts.ts | 22 +- .../sidebar/sidebar.component.html | 1 + .../video-player/sidebar/sidebar.component.ts | 2 + ...orkspace-command-palette.component.spec.ts | 12 +- .../workspace-shell-header.component.html | 3 + .../workspace-shell-header.component.scss | 5 + .../services/workspace-shell.facade.spec.ts | 51 +- tools/eslint/max-lines-baseline.mjs | 1 - 126 files changed, 15848 insertions(+), 2639 deletions(-) create mode 100644 .changes/epg-programme-guide.md create mode 100644 apps/electron-backend-e2e/src/epg-guide.e2e.ts create mode 100644 apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts create mode 100644 apps/electron-backend/src/app/events/epg-guide-query.service.ts create mode 100644 apps/electron-backend/src/app/events/epg-guide-window.util.ts create mode 100644 apps/electron-backend/src/app/events/epg-guide.events.spec.ts create mode 100644 apps/electron-backend/src/app/events/epg-program-row.util.ts create mode 100644 docs/superpowers/plans/2026-09-06-epg-guide-m3u.md create mode 100644 docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/channel-entry-identity.util.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player-guide-gating.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-harness.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-dialog.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-search.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-view-state.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.html create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts delete mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.html delete mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss delete mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts delete mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts delete mode 100644 libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts 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 71435ee62..28ab0a4a1 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 @@ -1485,7 +1485,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 EPG panel's Guide button (timeline or list view) 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/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts new file mode 100644 index 000000000..6e8b84692 --- /dev/null +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -0,0 +1,182 @@ +import { + buildM3uContent, + channelItemByTitle, + closeElectronApp, + createMutableTextServer, + expect, + importM3uPlaylistFromUrl, + launchElectronApp, + openWorkspaceSection, + test, +} from './electron-test-fixtures'; + +function formatXmltvDate(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + ].join(''); +} + +function xmltvWithCurrentProgramme( + channelId: string, + channelName: string, + title: string +): string { + const start = new Date(Date.now() - 15 * 60 * 1000); + const stop = new Date(Date.now() + 45 * 60 * 1000); + return ` + + ${channelName} + + ${title} + Guide E2E programme. + + +`; +} + +test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({ + dataDir, +}) => { + test.setTimeout(120000); + const epgServer = await createMutableTextServer( + xmltvWithCurrentProgramme( + 'guide-news', + 'Guide News', + 'Guide Bulletin Now' + ), + { + contentType: 'application/xml; charset=utf-8', + resourcePath: '/guide.xml', + } + ); + const playlistServer = await createMutableTextServer( + buildM3uContent([ + { + name: 'Guide News', + tvgId: 'guide-news', + url: 'https://example.com/live/guide-news.m3u8', + }, + { + name: 'Guide Silent', + tvgId: 'guide-silent', + url: 'https://example.com/live/guide-silent.m3u8', + }, + ]).replace('#EXTM3U', `#EXTM3U x-tvg-url="${epgServer.resourceUrl}"`), + { + contentType: 'application/x-mpegurl; charset=utf-8', + resourcePath: '/guide-playlist.m3u', + } + ); + const app = await launchElectronApp(dataDir); + + try { + await app.electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1600, 1000); + }); + // The demo stream URLs are unreachable; the player still mounts. + await app.mainWindow.route('https://example.com/**', (route) => + route.abort() + ); + + await importM3uPlaylistFromUrl( + app.mainWindow, + playlistServer.resourceUrl + ); + await expect( + app.mainWindow.locator( + '.epg-progress-panel .import-item.status-complete' + ) + ).toHaveCount(1, { timeout: 30000 }); + + await openWorkspaceSection(app.mainWindow, 'All channels'); + const newsRow = channelItemByTitle(app.mainWindow, 'Guide News'); + await expect(newsRow).toBeVisible(); + await newsRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + await expect(timeline).toBeVisible({ timeout: 20000 }); + await app.mainWindow.evaluate(() => + document + .querySelector('app-web-player-view') + ?.setAttribute('data-e2e-guide-marker', 'kept') + ); + + // Entry point: the Guide button in the timeline toolbar. + await timeline.locator('.epg-timeline__guide').click(); + const guide = app.mainWindow.locator('app-epg-guide'); + await expect(guide).toBeVisible(); + // The sidebar stays mounted (its channel list resets the active + // channel on destroy) and is hidden while the guide owns the layout. + await expect(app.mainWindow.locator('.sidebar')).toBeHidden(); + await expect(timeline).toHaveCount(0); + + // Rows are the playlist's channels, in order, with the playing row marked. + const rows = guide.locator('app-epg-guide-row'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0).locator('.epg-guide-row__name b')).toHaveText( + 'Guide News' + ); + await expect(rows.nth(1).locator('.epg-guide-row__name b')).toHaveText( + 'Guide Silent' + ); + await expect(rows.nth(0)).toHaveClass(/is-active/); + await expect( + rows.nth(0).locator('.epg-guide-row__block.is-now') + ).toContainText('Guide Bulletin Now', { timeout: 20000 }); + await expect(rows.nth(1).locator('.epg-guide-row__empty')).toBeVisible({ + timeout: 20000, + }); + + // "Only with EPG" hides the silent channel once coverage is known. + const toggle = guide.locator('.guide-toolbar__toggle input'); + await expect(toggle).toBeEnabled({ timeout: 20000 }); + await guide.locator('.guide-toolbar__toggle').click(); + await expect(rows).toHaveCount(1); + await guide.locator('.guide-toolbar__toggle').click(); + await expect(rows).toHaveCount(2); + + // Clicking a channel switches playback without closing the guide. + await rows.nth(1).locator('.epg-guide-row__channel').click(); + await expect(rows.nth(1)).toHaveClass(/is-active/); + await expect(rows.nth(0)).not.toHaveClass(/is-active/); + await expect(guide).toBeVisible(); + await expect( + app.mainWindow.locator('app-epg-guide-now-playing') + ).toContainText('Guide Silent'); + + // The player element survived both the mode switch and the channel switch. + await expect( + app.mainWindow.locator( + 'app-web-player-view[data-e2e-guide-marker="kept"]' + ) + ).toHaveCount(1); + + // Escape closes the guide and restores the sidebar and timeline. + await app.mainWindow.keyboard.press('Escape'); + await expect(guide).toHaveCount(0); + await expect(app.mainWindow.locator('.sidebar')).toBeVisible(); + await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible(); + await expect( + app.mainWindow.locator( + 'app-web-player-view[data-e2e-guide-marker="kept"]' + ) + ).toHaveCount(1); + + // G reopens it; the header shortcut is highlighted while open. + await app.mainWindow.keyboard.press('g'); + await expect(guide).toBeVisible(); + await expect( + app.mainWindow.locator('.header-shortcut.is-active') + ).toHaveCount(1); + } finally { + await closeElectronApp(app); + await playlistServer.close(); + await epgServer.close(); + } +}); diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 9b96075a1..83189e1c6 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -718,7 +718,7 @@ test.describe('Electron EPG', () => { async function getEpgChannelCount(page: Page): Promise { return page.evaluate(async () => { - const channels = await window.electron?.getEpgChannelsByRange?.(0, 20); - return Array.isArray(channels) ? channels.length : 0; + const result = await window.electron?.getEpgChannels?.(); + return Array.isArray(result?.channels) ? result.channels.length : 0; }); } diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index ff3b75bc3..de0f5816b 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -514,10 +514,16 @@ export const epgPreloadCases: PreloadInvokeCase[] = [ forwardedArgs: [], }, { - method: 'getEpgChannelsByRange', - args: [10, 20], - channel: 'EPG_GET_CHANNELS_BY_RANGE', - forwardedArgs: [{ skip: 10, limit: 20 }], + method: 'getEpgProgramsForChannels', + args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + channel: 'EPG_GET_PROGRAMS_FOR_CHANNELS', + forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + }, + { + method: 'getEpgProgramCoverage', + args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + channel: 'EPG_GET_PROGRAM_COVERAGE', + forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], }, { method: 'forceFetchEpg', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index b3a60ee3a..09098763d 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -37,6 +37,7 @@ import type { ElectronBridgeDbOperationEvent, ElectronBridgeDownloadStartPayload, ElectronBridgeCurrentProgramsOptions, + ElectronBridgeEpgGuideWindow, ElectronBridgeEpgLookupOptions, ElectronBridgeEpgProgress, ElectronBridgePlaybackPositionInput, @@ -672,8 +673,10 @@ const electronApi: ElectronBridgeApi = { ) => ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }), getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'), - getEpgChannelsByRange: (skip: number, limit: number) => - ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }), + getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window), + getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window), forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) => ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }), clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'), diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts index 3e5d07be5..a9f6fdf08 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts @@ -76,13 +76,40 @@ describe('epg-db.events', () => { }); it('searches programmes with a LIKE pattern built from the trimmed term', async () => { - const rows = [{ title: 'News', channel_name: 'NHK' }]; + const rows = [ + { + channel_id: 'nhk.jp', + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T16:30:00.000Z', + title: 'News', + description: 'Evening bulletin', + category: 'news', + icon_url: 'https://x/icon.png', + rating: null, + episode_num: 'S1E2', + channel_name: 'NHK', + }, + ]; const all = jest.fn().mockResolvedValue(rows); getDatabase.mockResolvedValue({ all }); + // Raw snake_case columns become the public `EpgProgram` shape. await expect( getIpcMainHandler('EPG_DB_SEARCH_PROGRAMS')({}, ' news ', 25) - ).resolves.toEqual(rows); + ).resolves.toEqual([ + { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T16:30:00.000Z', + channel: 'nhk.jp', + title: 'News', + desc: 'Evening bulletin', + category: 'news', + iconUrl: 'https://x/icon.png', + rating: null, + episodeNum: 'S1E2', + channelName: 'NHK', + }, + ]); expect(all).toHaveBeenCalledTimes(1); const query = all.mock.calls[0][0] as { diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.ts b/apps/electron-backend/src/app/events/database/epg-db.events.ts index 7d96345f1..fa11bec8e 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -9,8 +9,45 @@ import { sql } from 'drizzle-orm'; import { ipcMain } from 'electron'; +import type { ElectronBridgeEpgSearchProgram } from '@iptvnator/shared/interfaces'; import { getDatabase } from '../../database/connection'; +/** Raw `epg_programs` columns as `db.all` returns them, plus the joined channel name. */ +interface EpgSearchRow { + channel_id: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + icon_url: string | null; + rating: string | null; + episode_num: string | null; + channel_name: string | null; +} + +/** + * `db.all` hands back snake_case columns; the bridge promises the public + * `EpgProgram` shape (`channel`, `desc`, `iconUrl`, …), so map here or every + * consumer sees `program.channel === undefined`. + */ +export function toSearchProgram( + row: EpgSearchRow +): ElectronBridgeEpgSearchProgram { + return { + start: row.start, + stop: row.stop, + channel: row.channel_id, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.icon_url, + rating: row.rating, + episodeNum: row.episode_num, + channelName: row.channel_name ?? null, + }; +} + const loggerLabel = '[EPG DB]'; /** @@ -35,7 +72,7 @@ ipcMain.handle( // JOIN with epg_channels to get channel display name // Include all programs (past and future) for catchup/archive feature - const results = await db.all(sql` + const results = (await db.all(sql` SELECT p.*, c.display_name as channel_name @@ -48,9 +85,9 @@ ipcMain.handle( ) ORDER BY p.start LIMIT ${limit} - `); + `)) as EpgSearchRow[]; - return results; + return results.map(toSearchProgram); } catch (error) { console.error(loggerLabel, 'Error searching EPG programs:', error); throw error; diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts new file mode 100644 index 000000000..8e62f33a7 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -0,0 +1,685 @@ +import { execFileSync } from 'node:child_process'; +import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core'; +import { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST, + EpgGuideQueryService, + guideWindowCondition, + normalizeGuideWindow, +} from './epg-guide-query.service'; +import { epgLogger } from '../util/epg-logger'; + +const getDatabase = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../util/epg-logger', () => ({ + epgLogger: { error: jest.fn(), log: jest.fn() }, +})); + +function flattenSql(value: unknown, seen = new Set()): string { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return String(value ?? ''); + } + if (seen.has(value)) { + return ''; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => flattenSql(item, seen)).join(' '); + } + const sqlLike = value as { + name?: unknown; + queryChunks?: unknown[]; + value?: unknown; + }; + if (Array.isArray(sqlLike.queryChunks)) { + return sqlLike.queryChunks + .map((chunk) => flattenSql(chunk, seen)) + .join(' '); + } + if (Array.isArray(sqlLike.value)) { + return sqlLike.value.join(' '); + } + if (typeof sqlLike.value === 'string') { + return sqlLike.value; + } + if (typeof sqlLike.name === 'string') { + return sqlLike.name; + } + return ''; +} + +function programRow( + channelId: string, + start: string, + stop: string, + title: string +) { + return { + channelId, + start, + stop, + title, + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }; +} + +/** `db.select(cols).from().where().orderBy()` resolving to `rows`. */ +function programSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return { orderBy: jest.fn().mockResolvedValue(rows) }; + }), + })), + })); +} + +/** `db.selectDistinct(cols).from().where()` resolving to `rows`. */ +function coverageSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return Promise.resolve(rows); + }), + })), + })); +} + +const FROM = Date.UTC(2026, 8, 6, 0, 0, 0); +const TO = Date.UTC(2026, 8, 7, 0, 0, 0); + +const FROM_ISO = '2026-09-06T00:00:00.000Z'; +const TO_ISO = '2026-09-07T00:00:00.000Z'; +/** `FROM_ISO`/`TO_ISO` widened by the 48 h prefilter slack. */ +const FROM_SLACK_ISO = '2026-09-04T00:00:00.000Z'; +const TO_SLACK_ISO = '2026-09-09T00:00:00.000Z'; + +/** + * Renders the real Drizzle predicate (never a hand-written restatement of its + * logic) so both its SQL shape and its behaviour can be asserted. + */ +function renderQuery( + epgIds: string[], + sourceUrls: string[] = [] +): { sql: string; params: unknown[] } { + const window = normalizeGuideWindow({ + channelIds: epgIds, + fromMs: Date.parse(FROM_ISO), + toMs: Date.parse(TO_ISO), + sourceUrls, + }); + if (!window) { + throw new Error('expected a valid window'); + } + return new SQLiteSyncDialect().sqlToQuery( + guideWindowCondition(epgIds, window) + ); +} + +describe('normalizeGuideWindow', () => { + it('rejects an empty or inverted window', () => { + expect( + normalizeGuideWindow({ channelIds: ['a'], fromMs: TO, toMs: FROM }) + ).toBeNull(); + expect( + normalizeGuideWindow({ channelIds: [' '], fromMs: FROM, toMs: TO }) + ).toBeNull(); + expect( + normalizeGuideWindow({ + channelIds: ['a'], + fromMs: Number.NaN, + toMs: TO, + }) + ).toBeNull(); + }); + + it('trims, de-duplicates and caps the channel keys to the default limit', () => { + const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`); + const window = normalizeGuideWindow({ + channelIds: [' a ', 'a', ...ids], + fromMs: FROM, + toMs: TO, + }); + expect(window?.channelIds[0]).toBe('a'); + expect(window?.channelIds).toHaveLength( + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST + ); + expect(window?.fromIso).toBe('2026-09-06T00:00:00.000Z'); + expect(window?.toIso).toBe('2026-09-07T00:00:00.000Z'); + }); + + it('accepts an explicit larger cap for coverage-sized requests', () => { + const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`); + const window = normalizeGuideWindow( + { channelIds: ids, fromMs: FROM, toMs: TO }, + EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + ); + expect(window?.channelIds).toHaveLength(150); + }); + + it('caps sourceUrls at 50 regardless of the channel cap', () => { + const urls = Array.from( + { length: 60 }, + (_, index) => `https://epg.example.com/${index}.xml` + ); + const window = normalizeGuideWindow({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + sourceUrls: urls, + }); + expect(window?.sourceUrls).toHaveLength(50); + }); + + it('widens the index prefilter bounds to 48 h outside the exact window', () => { + const window = normalizeGuideWindow({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }); + expect(window?.fromSlackIso).toBe(FROM_SLACK_ISO); + expect(window?.toSlackIso).toBe(TO_SLACK_ISO); + expect(Date.parse(window?.fromSlackIso ?? '')).toBe( + FROM - 48 * 60 * 60 * 1000 + ); + expect(Date.parse(window?.toSlackIso ?? '')).toBe( + TO + 48 * 60 * 60 * 1000 + ); + }); + + it('clamps a slack bound that would overflow the serializable range', () => { + const window = normalizeGuideWindow({ + channelIds: ['a'], + fromMs: -8.64e15, + toMs: 8.64e15, + }); + // `toISOString()` throws past this range, so the widened bounds are + // clamped instead of widened. Such a window excludes every row through + // the exact predicate anyway. + expect(window?.fromSlackIso).toBe(new Date(-8.64e15).toISOString()); + expect(window?.toSlackIso).toBe(new Date(8.64e15).toISOString()); + }); +}); + +describe('guideWindowCondition rendered SQL', () => { + it('pairs index-usable plain comparisons with the exact datetime test', () => { + const { sql, params } = renderQuery(['a', 'b']); + // Plain comparisons on the bare indexed columns: what lets the planner + // bound its `(channel_id, start, stop)` range scan. + expect(sql).toContain('"epg_programs"."start" < ?'); + expect(sql).toContain('"epg_programs"."stop" > ?'); + // The exact overlap test stays — the prefilter only narrows the scan. + expect(sql).toContain('datetime("epg_programs"."start") < datetime(?)'); + expect(sql).toContain('datetime("epg_programs"."stop") > datetime(?)'); + expect(params).toEqual([ + 'a', + 'b', + TO_SLACK_ISO, + FROM_SLACK_ISO, + TO_ISO, + FROM_ISO, + ]); + }); +}); + +/** `true` when the CLI answers `sqlite3 -version`. Present on macOS and CI. */ +function hasSqlite(): boolean { + try { + execFileSync('sqlite3', ['-version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function quote(literal: string): string { + return `'${literal.replace(/'/g, "''")}'`; +} + +/** Substitutes a Drizzle-rendered query's `?` placeholders with literal SQL values. */ +function inlineParams(sqlText: string, params: unknown[]): string { + let index = 0; + return sqlText.replace(/\?/g, () => { + const value = params[index]; + index += 1; + return typeof value === 'string' ? quote(value) : String(value); + }); +} + +const describeWithSqlite = hasSqlite() ? describe : describe.skip; + +describeWithSqlite('guideWindowCondition rendered against SQLite', () => { + /** The real predicate as literal SQL, ready to prove against the engine. */ + function renderPredicate( + epgIds: string[], + sourceUrls: string[] = [] + ): string { + const { sql, params } = renderQuery(epgIds, sourceUrls); + return inlineParams(sql, params); + } + + const CREATE_TABLE = + 'CREATE TABLE epg_programs (channel_id TEXT, start TEXT, stop TEXT, source_url TEXT);'; + + function matchingChannelIds( + predicate: string, + rows: Array< + [ + channelId: string, + start: string, + stop: string, + sourceUrl: string | null, + ] + > + ): string[] { + const values = rows + .map( + ([channelId, start, stop, sourceUrl]) => + `(${quote(channelId)}, ${quote(start)}, ${quote(stop)}, ${ + sourceUrl === null ? 'NULL' : quote(sourceUrl) + })` + ) + .join(', '); + const script = [ + CREATE_TABLE, + `INSERT INTO epg_programs (channel_id, start, stop, source_url) VALUES ${values};`, + `SELECT channel_id FROM epg_programs WHERE ${predicate} ORDER BY channel_id;`, + ].join('\n'); + const out = execFileSync('sqlite3', [':memory:', script], { + encoding: 'utf8', + }); + return out + .trim() + .split('\n') + .filter((line) => line.length > 0); + } + + it('keeps rows overlapping the window and drops boundary-touching or non-overlapping rows', () => { + const predicate = renderPredicate(['a', 'b', 'c', 'd', 'e']); + const result = matchingChannelIds(predicate, [ + // Crosses the window start: overlaps. + ['a', '2026-09-05T23:30:00.000Z', '2026-09-06T00:30:00.000Z', null], + // Spans the whole window: overlaps. + ['b', '2026-09-01T00:00:00.000Z', '2026-09-10T00:00:00.000Z', null], + // stop === from: excluded (strict `>`). + ['c', '2026-09-05T22:00:00.000Z', '2026-09-06T00:00:00.000Z', null], + // start === to: excluded (strict `<`). + ['d', '2026-09-07T00:00:00.000Z', '2026-09-07T01:00:00.000Z', null], + // Stored with a +03:00 offset. In UTC this is + // [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps — + // but a raw string compare of "07T02:00...+03:00" against + // "07T00:00...Z" would say `start < to` is FALSE (lexically "02" + // sorts after "00"), wrongly excluding it. Only `datetime()` + // normalization recovers the correct overlap. + [ + 'e', + '2026-09-07T02:00:00+03:00', + '2026-09-07T03:30:00+03:00', + null, + ], + ]); + expect(result).toEqual(['a', 'b', 'e']); + }); + + it('scopes to the requested source URLs plus unsourced legacy rows', () => { + const predicate = renderPredicate( + ['u1-row', 'other-row', 'null-row', 'empty-row'], + ['u1'] + ); + expect(predicate).toContain('"source_url" is null'); + const result = matchingChannelIds(predicate, [ + [ + 'u1-row', + '2026-09-06T12:00:00.000Z', + '2026-09-06T13:00:00.000Z', + 'u1', + ], + [ + 'other-row', + '2026-09-06T12:00:00.000Z', + '2026-09-06T13:00:00.000Z', + 'other', + ], + [ + 'null-row', + '2026-09-06T12:00:00.000Z', + '2026-09-06T13:00:00.000Z', + null, + ], + [ + 'empty-row', + '2026-09-06T12:00:00.000Z', + '2026-09-06T13:00:00.000Z', + '', + ], + ]); + expect(result).toEqual(['empty-row', 'null-row', 'u1-row']); + }); + + it('keeps and drops the right rows around the 48 h prefilter slack', () => { + const predicate = renderPredicate(['far-east', 'far-future', 'junk']); + const result = matchingChannelIds(predicate, [ + // Stored with the largest real offset (+14:00). In UTC this is + // [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps — + // but its wall-clock prefix ("07T13:00") lies 13 h past `to`, so + // an un-slackened plain `start < to` compare would drop it. The + // 48 h of slack is what keeps the prefilter a superset. + [ + 'far-east', + '2026-09-07T13:00:00+14:00', + '2026-09-07T14:30:00+14:00', + null, + ], + // Starts three days past `to`: outside the slack AND outside the + // exact window, so both halves of the predicate reject it. + [ + 'far-future', + '2026-09-10T00:00:00.000Z', + '2026-09-10T01:00:00.000Z', + null, + ], + // XMLTV wire format, never normalized on import: `datetime()` + // yields NULL, so the exact predicate excludes it regardless of + // what the prefilter's string compare says. + ['junk', '20260906180000 +0300', '20260906190000 +0300', null], + ]); + expect(result).toEqual(['far-east']); + }); + + it('lets the planner use the programme time-range index', () => { + const script = [ + CREATE_TABLE, + 'CREATE INDEX idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);', + `EXPLAIN QUERY PLAN SELECT channel_id FROM epg_programs WHERE ${renderPredicate( + ['a', 'b'] + )};`, + ].join('\n'); + const plan = execFileSync('sqlite3', [':memory:', script], { + encoding: 'utf8', + }); + // Without the plain-string prefilter the planner can only constrain + // `channel_id=?` and then runs `datetime()` over the channel's whole + // retained history; `start { + const resolveChannelMetadata = jest.fn(); + let service: EpgGuideQueryService; + + beforeEach(() => { + getDatabase.mockReset(); + resolveChannelMetadata.mockReset(); + (epgLogger.log as jest.Mock).mockReset(); + (epgLogger.error as jest.Mock).mockReset(); + service = new EpgGuideQueryService( + { resolveChannelMetadata }, + '[Test]' + ); + }); + + it('returns an empty object (no per-key placeholders) for an invalid window', async () => { + const result = await service.getProgramsForChannels({ + channelIds: ['a', 'b'], + fromMs: TO, + toMs: FROM, + }); + expect(result).toEqual({}); + expect(resolveChannelMetadata).not.toHaveBeenCalled(); + }); + + it('rejects coverage when the channel lookup itself fails', async () => { + resolveChannelMetadata.mockRejectedValue(new Error('lookup down')); + await expect( + service.getProgramCoverage({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).rejects.toThrow('lookup down'); + // Programmes keep the documented fail-soft answer. + await expect( + service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).resolves.toEqual({ a: [] }); + }); + + it('rethrows a coverage failure after logging it', async () => { + resolveChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + getDatabase.mockRejectedValue(new Error('locked')); + await expect( + service.getProgramCoverage({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).rejects.toThrow('locked'); + }); + + it('resolves keys through channel metadata and maps rows back onto every requested key', async () => { + resolveChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + 'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + unknown: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect( + [ + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + ], + whereCalls + ), + }); + + const result = await service.getProgramsForChannels({ + channelIds: ['ZDF HD', 'zdf.de', 'unknown'], + fromMs: FROM, + toMs: TO, + }); + + expect(resolveChannelMetadata).toHaveBeenCalledWith( + ['ZDF HD', 'zdf.de', 'unknown'], + {} + ); + expect(result['ZDF HD']).toHaveLength(1); + expect(result['zdf.de']).toHaveLength(1); + expect(result['ZDF HD'][0]).toMatchObject({ + channel: 'zdf.de', + title: 'heute-journal', + }); + // Both keys resolved to the same channel — each must get its own + // array so mutating one can never affect the other. + expect(result['ZDF HD']).not.toBe(result['zdf.de']); + expect(result['unknown']).toEqual([]); + const condition = flattenSql(whereCalls[0]); + expect(condition).toContain('channel_id'); + expect(condition).toContain('2026-09-07T00:00:00.000Z'); + expect(condition).toContain('2026-09-06T00:00:00.000Z'); + }); + + it('scopes the programme rows to the requested source URLs plus legacy (unsourced) rows', async () => { + resolveChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect([], whereCalls), + }); + + await service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + + expect(resolveChannelMetadata).toHaveBeenCalledWith(['a'], { + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + const condition = flattenSql(whereCalls[0]).toLowerCase(); + expect(condition).toContain('source_url'); + expect(condition).toContain('is null'); + }); + + it('fails soft when the database throws', async () => { + resolveChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + getDatabase.mockRejectedValue(new Error('locked')); + + await expect( + service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).resolves.toEqual({ a: [] }); + }); + + it('keeps a provider key named __proto__ as an own response property', async () => { + resolveChannelMetadata.mockResolvedValue({ + __proto__: { id: 'proto.tv', displayName: 'Proto', iconUrl: null }, + }); + getDatabase.mockResolvedValue({ + select: programSelect( + [ + programRow( + 'proto.tv', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'Proto show' + ), + ], + [] + ), + }); + + const result = await service.getProgramsForChannels({ + channelIds: ['__proto__'], + fromMs: FROM, + toMs: TO, + }); + + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe( + true + ); + expect(result['__proto__'][0].title).toBe('Proto show'); + }); + + it('drops channel keys cut by the per-request cap from the result entirely', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `ch-${index}`); + resolveChannelMetadata.mockResolvedValue({}); + + const result = await service.getProgramsForChannels({ + channelIds: ids, + fromMs: FROM, + toMs: TO, + }); + + expect(Object.keys(result)).toHaveLength( + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST + ); + // The 101st key was cut by the cap: absent, never present as `[]`. + expect('ch-100' in result).toBe(false); + expect(epgLogger.log).toHaveBeenCalledWith( + '[Test]', + 'Guide programme request truncated', + { requested: 101, kept: EPG_GUIDE_MAX_CHANNELS_PER_REQUEST } + ); + }); + + it('reports coverage for the requested keys whose channel has a programme in the window', async () => { + resolveChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + ARTE: { id: 'arte.de', displayName: 'ARTE', iconUrl: null }, + none: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + selectDistinct: coverageSelect( + [{ channelId: 'zdf.de' }], + whereCalls + ), + }); + + const covered = await service.getProgramCoverage({ + channelIds: ['ZDF HD', 'ARTE', 'none'], + fromMs: FROM, + toMs: TO, + }); + + expect(covered).toEqual(['ZDF HD']); + expect(flattenSql(whereCalls[0])).toContain('channel_id'); + }); + + it('does not drop any of 150 requested keys under the larger coverage cap', async () => { + const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`); + resolveChannelMetadata.mockResolvedValue({}); + + await service.getProgramCoverage({ + channelIds: ids, + fromMs: FROM, + toMs: TO, + }); + + expect(resolveChannelMetadata).toHaveBeenCalledWith(ids, {}); + }); + + it('logs the coverage-specific truncation message when the coverage cap drops keys', async () => { + const ids = Array.from( + { length: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1 }, + (_, index) => `ch-${index}` + ); + resolveChannelMetadata.mockResolvedValue({}); + + await service.getProgramCoverage({ + channelIds: ids, + fromMs: FROM, + toMs: TO, + }); + + expect(epgLogger.log).toHaveBeenCalledWith( + '[Test]', + 'Guide coverage request truncated', + { + requested: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1, + kept: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST, + } + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts new file mode 100644 index 000000000..67cefbd42 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -0,0 +1,298 @@ +import { + and, + eq, + gt, + inArray, + isNull, + lt, + or, + sql, + type SQL, +} from 'drizzle-orm'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { epgLogger } from '../util/epg-logger'; +import { + EpgProgramRow, + isValidEpgProgram, + toEpgProgramFromRow, +} from './epg-program-row.util'; +import { epgQueryService, EpgQueryService } from './epg-query.service'; +import { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST, + EpgGuideWindowRequest, + NormalizedGuideWindow, + normalizeGuideWindow, + uniqueTrimmedStrings, +} from './epg-guide-window.util'; + +export { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST, + normalizeGuideWindow, +} from './epg-guide-window.util'; +export type { + EpgGuideWindowRequest, + NormalizedGuideWindow, +} from './epg-guide-window.util'; + +/** The strict resolver: a lookup failure rejects instead of answering `{}`. */ +type ChannelResolver = Pick; + +/** + * Overlap test in SQLite `datetime()` so provider-local offsets in the + * stored ISO strings compare correctly against the UTC window bounds. + * + * `datetime()` around the indexed `start`/`stop` columns is opaque to the + * planner, which would use `idx_epg_programs_time_range` for `channel_id` + * only and then run the function over the channel's ENTIRE retained history — + * on a multi-week XMLTV database a 2000-key coverage probe stalls on that. So + * the exact test is paired with plain string comparisons against the + * slack-widened bounds (`toSlackIso`/`fromSlackIso`), which the planner CAN + * turn into a `channel_id=? AND start datetime(${window.fromIso})` + ) as SQL; + if (window.sourceUrls.length === 0) { + return overlap; + } + return and( + overlap, + or( + inArray(schema.epgPrograms.sourceUrl, window.sourceUrls), + isNull(schema.epgPrograms.sourceUrl), + eq(schema.epgPrograms.sourceUrl, '') + ) as SQL + ) as SQL; +} + +/** + * Programme-guide reads for a batch of playlist channel keys: every programme + * overlapping a time window, and which keys have any programme in it at all. + * Keys resolve to XMLTV channel ids through the same metadata lookup the + * sidebar uses (exact id, case-insensitive id, display name); manual mappings + * are applied by the IPC layer before the keys reach this service. + */ +export class EpgGuideQueryService { + constructor( + private readonly resolver: ChannelResolver = epgQueryService, + private readonly loggerLabel = '[EPG Guide]' + ) {} + + /** + * Response keys are exactly the trimmed, de-duplicated, cap-respecting + * requested keys (`window.channelIds`) — never the raw request. A key cut + * by the per-request cap is absent from the result, never present with an + * empty list, so a caller can tell "queried, nothing found" apart from + * "not queried at all". An invalid window returns `{}`. + */ + async getProgramsForChannels( + request: EpgGuideWindowRequest + ): Promise> { + const window = normalizeGuideWindow( + request, + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST + ); + if (!window) { + return {}; + } + this.warnIfTruncated( + request.channelIds, + window.channelIds, + 'programme' + ); + const result = this.emptyResult(window.channelIds); + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return result; + } + const db = await getDatabase(); + const rows: EpgProgramRow[] = await db + .select({ + channelId: schema.epgPrograms.channelId, + start: schema.epgPrograms.start, + stop: schema.epgPrograms.stop, + title: schema.epgPrograms.title, + description: schema.epgPrograms.description, + category: schema.epgPrograms.category, + iconUrl: schema.epgPrograms.iconUrl, + rating: schema.epgPrograms.rating, + episodeNum: schema.epgPrograms.episodeNum, + }) + .from(schema.epgPrograms) + .where(guideWindowCondition(epgIds, window)) + .orderBy(schema.epgPrograms.start); + const byEpgId = this.groupPrograms(rows); + for (const [requestedId, epgId] of resolved) { + // Copy so two requested keys resolving to the same channel + // never share one array reference. + result[requestedId] = [...(byEpgId.get(epgId) ?? [])]; + } + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide programmes:', + error + ); + } + return result; + } + + /** + * Response keys are exactly the trimmed, de-duplicated, cap-respecting + * requested keys (`window.channelIds`) — never the raw request. A key cut + * by the per-request cap is absent from the result. An invalid window, + * or any failure while querying, returns `[]`. + */ + async getProgramCoverage( + request: EpgGuideWindowRequest + ): Promise { + const window = normalizeGuideWindow( + request, + EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + ); + if (!window) { + return []; + } + this.warnIfTruncated(request.channelIds, window.channelIds, 'coverage'); + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return []; + } + const db = await getDatabase(); + const rows: Array<{ channelId: string }> = await db + .selectDistinct({ channelId: schema.epgPrograms.channelId }) + .from(schema.epgPrograms) + .where(guideWindowCondition(epgIds, window)); + const covered = new Set(rows.map((row) => row.channelId)); + return window.channelIds.filter((requestedId) => { + const epgId = resolved.get(requestedId); + return epgId !== undefined && covered.has(epgId); + }); + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide coverage:', + error + ); + // Unlike programmes, coverage must not fail soft: an empty answer + // reads as "no channel has EPG" and the "Only with EPG" toggle + // would hide every row. A rejection keeps coverage unknown. + throw error; + } + } + + private emptyResult(channelIds: string[]): Record { + // Null prototype: a provider key such as `__proto__` must become an + // own property, not hit the legacy setter. + const result: Record = Object.create(null); + for (const id of channelIds) { + result[id] = []; + } + return result; + } + + /** + * Logs (counts only — no channel keys or source URLs) when the per-request + * cap dropped part of the request, so an oversized batch is visible in + * diagnostics instead of silently losing channels. `kind` names which + * read was truncated (programme lookup vs. coverage probe) since both + * share this helper but log to the same channel. + */ + private warnIfTruncated( + requestedIds: unknown, + keptIds: string[], + kind: 'programme' | 'coverage' + ): void { + const requestedCount = uniqueTrimmedStrings( + requestedIds, + Number.MAX_SAFE_INTEGER + ).length; + if (requestedCount > keptIds.length) { + epgLogger.log(this.loggerLabel, `Guide ${kind} request truncated`, { + requested: requestedCount, + kept: keptIds.length, + }); + } + } + + /** requested key → XMLTV channel id (keys without a match are absent). */ + private async resolveChannelIds( + window: NormalizedGuideWindow + ): Promise> { + const metadata = await this.resolver.resolveChannelMetadata( + window.channelIds, + window.sourceUrls.length > 0 + ? { sourceUrls: window.sourceUrls } + : {} + ); + const resolved = new Map(); + for (const requestedId of window.channelIds) { + const epgId = metadata[requestedId]?.id; + if (epgId) { + resolved.set(requestedId, epgId); + } + } + return resolved; + } + + /** + * Group by channel, drop rows with an invalid/unparsable start or stop + * (mirrors `EpgQueryService.isValidEpgProgram`), and collapse duplicate + * slots (same start + title). + */ + private groupPrograms(rows: EpgProgramRow[]): Map { + const grouped = new Map(); + const seen = new Set(); + for (const row of rows) { + const key = `${row.channelId}|${row.start}|${row.title}`; + if (seen.has(key) || !row.title) { + continue; + } + const program = toEpgProgramFromRow(row); + if (!isValidEpgProgram(program)) { + continue; + } + seen.add(key); + const list = grouped.get(row.channelId) ?? []; + list.push(program); + grouped.set(row.channelId, list); + } + return grouped; + } +} + +export const epgGuideQueryService = new EpgGuideQueryService(); diff --git a/apps/electron-backend/src/app/events/epg-guide-window.util.ts b/apps/electron-backend/src/app/events/epg-guide-window.util.ts new file mode 100644 index 000000000..8cf5c335f --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-window.util.ts @@ -0,0 +1,124 @@ +/** The renderer splits larger channel batches; anything beyond this is dropped. */ +export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100; + +/** + * Coverage checks are cheap (`selectDistinct` over an already-scoped id set) + * and portal hosts may probe many more keys than a single guide screen shows + * at once, so this cap is intentionally much larger than the programme cap. + */ +export const EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST = 2000; + +/** Bound on how many distinct EPG source URLs one request may scope to. */ +const MAX_SOURCE_URLS_PER_REQUEST = 50; + +/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */ +const MAX_SERIALIZABLE_MS = 8.64e15; + +/** + * Slack applied to the index-usable plain-string bounds + * (`fromSlackIso`/`toSlackIso`) that `guideWindowCondition` puts beside its + * exact `datetime()` overlap test. + * + * Stored `start`/`stop` are ISO-8601 strings, so a lexicographic compare + * orders them by their local wall-clock prefix rather than by the instant + * they denote. A provider offset shifts the true instant by at most 14 h, and + * a same-day `'YYYY-MM-DD HH:MM'` vs `'…THH:MM'` format difference by less + * than 24 h (a space sorts before 'T'), so 48 h of slack keeps the + * prefilter a strict superset of the exact result — while cutting the + * per-channel scan from the channel's whole retained history down to about + * four days. Rows whose strings `datetime()` cannot parse fail the exact + * predicate anyway, so the prefilter can never change the answer. + */ +const GUIDE_WINDOW_SLACK_MS = 48 * 60 * 60 * 1000; + +export interface EpgGuideWindowRequest { + channelIds: string[]; + /** Provider-clock instants (the renderer removes the display offset). */ + fromMs: number; + toMs: number; + sourceUrls?: string[]; +} + +export interface NormalizedGuideWindow { + channelIds: string[]; + fromIso: string; + toIso: string; + /** + * The same window widened by `GUIDE_WINDOW_SLACK_MS` on both sides, for + * the plain-string prefilter that lets SQLite bound its + * `(channel_id, start, stop)` index scan. Never a substitute for the + * exact `datetime()` comparison against `fromIso`/`toIso`. + */ + fromSlackIso: string; + toSlackIso: string; + sourceUrls: string[]; +} + +function isUsableInstant(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= MAX_SERIALIZABLE_MS + ); +} + +/** + * Serializes a widened bound. `toISOString()` throws past + * `MAX_SERIALIZABLE_MS`, so the slack is clamped there. A bound that lands in + * the extended `+YYYYYY-…` year form is one `datetime()` cannot parse, so the + * exact predicate already excludes every row for such a window and the + * narrower prefilter stays harmless. + */ +function slackIso(ms: number): string { + const clamped = Math.min( + Math.max(ms, -MAX_SERIALIZABLE_MS), + MAX_SERIALIZABLE_MS + ); + return new Date(clamped).toISOString(); +} + +/** Trims, drops blanks, de-duplicates (first-occurrence order) and caps. */ +export function uniqueTrimmedStrings(values: unknown, max: number): string[] { + if (!Array.isArray(values)) { + return []; + } + const seen = new Set(); + for (const value of values) { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed.length > 0) { + seen.add(trimmed); + } + } + return Array.from(seen).slice(0, max); +} + +/** Validates and trims a request; `null` means "nothing to query". */ +export function normalizeGuideWindow( + request: EpgGuideWindowRequest, + maxChannels = EPG_GUIDE_MAX_CHANNELS_PER_REQUEST +): NormalizedGuideWindow | null { + if ( + !isUsableInstant(request.fromMs) || + !isUsableInstant(request.toMs) || + request.fromMs >= request.toMs || + !Array.isArray(request.channelIds) + ) { + return null; + } + const channelIds = uniqueTrimmedStrings(request.channelIds, maxChannels); + if (channelIds.length === 0) { + return null; + } + const sourceUrls = uniqueTrimmedStrings( + request.sourceUrls ?? [], + MAX_SOURCE_URLS_PER_REQUEST + ); + return { + channelIds, + fromIso: new Date(request.fromMs).toISOString(), + toIso: new Date(request.toMs).toISOString(), + fromSlackIso: slackIso(request.fromMs - GUIDE_WINDOW_SLACK_MS), + toSlackIso: slackIso(request.toMs + GUIDE_WINDOW_SLACK_MS), + sourceUrls, + }; +} diff --git a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts new file mode 100644 index 000000000..ccf491003 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts @@ -0,0 +1,196 @@ +import type EpgEventsType from './epg.events'; + +const getProgramsForChannels = jest.fn(); +const getProgramCoverage = jest.fn(); +const resolveChannelIds = jest.fn(); +const resolveChannelIdsStrict = jest.fn(); +const ipcHandlers = new Map< + string, + (event: unknown, args: unknown) => Promise +>(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('./epg-guide-query.service', () => ({ + epgGuideQueryService: { + getProgramsForChannels: (...args: unknown[]) => + getProgramsForChannels(...args), + getProgramCoverage: (...args: unknown[]) => getProgramCoverage(...args), + }, +})); + +jest.mock('./epg-mapping.service', () => ({ + resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args), + resolveChannelIdsStrict: (...args: unknown[]) => + resolveChannelIdsStrict(...args), + queryByResolvedChannelIds: jest.fn(), + handleGetEpgMapping: jest.fn(), + handleGetEpgMappingsBatch: jest.fn(), + handleSetEpgMapping: jest.fn(), + handleDeleteEpgMapping: jest.fn(), + handleSearchEpgChannels: jest.fn(), +})); + +/** + * `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` apply manual + * mappings (`resolveChannelIds`) before delegating to `epgGuideQueryService`, + * then key the answer back by the verbatim requested id. The service keys + * its own answer by the TRIMMED, deduplicated form of the ids it was given + * (`normalizeGuideWindow`), so the handler must resolve the same trimmed key + * to find that answer — these tests pin the trim mismatch, the "absent + * means not queried" contract, and the coverage de-duplication. + */ +describe('EPG guide IPC handlers', () => { + let EpgEvents: typeof EpgEventsType; + + const program = { + channelId: 'xmltv.id', + title: 'News', + start: '2026-01-01T00:00:00.000Z', + stop: '2026-01-01T01:00:00.000Z', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }; + + beforeEach(async () => { + jest.resetModules(); + ipcHandlers.clear(); + [getProgramsForChannels, getProgramCoverage, resolveChannelIds].forEach( + (mock) => mock.mockReset() + ); + resolveChannelIds.mockResolvedValue(new Map()); + resolveChannelIdsStrict.mockResolvedValue(new Map()); + + ({ default: EpgEvents } = await import('./epg.events')); + + const { ipcMain } = jest.requireMock('electron'); + (ipcMain.handle as jest.Mock).mockReset(); + (ipcMain.handle as jest.Mock).mockImplementation( + ( + channel: string, + handler: (event: unknown, args: unknown) => Promise + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + const handler = ipcHandlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler({}, args); + } + + it('applies a manual mapping before querying and keys the answer back by the requested key', async () => { + resolveChannelIds.mockResolvedValue(new Map([['m3u-key', 'xmltv.id']])); + getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] }); + + const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: ['m3u-key'], + fromMs: 0, + toMs: 1, + }); + + expect(getProgramsForChannels).toHaveBeenCalledWith( + expect.objectContaining({ channelIds: ['xmltv.id'] }) + ); + expect(result).toEqual({ 'm3u-key': [program] }); + }); + + it('gives two requested keys resolving to one target their own array reference', async () => { + resolveChannelIds.mockResolvedValue( + new Map([ + ['a', 'xmltv.id'], + ['b', 'xmltv.id'], + ]) + ); + getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] }); + + const result = (await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: ['a', 'b'], + fromMs: 0, + toMs: 1, + })) as Record; + + expect(result['a']).toEqual([program]); + expect(result['b']).toEqual([program]); + expect(result['a']).not.toBe(result['b']); + }); + + it('trims a padded requested id to find the service answer keyed by the trimmed form', async () => { + getProgramsForChannels.mockResolvedValue({ CNN: [program] }); + + const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: [' CNN '], + fromMs: 0, + toMs: 1, + }); + + expect(getProgramsForChannels).toHaveBeenCalledWith( + expect.objectContaining({ channelIds: [' CNN '] }) + ); + expect(result).toEqual({ ' CNN ': [program] }); + }); + + it('omits a key the service did not answer for instead of filling it with []', async () => { + getProgramsForChannels.mockResolvedValue({}); + + const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: ['dropped'], + fromMs: 0, + toMs: 1, + }); + + expect(result).toEqual({}); + }); + + it('rejects coverage when the manual-mapping lookup fails, but still answers programmes', async () => { + resolveChannelIdsStrict.mockRejectedValue(new Error('mapping db down')); + getProgramsForChannels.mockResolvedValue({ a: [] }); + + await expect( + invoke('EPG_GET_PROGRAM_COVERAGE', { + channelIds: ['a'], + fromMs: 0, + toMs: 1, + }) + ).rejects.toThrow('mapping db down'); + await expect( + invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', { + channelIds: ['a'], + fromMs: 0, + toMs: 1, + }) + ).resolves.toEqual({ a: [] }); + }); + + it('de-duplicates requested coverage keys and keeps only the covered ones', async () => { + getProgramCoverage.mockResolvedValue(['a']); + + const result = await invoke('EPG_GET_PROGRAM_COVERAGE', { + channelIds: ['a', 'a', 'b'], + fromMs: 0, + toMs: 1, + }); + + expect(result).toEqual(['a']); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts index 7b767ce0a..2778507e9 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.service.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -34,14 +34,24 @@ export async function resolveChannelIds( channelIds: string[] ): Promise> { try { - const db = await getDatabase(); - const mappings = await getEpgMappingsBatch(db, channelIds); - return mappings; + return await resolveChannelIdsStrict(channelIds); } catch { return new Map(); } } +/** + * Same lookup, but a database failure rejects. The guide's coverage read + * uses it: answering "unmapped" for a mapped channel would let the + * "Only with EPG" filter hide it, whereas a rejection keeps coverage unknown. + */ +export async function resolveChannelIdsStrict( + channelIds: string[] +): Promise> { + const db = await getDatabase(); + return getEpgMappingsBatch(db, channelIds); +} + /** * Run a batch query against mapping-resolved channel IDs and remap the results * back onto the original request keys the renderer asked for. diff --git a/apps/electron-backend/src/app/events/epg-program-row.util.ts b/apps/electron-backend/src/app/events/epg-program-row.util.ts new file mode 100644 index 000000000..d4ad31efe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-program-row.util.ts @@ -0,0 +1,47 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; + +/** + * Shared shape for a raw `epg_programs` row as read by both + * `EpgQueryService` and `EpgGuideQueryService`. Callers with additional + * columns (e.g. `id`) satisfy this structurally without extending it. + */ +export interface EpgProgramRow { + channelId: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + iconUrl: string | null; + rating: string | null; + episodeNum: string | null; +} + +/** Maps a raw DB row onto the public `EpgProgram` shape. No validation. */ +export function toEpgProgramFromRow(row: EpgProgramRow): EpgProgram { + return { + start: row.start, + stop: row.stop, + channel: row.channelId, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.iconUrl, + rating: row.rating, + episodeNum: row.episodeNum, + }; +} + +/** + * A programme is only usable once its start/stop are non-empty AND parse to + * a real instant — a malformed XMLTV timestamp must not reach the renderer + * as a bogus block. + */ +export function isValidEpgProgram(program: EpgProgram): boolean { + return Boolean( + program.start && + program.stop && + !Number.isNaN(new Date(program.start).getTime()) && + !Number.isNaN(new Date(program.stop).getTime()) + ); +} diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index c23764644..22544a29b 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,13 +1,11 @@ import { epgLogger } from '../util/epg-logger'; import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; -import { - EpgChannelMetadata, - EpgProgram, -} from '@iptvnator/shared/interfaces'; +import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; +import { isValidEpgProgram, toEpgProgramFromRow } from './epg-program-row.util'; -interface EpgProgramRow { +interface EpgProgramSelectRow { id: number; channelId: string; start: string; @@ -215,11 +213,12 @@ export class EpgQueryService { unmatchedIds = validIds.filter((id) => !(id in result)); if (unmatchedIds.length > 0) { - const scopedCandidates = await this.selectChannelLookupCandidates( - db, - unmatchedIds, - sourceUrls - ); + const scopedCandidates = + await this.selectChannelLookupCandidates( + db, + unmatchedIds, + sourceUrls + ); const unresolvedIds = unmatchedIds.filter( (channelId) => !this.resolveChannelMetadataCandidate( @@ -345,52 +344,62 @@ export class EpgQueryService { } } + /** + * Resolve channel lookup keys to XMLTV channel metadata. Throws on a + * database failure so callers that must tell "no match" apart from "could + * not look up" (the guide's coverage read) can fail open. + */ + async resolveChannelMetadata( + channelIds: string[], + options: { sourceUrls?: string[] } = {} + ): Promise> { + const normalizedChannelIds = + this.normalizeChannelLookupKeys(channelIds); + + if (normalizedChannelIds.length === 0) { + return {}; + } + + const db = await getDatabase(); + const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); + let candidates = await this.selectChannelMetadataCandidates( + db, + normalizedChannelIds, + sourceUrls + ); + if (sourceUrls.length > 0) { + const missingChannelIds = normalizedChannelIds.filter( + (channelId) => + !this.resolveChannelMetadataCandidate(channelId, candidates) + ); + if (missingChannelIds.length > 0) { + candidates = [ + ...candidates, + ...(await this.selectChannelMetadataCandidates( + db, + missingChannelIds, + sourceUrls, + { legacyOnly: true } + )), + ]; + } + } + + return Object.fromEntries( + normalizedChannelIds.map((channelId) => [ + channelId, + this.resolveChannelMetadataCandidate(channelId, candidates), + ]) + ); + } + + /** Fail-soft variant of `resolveChannelMetadata`: a failure logs and answers `{}`. */ async getChannelMetadata( channelIds: string[], options: { sourceUrls?: string[] } = {} ): Promise> { try { - const normalizedChannelIds = - this.normalizeChannelLookupKeys(channelIds); - - if (normalizedChannelIds.length === 0) { - return {}; - } - - const db = await getDatabase(); - const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); - let candidates = await this.selectChannelMetadataCandidates( - db, - normalizedChannelIds, - sourceUrls - ); - if (sourceUrls.length > 0) { - const missingChannelIds = normalizedChannelIds.filter( - (channelId) => - !this.resolveChannelMetadataCandidate( - channelId, - candidates - ) - ); - if (missingChannelIds.length > 0) { - candidates = [ - ...candidates, - ...(await this.selectChannelMetadataCandidates( - db, - missingChannelIds, - sourceUrls, - { legacyOnly: true } - )), - ]; - } - } - - return Object.fromEntries( - normalizedChannelIds.map((channelId) => [ - channelId, - this.resolveChannelMetadataCandidate(channelId, candidates), - ]) - ); + return await this.resolveChannelMetadata(channelIds, options); } catch (error) { epgLogger.error( this.loggerLabel, @@ -401,54 +410,6 @@ export class EpgQueryService { } } - async getChannelsByRange( - skip: number, - limit: number - ): Promise< - Array<{ - id: string; - displayName: string; - iconUrl: string | null; - programs: EpgProgram[]; - }> - > { - try { - const db = await getDatabase(); - const channels = await db - .select({ - id: schema.epgChannels.id, - displayName: schema.epgChannels.displayName, - iconUrl: schema.epgChannels.iconUrl, - }) - .from(schema.epgChannels) - .orderBy(schema.epgChannels.displayName) - .offset(skip) - .limit(limit); - - return Promise.all( - channels.map(async (channel) => { - const programs = await db - .select() - .from(schema.epgPrograms) - .where(eq(schema.epgPrograms.channelId, channel.id)) - .orderBy(schema.epgPrograms.start); - - return { - ...channel, - programs: programs.map(this.transformDbRowToEpgProgram), - }; - }) - ); - } catch (error) { - epgLogger.error( - this.loggerLabel, - 'Error getting channels by range:', - error - ); - return []; - } - } - private normalizeChannelLookupKeys(channelIds: string[]): string[] { return Array.from( new Set( @@ -471,7 +432,7 @@ export class EpgQueryService { private assignCurrentProgramRows( result: Record, - rows: EpgProgramRow[] + rows: EpgProgramSelectRow[] ): Set { const matchedChannelIds = new Set(); for (const row of rows) { @@ -479,8 +440,8 @@ export class EpgQueryService { continue; } - const program = this.transformDbRowToEpgProgram(row); - if (this.isValidEpgProgram(program)) { + const program = toEpgProgramFromRow(row); + if (isValidEpgProgram(program)) { result[row.channelId] = program; matchedChannelIds.add(row.channelId); } @@ -509,12 +470,12 @@ export class EpgQueryService { private assignCandidateCurrentProgramRows( result: Record, candidateIdsByRequestedId: Map, - rows: EpgProgramRow[] + rows: EpgProgramSelectRow[] ): Set { const matchedCandidateIds = new Set(); for (const row of rows) { - const program = this.transformDbRowToEpgProgram(row); - if (!this.isValidEpgProgram(program)) { + const program = toEpgProgramFromRow(row); + if (!isValidEpgProgram(program)) { continue; } @@ -537,32 +498,34 @@ export class EpgQueryService { db: EpgDatabase, channelId: string, sourceUrls: string[] - ): Promise { - return db - .select() - .from(schema.epgPrograms) - .where( - this.withProgramSourceScope( - eq(schema.epgPrograms.channelId, channelId), - sourceUrls + ): Promise { + return ( + db + .select() + .from(schema.epgPrograms) + .where( + this.withProgramSourceScope( + eq(schema.epgPrograms.channelId, channelId), + sourceUrls + ) ) - ) - // Collapse identical slots from multiple sources in SQL so the - // 500-row cap counts distinct programmes, not duplicate rows. - .groupBy( - schema.epgPrograms.channelId, - schema.epgPrograms.start, - schema.epgPrograms.title - ) - .orderBy(schema.epgPrograms.start) - .limit(500); + // Collapse identical slots from multiple sources in SQL so the + // 500-row cap counts distinct programmes, not duplicate rows. + .groupBy( + schema.epgPrograms.channelId, + schema.epgPrograms.start, + schema.epgPrograms.title + ) + .orderBy(schema.epgPrograms.start) + .limit(500) + ); } private async selectLegacyChannelPrograms( db: EpgDatabase, channelId: string, sourceUrls: string[] - ): Promise { + ): Promise { if (sourceUrls.length === 0) { return []; } @@ -592,7 +555,7 @@ export class EpgQueryService { now: string, sourceUrls: string[], options: { legacyOnly?: boolean } = {} - ): Promise { + ): Promise { if (channelIds.length === 0) { return []; } @@ -600,23 +563,25 @@ export class EpgQueryService { return []; } - return db - .select() - .from(schema.epgPrograms) - .where( - this.withProgramSourceScope( - and( - inArray(schema.epgPrograms.channelId, channelIds), - this.isAiringAt(now) - ) as SQL, - sourceUrls, - options + return ( + db + .select() + .from(schema.epgPrograms) + .where( + this.withProgramSourceScope( + and( + inArray(schema.epgPrograms.channelId, channelIds), + this.isAiringAt(now) + ) as SQL, + sourceUrls, + options + ) ) - ) - // One current row per channel so duplicate cross-source slots - // don't consume the per-channel cap and starve other channels. - .groupBy(schema.epgPrograms.channelId) - .limit(channelIds.length); + // One current row per channel so duplicate cross-source slots + // don't consume the per-channel cap and starve other channels. + .groupBy(schema.epgPrograms.channelId) + .limit(channelIds.length) + ); } /** @@ -701,9 +666,7 @@ export class EpgQueryService { return db .select() .from(schema.epgChannels) - .where( - this.withChannelSourceScope(condition, sourceUrls) - ) + .where(this.withChannelSourceScope(condition, sourceUrls)) .limit(1); } @@ -725,11 +688,9 @@ export class EpgQueryService { .select() .from(schema.epgChannels) .where( - this.withChannelSourceScope( - condition, - sourceUrls, - { legacyOnly: true } - ) + this.withChannelSourceScope(condition, sourceUrls, { + legacyOnly: true, + }) ) .limit(1); } @@ -874,7 +835,9 @@ export class EpgQueryService { } private channelHasProgramsForSourceScope(sourceUrls: string[]): SQL { - const sourceUrlValues = sourceUrls.map((sourceUrl) => sql`${sourceUrl}`); + const sourceUrlValues = sourceUrls.map( + (sourceUrl) => sql`${sourceUrl}` + ); return sql`EXISTS ( SELECT 1 @@ -927,12 +890,12 @@ export class EpgQueryService { * `sourceUrls`) can return the same channel/start/title from two EPG * sources; keep the first so a programme is never shown twice. */ - private toEpgPrograms(rows: EpgProgramRow[]): EpgProgram[] { + private toEpgPrograms(rows: EpgProgramSelectRow[]): EpgProgram[] { const seen = new Set(); const programs: EpgProgram[] = []; for (const row of rows) { - const program = this.transformDbRowToEpgProgram(row); - if (!this.isValidEpgProgram(program)) { + const program = toEpgProgramFromRow(row); + if (!isValidEpgProgram(program)) { continue; } const key = `${program.channel}|${program.start}|${program.title}`; @@ -945,29 +908,6 @@ export class EpgQueryService { return programs; } - private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram { - return { - start: row.start, - stop: row.stop, - channel: row.channelId, - title: row.title, - desc: row.description, - category: row.category, - iconUrl: row.iconUrl, - rating: row.rating, - episodeNum: row.episodeNum, - }; - } - - private isValidEpgProgram(program: EpgProgram): boolean { - return Boolean( - program.start && - program.stop && - !Number.isNaN(new Date(program.start).getTime()) && - !Number.isNaN(new Date(program.stop).getTime()) - ); - } - private async getMapping( db: EpgDatabase, channelKey: string @@ -979,9 +919,7 @@ export class EpgQueryService { epgChannelId: schema.epgChannelMappings.epgChannelId, }) .from(schema.epgChannelMappings) - .where( - eq(schema.epgChannelMappings.channelKey, channelKey) - ) + .where(eq(schema.epgChannelMappings.channelKey, channelKey)) .limit(1); if (rows.length > 0) { return rows[0].epgChannelId; @@ -1022,10 +960,7 @@ export class EpgQueryService { ) ) .where(eq(schema.content.epgChannelId, channelKey)) - .orderBy( - schema.categories.playlistId, - schema.content.xtreamId - ) + .orderBy(schema.categories.playlistId, schema.content.xtreamId) .limit(1); if (mapped.length > 0) { return mapped[0].epgChannelId; diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index fb40baa91..91801b10b 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -2,11 +2,13 @@ import { reconcileEpgSources } from './epg-source-settings.service'; import { ipcMain } from 'electron'; import { ElectronBridgeCurrentProgramsOptions, + ElectronBridgeEpgGuideWindow, ElectronBridgeTrustOptions, EpgChannelMetadata, EpgProgram, } from '@iptvnator/shared/interfaces'; import { epgQueryService } from './epg-query.service'; +import { epgGuideQueryService } from './epg-guide-query.service'; import { epgWorkerService } from './epg-worker.service'; import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service'; import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service'; @@ -17,6 +19,8 @@ import { handleSearchEpgChannels, handleSetEpgMapping, queryByResolvedChannelIds, + resolveChannelIds, + resolveChannelIdsStrict, } from './epg-mapping.service'; /** @@ -98,9 +102,16 @@ export default class EpgEvents { ); ipcMain.handle( - 'EPG_GET_CHANNELS_BY_RANGE', - async (_event, args: { skip: number; limit: number }) => { - return this.handleGetChannelsByRange(args.skip, args.limit); + 'EPG_GET_PROGRAMS_FOR_CHANNELS', + async (_event, args: ElectronBridgeEpgGuideWindow) => { + return this.handleGetGuidePrograms(args); + } + ); + + ipcMain.handle( + 'EPG_GET_PROGRAM_COVERAGE', + async (_event, args: ElectronBridgeEpgGuideWindow) => { + return this.handleGetGuideCoverage(args); } ); @@ -249,18 +260,81 @@ export default class EpgEvents { ); } - private static async handleGetChannelsByRange( - skip: number, - limit: number - ): Promise< - Array<{ - id: string; - displayName: string; - iconUrl: string | null; - programs: EpgProgram[]; - }> - > { - return epgQueryService.getChannelsByRange(skip, limit); + /** + * The guide query service keys its answer by the TRIMMED, deduplicated, + * cap-respecting form of the channel ids it was given + * (`normalizeGuideWindow`). Handlers must resolve the same trimmed key + * to look up that answer, or a padded request id (`" CNN "`) would never + * find its entry even though the service queried it successfully. + */ + private static resolvedGuideKey( + mapping: Map, + id: unknown + ): string { + return typeof id === 'string' ? (mapping.get(id) ?? id).trim() : ''; + } + + /** + * Guide reads take playlist channel keys. Manual mappings are applied + * here, before the query, and the answer is keyed back by the requested + * key so the renderer never sees a mapped id. A key the service did not + * answer for (cut by its per-request cap) stays absent from the result + * rather than being filled with `[]`, so callers can tell "queried, + * nothing found" apart from "not queried at all". + */ + private static async handleGetGuidePrograms( + args: ElectronBridgeEpgGuideWindow + ): Promise> { + const requested = Array.isArray(args?.channelIds) + ? args.channelIds + : []; + const mapping = await resolveChannelIds(requested); + const resolvedIds = requested.map((id) => mapping.get(id) ?? id); + const programs = await epgGuideQueryService.getProgramsForChannels({ + ...args, + channelIds: resolvedIds, + }); + const answer: Record = Object.create(null); + for (const id of requested) { + const key = this.resolvedGuideKey(mapping, id); + if (Object.prototype.hasOwnProperty.call(programs, key)) { + // Copy so two requested ids resolving to one target never + // share an array reference. + answer[String(id)] = [...programs[key]]; + } + } + return answer; + } + + /** A thrown error rejects the renderer's `invoke`, which is the intended fail-open path for coverage. */ + private static async handleGetGuideCoverage( + args: ElectronBridgeEpgGuideWindow + ): Promise { + const requested = Array.isArray(args?.channelIds) + ? args.channelIds + : []; + // Strict on purpose: a mapping lookup failure must reject (coverage + // unknown), not report mapped channels as unmapped and uncovered. + const mapping = await resolveChannelIdsStrict(requested); + const resolvedIds = requested.map((id) => mapping.get(id) ?? id); + const covered = new Set( + await epgGuideQueryService.getProgramCoverage({ + ...args, + channelIds: resolvedIds, + }) + ); + const seen = new Set(); + const answer: string[] = []; + for (const id of requested) { + if (seen.has(id)) { + continue; + } + seen.add(id); + if (covered.has(this.resolvedGuideKey(mapping, id))) { + answer.push(id); + } + } + return answer; } static async clearEpgData(): Promise { diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 924c0a2d3..f09a299ce 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -240,7 +240,8 @@ export function createElectronStub(): typeof window.electron { .fn() .mockResolvedValue(DEFAULT_APP_UPDATE_STATUS), getChannelPrograms: jest.fn().mockResolvedValue([]), - getEpgChannelsByRange: jest.fn().mockResolvedValue([]), + getEpgProgramsForChannels: jest.fn().mockResolvedValue({}), + getEpgProgramCoverage: jest.fn().mockResolvedValue([]), getLocalIpAddresses: jest.fn().mockResolvedValue([]), checkForAppUpdate: jest .fn() diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index df9c49545..7601cbcf0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "فتح عرض Multi-EPG" + "OPEN_EPG_GUIDE": "دليل البرامج" }, "EPG": { "NEXT_DAY": "اليوم التالي", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", "COLLAPSE_PANEL": "طي لوحة EPG", "EXPAND_PANEL": "توسيع لوحة EPG", + "GUIDE": { + "TITLE": "دليل البرامج", + "CLOSE": "إغلاق الدليل", + "CHANNEL": "قناة", + "TODAY": "اليوم", + "NOW": "الآن", + "JUMP_NOW": "الانتقال إلى الآن", + "SCOPE": "القنوات المعروضة", + "ONLY_WITH_EPG": "فقط مع EPG", + "ONLY_WITH_EPG_HINT": "إخفاء القنوات التي لا تحتوي على بيانات برامج", + "DENSITY_COMFORTABLE": "صفوف مريحة", + "DENSITY_COMPACT": "صفوف مضغوطة", + "ZOOM": "تكبير", + "FILTER_CHANNELS": "تصفية القنوات…", + "SEARCH_PROGRAMMES": "بحث في البرامج…", + "NO_RESULTS": "لم يتم العثور على برامج", + "NO_PROGRAMME_INFO": "لا توجد معلومات عن البرنامج", + "NO_PROGRAMME_INFO_LONG": "لا توجد معلومات عن البرنامج لهذه القناة", + "PLAYING": "قيد التشغيل", + "NO_CHANNELS": "لا توجد قنوات تطابق “{{query}}”", + "CLEAR_FILTER": "مسح التصفية", + "COLLAPSE_PLAYER": "طي المشغل", + "EXPAND_PLAYER": "توسيع المشغل", + "KEYS": { + "CHANNEL": "قناة", + "PROGRAMME": "برنامج", + "PLAY": "تشغيل", + "DETAILS": "التفاصيل", + "NOW": "الآن", + "DAY": "اليوم", + "CLOSE": "إغلاق" + } + }, "TIMELINE": { + "OPEN_GUIDE": "فتح دليل البرامج", "TODAY": "اليوم", "NOW": "الآن", "JUMP_NOW": "الانتقال إلى الآن", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "عرض تفاصيل حساب خادم Xtream", "OPEN_VIEW_DESCRIPTION": "الانتقال إلى {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} المرئية في هذا العرض", - "OPEN_MULTI_EPG_DESCRIPTION": "فتح عرض EPG متعدد القنوات", + "OPEN_EPG_GUIDE_DESCRIPTION": "فتح دليل البرامج لقائمة التشغيل هذه", "GO_TO_VOD_LABEL": "الأفلام", "GO_TO_VOD_DESCRIPTION": "الانتقال إلى قسم الأفلام", "GO_TO_LIVE_LABEL": "البث المباشر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7239c91d4..1b8e21c33 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "حل عرض Multi-EPG" + "OPEN_EPG_GUIDE": "دليل البرامج" }, "EPG": { "NEXT_DAY": "اليوم التالي", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", "COLLAPSE_PANEL": "طوي لوحة EPG", "EXPAND_PANEL": "وسع لوحة EPG", + "GUIDE": { + "TITLE": "دليل البرامج", + "CLOSE": "سد الدليل", + "CHANNEL": "قناة", + "TODAY": "اليوم", + "NOW": "دابا", + "JUMP_NOW": "نقز لدابا", + "SCOPE": "القنوات اللي بانين", + "ONLY_WITH_EPG": "فقط مع EPG", + "ONLY_WITH_EPG_HINT": "خبّي القنوات اللي ما عندهاش بيانات البرامج", + "DENSITY_COMFORTABLE": "صفوف مريحة", + "DENSITY_COMPACT": "صفوف مضغوطة", + "ZOOM": "تكبير", + "FILTER_CHANNELS": "فلتر القنوات…", + "SEARCH_PROGRAMMES": "بحث في البرامج…", + "NO_RESULTS": "ما لقيناش برامج", + "NO_PROGRAMME_INFO": "ما كايناش معلومات على البرنامج", + "NO_PROGRAMME_INFO_LONG": "ما كايناش معلومات على البرنامج ديال هاد القناة", + "PLAYING": "كيتشغل", + "NO_CHANNELS": "ما كاين حتى قناة كتطابق “{{query}}”", + "CLEAR_FILTER": "مسح الفلتر", + "COLLAPSE_PLAYER": "صغّر المشغل", + "EXPAND_PLAYER": "كبّر المشغل", + "KEYS": { + "CHANNEL": "قناة", + "PROGRAMME": "برنامج", + "PLAY": "شغّل", + "DETAILS": "التفاصيل", + "NOW": "دابا", + "DAY": "النهار", + "CLOSE": "سد" + } + }, "TIMELINE": { + "OPEN_GUIDE": "حل دليل البرامج", "TODAY": "اليوم", "NOW": "دابا", "JUMP_NOW": "نقز لدابا", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "وري تفاصيل حساب سيرفر Xtream", "OPEN_VIEW_DESCRIPTION": "وجه ل{{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} الظاهرة في هاد العرض", - "OPEN_MULTI_EPG_DESCRIPTION": "حل عرض EPG متعدد القنوات", + "OPEN_EPG_GUIDE_DESCRIPTION": "حل دليل البرامج ديال قائمة التشغيل هاد", "GO_TO_VOD_LABEL": "الأفلام", "GO_TO_VOD_DESCRIPTION": "اقفز لقسم الأفلام", "GO_TO_LIVE_LABEL": "البث المباشر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ff82bccf8..54db75978 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Адкрыць прагляд Multi-EPG" + "OPEN_EPG_GUIDE": "Праграма перадач" }, "EPG": { "NEXT_DAY": "Наступны дзень", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Прайграванне архіва", "COLLAPSE_PANEL": "Згарнуць панэль EPG", "EXPAND_PANEL": "Разгарнуць панэль EPG", + "GUIDE": { + "TITLE": "Праграма перадач", + "CLOSE": "Закрыць праграму перадач", + "CHANNEL": "Канал", + "TODAY": "Сёння", + "NOW": "Цяпер", + "JUMP_NOW": "Перайсці да цяперашняга", + "SCOPE": "Паказаныя каналы", + "ONLY_WITH_EPG": "Толькі з EPG", + "ONLY_WITH_EPG_HINT": "Схаваць каналы без даных праграмы", + "DENSITY_COMFORTABLE": "Зручныя радкі", + "DENSITY_COMPACT": "Кампактныя радкі", + "ZOOM": "Маштаб", + "FILTER_CHANNELS": "Фільтраваць каналы…", + "SEARCH_PROGRAMMES": "Пошук перадач…", + "NO_RESULTS": "Праграмы не знойдзены", + "NO_PROGRAMME_INFO": "Няма інфармацыі аб праграме", + "NO_PROGRAMME_INFO_LONG": "Няма інфармацыі аб праграме для гэтага канала", + "PLAYING": "Прайграецца", + "NO_CHANNELS": "Няма каналаў, якія адпавядаюць «{{query}}»", + "CLEAR_FILTER": "Ачысціць фільтр", + "COLLAPSE_PLAYER": "Згарнуць плэер", + "EXPAND_PLAYER": "Разгарнуць плэер", + "KEYS": { + "CHANNEL": "канал", + "PROGRAMME": "праграма", + "PLAY": "прайграць", + "DETAILS": "падрабязнасці", + "NOW": "цяпер", + "DAY": "дзень", + "CLOSE": "закрыць" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Адкрыць праграму перадач", "TODAY": "Сёння", "NOW": "Цяпер", "JUMP_NOW": "Перайсці да цяперашняга", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Паказаць дэталі ўліковага запісу сервера Xtream", "OPEN_VIEW_DESCRIPTION": "Перайсці да {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Ачысціць усе бачныя элементы тыпу {{type}} у гэтым выглядзе", - "OPEN_MULTI_EPG_DESCRIPTION": "Адкрыць выгляд мульты-EPG", + "OPEN_EPG_GUIDE_DESCRIPTION": "Адкрыць праграму перадач для гэтага плэйліста", "GO_TO_VOD_LABEL": "Фільмы", "GO_TO_VOD_DESCRIPTION": "Перайсці да раздзела фільмаў", "GO_TO_LIVE_LABEL": "Прамы эфір", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b3ba34b20..ca612ef99 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Multi-EPG-Ansicht öffnen" + "OPEN_EPG_GUIDE": "Programmführer" }, "EPG": { "NEXT_DAY": "Zum nächsten Tag", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Archivwiedergabe", "COLLAPSE_PANEL": "EPG-Panel einklappen", "EXPAND_PANEL": "EPG-Panel ausklappen", + "GUIDE": { + "TITLE": "Programmführer", + "CLOSE": "Programmführer schließen", + "CHANNEL": "Sender", + "TODAY": "Heute", + "NOW": "Jetzt", + "JUMP_NOW": "Zu jetzt springen", + "SCOPE": "Angezeigte Sender", + "ONLY_WITH_EPG": "Nur mit EPG", + "ONLY_WITH_EPG_HINT": "Sender ohne Programmdaten ausblenden", + "DENSITY_COMFORTABLE": "Komfortable Zeilen", + "DENSITY_COMPACT": "Kompakte Zeilen", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Sender filtern…", + "SEARCH_PROGRAMMES": "Sendungen durchsuchen…", + "NO_RESULTS": "Keine Sendungen gefunden", + "NO_PROGRAMME_INFO": "Keine Programminformationen", + "NO_PROGRAMME_INFO_LONG": "Keine Programminformationen für diesen Sender", + "PLAYING": "Läuft", + "NO_CHANNELS": "Keine Sender stimmen mit „{{query}}“ überein", + "CLEAR_FILTER": "Filter zurücksetzen", + "COLLAPSE_PLAYER": "Player einklappen", + "EXPAND_PLAYER": "Player ausklappen", + "KEYS": { + "CHANNEL": "Kanal", + "PROGRAMME": "Programm", + "PLAY": "Wiedergabe", + "DETAILS": "Details", + "NOW": "jetzt", + "DAY": "Tag", + "CLOSE": "schließen" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Programmführer öffnen", "TODAY": "Heute", "NOW": "Jetzt", "JUMP_NOW": "Zu jetzt springen", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Xtream-Server-Kontodaten anzeigen", "OPEN_VIEW_DESCRIPTION": "Zu {{view}} wechseln", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Alle sichtbaren {{type}}-Einträge in dieser Ansicht löschen", - "OPEN_MULTI_EPG_DESCRIPTION": "Die Multi-EPG-Ansicht öffnen", + "OPEN_EPG_GUIDE_DESCRIPTION": "Programmführer für diese Playlist öffnen", "GO_TO_VOD_LABEL": "Filme", "GO_TO_VOD_DESCRIPTION": "Zum Filmbereich wechseln", "GO_TO_LIVE_LABEL": "Live-TV", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dba844c76..ac42fd3d7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Άνοιγμα προβολής Multi-EPG" + "OPEN_EPG_GUIDE": "Οδηγός προγράμματος" }, "EPG": { "NEXT_DAY": "Επόμενη μέρα", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου", "COLLAPSE_PANEL": "Σύμπτυξη πίνακα EPG", "EXPAND_PANEL": "Ανάπτυξη πίνακα EPG", + "GUIDE": { + "TITLE": "Οδηγός προγράμματος", + "CLOSE": "Κλείσιμο οδηγού", + "CHANNEL": "Κανάλι", + "TODAY": "Σήμερα", + "NOW": "Τώρα", + "JUMP_NOW": "Μετάβαση στο τώρα", + "SCOPE": "Εμφανιζόμενα κανάλια", + "ONLY_WITH_EPG": "Μόνο με EPG", + "ONLY_WITH_EPG_HINT": "Απόκρυψη καναλιών χωρίς δεδομένα προγράμματος", + "DENSITY_COMFORTABLE": "Άνετες γραμμές", + "DENSITY_COMPACT": "Συμπαγείς γραμμές", + "ZOOM": "Ζουμ", + "FILTER_CHANNELS": "Φιλτράρισμα καναλιών…", + "SEARCH_PROGRAMMES": "Αναζήτηση προγραμμάτων…", + "NO_RESULTS": "Δεν βρέθηκαν προγράμματα", + "NO_PROGRAMME_INFO": "Δεν υπάρχουν πληροφορίες προγράμματος", + "NO_PROGRAMME_INFO_LONG": "Δεν υπάρχουν πληροφορίες προγράμματος για αυτό το κανάλι", + "PLAYING": "Παίζει", + "NO_CHANNELS": "Κανένα κανάλι δεν ταιριάζει με «{{query}}»", + "CLEAR_FILTER": "Απαλοιφή φίλτρου", + "COLLAPSE_PLAYER": "Σύμπτυξη player", + "EXPAND_PLAYER": "Ανάπτυξη player", + "KEYS": { + "CHANNEL": "κανάλι", + "PROGRAMME": "πρόγραμμα", + "PLAY": "αναπαραγωγή", + "DETAILS": "λεπτομέρειες", + "NOW": "τώρα", + "DAY": "ημέρα", + "CLOSE": "κλείσιμο" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Άνοιγμα του οδηγού προγράμματος", "TODAY": "Σήμερα", "NOW": "Τώρα", "JUMP_NOW": "Μετάβαση στο τώρα", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Εμφάνιση στοιχείων λογαριασμού διακομιστή Xtream", "OPEN_VIEW_DESCRIPTION": "Πλοήγηση στο {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Εκκαθάριση όλων των ορατών στοιχείων {{type}} σε αυτή την προβολή", - "OPEN_MULTI_EPG_DESCRIPTION": "Άνοιγμα της προβολής EPG πολλαπλών καναλιών", + "OPEN_EPG_GUIDE_DESCRIPTION": "Άνοιγμα του οδηγού προγράμματος για αυτή τη λίστα αναπαραγωγής", "GO_TO_VOD_LABEL": "Ταινίες", "GO_TO_VOD_DESCRIPTION": "Μετάβαση στην ενότητα ταινιών", "GO_TO_LIVE_LABEL": "Ζωντανή TV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b88314af5..cc7ce7ed2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Open Multi-EPG view" + "OPEN_EPG_GUIDE": "Programme guide" }, "EPG": { "NEXT_DAY": "Next day", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Archive playback", "COLLAPSE_PANEL": "Collapse EPG panel", "EXPAND_PANEL": "Expand EPG panel", + "GUIDE": { + "TITLE": "Programme guide", + "CLOSE": "Close guide", + "CHANNEL": "Channel", + "TODAY": "Today", + "NOW": "Now", + "JUMP_NOW": "Jump to now", + "SCOPE": "Channels shown", + "ONLY_WITH_EPG": "Only with EPG", + "ONLY_WITH_EPG_HINT": "Hide channels without programme data", + "DENSITY_COMFORTABLE": "Comfortable rows", + "DENSITY_COMPACT": "Compact rows", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filter channels…", + "SEARCH_PROGRAMMES": "Search programmes…", + "NO_RESULTS": "No programmes found", + "NO_PROGRAMME_INFO": "No programme information", + "NO_PROGRAMME_INFO_LONG": "No programme information for this channel", + "PLAYING": "Playing", + "NO_CHANNELS": "No channels match \"{{query}}\"", + "CLEAR_FILTER": "Clear filter", + "COLLAPSE_PLAYER": "Collapse player", + "EXPAND_PLAYER": "Expand player", + "KEYS": { + "CHANNEL": "channel", + "PROGRAMME": "programme", + "PLAY": "play", + "DETAILS": "details", + "NOW": "now", + "DAY": "day", + "CLOSE": "close" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Open the programme guide", "TODAY": "Today", "NOW": "Now", "JUMP_NOW": "Jump to now", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details", "OPEN_VIEW_DESCRIPTION": "Navigate to {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Clear all visible {{type}} items in this view", - "OPEN_MULTI_EPG_DESCRIPTION": "Open the multi-channel EPG view", + "OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist", "GO_TO_VOD_LABEL": "Movies", "GO_TO_VOD_DESCRIPTION": "Jump to movies section", "GO_TO_LIVE_LABEL": "Live TV", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f9eed4b71..5d4fa0409 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Abrir vista Multi-EPG" + "OPEN_EPG_GUIDE": "Guía de programas" }, "EPG": { "NEXT_DAY": "Día siguiente", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Reproducción de archivo", "COLLAPSE_PANEL": "Contraer panel EPG", "EXPAND_PANEL": "Expandir panel EPG", + "GUIDE": { + "TITLE": "Guía de programas", + "CLOSE": "Cerrar guía", + "CHANNEL": "Canal", + "TODAY": "Hoy", + "NOW": "Ahora", + "JUMP_NOW": "Ir a ahora", + "SCOPE": "Canales mostrados", + "ONLY_WITH_EPG": "Solo con EPG", + "ONLY_WITH_EPG_HINT": "Oculta los canales sin datos de programación", + "DENSITY_COMFORTABLE": "Filas cómodas", + "DENSITY_COMPACT": "Filas compactas", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filtrar canales…", + "SEARCH_PROGRAMMES": "Buscar programas…", + "NO_RESULTS": "No se encontraron programas", + "NO_PROGRAMME_INFO": "Sin información del programa", + "NO_PROGRAMME_INFO_LONG": "Sin información del programa para este canal", + "PLAYING": "Reproduciendo", + "NO_CHANNELS": "Ningún canal coincide con “{{query}}”", + "CLEAR_FILTER": "Borrar filtro", + "COLLAPSE_PLAYER": "Contraer reproductor", + "EXPAND_PLAYER": "Expandir reproductor", + "KEYS": { + "CHANNEL": "canal", + "PROGRAMME": "programa", + "PLAY": "reproducir", + "DETAILS": "detalles", + "NOW": "ahora", + "DAY": "día", + "CLOSE": "cerrar" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Abrir la guía de programas", "TODAY": "Hoy", "NOW": "Ahora", "JUMP_NOW": "Ir a ahora", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Mostrar los detalles de la cuenta del servidor Xtream", "OPEN_VIEW_DESCRIPTION": "Ir a {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Borrar todos los elementos {{type}} visibles en esta vista", - "OPEN_MULTI_EPG_DESCRIPTION": "Abrir la vista EPG de varios canales", + "OPEN_EPG_GUIDE_DESCRIPTION": "Abrir la guía de programas de esta lista de reproducción", "GO_TO_VOD_LABEL": "Películas", "GO_TO_VOD_DESCRIPTION": "Ir a la sección de películas", "GO_TO_LIVE_LABEL": "TV en vivo", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 664c34688..bef9faadd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG" + "OPEN_EPG_GUIDE": "Guide des programmes" }, "EPG": { "NEXT_DAY": "Jour suivant", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Lecture archive", "COLLAPSE_PANEL": "Réduire le panneau EPG", "EXPAND_PANEL": "Développer le panneau EPG", + "GUIDE": { + "TITLE": "Guide des programmes", + "CLOSE": "Fermer le guide", + "CHANNEL": "Chaîne", + "TODAY": "Aujourd'hui", + "NOW": "Maintenant", + "JUMP_NOW": "Aller à maintenant", + "SCOPE": "Chaînes affichées", + "ONLY_WITH_EPG": "Uniquement avec EPG", + "ONLY_WITH_EPG_HINT": "Masquer les chaînes sans données de programme", + "DENSITY_COMFORTABLE": "Lignes confortables", + "DENSITY_COMPACT": "Lignes compactes", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filtrer les chaînes…", + "SEARCH_PROGRAMMES": "Rechercher des programmes…", + "NO_RESULTS": "Aucun programme trouvé", + "NO_PROGRAMME_INFO": "Aucune information sur le programme", + "NO_PROGRAMME_INFO_LONG": "Aucune information sur le programme pour cette chaîne", + "PLAYING": "En lecture", + "NO_CHANNELS": "Aucune chaîne ne correspond à « {{query}} »", + "CLEAR_FILTER": "Effacer le filtre", + "COLLAPSE_PLAYER": "Réduire le lecteur", + "EXPAND_PLAYER": "Agrandir le lecteur", + "KEYS": { + "CHANNEL": "chaîne", + "PROGRAMME": "programme", + "PLAY": "lecture", + "DETAILS": "détails", + "NOW": "maintenant", + "DAY": "jour", + "CLOSE": "fermer" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Ouvrir le guide des programmes", "TODAY": "Aujourd'hui", "NOW": "Maintenant", "JUMP_NOW": "Aller à maintenant", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Afficher les détails du compte du serveur Xtream", "OPEN_VIEW_DESCRIPTION": "Naviguer vers {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Effacer tous les éléments {{type}} visibles dans cette vue", - "OPEN_MULTI_EPG_DESCRIPTION": "Ouvrir la vue EPG multi-chaînes", + "OPEN_EPG_GUIDE_DESCRIPTION": "Ouvrir le guide des programmes pour cette liste de lecture", "GO_TO_VOD_LABEL": "Films", "GO_TO_VOD_DESCRIPTION": "Aller à la section films", "GO_TO_LIVE_LABEL": "TV en direct", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f124d7e43..802970bc2 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Többcsatornás EPG-nézet megnyitása" + "OPEN_EPG_GUIDE": "Műsorújság" }, "EPG": { "NEXT_DAY": "Következő nap", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Műsor visszanézése", "COLLAPSE_PANEL": "EPG-panel összecsukása", "EXPAND_PANEL": "EPG-panel kibontása", + "GUIDE": { + "TITLE": "Műsorújság", + "CLOSE": "Műsorújság bezárása", + "CHANNEL": "Csatorna", + "TODAY": "Ma", + "NOW": "Most", + "JUMP_NOW": "Ugrás a jelenhez", + "SCOPE": "Megjelenített csatornák", + "ONLY_WITH_EPG": "Csak EPG-vel", + "ONLY_WITH_EPG_HINT": "A műsoradatok nélküli csatornák elrejtése", + "DENSITY_COMFORTABLE": "Kényelmes sorok", + "DENSITY_COMPACT": "Kompakt sorok", + "ZOOM": "Nagyítás", + "FILTER_CHANNELS": "Csatornák szűrése…", + "SEARCH_PROGRAMMES": "Műsorok keresése…", + "NO_RESULTS": "Nem található műsor", + "NO_PROGRAMME_INFO": "Nincs műsorinformáció", + "NO_PROGRAMME_INFO_LONG": "Nincs műsorinformáció ehhez a csatornához", + "PLAYING": "Lejátszás alatt", + "NO_CHANNELS": "Nincs „{{query}}”-nak megfelelő csatorna", + "CLEAR_FILTER": "Szűrő törlése", + "COLLAPSE_PLAYER": "Lejátszó összecsukása", + "EXPAND_PLAYER": "Lejátszó kibontása", + "KEYS": { + "CHANNEL": "csatorna", + "PROGRAMME": "műsor", + "PLAY": "lejátszás", + "DETAILS": "részletek", + "NOW": "most", + "DAY": "nap", + "CLOSE": "bezárás" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Műsorújság megnyitása", "TODAY": "Ma", "NOW": "Most", "JUMP_NOW": "Ugrás a jelenhez", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Az Xtream-kiszolgálóhoz tartozó fiókadatok megjelenítése", "OPEN_VIEW_DESCRIPTION": "Nézet megnyitása: {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "A nézetben látható elemek törlése – {{type}}", - "OPEN_MULTI_EPG_DESCRIPTION": "Többcsatornás EPG-nézet megnyitása", + "OPEN_EPG_GUIDE_DESCRIPTION": "Műsorújság megnyitása ehhez a lejátszási listához", "GO_TO_VOD_LABEL": "Filmek", "GO_TO_VOD_DESCRIPTION": "Ugrás a filmekhez", "GO_TO_LIVE_LABEL": "Élő tévéadás", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 500fc5288..17b624a02 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Apri vista Multi-EPG" + "OPEN_EPG_GUIDE": "Guida programmi" }, "EPG": { "NEXT_DAY": "Giorno successivo", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Riproduzione archivio", "COLLAPSE_PANEL": "Comprimi pannello EPG", "EXPAND_PANEL": "Espandi pannello EPG", + "GUIDE": { + "TITLE": "Guida programmi", + "CLOSE": "Chiudi guida", + "CHANNEL": "Canale", + "TODAY": "Oggi", + "NOW": "Ora", + "JUMP_NOW": "Vai a ora", + "SCOPE": "Canali mostrati", + "ONLY_WITH_EPG": "Solo con EPG", + "ONLY_WITH_EPG_HINT": "Nascondi i canali senza dati di programmazione", + "DENSITY_COMFORTABLE": "Righe comode", + "DENSITY_COMPACT": "Righe compatte", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filtra canali…", + "SEARCH_PROGRAMMES": "Cerca programmi…", + "NO_RESULTS": "Nessun programma trovato", + "NO_PROGRAMME_INFO": "Nessuna informazione sul programma", + "NO_PROGRAMME_INFO_LONG": "Nessuna informazione sul programma per questo canale", + "PLAYING": "In riproduzione", + "NO_CHANNELS": "Nessun canale corrisponde a “{{query}}”", + "CLEAR_FILTER": "Cancella filtro", + "COLLAPSE_PLAYER": "Comprimi player", + "EXPAND_PLAYER": "Espandi player", + "KEYS": { + "CHANNEL": "canale", + "PROGRAMME": "programma", + "PLAY": "riproduzione", + "DETAILS": "dettagli", + "NOW": "ora", + "DAY": "giorno", + "CLOSE": "chiudi" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Apri la guida programmi", "TODAY": "Oggi", "NOW": "Ora", "JUMP_NOW": "Vai a ora", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Mostra i dettagli account del server Xtream", "OPEN_VIEW_DESCRIPTION": "Vai a {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Cancella tutti gli elementi {{type}} visibili in questa vista", - "OPEN_MULTI_EPG_DESCRIPTION": "Apri la vista EPG multi-canale", + "OPEN_EPG_GUIDE_DESCRIPTION": "Apri la guida programmi per questa playlist", "GO_TO_VOD_LABEL": "Film", "GO_TO_VOD_DESCRIPTION": "Vai alla sezione film", "GO_TO_LIVE_LABEL": "TV in diretta", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ee6d83bbd..6dc6ab3a3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "マルチ EPG ビューを開く" + "OPEN_EPG_GUIDE": "番組表" }, "EPG": { "NEXT_DAY": "翌日", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "アーカイブ再生", "COLLAPSE_PANEL": "EPG パネルを折りたたむ", "EXPAND_PANEL": "EPG パネルを展開", + "GUIDE": { + "TITLE": "番組表", + "CLOSE": "番組表を閉じる", + "CHANNEL": "チャンネル", + "TODAY": "今日", + "NOW": "現在", + "JUMP_NOW": "現在へ移動", + "SCOPE": "表示するチャンネル", + "ONLY_WITH_EPG": "EPGのみ", + "ONLY_WITH_EPG_HINT": "番組データがないチャンネルを非表示にする", + "DENSITY_COMFORTABLE": "ゆったり表示", + "DENSITY_COMPACT": "コンパクト表示", + "ZOOM": "ズーム", + "FILTER_CHANNELS": "チャンネルをフィルター…", + "SEARCH_PROGRAMMES": "番組を検索…", + "NO_RESULTS": "番組が見つかりません", + "NO_PROGRAMME_INFO": "番組情報がありません", + "NO_PROGRAMME_INFO_LONG": "このチャンネルの番組情報がありません", + "PLAYING": "再生中", + "NO_CHANNELS": "「{{query}}」に一致するチャンネルはありません", + "CLEAR_FILTER": "フィルターをクリア", + "COLLAPSE_PLAYER": "プレーヤーを折りたたむ", + "EXPAND_PLAYER": "プレーヤーを展開", + "KEYS": { + "CHANNEL": "チャンネル", + "PROGRAMME": "番組", + "PLAY": "再生", + "DETAILS": "詳細", + "NOW": "現在", + "DAY": "日", + "CLOSE": "閉じる" + } + }, "TIMELINE": { + "OPEN_GUIDE": "番組表を開く", "TODAY": "今日", "NOW": "現在", "JUMP_NOW": "現在へ移動", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Xtreamサーバーのアカウント詳細を表示", "OPEN_VIEW_DESCRIPTION": "{{view}}に移動", "CLEAR_CURRENT_VIEW_DESCRIPTION": "このビューに表示中の{{type}}項目をすべてクリア", - "OPEN_MULTI_EPG_DESCRIPTION": "マルチチャンネルEPGビューを開く", + "OPEN_EPG_GUIDE_DESCRIPTION": "このプレイリストの番組表を開く", "GO_TO_VOD_LABEL": "映画", "GO_TO_VOD_DESCRIPTION": "映画セクションに移動", "GO_TO_LIVE_LABEL": "ライブTV", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 513200dae..0dc3c63e2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "멀티 EPG 보기 열기" + "OPEN_EPG_GUIDE": "프로그램 가이드" }, "EPG": { "NEXT_DAY": "다음날", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "아카이브 재생", "COLLAPSE_PANEL": "EPG 패널 접기", "EXPAND_PANEL": "EPG 패널 펼치기", + "GUIDE": { + "TITLE": "프로그램 가이드", + "CLOSE": "가이드 닫기", + "CHANNEL": "채널", + "TODAY": "오늘", + "NOW": "지금", + "JUMP_NOW": "현재 시각으로 이동", + "SCOPE": "표시할 채널", + "ONLY_WITH_EPG": "EPG 채널만", + "ONLY_WITH_EPG_HINT": "프로그램 정보가 없는 채널 숨기기", + "DENSITY_COMFORTABLE": "여유로운 행", + "DENSITY_COMPACT": "조밀한 행", + "ZOOM": "확대/축소", + "FILTER_CHANNELS": "채널 필터…", + "SEARCH_PROGRAMMES": "프로그램 검색…", + "NO_RESULTS": "프로그램을 찾을 수 없습니다", + "NO_PROGRAMME_INFO": "프로그램 정보 없음", + "NO_PROGRAMME_INFO_LONG": "이 채널에 대한 프로그램 정보가 없습니다", + "PLAYING": "재생 중", + "NO_CHANNELS": "“{{query}}”와 일치하는 채널이 없습니다", + "CLEAR_FILTER": "필터 지우기", + "COLLAPSE_PLAYER": "플레이어 접기", + "EXPAND_PLAYER": "플레이어 펼치기", + "KEYS": { + "CHANNEL": "채널", + "PROGRAMME": "프로그램", + "PLAY": "재생", + "DETAILS": "상세정보", + "NOW": "지금", + "DAY": "날짜", + "CLOSE": "닫기" + } + }, "TIMELINE": { + "OPEN_GUIDE": "프로그램 가이드 열기", "TODAY": "오늘", "NOW": "지금", "JUMP_NOW": "현재 시각으로 이동", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Xtream 서버 계정 세부정보 표시", "OPEN_VIEW_DESCRIPTION": "{{view}}(으)로 이동", "CLEAR_CURRENT_VIEW_DESCRIPTION": "이 화면에서 보이는 모든 {{type}} 항목 지우기", - "OPEN_MULTI_EPG_DESCRIPTION": "다중 채널 EPG 화면 열기", + "OPEN_EPG_GUIDE_DESCRIPTION": "이 재생목록의 프로그램 가이드 열기", "GO_TO_VOD_LABEL": "영화", "GO_TO_VOD_DESCRIPTION": "영화 섹션으로 이동", "GO_TO_LIVE_LABEL": "라이브 TV", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5dbcaa5c2..7b8b26776 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Multi-EPG-weergave openen" + "OPEN_EPG_GUIDE": "Programmagids" }, "EPG": { "NEXT_DAY": "Volgende dag", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Archief afspelen", "COLLAPSE_PANEL": "EPG-paneel inklappen", "EXPAND_PANEL": "EPG-paneel uitklappen", + "GUIDE": { + "TITLE": "Programmagids", + "CLOSE": "Programmagids sluiten", + "CHANNEL": "Kanaal", + "TODAY": "Vandaag", + "NOW": "Nu", + "JUMP_NOW": "Ga naar nu", + "SCOPE": "Getoonde kanalen", + "ONLY_WITH_EPG": "Alleen met EPG", + "ONLY_WITH_EPG_HINT": "Verberg kanalen zonder programmagegevens", + "DENSITY_COMFORTABLE": "Comfortabele rijen", + "DENSITY_COMPACT": "Compacte rijen", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Kanalen filteren…", + "SEARCH_PROGRAMMES": "Programma's zoeken…", + "NO_RESULTS": "Geen programma's gevonden", + "NO_PROGRAMME_INFO": "Geen programma-informatie", + "NO_PROGRAMME_INFO_LONG": "Geen programma-informatie voor dit kanaal", + "PLAYING": "Speelt nu", + "NO_CHANNELS": "Geen kanalen komen overeen met “{{query}}”", + "CLEAR_FILTER": "Filter wissen", + "COLLAPSE_PLAYER": "Speler inklappen", + "EXPAND_PLAYER": "Speler uitklappen", + "KEYS": { + "CHANNEL": "kanaal", + "PROGRAMME": "programma", + "PLAY": "afspelen", + "DETAILS": "details", + "NOW": "nu", + "DAY": "dag", + "CLOSE": "sluiten" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Programmagids openen", "TODAY": "Vandaag", "NOW": "Nu", "JUMP_NOW": "Ga naar nu", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Toon Xtream-serveraccountdetails", "OPEN_VIEW_DESCRIPTION": "Navigeer naar {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Wis alle zichtbare {{type}}-items in deze weergave", - "OPEN_MULTI_EPG_DESCRIPTION": "Open de multi-kanaal EPG-weergave", + "OPEN_EPG_GUIDE_DESCRIPTION": "Programmagids voor deze afspeellijst openen", "GO_TO_VOD_LABEL": "Films", "GO_TO_VOD_DESCRIPTION": "Spring naar de filmsectie", "GO_TO_LIVE_LABEL": "Live tv", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 23f1c5005..03b0db6c8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Otwórz widok Multi-EPG" + "OPEN_EPG_GUIDE": "Przewodnik po programach" }, "EPG": { "NEXT_DAY": "Następny dzień", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Odtwarzanie archiwum", "COLLAPSE_PANEL": "Zwiń panel EPG", "EXPAND_PANEL": "Rozwiń panel EPG", + "GUIDE": { + "TITLE": "Przewodnik po programach", + "CLOSE": "Zamknij przewodnik", + "CHANNEL": "Kanał", + "TODAY": "Dzisiaj", + "NOW": "Teraz", + "JUMP_NOW": "Przejdź do teraz", + "SCOPE": "Wyświetlane kanały", + "ONLY_WITH_EPG": "Tylko z EPG", + "ONLY_WITH_EPG_HINT": "Ukryj kanały bez danych programu", + "DENSITY_COMFORTABLE": "Wygodne wiersze", + "DENSITY_COMPACT": "Kompaktowe wiersze", + "ZOOM": "Powiększenie", + "FILTER_CHANNELS": "Filtruj kanały…", + "SEARCH_PROGRAMMES": "Szukaj programów…", + "NO_RESULTS": "Nie znaleziono programów", + "NO_PROGRAMME_INFO": "Brak informacji o programie", + "NO_PROGRAMME_INFO_LONG": "Brak informacji o programie dla tego kanału", + "PLAYING": "Odtwarzane", + "NO_CHANNELS": "Brak kanałów pasujących do „{{query}}”", + "CLEAR_FILTER": "Wyczyść filtr", + "COLLAPSE_PLAYER": "Zwiń odtwarzacz", + "EXPAND_PLAYER": "Rozwiń odtwarzacz", + "KEYS": { + "CHANNEL": "kanał", + "PROGRAMME": "program", + "PLAY": "odtwórz", + "DETAILS": "szczegóły", + "NOW": "teraz", + "DAY": "dzień", + "CLOSE": "zamknij" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Otwórz przewodnik po programach", "TODAY": "Dzisiaj", "NOW": "Teraz", "JUMP_NOW": "Przejdź do teraz", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Pokaż szczegóły konta serwera Xtream", "OPEN_VIEW_DESCRIPTION": "Przejdź do {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Wyczyść wszystkie widoczne elementy {{type}} w tym widoku", - "OPEN_MULTI_EPG_DESCRIPTION": "Otwórz widok Multi-EPG dla wielu kanałów", + "OPEN_EPG_GUIDE_DESCRIPTION": "Otwórz przewodnik po programach dla tej listy odtwarzania", "GO_TO_VOD_LABEL": "Filmy", "GO_TO_VOD_DESCRIPTION": "Przejdź do sekcji filmów", "GO_TO_LIVE_LABEL": "Telewizja na żywo", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index ec5a90c4a..9ab426739 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Abrir visualização Multi-EPG" + "OPEN_EPG_GUIDE": "Guia de programação" }, "EPG": { "NEXT_DAY": "Próximo dia", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Reprodução do arquivo", "COLLAPSE_PANEL": "Recolher painel EPG", "EXPAND_PANEL": "Expandir painel EPG", + "GUIDE": { + "TITLE": "Guia de programação", + "CLOSE": "Fechar guia", + "CHANNEL": "Canal", + "TODAY": "Hoje", + "NOW": "Agora", + "JUMP_NOW": "Ir para agora", + "SCOPE": "Canais exibidos", + "ONLY_WITH_EPG": "Somente com EPG", + "ONLY_WITH_EPG_HINT": "Ocultar canais sem dados de programação", + "DENSITY_COMFORTABLE": "Linhas confortáveis", + "DENSITY_COMPACT": "Linhas compactas", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filtrar canais…", + "SEARCH_PROGRAMMES": "Pesquisar programas…", + "NO_RESULTS": "Nenhum programa encontrado", + "NO_PROGRAMME_INFO": "Sem informações do programa", + "NO_PROGRAMME_INFO_LONG": "Sem informações do programa para este canal", + "PLAYING": "Reproduzindo", + "NO_CHANNELS": "Nenhum canal corresponde a “{{query}}”", + "CLEAR_FILTER": "Limpar filtro", + "COLLAPSE_PLAYER": "Recolher player", + "EXPAND_PLAYER": "Expandir player", + "KEYS": { + "CHANNEL": "canal", + "PROGRAMME": "programa", + "PLAY": "reproduzir", + "DETAILS": "detalhes", + "NOW": "agora", + "DAY": "dia", + "CLOSE": "fechar" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Abrir o guia de programação", "TODAY": "Hoje", "NOW": "Agora", "JUMP_NOW": "Ir para agora", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Mostrar detalhes da conta do servidor Xtream", "OPEN_VIEW_DESCRIPTION": "Navegar até {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Limpar todos os itens de {{type}} visíveis nesta visualização", - "OPEN_MULTI_EPG_DESCRIPTION": "Abrir a visualização Multi-EPG", + "OPEN_EPG_GUIDE_DESCRIPTION": "Abrir o guia de programação desta playlist", "GO_TO_VOD_LABEL": "Filmes", "GO_TO_VOD_DESCRIPTION": "Ir para a seção de filmes", "GO_TO_LIVE_LABEL": "TV ao vivo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8bd781ad2..24220cc25 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Открыть вид Multi-EPG" + "OPEN_EPG_GUIDE": "Программа передач" }, "EPG": { "NEXT_DAY": "Следующий день", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Архивная запись", "COLLAPSE_PANEL": "Свернуть панель EPG", "EXPAND_PANEL": "Развернуть панель EPG", + "GUIDE": { + "TITLE": "Программа передач", + "CLOSE": "Закрыть программу передач", + "CHANNEL": "Канал", + "TODAY": "Сегодня", + "NOW": "Сейчас", + "JUMP_NOW": "Перейти к текущему", + "SCOPE": "Отображаемые каналы", + "ONLY_WITH_EPG": "Только с EPG", + "ONLY_WITH_EPG_HINT": "Скрыть каналы без данных о программе", + "DENSITY_COMFORTABLE": "Комфортные строки", + "DENSITY_COMPACT": "Компактные строки", + "ZOOM": "Масштаб", + "FILTER_CHANNELS": "Фильтровать каналы…", + "SEARCH_PROGRAMMES": "Поиск передач…", + "NO_RESULTS": "Программы не найдены", + "NO_PROGRAMME_INFO": "Нет информации о программе", + "NO_PROGRAMME_INFO_LONG": "Нет информации о программе для этого канала", + "PLAYING": "Играет", + "NO_CHANNELS": "Нет каналов, соответствующих «{{query}}»", + "CLEAR_FILTER": "Очистить фильтр", + "COLLAPSE_PLAYER": "Свернуть плеер", + "EXPAND_PLAYER": "Развернуть плеер", + "KEYS": { + "CHANNEL": "канал", + "PROGRAMME": "программа", + "PLAY": "воспроизвести", + "DETAILS": "подробности", + "NOW": "сейчас", + "DAY": "день", + "CLOSE": "закрыть" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Открыть программу передач", "TODAY": "Сегодня", "NOW": "Сейчас", "JUMP_NOW": "Перейти к текущему", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Показать данные аккаунта Xtream", "OPEN_VIEW_DESCRIPTION": "Перейти к разделу {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Очистить все видимые элементы «{{type}}» в этом представлении", - "OPEN_MULTI_EPG_DESCRIPTION": "Открыть представление Multi-EPG", + "OPEN_EPG_GUIDE_DESCRIPTION": "Открыть программу передач для этого плейлиста", "GO_TO_VOD_LABEL": "Фильмы", "GO_TO_VOD_DESCRIPTION": "Перейти к разделу фильмов", "GO_TO_LIVE_LABEL": "Прямой эфир", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index c7f6a8aee..638a39f8c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "Multi-EPG görünümünü aç" + "OPEN_EPG_GUIDE": "Program rehberi" }, "EPG": { "NEXT_DAY": "Sonraki gün", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "Arşiv oynatma", "COLLAPSE_PANEL": "EPG panelini daralt", "EXPAND_PANEL": "EPG panelini genişlet", + "GUIDE": { + "TITLE": "Program rehberi", + "CLOSE": "Rehberi kapat", + "CHANNEL": "Kanal", + "TODAY": "Bugün", + "NOW": "Şimdi", + "JUMP_NOW": "Şimdiye atla", + "SCOPE": "Gösterilen kanallar", + "ONLY_WITH_EPG": "Yalnızca EPG'li", + "ONLY_WITH_EPG_HINT": "Program verisi olmayan kanalları gizle", + "DENSITY_COMFORTABLE": "Rahat satırlar", + "DENSITY_COMPACT": "Sıkışık satırlar", + "ZOOM": "Yakınlaştırma", + "FILTER_CHANNELS": "Kanalları filtrele…", + "SEARCH_PROGRAMMES": "Programları ara…", + "NO_RESULTS": "Program bulunamadı", + "NO_PROGRAMME_INFO": "Program bilgisi yok", + "NO_PROGRAMME_INFO_LONG": "Bu kanal için program bilgisi yok", + "PLAYING": "Oynatılıyor", + "NO_CHANNELS": "“{{query}}” ile eşleşen kanal yok", + "CLEAR_FILTER": "Filtreyi temizle", + "COLLAPSE_PLAYER": "Oynatıcıyı daralt", + "EXPAND_PLAYER": "Oynatıcıyı genişlet", + "KEYS": { + "CHANNEL": "kanal", + "PROGRAMME": "program", + "PLAY": "oynat", + "DETAILS": "ayrıntılar", + "NOW": "şimdi", + "DAY": "gün", + "CLOSE": "kapat" + } + }, "TIMELINE": { + "OPEN_GUIDE": "Program rehberini aç", "TODAY": "Bugün", "NOW": "Şimdi", "JUMP_NOW": "Şimdiye atla", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "Xtream sunucu hesabı ayrıntılarını göster", "OPEN_VIEW_DESCRIPTION": "{{view}} görünümüne git", "CLEAR_CURRENT_VIEW_DESCRIPTION": "Bu görünümdeki görünür tüm {{type}} öğelerini temizle", - "OPEN_MULTI_EPG_DESCRIPTION": "Çoklu kanal EPG görünümünü aç", + "OPEN_EPG_GUIDE_DESCRIPTION": "Bu oynatma listesi için program rehberini aç", "GO_TO_VOD_LABEL": "Filmler", "GO_TO_VOD_DESCRIPTION": "Filmler bölümüne atla", "GO_TO_LIVE_LABEL": "Canlı TV", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 26c86aefd..0dd368e2c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "打开多频道 EPG 视图" + "OPEN_EPG_GUIDE": "节目指南" }, "EPG": { "NEXT_DAY": "明天", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "回看播放", "COLLAPSE_PANEL": "收起 EPG 面板", "EXPAND_PANEL": "展开 EPG 面板", + "GUIDE": { + "TITLE": "节目指南", + "CLOSE": "关闭指南", + "CHANNEL": "频道", + "TODAY": "今天", + "NOW": "现在", + "JUMP_NOW": "跳转到现在", + "SCOPE": "显示的频道", + "ONLY_WITH_EPG": "仅含 EPG", + "ONLY_WITH_EPG_HINT": "隐藏没有节目数据的频道", + "DENSITY_COMFORTABLE": "舒适行距", + "DENSITY_COMPACT": "紧凑行距", + "ZOOM": "缩放", + "FILTER_CHANNELS": "筛选频道…", + "SEARCH_PROGRAMMES": "搜索节目…", + "NO_RESULTS": "未找到节目", + "NO_PROGRAMME_INFO": "没有节目信息", + "NO_PROGRAMME_INFO_LONG": "此频道没有节目信息", + "PLAYING": "正在播放", + "NO_CHANNELS": "没有频道匹配“{{query}}”", + "CLEAR_FILTER": "清除筛选", + "COLLAPSE_PLAYER": "收起播放器", + "EXPAND_PLAYER": "展开播放器", + "KEYS": { + "CHANNEL": "频道", + "PROGRAMME": "节目", + "PLAY": "播放", + "DETAILS": "详情", + "NOW": "现在", + "DAY": "日期", + "CLOSE": "关闭" + } + }, "TIMELINE": { + "OPEN_GUIDE": "打开节目指南", "TODAY": "今天", "NOW": "现在", "JUMP_NOW": "跳转到现在", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "显示 Xtream 服务器的账户详情", "OPEN_VIEW_DESCRIPTION": "跳转到 {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "清除此视图中所有可见的{{type}}项目", - "OPEN_MULTI_EPG_DESCRIPTION": "打开多频道 EPG 视图", + "OPEN_EPG_GUIDE_DESCRIPTION": "打开此播放列表的节目指南", "GO_TO_VOD_LABEL": "电影", "GO_TO_VOD_DESCRIPTION": "跳转到电影区", "GO_TO_LIVE_LABEL": "直播电视", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index d2ebfcd78..04bab7f42 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -764,7 +764,7 @@ } }, "TOP_MENU": { - "OPEN_MULTI_EPG": "開啟多 EPG 視圖" + "OPEN_EPG_GUIDE": "節目表" }, "EPG": { "NEXT_DAY": "下一天", @@ -814,7 +814,41 @@ "ARCHIVE_PLAYBACK": "回看播放", "COLLAPSE_PANEL": "收合 EPG 面板", "EXPAND_PANEL": "展開 EPG 面板", + "GUIDE": { + "TITLE": "節目表", + "CLOSE": "關閉節目表", + "CHANNEL": "頻道", + "TODAY": "今天", + "NOW": "現在", + "JUMP_NOW": "跳至現在", + "SCOPE": "顯示的頻道", + "ONLY_WITH_EPG": "僅含 EPG", + "ONLY_WITH_EPG_HINT": "隱藏沒有節目資料的頻道", + "DENSITY_COMFORTABLE": "舒適列距", + "DENSITY_COMPACT": "緊湊列距", + "ZOOM": "縮放", + "FILTER_CHANNELS": "篩選頻道…", + "SEARCH_PROGRAMMES": "搜尋節目…", + "NO_RESULTS": "找不到節目", + "NO_PROGRAMME_INFO": "沒有節目資訊", + "NO_PROGRAMME_INFO_LONG": "此頻道沒有節目資訊", + "PLAYING": "播放中", + "NO_CHANNELS": "沒有頻道符合「{{query}}」", + "CLEAR_FILTER": "清除篩選", + "COLLAPSE_PLAYER": "收合播放器", + "EXPAND_PLAYER": "展開播放器", + "KEYS": { + "CHANNEL": "頻道", + "PROGRAMME": "節目", + "PLAY": "播放", + "DETAILS": "詳情", + "NOW": "現在", + "DAY": "日期", + "CLOSE": "關閉" + } + }, "TIMELINE": { + "OPEN_GUIDE": "開啟節目表", "TODAY": "今天", "NOW": "現在", "JUMP_NOW": "跳至現在", @@ -1537,7 +1571,7 @@ "ACCOUNT_INFO_DESCRIPTION": "顯示 Xtream 伺服器帳戶詳細資訊", "OPEN_VIEW_DESCRIPTION": "前往 {{view}}", "CLEAR_CURRENT_VIEW_DESCRIPTION": "清除此畫面中所有可見的{{type}}項目", - "OPEN_MULTI_EPG_DESCRIPTION": "開啟多頻道 EPG 視圖", + "OPEN_EPG_GUIDE_DESCRIPTION": "開啟此播放清單的節目表", "GO_TO_VOD_LABEL": "電影", "GO_TO_VOD_DESCRIPTION": "跳至電影區段", "GO_TO_LIVE_LABEL": "直播電視", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index e6e8659ad..8b326b5a0 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -98,15 +98,11 @@ app-root.macos-platform .current-playlist { // Windows/Linux frameless title bar: reserve room in top-aligned drag // regions for the renderer-drawn window controls (3 × 46px buttons). -// Body-level class so it also reaches cdk-overlay content (multi-EPG). +// Body-level class so it also reaches cdk-overlay content (dialogs, menus). body.frameless-platform .workspace-header { padding-right: 150px; } -body.frameless-platform #epg-navigation { - padding-right: 150px; -} - // ─── Base ───────────────────────────────────────────────────────────────────── // Native UI (scrollbars, form controls, popups) must follow the app theme, // not the OS preference. Chromium colors unstyled/native scrollbars from the diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 39abd87b7..94c35e3b3 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 @@ -764,7 +764,10 @@ matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure `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. +activation, and the details dialog behave identically to the timeline. Both +views also carry the optional `guideAvailable` input and `openGuide` output: +the M3U host binds them in both branches, so the programme guide's Guide +action is reachable whichever view the setting selects. - **One channel, preloaded window.** The panel always shows a single channel. Each provider returns a multi-day window in roughly one call (M3U @@ -1053,12 +1056,12 @@ class EpgService { ### EPG Components -| Component | Purpose | -| ----------------------------- | ------------------------------------ | -| `EpgTimelineComponent` | Horizontal timeline for one channel | -| `EpgListViewComponent` | Vertical single-day list alternative | -| `EpgItemDescriptionComponent` | Program details dialog | -| `MultiEpgContainerComponent` | Grid view of all channels' schedules | +| Component | Purpose | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `EpgTimelineComponent` | Horizontal timeline for one channel | +| `EpgListViewComponent` | Vertical single-day list alternative | +| `EpgItemDescriptionComponent` | Program details dialog | +| `EpgGuideComponent` | Multi-channel programme guide grid fed by `EPG_GUIDE_SOURCE` (rows: `EpgGuideRowComponent`, toolbar: `EpgGuideToolbarComponent`, docked strip: `EpgGuideNowPlayingComponent`) | ## Video Player @@ -1078,7 +1081,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 @@ -1120,6 +1123,104 @@ 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. + +Row ids are not channel ids: `createChannel` falls back to the stream URL for +an entry without an explicit id, so one stream listed in two groups yields two +channels sharing an id. The adapter therefore builds SCOPE-LOCAL row ids by +prefixing every row with its position in the scope (`:`) — +the guide keys its programme, coverage and selection maps by row id, so +without that both copies lit up as playing and activating either one played +the first, and suffixing only the repeats was not enough because a real +channel id can itself look like a generated suffix (ids `x`, `x`, `x#1` +produced `x#1` twice). Ids move with the scope and the channel list, so only +ids the guide was just handed may be passed back. The active channel is +resolved to a row in three steps, because the store spreads the selected +channel (identity is gone) and copies can share an id: the row whose channel +matches the playing one field for field (every field except the +reducer-rewritten `epgParams`, key order ignored — `isSameChannelEntry`), +else the first same-id row with the same stream url, else the first same-id +row; `null` when the channel is outside the scope. `activate(rowId)` +resolves the row back to its own channel object. Opening the +guide mirrors the sidebar view (`applyInitialScope`): favorites stays +favorites, and the groups view opens on the group the sidebar's rail is +SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the +channel list container and `app-sidebar` into +`VideoPlayerComponent.selectedSidebarGroup` — because the user may have +browsed away from the playing channel's group before opening the guide. The +playing channel's group is the fallback, then `all`. + +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 48 px line (`epg-guide:dock-collapsed`), which is also the +fixed height of the strip shown for an external MPV/VLC session — that one has +no video to reveal, so it hides the Collapse toggle (`collapsible=false`) and +writes no preference. Nothing remounts, so playback and native-view Embedded +MPV bounds survive. While the guide is open the docked `.video-player` carries +`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and +legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own +keyboard controller. Entry points: the workspace header action +(`m3u-epg-guide`), the command palette, the Guide button in the EPG panel's +toolbar (`openGuide` on both `EpgTimelineComponent` and `EpgListViewComponent`, +so the action survives the list-view setting) and the `G` key on the player +page. +The header action reports `disabled` whenever the guide cannot open, which +greys out the header button and disables its palette command instead of +offering a no-op. Player fullscreen, radio, recognised movies, switching to +another playlist and the PWA close or withhold it. `openGuide()` itself also +refuses while ANY element is fullscreen or a CDK dialog is open — the guide +mounts in the page flow, so it would be painted over and still swallow the +keyboard — and the `G` key additionally ignores a press raised inside a +`.cdk-overlay-pane`, `[role="menu"]` or `[role="dialog"]`, the same surfaces +the PageUp/PageDown zapping yields to. 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 843df5393..a53ca1215 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -277,8 +277,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 @@ -413,8 +412,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/plans/2026-09-06-epg-guide-m3u.md b/docs/superpowers/plans/2026-09-06-epg-guide-m3u.md new file mode 100644 index 000000000..42893b4c3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-epg-guide-m3u.md @@ -0,0 +1,4961 @@ +# Programme Guide (Multi-EPG) Redesign — M3U Host — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the SVG multi-EPG overlay with a host-agnostic `app-epg-guide` grid that shows the M3U playlist's own channels, switches playback on click, keeps the player docked on top, and looks like the EPG timeline. + +**Architecture:** `libs/ui/epg/src/lib/epg-guide/` holds the grid and a data contract (`EPG_GUIDE_SOURCE`). The M3U host (`VideoPlayerComponent`) provides an adapter for that contract, owns a `guideOpen` layout mode that CSS-reflows the player into a docked strip, and exposes four entry points. Two new Electron IPCs (`EPG_GET_PROGRAMS_FOR_CHANNELS`, `EPG_GET_PROGRAM_COVERAGE`) replace `EPG_GET_CHANNELS_BY_RANGE`. Spec: `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md`. + +**Tech Stack:** Angular 20 signals + CDK virtual scroll, NgRx (`@iptvnator/m3u-state`), Electron IPC + Drizzle/SQLite, Jest (`run-web-esm-lib-tests.mjs` for web libs, `@nx/jest` for `electron-backend`), Playwright (`electron-backend-e2e`). + +--- + +## Conventions for every task + +- Run all commands from the worktree root: `/Users/4gray/Code/iptvnator/.claude/worktrees/multi-epg-view-feature-1efd4d`. +- **Running one web-lib spec.** `pnpm nx test ` on `ui-epg`, `playlist-m3u-feature-player`, `epg-data-access` and `services` always runs the whole project (the runner passes `--runTestsByPath` for every spec). To run one file: + ```bash + NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath + ``` +- **Running one backend spec:** `pnpm nx test electron-backend --testPathPatterns=`. +- Production TypeScript files stay under 300 lines (hard limit 400, blank lines and comments not counted). Never add a file to `tools/eslint/max-lines-baseline.mjs`. +- Use `@iptvnator/...` scoped aliases only. Signal-based inputs/outputs, `@if`/`@for` control flow. +- Commit after every task with the trailer `Co-Authored-By: Claude Fable 5.1 `. +- Existing helpers referenced below (do not re-implement): `getProgramTimeMs` (`libs/ui/epg/src/lib/epg-program.utils.ts`), `buildTimelineBlocks`/`buildTimelineAxis`/`classifyTimelineWhen` (`epg-timeline.utils.ts`), `buildTimelineRenderItems`/`tierFor` (`epg-timeline-render.util.ts`), `epgProviderClockMs`/`epgDisplayTimeMs` (`@iptvnator/shared/interfaces`), `getTodayEpgDateKey`/`parseEpgDateKey`/`shiftEpgDateKey` (`libs/ui/epg/src/lib/epg-date.ts`), `isTypingInInput` (`@iptvnator/portal/shared/util`), `resolveChannelEpgLookupKey` (`@iptvnator/m3u-state`), `EpgProgrammeDialogService` (`libs/ui/epg`), `queryByResolvedChannelIds`/`resolveChannelIds` (`apps/electron-backend/src/app/events/epg-mapping.service.ts`). + +## File structure + +**Created** + +| File | Responsibility | +| --- | --- | +| `apps/electron-backend/src/app/events/epg-guide-query.service.ts` | Window-scoped programme + coverage SQL for a batch of channel keys | +| `apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts` | Backend unit tests | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` | `EPG_GUIDE_SOURCE` token and contract types | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts` (+ spec) | Day axis, ticks, now-x, block layout for one row | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts` (+ spec) | localStorage persistence for density/zoom/toggle/dock | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts` (+ spec) | Per-day programme cache, batched loading, coverage | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts` (+ spec) | Roving focus + key commands | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts/.html/.scss` | Toolbar | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts/.html/.scss` | One channel row | +| `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts/.html/.scss` (+ spec) | Grid shell | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts/.html/.scss` | Docked strip info block | +| `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts` (+ spec) | M3U adapter for the contract | +| `apps/electron-backend-e2e/src/epg-guide.e2e.ts` | E2E | +| `.changes/epg-programme-guide.md` | Release note | + +**Modified** + +| File | Change | +| --- | --- | +| `libs/shared/interfaces/src/lib/electron-api.interface.ts` | New bridge methods/types; drop `getEpgChannelsByRange` + `ElectronBridgeEpgChannelWithPrograms` | +| `libs/shared/interfaces/src/lib/ipc-commands.ts` | Drop `EPG_GET_CHANNELS_BY_RANGE*` constants | +| `apps/electron-backend/src/app/api/main.preload.ts` (+ `.spec-data.ts`) | New methods, drop old | +| `apps/electron-backend/src/app/events/epg.events.ts` | Register two IPCs, drop old | +| `apps/electron-backend/src/app/events/epg-query.service.ts` | Drop `getChannelsByRange` | +| `libs/services/src/lib/runtime-capabilities.service.ts` (+ spec) | `supportsEpgGuide` replaces `supportsEpgChannelBrowser` | +| `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts` (+ spec) | New bridge calls, drop old | +| `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts` | Stub method rename | +| `libs/ui/epg/src/index.ts` | Export guide, drop multi-epg | +| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts/.html/.scss` | `openGuide` output + Guide button | +| `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts/.html/.scss/.spec.ts/.spec-stubs.ts` | Guide mode, entry points | +| `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts`, `.../workspace-command-palette/workspace-command-palette.component.spec.ts` | Command id rename | +| `apps/web/src/assets/i18n/*.json` (19 files) | `EPG.GUIDE.*` keys, drop `TOP_MENU.OPEN_MULTI_EPG`, `WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION` | +| `apps/web/src/styles.scss` | Drop `#epg-navigation` drag-region rule | +| `libs/ui/components/src/lib/window-controls/window-controls.component.ts`, `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts` | Comment updates | +| `apps/electron-backend-e2e/src/epg.e2e.ts` | `getEpgChannelCount` uses `getEpgChannels` | +| `tools/eslint/max-lines-baseline.mjs` | Regenerated (multi-epg entry gone) | +| `docs/architecture/m3u-playlist-module.md`, `docs/architecture/workspace-shell.md`, `docs/architecture/pwa-self-hosted.md`, `CLAUDE.md` | Docs | + +**Deleted**: `libs/ui/epg/src/lib/multi-epg/` (all files). + +--- + +### Task 1: Backend guide query service (programmes + coverage for a channel batch) + +**Files:** +- Create: `apps/electron-backend/src/app/events/epg-guide-query.service.ts` +- Create: `apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts` + +Design notes: keys are resolved to XMLTV channel ids with the existing +`EpgQueryService.getChannelMetadata` (exact id → case-insensitive id → +display name, scoped then legacy). Programmes are read with one SQL over +`idx_epg_programs_time_range`. Like the M3U timeline (`EpgService.getChannelPrograms` +without options) the guide queries unscoped by default; `sourceUrls` stays in the +contract for portal hosts. Manual mappings are applied at the IPC boundary +(Task 2), not here. + +- [ ] **Step 1: Write the failing spec** + +```ts +// apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +import { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EpgGuideQueryService, + normalizeGuideWindow, +} from './epg-guide-query.service'; + +const getDatabase = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../util/epg-logger', () => ({ + epgLogger: { error: jest.fn(), warn: jest.fn(), log: jest.fn() }, +})); + +function flattenSql(value: unknown, seen = new Set()): string { + if ( + value === null || + value === undefined || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return String(value ?? ''); + } + if (seen.has(value)) { + return ''; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => flattenSql(item, seen)).join(' '); + } + const sqlLike = value as { + name?: unknown; + queryChunks?: unknown[]; + value?: unknown; + }; + if (Array.isArray(sqlLike.queryChunks)) { + return sqlLike.queryChunks + .map((chunk) => flattenSql(chunk, seen)) + .join(' '); + } + if (Array.isArray(sqlLike.value)) { + return sqlLike.value.join(' '); + } + if (typeof sqlLike.value === 'string') { + return sqlLike.value; + } + if (typeof sqlLike.name === 'string') { + return sqlLike.name; + } + return ''; +} + +function programRow( + channelId: string, + start: string, + stop: string, + title: string +) { + return { + channelId, + start, + stop, + title, + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + }; +} + +/** `db.select(cols).from().where().orderBy()` resolving to `rows`. */ +function programSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return { orderBy: jest.fn().mockResolvedValue(rows) }; + }), + })), + })); +} + +/** `db.selectDistinct(cols).from().where()` resolving to `rows`. */ +function coverageSelect(rows: unknown[], whereCalls: unknown[]) { + return jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn((condition: unknown) => { + whereCalls.push(condition); + return Promise.resolve(rows); + }), + })), + })); +} + +const FROM = Date.UTC(2026, 8, 6, 0, 0, 0); +const TO = Date.UTC(2026, 8, 7, 0, 0, 0); + +describe('normalizeGuideWindow', () => { + it('rejects an empty or inverted window', () => { + expect( + normalizeGuideWindow({ channelIds: ['a'], fromMs: TO, toMs: FROM }) + ).toBeNull(); + expect( + normalizeGuideWindow({ channelIds: [' '], fromMs: FROM, toMs: TO }) + ).toBeNull(); + expect( + normalizeGuideWindow({ + channelIds: ['a'], + fromMs: Number.NaN, + toMs: TO, + }) + ).toBeNull(); + }); + + it('trims, de-duplicates and caps the channel keys', () => { + const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`); + const window = normalizeGuideWindow({ + channelIds: [' a ', 'a', ...ids], + fromMs: FROM, + toMs: TO, + }); + expect(window?.channelIds[0]).toBe('a'); + expect(window?.channelIds).toHaveLength( + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST + ); + expect(window?.fromIso).toBe('2026-09-06T00:00:00.000Z'); + expect(window?.toIso).toBe('2026-09-07T00:00:00.000Z'); + }); +}); + +describe('EpgGuideQueryService', () => { + const getChannelMetadata = jest.fn(); + let service: EpgGuideQueryService; + + beforeEach(() => { + getDatabase.mockReset(); + getChannelMetadata.mockReset(); + service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]'); + }); + + it('returns an empty list per requested key for an invalid window', async () => { + const result = await service.getProgramsForChannels({ + channelIds: ['a', 'b'], + fromMs: TO, + toMs: FROM, + }); + expect(result).toEqual({ a: [], b: [] }); + expect(getChannelMetadata).not.toHaveBeenCalled(); + }); + + it('resolves keys through channel metadata and maps rows back onto every requested key', async () => { + getChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + 'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + unknown: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect( + [ + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + programRow( + 'zdf.de', + '2026-09-06T16:00:00.000Z', + '2026-09-06T16:45:00.000Z', + 'heute-journal' + ), + ], + whereCalls + ), + }); + + const result = await service.getProgramsForChannels({ + channelIds: ['ZDF HD', 'zdf.de', 'unknown'], + fromMs: FROM, + toMs: TO, + }); + + expect(getChannelMetadata).toHaveBeenCalledWith( + ['ZDF HD', 'zdf.de', 'unknown'], + {} + ); + expect(result['ZDF HD']).toHaveLength(1); + expect(result['zdf.de']).toHaveLength(1); + expect(result['ZDF HD'][0]).toMatchObject({ + channel: 'zdf.de', + title: 'heute-journal', + }); + expect(result['unknown']).toEqual([]); + const condition = flattenSql(whereCalls[0]); + expect(condition).toContain('channel_id'); + expect(condition).toContain('2026-09-07T00:00:00.000Z'); + expect(condition).toContain('2026-09-06T00:00:00.000Z'); + }); + + it('scopes the programme rows to the requested source URLs', async () => { + getChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + select: programSelect([], whereCalls), + }); + + await service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + + expect(getChannelMetadata).toHaveBeenCalledWith(['a'], { + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + expect(flattenSql(whereCalls[0])).toContain('source_url'); + }); + + it('fails soft when the database throws', async () => { + getChannelMetadata.mockResolvedValue({ + a: { id: 'a', displayName: 'A', iconUrl: null }, + }); + getDatabase.mockRejectedValue(new Error('locked')); + + await expect( + service.getProgramsForChannels({ + channelIds: ['a'], + fromMs: FROM, + toMs: TO, + }) + ).resolves.toEqual({ a: [] }); + }); + + it('reports coverage for the requested keys whose channel has a programme in the window', async () => { + getChannelMetadata.mockResolvedValue({ + 'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null }, + 'ARTE': { id: 'arte.de', displayName: 'ARTE', iconUrl: null }, + none: null, + }); + const whereCalls: unknown[] = []; + getDatabase.mockResolvedValue({ + selectDistinct: coverageSelect([{ channelId: 'zdf.de' }], whereCalls), + }); + + const covered = await service.getProgramCoverage({ + channelIds: ['ZDF HD', 'ARTE', 'none'], + fromMs: FROM, + toMs: TO, + }); + + expect(covered).toEqual(['ZDF HD']); + expect(flattenSql(whereCalls[0])).toContain('channel_id'); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +Run: `pnpm nx test electron-backend --testPathPatterns=epg-guide-query.service` +Expected: FAIL — `Cannot find module './epg-guide-query.service'`. + +- [ ] **Step 3: Implement the service** + +```ts +// apps/electron-backend/src/app/events/epg-guide-query.service.ts +import { and, inArray, sql, type SQL } from 'drizzle-orm'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getDatabase } from '../database/connection'; +import * as schema from '../database/schema'; +import { epgLogger } from '../util/epg-logger'; +import { epgQueryService, EpgQueryService } from './epg-query.service'; + +/** The renderer splits larger batches; anything beyond this is dropped. */ +export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100; + +/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */ +const MAX_SERIALIZABLE_MS = 8.64e15; + +export interface EpgGuideWindowRequest { + channelIds: string[]; + /** Provider-clock instants (the renderer removes the display offset). */ + fromMs: number; + toMs: number; + sourceUrls?: string[]; +} + +export interface NormalizedGuideWindow { + channelIds: string[]; + fromIso: string; + toIso: string; + sourceUrls: string[]; +} + +interface GuideProgramRow { + channelId: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + iconUrl: string | null; + rating: string | null; + episodeNum: string | null; +} + +type ChannelResolver = Pick; + +function isUsableInstant(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= MAX_SERIALIZABLE_MS + ); +} + +/** Validates and trims a request; `null` means "nothing to query". */ +export function normalizeGuideWindow( + request: EpgGuideWindowRequest +): NormalizedGuideWindow | null { + if ( + !isUsableInstant(request.fromMs) || + !isUsableInstant(request.toMs) || + request.fromMs >= request.toMs || + !Array.isArray(request.channelIds) + ) { + return null; + } + const channelIds = Array.from( + new Set( + request.channelIds + .map((id) => (typeof id === 'string' ? id.trim() : '')) + .filter((id) => id.length > 0) + ) + ).slice(0, EPG_GUIDE_MAX_CHANNELS_PER_REQUEST); + if (channelIds.length === 0) { + return null; + } + const sourceUrls = Array.from( + new Set( + (request.sourceUrls ?? []) + .map((url) => url.trim()) + .filter((url) => url.length > 0) + ) + ); + return { + channelIds, + fromIso: new Date(request.fromMs).toISOString(), + toIso: new Date(request.toMs).toISOString(), + sourceUrls, + }; +} + +/** + * Programme-guide reads for a batch of playlist channel keys: every programme + * overlapping a time window, and which keys have any programme in it at all. + * Keys resolve to XMLTV channel ids through the same metadata lookup the + * sidebar uses (exact id, case-insensitive id, display name); manual mappings + * are applied by the IPC layer before the keys reach this service. + */ +export class EpgGuideQueryService { + constructor( + private readonly resolver: ChannelResolver = epgQueryService, + private readonly loggerLabel = '[EPG Guide]' + ) {} + + async getProgramsForChannels( + request: EpgGuideWindowRequest + ): Promise> { + const result = this.emptyResult(request.channelIds); + const window = normalizeGuideWindow(request); + if (!window) { + return result; + } + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return result; + } + const db = await getDatabase(); + const rows: GuideProgramRow[] = await db + .select({ + channelId: schema.epgPrograms.channelId, + start: schema.epgPrograms.start, + stop: schema.epgPrograms.stop, + title: schema.epgPrograms.title, + description: schema.epgPrograms.description, + category: schema.epgPrograms.category, + iconUrl: schema.epgPrograms.iconUrl, + rating: schema.epgPrograms.rating, + episodeNum: schema.epgPrograms.episodeNum, + }) + .from(schema.epgPrograms) + .where(this.windowCondition(epgIds, window)) + .orderBy(schema.epgPrograms.start); + const byEpgId = this.groupPrograms(rows); + for (const [requestedId, epgId] of resolved) { + result[requestedId] = byEpgId.get(epgId) ?? []; + } + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide programmes:', + error + ); + } + return result; + } + + async getProgramCoverage( + request: EpgGuideWindowRequest + ): Promise { + const window = normalizeGuideWindow(request); + if (!window) { + return []; + } + try { + const resolved = await this.resolveChannelIds(window); + const epgIds = Array.from(new Set(resolved.values())); + if (epgIds.length === 0) { + return []; + } + const db = await getDatabase(); + const rows: Array<{ channelId: string }> = await db + .selectDistinct({ channelId: schema.epgPrograms.channelId }) + .from(schema.epgPrograms) + .where(this.windowCondition(epgIds, window)); + const covered = new Set(rows.map((row) => row.channelId)); + return window.channelIds.filter((requestedId) => { + const epgId = resolved.get(requestedId); + return epgId !== undefined && covered.has(epgId); + }); + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide coverage:', + error + ); + return []; + } + } + + private emptyResult(channelIds: string[]): Record { + const result: Record = {}; + for (const id of Array.isArray(channelIds) ? channelIds : []) { + if (typeof id === 'string' && id.trim().length > 0) { + result[id.trim()] = []; + } + } + return result; + } + + /** requested key → XMLTV channel id (keys without a match are absent). */ + private async resolveChannelIds( + window: NormalizedGuideWindow + ): Promise> { + const metadata = await this.resolver.getChannelMetadata( + window.channelIds, + window.sourceUrls.length > 0 + ? { sourceUrls: window.sourceUrls } + : {} + ); + const resolved = new Map(); + for (const requestedId of window.channelIds) { + const epgId = metadata[requestedId]?.id; + if (epgId) { + resolved.set(requestedId, epgId); + } + } + return resolved; + } + + /** + * Overlap test in SQLite `datetime()` so provider-local offsets in the + * stored ISO strings compare correctly against the UTC window bounds. + */ + private windowCondition( + epgIds: string[], + window: NormalizedGuideWindow + ): SQL { + const overlap = and( + inArray(schema.epgPrograms.channelId, epgIds), + sql`datetime(${schema.epgPrograms.start}) < datetime(${window.toIso})`, + sql`datetime(${schema.epgPrograms.stop}) > datetime(${window.fromIso})` + ) as SQL; + if (window.sourceUrls.length === 0) { + return overlap; + } + return and( + overlap, + inArray(schema.epgPrograms.sourceUrl, window.sourceUrls) + ) as SQL; + } + + /** Group by channel and collapse duplicate slots (same start + title). */ + private groupPrograms(rows: GuideProgramRow[]): Map { + const grouped = new Map(); + const seen = new Set(); + for (const row of rows) { + const key = `${row.channelId}|${row.start}|${row.title}`; + if (seen.has(key) || !row.start || !row.stop || !row.title) { + continue; + } + seen.add(key); + const list = grouped.get(row.channelId) ?? []; + list.push({ + start: row.start, + stop: row.stop, + channel: row.channelId, + title: row.title, + desc: row.description, + category: row.category, + iconUrl: row.iconUrl, + rating: row.rating, + episodeNum: row.episodeNum, + }); + grouped.set(row.channelId, list); + } + return grouped; + } +} + +export const epgGuideQueryService = new EpgGuideQueryService(); +``` + +- [ ] **Step 4: Run the spec to verify it passes** + +Run: `pnpm nx test electron-backend --testPathPatterns=epg-guide-query.service` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/electron-backend/src/app/events/epg-guide-query.service.ts apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +git commit -m "feat(epg): add window-scoped guide programme queries + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 2: IPC, bridge contract, preload and runtime capability + +**Files:** +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts:366-412` (types) and `:797-810` (methods) +- Modify: `apps/electron-backend/src/app/events/epg.events.ts:1-20` (imports), `:100-106` (handler registration), `:252-264` (handler) +- Modify: `apps/electron-backend/src/app/api/main.preload.ts:661-676` +- Modify: `apps/electron-backend/src/app/api/main.preload.spec-data.ts:498-521` +- Modify: `libs/services/src/lib/runtime-capabilities.service.ts:94-139` and `libs/services/src/lib/runtime-capabilities.service.spec.ts` +- Modify: `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts` and `.spec.ts` +- Modify: `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts:243` +- Modify: `libs/shared/interfaces/src/lib/ipc-commands.ts:7-9` + +This task removes `EPG_GET_CHANNELS_BY_RANGE` at the same time so the bridge +never carries two guide feeds. The old grid still compiles against +`getChannelsByRange` until Task 10 deletes it, so keep `EpgQueryService.getChannelsByRange` +for now (Task 10 removes it) but drop every bridge/preload/contract mention here. + +- [ ] **Step 1: Add contract types and methods** + +In `libs/shared/interfaces/src/lib/electron-api.interface.ts`, right after +`ElectronBridgeCurrentProgramsOptions` (line ~381) add: + +```ts +export interface ElectronBridgeEpgGuideWindow + extends ElectronBridgeEpgLookupOptions { + /** Playlist channel lookup keys (tvg-id, else name), ≤100 per call. */ + channelIds: string[]; + /** Provider-clock window bounds in epoch ms. */ + fromMs: number; + toMs: number; +} +``` + +Delete the `ElectronBridgeEpgChannelWithPrograms` interface (line ~409) and, if +`ElectronBridgeEpgChannelSummary` is now unused elsewhere (`grep -rn ElectronBridgeEpgChannelSummary libs apps`), delete it too. + +Replace the `getEpgChannelsByRange` member (lines ~806-809) with: + +```ts + getEpgProgramsForChannels: ( + window: ElectronBridgeEpgGuideWindow + ) => Promise>; + getEpgProgramCoverage: ( + window: ElectronBridgeEpgGuideWindow + ) => Promise; +``` + +In `libs/shared/interfaces/src/lib/ipc-commands.ts` delete the two +`EPG_GET_CHANNELS_BY_RANGE*` constants (lines 7-9); nothing imports them. + +- [ ] **Step 2: Preload + spec-data** + +In `apps/electron-backend/src/app/api/main.preload.ts` replace the +`getEpgChannelsByRange` entry with: + +```ts + getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window), + getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window), +``` + +and add `ElectronBridgeEpgGuideWindow` to the `@iptvnator/shared/interfaces` import list at the top of the file. + +In `main.preload.spec-data.ts` replace the `getEpgChannelsByRange` case with: + +```ts + { + method: 'getEpgProgramsForChannels', + args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + channel: 'EPG_GET_PROGRAMS_FOR_CHANNELS', + forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + }, + { + method: 'getEpgProgramCoverage', + args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + channel: 'EPG_GET_PROGRAM_COVERAGE', + forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }], + }, +``` + +- [ ] **Step 3: Register the IPC handlers** + +In `apps/electron-backend/src/app/events/epg.events.ts`: + +Imports — add `ElectronBridgeEpgGuideWindow` to the `@iptvnator/shared/interfaces` import, add +`import { epgGuideQueryService } from './epg-guide-query.service';`, and add +`resolveChannelIds` to the `./epg-mapping.service` import list. + +Replace the `EPG_GET_CHANNELS_BY_RANGE` registration block with: + +```ts + ipcMain.handle( + 'EPG_GET_PROGRAMS_FOR_CHANNELS', + async (_event, args: ElectronBridgeEpgGuideWindow) => { + return this.handleGetGuidePrograms(args); + } + ); + + ipcMain.handle( + 'EPG_GET_PROGRAM_COVERAGE', + async (_event, args: ElectronBridgeEpgGuideWindow) => { + return this.handleGetGuideCoverage(args); + } + ); +``` + +Replace the `handleGetChannelsByRange` static method with: + +```ts + /** + * Guide reads take playlist channel keys. Manual mappings are applied + * here, before the query, and the answer is keyed back by the requested + * key so the renderer never sees a mapped id. + */ + private static async handleGetGuidePrograms( + args: ElectronBridgeEpgGuideWindow + ): Promise> { + const requested = Array.isArray(args?.channelIds) ? args.channelIds : []; + const mapping = await resolveChannelIds(requested); + const resolvedIds = requested.map((id) => mapping.get(id) ?? id); + const programs = await epgGuideQueryService.getProgramsForChannels({ + ...args, + channelIds: resolvedIds, + }); + return Object.fromEntries( + requested.map((id) => [id, programs[mapping.get(id) ?? id] ?? []]) + ); + } + + private static async handleGetGuideCoverage( + args: ElectronBridgeEpgGuideWindow + ): Promise { + const requested = Array.isArray(args?.channelIds) ? args.channelIds : []; + const mapping = await resolveChannelIds(requested); + const resolvedIds = requested.map((id) => mapping.get(id) ?? id); + const covered = new Set( + await epgGuideQueryService.getProgramCoverage({ + ...args, + channelIds: resolvedIds, + }) + ); + return requested.filter((id) => covered.has(mapping.get(id) ?? id)); + } +``` + +- [ ] **Step 4: Runtime capability** + +In `libs/services/src/lib/runtime-capabilities.service.ts` replace the +`supportsEpgChannelBrowser` getter with: + +```ts + /** Both guide reads exist: programmes for a channel batch and coverage. */ + get supportsEpgGuide(): boolean { + return ( + this.hasElectronMethod('getEpgProgramsForChannels') && + this.hasElectronMethod('getEpgProgramCoverage') + ); + } +``` + +and inside `get supportsEpg()` replace `this.supportsEpgChannelBrowser &&` with `this.supportsEpgGuide &&`. + +In `runtime-capabilities.service.spec.ts` replace every `getEpgChannelsByRange: jest.fn()` stub with both +`getEpgProgramsForChannels: jest.fn(), getEpgProgramCoverage: jest.fn(),` and every +`expect(service.supportsEpgChannelBrowser)` with `expect(service.supportsEpgGuide)`. Add one case: + +```ts + it('does not report guide support with only one of the two guide reads', () => { + window.electron = { + getEpgProgramsForChannels: jest.fn(), + } as unknown as typeof window.electron; + const service = TestBed.inject(RuntimeCapabilitiesService); + expect(service.supportsEpgGuide).toBe(false); + }); +``` + +(Mirror how the surrounding tests construct `window.electron`; keep their style.) + +In `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts:243` replace +`getEpgChannelsByRange: jest.fn().mockResolvedValue([]),` with +`getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),` and +`getEpgProgramCoverage: jest.fn().mockResolvedValue([]),`. + +- [ ] **Step 5: Bridge service** + +In `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts`: + +- Import list: replace `ElectronBridgeEpgChannelWithPrograms` with `ElectronBridgeEpgGuideWindow`. +- `EpgElectronBridge` pick list: replace `'getEpgChannelsByRange'` with `'getEpgProgramsForChannels' | 'getEpgProgramCoverage'`. +- Replace the `supportsChannelBrowser` getter with `get supportsGuide(): boolean { return this.runtime.supportsEpgGuide; }`. +- Replace `getChannelsByRange` with: + +```ts + getProgramsForChannels( + window: ElectronBridgeEpgGuideWindow + ): Promise | null> { + if (!this.supportsGuide || window.channelIds.length === 0) { + return Promise.resolve(null); + } + return ( + this.bridge?.getEpgProgramsForChannels?.(window) ?? + Promise.resolve(null) + ); + } + + getProgramCoverage( + window: ElectronBridgeEpgGuideWindow + ): Promise { + if (!this.supportsGuide || window.channelIds.length === 0) { + return Promise.resolve(null); + } + return ( + this.bridge?.getEpgProgramCoverage?.(window) ?? + Promise.resolve(null) + ); + } +``` + +In `epg-runtime-bridge.service.spec.ts`: rename the capability stub field +`supportsEpgChannelBrowser` → `supportsEpgGuide`, and replace the +`getEpgChannelsByRange` test (lines ~125-165) with: + +```ts + it('forwards guide reads when the runtime supports them', async () => { + const getEpgProgramsForChannels = jest.fn().mockResolvedValue({ a: [] }); + const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']); + window.electron = { + getEpgProgramsForChannels, + getEpgProgramCoverage, + } as unknown as typeof window.electron; + runtimeCapabilities.supportsEpgGuide = true; + + const window_ = { channelIds: ['a'], fromMs: 1, toMs: 2 }; + await expect(service.getProgramsForChannels(window_)).resolves.toEqual({ a: [] }); + await expect(service.getProgramCoverage(window_)).resolves.toEqual(['a']); + expect(getEpgProgramsForChannels).toHaveBeenCalledWith(window_); + expect(getEpgProgramCoverage).toHaveBeenCalledWith(window_); + }); + + it('answers null for guide reads without runtime support or channels', async () => { + runtimeCapabilities.supportsEpgGuide = false; + await expect( + service.getProgramsForChannels({ channelIds: ['a'], fromMs: 1, toMs: 2 }) + ).resolves.toBeNull(); + runtimeCapabilities.supportsEpgGuide = true; + await expect( + service.getProgramCoverage({ channelIds: [], fromMs: 1, toMs: 2 }) + ).resolves.toBeNull(); + }); +``` + +(Adapt variable names to the existing `describe` scaffolding: it already has `service` and `runtimeCapabilities`.) + +- [ ] **Step 6: Temporarily keep the old grid compiling** + +`libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts` calls +`epgBridge.supportsChannelBrowser` and `epgBridge.getChannelsByRange`. Until +Task 10 deletes it, change both call sites to `this.epgBridge.supportsGuide` +and `Promise.resolve([])` respectively so `typecheck` passes: + +```ts + if (!this.epgBridge.supportsGuide) { +``` +```ts + const response = await Promise.resolve( + [] as Array<{ id: string; displayName: string; iconUrl: string | null; programs: EpgProgram[] }> + ); +``` + +(`multi-epg-container.component.spec.ts` stubs `getChannelsByRange`; delete that stub line and the assertion on it, or `it.skip` the affected case — the file goes away in Task 10.) + +- [ ] **Step 7: Run the affected specs and typecheck** + +Run: +```bash +pnpm nx test electron-backend --testPathPatterns='main.preload|epg.events' +pnpm nx test services +pnpm nx test epg-data-access +pnpm run typecheck:ci +``` +Expected: all PASS; typecheck clean. + +- [ ] **Step 8: Commit** + +```bash +git add -A libs/shared/interfaces apps/electron-backend/src/app/api apps/electron-backend/src/app/events/epg.events.ts libs/services libs/epg/data-access apps/web/src/app/settings/test-stubs libs/ui/epg/src/lib/multi-epg +git commit -m "feat(epg): expose guide programme and coverage reads over the bridge + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 3: Guide contract, day-axis layout util and preferences (`libs/ui/epg`) + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts` + +- [ ] **Step 1: Write the contract (types only, no tests needed)** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts +import { InjectionToken, Signal } from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; + +/** One row of the guide. `id` is host-stable (M3U: `Channel.id`). */ +export interface EpgGuideChannel { + id: string; + /** 1-based position inside the current scope; shown in the channel cell. */ + number: number; + name: string; + logoUrl: string | null; + /** + * Programme lookup key (M3U: tvg-id, else tvg-name, else name). `null` + * means the host already knows there is no EPG binding, so the guide + * renders "no programme information" without asking. + */ + epgKey: string | null; +} + +export type EpgGuideScopeKind = 'all' | 'group' | 'favorites'; + +export interface EpgGuideScope { + id: string; + label: string; + kind: EpgGuideScopeKind; +} + +/** A request window. Instants are provider-clock ms (display offset removed). */ +export interface EpgGuideWindow { + channels: EpgGuideChannel[]; + fromMs: number; + toMs: number; +} + +export interface EpgGuideCatchUp { + canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean; + watch(channel: EpgGuideChannel, program: EpgProgram): void; +} + +/** + * Everything the guide needs from its host. The host owns scope state, + * playback and the player; the guide owns rendering, caching and keyboard + * navigation. Portal hosts implement the same contract with their own EPG + * feeds (sub-project 2). + */ +export interface EpgGuideSource { + /** Scope-resolved channels in playlist order (radio/movies excluded by host). */ + readonly channels: Signal; + readonly scopes: Signal; + readonly scopeId: Signal; + setScope(id: string): void; + /** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */ + loadPrograms(window: EpgGuideWindow): Promise>; + /** Ids of channels with at least one programme in the window. */ + loadCoverage(window: EpgGuideWindow): Promise>; + readonly activeChannelId: Signal; + /** Switch playback; the guide stays open. */ + activate(channelId: string): void; + /** Optional programme search; the toolbar hides its field when absent. */ + searchPrograms?(query: string): Promise; + readonly catchUp?: EpgGuideCatchUp; +} + +export const EPG_GUIDE_SOURCE = new InjectionToken( + 'EPG_GUIDE_SOURCE' +); +``` + +- [ ] **Step 2: Write the failing layout spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildGuideDayAxis, + buildGuideRowBlocks, + buildGuideTicks, + EPG_GUIDE_ZOOM_DEFAULT, + guideNowLeftPx, + guideTrackWidthPx, + guideXForMs, +} from './epg-guide-layout.util'; + +const HOUR = 3_600_000; + +function program(startMs: number, stopMs: number, title = 'P'): EpgProgram { + return { + start: new Date(startMs).toISOString(), + stop: new Date(stopMs).toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +describe('epg-guide-layout.util', () => { + const axis = buildGuideDayAxis('2026-09-06'); + + it('builds a local-midnight day axis of exactly 24 hours', () => { + expect(axis.dayKey).toBe('2026-09-06'); + expect(axis.endMs - axis.startMs).toBe(24 * HOUR); + expect(new Date(axis.startMs).getHours()).toBe(0); + }); + + it('places a tick every 30 minutes, hours emphasised', () => { + const ticks = buildGuideTicks(axis, 240); + expect(ticks).toHaveLength(48); + expect(ticks[0]).toEqual({ ms: axis.startMs, leftPx: 0, kind: 'hour' }); + expect(ticks[1]).toEqual({ + ms: axis.startMs + HOUR / 2, + leftPx: 120, + kind: 'half', + }); + expect(guideTrackWidthPx(240)).toBe(5760); + }); + + it('maps instants to x by the hour width and hides "now" outside the day', () => { + expect(guideXForMs(axis, axis.startMs + 2 * HOUR, 240)).toBe(480); + expect(guideNowLeftPx(axis, axis.startMs + HOUR, 240)).toBe(240); + expect(guideNowLeftPx(axis, axis.startMs - 1, 240)).toBeNull(); + expect(guideNowLeftPx(axis, axis.endMs, 240)).toBeNull(); + }); + + it('lays out programmes overlapping the day, including boundary crossers, with tiers', () => { + const nowMs = axis.startMs + 16 * HOUR + 4 * 60_000; + const blocks = buildGuideRowBlocks( + [ + program(axis.startMs - HOUR, axis.startMs + HOUR, 'Crosser'), + program(axis.startMs + 16 * HOUR, axis.startMs + 16.75 * HOUR, 'Now'), + program(axis.startMs + 17 * HOUR, axis.startMs + 17 * HOUR + 5 * 60_000, 'Micro'), + program(axis.endMs + HOUR, axis.endMs + 2 * HOUR, 'Tomorrow'), + ], + { axis, hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT, nowMs, offsetMinutes: 0 } + ); + expect(blocks.map((block) => block.block.program.title)).toEqual([ + 'Crosser', + 'Now', + 'Micro', + ]); + expect(blocks[0].leftPx).toBe(-240); + expect(blocks[1].block.when).toBe('now'); + expect(blocks[1].nowFillPercent).toBeCloseTo((4 / 45) * 100, 3); + expect(blocks[2].tier).toBe('narrow'); + }); + + it('shifts programme times by the display offset before layout', () => { + const blocks = buildGuideRowBlocks( + [program(axis.startMs + 10 * HOUR, axis.startMs + 11 * HOUR)], + { axis, hourWidthPx: 240, nowMs: axis.startMs, offsetMinutes: 60 } + ); + expect(blocks[0].leftPx).toBe(11 * 240); + }); +}); +``` + +- [ ] **Step 3: Run the spec to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts` +Expected: FAIL — cannot find module `./epg-guide-layout.util`. + +- [ ] **Step 4: Implement the layout util** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { addDays, addMinutes } from 'date-fns'; +import { parseEpgDateKey } from '../epg-date'; +import { + buildTimelineBlocks, + TimelineAxis, +} from '../epg-timeline/epg-timeline.utils'; +import { + buildTimelineRenderItems, + TimelineRenderBlock, +} from '../epg-timeline/epg-timeline-render.util'; + +const HOUR_MS = 3_600_000; + +/** Pixels per hour. */ +export const EPG_GUIDE_ZOOM_MIN = 120; +export const EPG_GUIDE_ZOOM_MAX = 480; +export const EPG_GUIDE_ZOOM_STEP = 20; +export const EPG_GUIDE_ZOOM_DEFAULT = 240; + +export type EpgGuideDensity = 'comfortable' | 'compact'; +export const EPG_GUIDE_ROW_HEIGHT_PX: Record = { + comfortable: 60, + compact: 44, +}; +export const EPG_GUIDE_CHANNEL_COLUMN_PX = 232; +/** Rows loaded ahead of the rendered range, in each direction. */ +export const EPG_GUIDE_ROW_BUFFER = 10; + +/** One selected day in DISPLAY time (local midnight to local midnight). */ +export interface EpgGuideDayAxis extends TimelineAxis { + readonly dayKey: string; +} + +export interface EpgGuideTick { + readonly ms: number; + readonly leftPx: number; + readonly kind: 'hour' | 'half'; +} + +export interface EpgGuideRowLayoutOptions { + readonly axis: EpgGuideDayAxis; + readonly hourWidthPx: number; + readonly nowMs: number; + readonly offsetMinutes: number; + readonly catchUpAvailable?: boolean; +} + +export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis { + const start = parseEpgDateKey(dayKey); + return { + dayKey, + startMs: start.getTime(), + endMs: addDays(start, 1).getTime(), + }; +} + +export function guideTrackWidthPx(hourWidthPx: number): number { + return hourWidthPx * 24; +} + +export function guideXForMs( + axis: TimelineAxis, + ms: number, + hourWidthPx: number +): number { + return ((ms - axis.startMs) / HOUR_MS) * hourWidthPx; +} + +/** x of the now-line, or null when "now" is not on the selected day. */ +export function guideNowLeftPx( + axis: TimelineAxis, + nowMs: number, + hourWidthPx: number +): number | null { + if (nowMs < axis.startMs || nowMs >= axis.endMs) { + return null; + } + return guideXForMs(axis, nowMs, hourWidthPx); +} + +export function buildGuideTicks( + axis: TimelineAxis, + hourWidthPx: number +): EpgGuideTick[] { + const ticks: EpgGuideTick[] = []; + const start = new Date(axis.startMs); + for (let minute = 0; minute < 24 * 60; minute += 30) { + const ms = addMinutes(start, minute).getTime(); + ticks.push({ + ms, + leftPx: guideXForMs(axis, ms, hourWidthPx), + kind: minute % 60 === 0 ? 'hour' : 'half', + }); + } + return ticks; +} + +/** + * Positioned blocks for one channel row, sharing the timeline's block maths + * (`buildTimelineBlocks` → `buildTimelineRenderItems`) so both guides agree + * on tiers, minimum widths and the on-now fill. Programmes are shifted into + * display time by `offsetMinutes` and compared with the wall-clock `nowMs` + * (the display form of the EPG offset contract). Short-run grouping is off: + * a grid row has no room for group chips. + */ +export function buildGuideRowBlocks( + programs: readonly EpgProgram[], + options: EpgGuideRowLayoutOptions +): TimelineRenderBlock[] { + const { axis, hourWidthPx, nowMs, offsetMinutes } = options; + const blocks = buildTimelineBlocks( + programs, + axis, + nowMs, + offsetMinutes + ).filter( + (block) => block.stopMs > axis.startMs && block.startMs < axis.endMs + ); + const items = buildTimelineRenderItems(blocks, hourWidthPx / 60, { + allowGroup: false, + nowMs, + archivePlaybackAvailable: options.catchUpAvailable ?? false, + }); + return items.filter( + (item): item is TimelineRenderBlock => item.kind === 'block' + ); +} +``` + +- [ ] **Step 5: Run the layout spec to verify it passes** + +Same command as Step 3. Expected: PASS (5 tests). + +- [ ] **Step 6: Write the failing preferences spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts +import { + EPG_GUIDE_DENSITY_KEY, + EPG_GUIDE_DOCK_COLLAPSED_KEY, + EPG_GUIDE_ONLY_WITH_EPG_KEY, + EPG_GUIDE_ZOOM_KEY, + persistEpgGuideDockCollapsed, + persistEpgGuidePreferences, + restoreEpgGuideDockCollapsed, + restoreEpgGuidePreferences, +} from './epg-guide-preferences'; +import { EPG_GUIDE_ZOOM_DEFAULT } from './epg-guide-layout.util'; + +describe('epg-guide-preferences', () => { + beforeEach(() => localStorage.clear()); + + it('falls back to comfortable density, default zoom and the toggle off', () => { + expect(restoreEpgGuidePreferences()).toEqual({ + density: 'comfortable', + zoom: EPG_GUIDE_ZOOM_DEFAULT, + onlyWithEpg: false, + }); + expect(restoreEpgGuideDockCollapsed()).toBe(false); + }); + + it('round-trips every preference and clamps the zoom', () => { + persistEpgGuidePreferences({ + density: 'compact', + zoom: 9_999, + onlyWithEpg: true, + }); + persistEpgGuideDockCollapsed(true); + expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact'); + expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1'); + expect(localStorage.getItem(EPG_GUIDE_DOCK_COLLAPSED_KEY)).toBe('1'); + expect(restoreEpgGuidePreferences()).toEqual({ + density: 'compact', + zoom: 480, + onlyWithEpg: true, + }); + expect(restoreEpgGuideDockCollapsed()).toBe(true); + }); + + it('ignores corrupt stored values', () => { + localStorage.setItem(EPG_GUIDE_DENSITY_KEY, 'huge'); + localStorage.setItem(EPG_GUIDE_ZOOM_KEY, 'abc'); + expect(restoreEpgGuidePreferences().density).toBe('comfortable'); + expect(restoreEpgGuidePreferences().zoom).toBe(EPG_GUIDE_ZOOM_DEFAULT); + }); + + it('survives a throwing storage', () => { + const broken = { + getItem: () => { + throw new Error('blocked'); + }, + setItem: () => { + throw new Error('blocked'); + }, + } as unknown as Storage; + expect(() => + persistEpgGuidePreferences( + { density: 'compact', zoom: 200, onlyWithEpg: false }, + broken + ) + ).not.toThrow(); + expect(restoreEpgGuidePreferences(broken).density).toBe('comfortable'); + }); +}); +``` + +- [ ] **Step 7: Implement preferences** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts +import { + EPG_GUIDE_ZOOM_DEFAULT, + EPG_GUIDE_ZOOM_MAX, + EPG_GUIDE_ZOOM_MIN, + EpgGuideDensity, +} from './epg-guide-layout.util'; + +export const EPG_GUIDE_DENSITY_KEY = 'epg-guide:density'; +export const EPG_GUIDE_ZOOM_KEY = 'epg-guide:zoom'; +export const EPG_GUIDE_ONLY_WITH_EPG_KEY = 'epg-guide:only-with-epg'; +export const EPG_GUIDE_DOCK_COLLAPSED_KEY = 'epg-guide:dock-collapsed'; + +export interface EpgGuidePreferences { + density: EpgGuideDensity; + zoom: number; + onlyWithEpg: boolean; +} + +function isDensity(value: unknown): value is EpgGuideDensity { + return value === 'comfortable' || value === 'compact'; +} + +export function clampGuideZoom(value: number): number { + if (!Number.isFinite(value)) { + return EPG_GUIDE_ZOOM_DEFAULT; + } + return Math.min(EPG_GUIDE_ZOOM_MAX, Math.max(EPG_GUIDE_ZOOM_MIN, value)); +} + +function read(storage: Storage, key: string): string | null { + try { + return storage.getItem(key); + } catch { + return null; + } +} + +function write(storage: Storage, key: string, value: string): void { + try { + storage.setItem(key, value); + } catch { + // Storage may be unavailable (private mode, quota); the guide simply + // starts from defaults next time. + } +} + +function defaultStorage(): Storage { + return globalThis.localStorage; +} + +export function restoreEpgGuidePreferences( + storage: Storage = defaultStorage() +): EpgGuidePreferences { + const density = read(storage, EPG_GUIDE_DENSITY_KEY); + const zoom = Number(read(storage, EPG_GUIDE_ZOOM_KEY)); + return { + density: isDensity(density) ? density : 'comfortable', + zoom: clampGuideZoom(zoom === 0 ? Number.NaN : zoom), + onlyWithEpg: read(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY) === '1', + }; +} + +export function persistEpgGuidePreferences( + preferences: EpgGuidePreferences, + storage: Storage = defaultStorage() +): void { + write(storage, EPG_GUIDE_DENSITY_KEY, preferences.density); + write(storage, EPG_GUIDE_ZOOM_KEY, String(clampGuideZoom(preferences.zoom))); + write(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY, preferences.onlyWithEpg ? '1' : '0'); +} + +export function restoreEpgGuideDockCollapsed( + storage: Storage = defaultStorage() +): boolean { + return read(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY) === '1'; +} + +export function persistEpgGuideDockCollapsed( + collapsed: boolean, + storage: Storage = defaultStorage() +): void { + write(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY, collapsed ? '1' : '0'); +} +``` + +- [ ] **Step 8: Run the preferences spec to verify it passes** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 9: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide +git commit -m "feat(epg): add guide source contract, day layout maths and preferences + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 4: Programme cache service with batched loading and coverage + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideSource, +} from './epg-guide-source'; +import { + EPG_GUIDE_LOAD_CHUNK, + EpgGuideProgramsService, +} from './epg-guide-programs.service'; + +function channel(id: string, epgKey: string | null = id): EpgGuideChannel { + return { id, number: 1, name: id, logoUrl: null, epgKey }; +} + +function programFor(channelId: string): EpgProgram { + return { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T17:00:00.000Z', + channel: channelId, + title: `${channelId} show`, + desc: null, + category: null, + }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EpgGuideProgramsService', () => { + const channels = signal([]); + const loadPrograms = jest.fn>, [unknown]>(); + const loadCoverage = jest.fn>, [unknown]>(); + let service: EpgGuideProgramsService; + + beforeEach(() => { + loadPrograms.mockReset(); + loadCoverage.mockReset(); + loadCoverage.mockResolvedValue(new Set()); + channels.set([channel('a'), channel('b'), channel('c', null)]); + const source: EpgGuideSource = { + channels, + scopes: signal([]), + scopeId: signal('all'), + setScope: jest.fn(), + loadPrograms, + loadCoverage, + activeChannelId: signal(null), + activate: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + EpgGuideProgramsService, + { provide: EPG_GUIDE_SOURCE, useValue: source }, + ], + }); + service = TestBed.inject(EpgGuideProgramsService); + }); + + it('reports "none" for channels without an EPG key and never requests them', async () => { + loadPrograms.mockResolvedValue(new Map()); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.statusFor('c')).toBe('none'); + const requested = loadPrograms.mock.calls[0][0] as { channels: EpgGuideChannel[] }; + expect(requested.channels.map((item) => item.id)).toEqual(['a', 'b']); + }); + + it('loads visible rows once, in chunks, and exposes their programmes', async () => { + const many = Array.from({ length: EPG_GUIDE_LOAD_CHUNK + 5 }, (_, i) => + channel(`ch-${i}`) + ); + channels.set(many); + loadPrograms.mockImplementation(async (window) => { + const { channels: requested } = window as { channels: EpgGuideChannel[] }; + return new Map(requested.map((item) => [item.id, [programFor(item.id)]])); + }); + service.setWindow(1_000, 2_000); + service.ensureLoaded(many); + expect(service.statusFor('ch-0')).toBe('loading'); + await flush(); + expect(loadPrograms).toHaveBeenCalledTimes(2); + expect(service.statusFor('ch-0')).toBe('loaded'); + expect(service.programsFor('ch-0')[0].title).toBe('ch-0 show'); + service.ensureLoaded(many); + expect(loadPrograms).toHaveBeenCalledTimes(2); + }); + + it('drops responses that belong to a previous window', async () => { + let resolveFirst!: (value: Map) => void; + loadPrograms.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)) + ); + loadPrograms.mockResolvedValueOnce(new Map()); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + service.setWindow(3_000, 4_000); + service.ensureLoaded(channels()); + resolveFirst(new Map([['a', [programFor('a')]]])); + await flush(); + expect(service.programsFor('a')).toEqual([]); + }); + + it('marks a failed batch as loaded-empty instead of retrying on every scroll', async () => { + loadPrograms.mockRejectedValue(new Error('ipc down')); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.statusFor('a')).toBe('loaded'); + service.ensureLoaded(channels()); + expect(loadPrograms).toHaveBeenCalledTimes(1); + }); + + it('loads coverage for the whole scope when the window is set and answers isCovered', async () => { + loadCoverage.mockResolvedValue(new Set(['a'])); + expect(service.coverageLoaded()).toBe(false); + expect(service.isCovered('b')).toBe(true); + service.setWindow(1_000, 2_000); + await flush(); + expect(loadCoverage).toHaveBeenCalledTimes(1); + expect(service.coverageLoaded()).toBe(true); + expect(service.isCovered('a')).toBe(true); + expect(service.isCovered('b')).toBe(false); + expect(service.isCovered('c')).toBe(false); + }); + + it('resets programmes and coverage when the scope channels change', async () => { + loadPrograms.mockResolvedValue(new Map([['a', [programFor('a')]]])); + service.setWindow(1_000, 2_000); + service.ensureLoaded(channels()); + await flush(); + expect(service.programsFor('a')).toHaveLength(1); + channels.set([channel('a'), channel('z')]); + TestBed.flushEffects(); + await flush(); + expect(service.statusFor('a')).toBe('idle'); + expect(loadCoverage).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the service** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts +import { computed, effect, inject, Injectable, signal } from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideWindow, +} from './epg-guide-source'; + +/** Channels per `loadPrograms` call (the programme IPC caps at 100 keys). */ +export const EPG_GUIDE_LOAD_CHUNK = 100; +/** Channels per `loadCoverage` call (the coverage IPC caps at 2000 keys). */ +export const EPG_GUIDE_COVERAGE_CHUNK = 1000; + +export type EpgGuideRowStatus = 'idle' | 'loading' | 'loaded' | 'none'; + +const EMPTY: EpgProgram[] = []; + +/** + * Per-day programme cache for the guide. Programmes load lazily for the rows + * the viewport asks about; coverage (which rows have anything at all) loads + * eagerly for the whole scope so "Only with EPG" can answer before rows + * scroll into view. Every response is tagged with the generation it was + * requested under, so a window or scope change makes older answers no-ops. + */ +@Injectable() +export class EpgGuideProgramsService { + private readonly source = inject(EPG_GUIDE_SOURCE); + + private readonly programs = signal>( + new Map() + ); + private readonly statuses = signal>( + new Map() + ); + private readonly coverage = signal | null>(null); + + private window: { fromMs: number; toMs: number } | null = null; + private generation = 0; + + readonly coverageLoaded = computed(() => this.coverage() !== null); + + constructor() { + // A new channel set (scope change) invalidates everything, including + // coverage: the toggle must be answered for the new rows. + effect(() => { + this.source.channels(); + this.invalidate(); + this.requestCoverage(); + }); + } + + setWindow(fromMs: number, toMs: number): void { + if (this.window?.fromMs === fromMs && this.window?.toMs === toMs) { + return; + } + this.window = { fromMs, toMs }; + this.invalidate(); + this.requestCoverage(); + } + + programsFor(channelId: string): EpgProgram[] { + return this.programs().get(channelId) ?? EMPTY; + } + + statusFor(channelId: string): EpgGuideRowStatus { + return this.statuses().get(channelId) ?? 'idle'; + } + + /** Unknown (coverage not loaded yet) counts as covered so rows never blink. */ + isCovered(channelId: string): boolean { + const coverage = this.coverage(); + return coverage === null || coverage.has(channelId); + } + + /** Request programmes for rows that are idle; chunked, generation-tagged. */ + ensureLoaded(channels: readonly EpgGuideChannel[]): void { + const window = this.window; + if (!window) { + return; + } + const pending = channels.filter( + (channel) => this.statusFor(channel.id) === 'idle' && channel.epgKey + ); + if (pending.length === 0) { + return; + } + this.patchStatuses(pending.map((channel) => [channel.id, 'loading'])); + const generation = this.generation; + for (let start = 0; start < pending.length; start += EPG_GUIDE_LOAD_CHUNK) { + const chunk = pending.slice(start, start + EPG_GUIDE_LOAD_CHUNK); + this.loadChunk({ channels: chunk, ...window }, generation); + } + } + + private loadChunk(window: EpgGuideWindow, generation: number): void { + this.source + .loadPrograms(window) + .then( + (result) => result, + () => new Map() + ) + .then((result) => { + if (generation !== this.generation) { + return; + } + const next = new Map(this.programs()); + for (const channel of window.channels) { + next.set(channel.id, result.get(channel.id) ?? EMPTY); + } + this.programs.set(next); + this.patchStatuses( + window.channels.map((channel) => [channel.id, 'loaded']) + ); + }); + } + + private requestCoverage(): void { + const window = this.window; + if (!window) { + return; + } + const generation = this.generation; + const keyed = this.source + .channels() + .filter((channel) => channel.epgKey !== null); + const chunks: EpgGuideChannel[][] = []; + for (let start = 0; start < keyed.length; start += EPG_GUIDE_COVERAGE_CHUNK) { + chunks.push(keyed.slice(start, start + EPG_GUIDE_COVERAGE_CHUNK)); + } + Promise.all( + chunks.map((channels) => + this.source + .loadCoverage({ channels, ...window }) + .then( + (covered) => covered, + () => new Set() + ) + ) + ).then((sets) => { + if (generation !== this.generation) { + return; + } + const merged = new Set(); + sets.forEach((set) => set.forEach((id) => merged.add(id))); + this.coverage.set(merged); + }); + } + + private invalidate(): void { + this.generation += 1; + this.programs.set(new Map()); + this.statuses.set( + new Map( + this.source + .channels() + .filter((channel) => channel.epgKey === null) + .map((channel) => [channel.id, 'none' as const]) + ) + ); + this.coverage.set(null); + } + + private patchStatuses(entries: Array<[string, EpgGuideRowStatus]>): void { + const next = new Map(this.statuses()); + for (const [id, status] of entries) { + next.set(id, status); + } + this.statuses.set(next); + } +} +``` + +Note on the first spec case: the constructor `effect` runs on the first +change-detection flush; `setWindow` sets `none` statuses itself through +`invalidate()`, which is why the test passes without flushing effects. + +- [ ] **Step 4: Run the spec to verify it passes** + +Same command as Step 2. Expected: PASS (6 tests). If the last case's second +`loadCoverage` call is missing, call `TestBed.flushEffects()` once right after +`TestBed.inject` in `beforeEach` so the initial effect run is consumed before +the assertions count calls. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts +git commit -m "feat(epg): cache guide programmes per day with batched loading + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 5: Keyboard controller + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +import { + EpgGuideKeyboardController, + EpgGuideKeyboardHost, +} from './epg-guide-keyboard.controller'; + +function key( + keyName: string, + init: Partial & { target?: EventTarget } = {} +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key: keyName, ...init }); + if (init.target) { + Object.defineProperty(event, 'target', { value: init.target }); + } + return event; +} + +describe('EpgGuideKeyboardController', () => { + let host: jest.Mocked; + let controller: EpgGuideKeyboardController; + + beforeEach(() => { + host = { + rowCount: jest.fn(() => 5), + blockCount: jest.fn(() => 3), + activeRow: jest.fn(() => 2), + isBlocked: jest.fn(() => false), + play: jest.fn(), + details: jest.fn(), + jumpNow: jest.fn(), + stepDay: jest.fn(), + close: jest.fn(), + }; + controller = new EpgGuideKeyboardController(host); + }); + + it('starts row focus from the active channel and clamps at the ends', () => { + expect(controller.handle(key('ArrowDown'))).toBe(true); + expect(controller.focus()).toEqual({ row: 3, block: null }); + controller.handle(key('ArrowDown')); + controller.handle(key('ArrowDown')); + controller.handle(key('ArrowDown')); + expect(controller.focus()).toEqual({ row: 4, block: null }); + host.activeRow.mockReturnValue(-1); + controller.focus.set(null); + controller.handle(key('ArrowUp')); + expect(controller.focus()).toEqual({ row: 4, block: null }); + }); + + it('moves block focus inside the focused row', () => { + controller.handle(key('ArrowRight')); + expect(controller.focus()).toEqual({ row: 2, block: 0 }); + controller.handle(key('ArrowRight')); + controller.handle(key('ArrowRight')); + controller.handle(key('ArrowRight')); + expect(controller.focus()).toEqual({ row: 2, block: 2 }); + controller.handle(key('ArrowLeft')); + expect(controller.focus()).toEqual({ row: 2, block: 1 }); + controller.handle(key('ArrowDown')); + expect(controller.focus()).toEqual({ row: 3, block: null }); + }); + + it('plays the focused (or active) row on Enter and opens details with I', () => { + controller.handle(key('Enter')); + expect(host.play).toHaveBeenCalledWith(2); + controller.handle(key('ArrowDown')); + controller.handle(key('ArrowRight')); + controller.handle(key('i')); + expect(host.details).toHaveBeenCalledWith(3, 0); + controller.handle(key('Enter')); + expect(host.play).toHaveBeenLastCalledWith(3); + }); + + it('maps N, PageUp/PageDown and Escape', () => { + controller.handle(key('n')); + expect(host.jumpNow).toHaveBeenCalled(); + controller.handle(key('PageUp')); + expect(host.stepDay).toHaveBeenCalledWith('prev'); + controller.handle(key('PageDown')); + expect(host.stepDay).toHaveBeenCalledWith('next'); + expect(controller.handle(key('Escape'))).toBe(true); + expect(host.close).toHaveBeenCalled(); + }); + + it('ignores typing, modifier chords, blocked state and unknown keys', () => { + const input = document.createElement('input'); + expect(controller.handle(key('ArrowDown', { target: input }))).toBe(false); + expect(controller.handle(key('ArrowDown', { ctrlKey: true }))).toBe(false); + host.isBlocked.mockReturnValue(true); + expect(controller.handle(key('Escape'))).toBe(false); + host.isBlocked.mockReturnValue(false); + expect(controller.handle(key('x'))).toBe(false); + expect(host.close).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the controller** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts +import { signal } from '@angular/core'; +import { EpgDateNavigationDirection } from '../epg-date'; + +export interface EpgGuideFocus { + readonly row: number; + /** Block index inside the row, or null when the whole row is focused. */ + readonly block: number | null; +} + +export interface EpgGuideKeyboardHost { + rowCount(): number; + blockCount(row: number): number; + /** Index of the playing channel's row, or -1. */ + activeRow(): number; + /** True while a dialog owns the keyboard. */ + isBlocked(): boolean; + play(row: number): void; + details(row: number, block: number): void; + jumpNow(): void; + stepDay(direction: EpgDateNavigationDirection): void; + close(): void; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) + ); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Roving focus for the guide grid. ↑/↓ move between rows, ←/→ between the + * focused row's programmes, Enter plays the row, I opens details, N jumps to + * now, PgUp/PgDn change the day, Esc closes. Returns whether the event was + * consumed so the caller can `preventDefault()`. + */ +export class EpgGuideKeyboardController { + readonly focus = signal(null); + + constructor(private readonly host: EpgGuideKeyboardHost) {} + + handle(event: KeyboardEvent): boolean { + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + isEditableTarget(event.target) || + this.host.isBlocked() + ) { + return false; + } + switch (event.key) { + case 'Escape': + this.host.close(); + return true; + case 'ArrowDown': + return this.moveRow(1); + case 'ArrowUp': + return this.moveRow(-1); + case 'ArrowRight': + return this.moveBlock(1); + case 'ArrowLeft': + return this.moveBlock(-1); + case 'Enter': + return this.play(); + case 'i': + case 'I': + return this.details(); + case 'n': + case 'N': + this.host.jumpNow(); + return true; + case 'PageUp': + this.host.stepDay('prev'); + return true; + case 'PageDown': + this.host.stepDay('next'); + return true; + default: + return false; + } + } + + private currentRow(): number { + const focused = this.focus(); + if (focused) { + return focused.row; + } + return this.host.activeRow(); + } + + private moveRow(delta: number): boolean { + const count = this.host.rowCount(); + if (count === 0) { + return false; + } + const current = this.currentRow(); + const next = + current < 0 + ? delta > 0 + ? 0 + : count - 1 + : clamp(current + delta, 0, count - 1); + this.focus.set({ row: next, block: null }); + return true; + } + + private moveBlock(delta: number): boolean { + const count = this.host.rowCount(); + if (count === 0) { + return false; + } + const row = clamp(Math.max(0, this.currentRow()), 0, count - 1); + const blocks = this.host.blockCount(row); + if (blocks === 0) { + this.focus.set({ row, block: null }); + return true; + } + const current = this.focus()?.row === row ? this.focus()?.block ?? null : null; + const start = current ?? (delta > 0 ? -1 : blocks); + this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) }); + return true; + } + + private play(): boolean { + const row = this.currentRow(); + if (row < 0 || row >= this.host.rowCount()) { + return false; + } + this.host.play(row); + return true; + } + + private details(): boolean { + const focused = this.focus(); + if (!focused || focused.block === null) { + return false; + } + this.host.details(focused.row, focused.block); + return true; + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Same command as Step 2. Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +git commit -m "feat(epg): add guide keyboard navigation controller + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 6: Guide components (toolbar, row, shell, now-playing) + English i18n + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.{ts,html,scss}` +- Modify: `libs/ui/epg/src/index.ts` +- Modify: `apps/web/src/assets/i18n/en.json` (`EPG.GUIDE`, `EPG.TIMELINE.GUIDE`, `TOP_MENU.OPEN_EPG_GUIDE`, `WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION`) + +Visual reference: the Q2 mockup (https://claude.ai/code/artifact/0cac36f6-9027-46a7-bd0c-b264416e6acf). All colours come from `libs/ui/epg/src/lib/_epg-theme.scss` (`@use '../epg-theme' as *;`). + +- [ ] **Step 1: English i18n keys** + +In `apps/web/src/assets/i18n/en.json`: + +Inside `"EPG": { ... "TIMELINE": { ... } }` add to the `TIMELINE` object: +```json + "GUIDE": "Guide", + "OPEN_GUIDE": "Open the programme guide" +``` +and add a sibling object under `"EPG"`: +```json + "GUIDE": { + "TITLE": "Programme guide", + "CLOSE": "Close guide", + "CHANNEL": "Channel", + "TODAY": "Today", + "NOW": "Now", + "JUMP_NOW": "Jump to now", + "SCOPE": "Channels shown", + "ONLY_WITH_EPG": "Only with EPG", + "ONLY_WITH_EPG_HINT": "Hide channels without programme data", + "DENSITY_COMFORTABLE": "Comfortable rows", + "DENSITY_COMPACT": "Compact rows", + "ZOOM": "Zoom", + "FILTER_CHANNELS": "Filter channels…", + "SEARCH_PROGRAMMES": "Search programmes…", + "NO_RESULTS": "No programmes found", + "NO_PROGRAMME_INFO": "No programme information", + "NO_PROGRAMME_INFO_LONG": "No programme information for this channel", + "PLAYING": "Playing", + "NO_CHANNELS": "No channels match \"{{query}}\"", + "CLEAR_FILTER": "Clear filter", + "COLLAPSE_PLAYER": "Collapse player", + "EXPAND_PLAYER": "Expand player", + "KEYS": { + "CHANNEL": "channel", + "PROGRAMME": "programme", + "PLAY": "play", + "DETAILS": "details", + "NOW": "now", + "DAY": "day", + "CLOSE": "close" + } + }, +``` +In `"TOP_MENU"` replace `"OPEN_MULTI_EPG": "Open Multi-EPG view"` with `"OPEN_EPG_GUIDE": "Programme guide"`. +In `"WORKSPACE" → "SHELL" → "COMMANDS"` replace `"OPEN_MULTI_EPG_DESCRIPTION"` with `"OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist"`. +(Other locales: Task 11.) + +- [ ] **Step 2: Now-playing component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + OnDestroy, + OnInit, + output, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslatePipe } from '@ngx-translate/core'; +import { getProgramTimeMs } from '../epg-program.utils'; + +/** + * Info block of the docked player strip while the guide is open: channel, + * current programme, progress, Close and Collapse. The host renders it next to + * the player because the strip itself is host layout. + */ +@Component({ + selector: 'app-epg-guide-now-playing', + imports: [DatePipe, MatButtonModule, MatIconModule, TranslatePipe], + templateUrl: './epg-guide-now-playing.component.html', + styleUrl: './epg-guide-now-playing.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy { + readonly channelName = input(''); + readonly program = input(null); + readonly offsetMinutes = input(0); + readonly collapsed = input(false); + + readonly closeRequested = output(); + readonly collapsedChange = output(); + + private readonly nowMs = signal(Date.now()); + private timer?: number; + + readonly startMs = computed(() => this.boundary('start')); + readonly stopMs = computed(() => this.boundary('stop')); + readonly isOnNow = computed(() => { + const start = this.startMs(); + const stop = this.stopMs(); + const now = this.nowMs(); + return start !== null && stop !== null && start <= now && now < stop; + }); + readonly progress = computed(() => { + const start = this.startMs(); + const stop = this.stopMs(); + if (start === null || stop === null || stop <= start) { + return null; + } + const pct = ((this.nowMs() - start) / (stop - start)) * 100; + return Math.min(100, Math.max(0, pct)); + }); + + ngOnInit(): void { + this.timer = window.setInterval(() => this.nowMs.set(Date.now()), 30_000); + } + + ngOnDestroy(): void { + window.clearInterval(this.timer); + } + + private boundary(edge: 'start' | 'stop'): number | null { + const program = this.program(); + if (!program) { + return null; + } + const ms = getProgramTimeMs( + program[edge], + edge === 'start' ? program.startTimestamp : program.stopTimestamp, + this.offsetMinutes() + ); + return Number.isFinite(ms) ? ms : null; + } +} +``` + +```html + +
+
+
+ {{ channelName() }} + @if (isOnNow()) { + {{ 'EPG.TIMELINE.ON_NOW' | translate }} + } +
+ @if (program(); as program) { +
{{ program.title }}
+ @if (!collapsed() && program.desc) { +
{{ program.desc }}
+ } + @if (!collapsed() && progress() !== null) { +
+ {{ startMs() | date: 'HH:mm' }} + + {{ stopMs() | date: 'HH:mm' }} +
+ } + } @else { +
+ {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }} +
+ } +
+
+ + +
+
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss +@use '../epg-theme' as *; + +:host { display: block; min-width: 0; height: 100%; } + +.now-playing { + display: flex; + align-items: center; + gap: 18px; + height: 100%; + min-width: 0; + color: $text-primary; +} +.now-playing__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; } +.now-playing__eyebrow { + display: flex; gap: 10px; align-items: center; + font: 500 10px $font-mono; letter-spacing: 0.12em; text-transform: uppercase; color: $text-secondary; + .now-playing__on { color: $live-text; } + .now-playing__on::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: $accent-live; margin-right: 5px; vertical-align: 1px; } +} +.now-playing__title { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.now-playing__title--muted { color: $text-tertiary; font-weight: 400; } +.now-playing__desc { font-size: 12px; color: $text-secondary; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60ch; } +.now-playing__progress { + display: flex; align-items: center; gap: 10px; font: 11px $font-mono; color: $text-secondary; + i { flex: 1; max-width: 360px; height: 4px; border-radius: 2px; background: $surface-3; overflow: hidden; display: block; } + b { display: block; height: 100%; background: $accent-blue; } +} +.now-playing__actions { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } +.now-playing--collapsed { + gap: 12px; + .now-playing__body { flex-direction: row; align-items: center; gap: 12px; } + .now-playing__title { font-size: 13px; } + .now-playing__actions { flex-direction: row; } +} +kbd { font: 10px $font-mono; border: 1px solid $line-strong; border-radius: 4px; padding: 0 4px; margin-left: 6px; color: $text-secondary; } +``` + +- [ ] **Step 3: Toolbar component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { EpgDateNavigationDirection } from '../epg-date'; +import { + EPG_GUIDE_ZOOM_MAX, + EPG_GUIDE_ZOOM_MIN, + EPG_GUIDE_ZOOM_STEP, + EpgGuideDensity, +} from './epg-guide-layout.util'; +import { EpgGuideScope } from './epg-guide-source'; + +@Component({ + selector: 'app-epg-guide-toolbar', + imports: [ + DatePipe, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + TranslatePipe, + ], + templateUrl: './epg-guide-toolbar.component.html', + styleUrl: './epg-guide-toolbar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EpgGuideToolbarComponent { + readonly dayMs = input.required(); + readonly isToday = input(false); + readonly currentLocale = input('en'); + readonly scopes = input([]); + readonly scopeId = input(''); + readonly onlyWithEpg = input(false); + readonly coverageLoaded = input(false); + readonly density = input('comfortable'); + readonly zoom = input(EPG_GUIDE_ZOOM_MIN); + readonly filter = input(''); + readonly searchEnabled = input(false); + readonly searchQuery = input(''); + readonly shownCount = input(0); + readonly totalCount = input(0); + + readonly stepDay = output(); + readonly jumpNow = output(); + readonly scopeChange = output(); + readonly onlyWithEpgChange = output(); + readonly densityChange = output(); + readonly zoomChange = output(); + readonly filterChange = output(); + readonly searchQueryChange = output(); + + readonly zoomMin = EPG_GUIDE_ZOOM_MIN; + readonly zoomMax = EPG_GUIDE_ZOOM_MAX; + readonly zoomStep = EPG_GUIDE_ZOOM_STEP; + + readonly scopeLabel = computed( + () => + this.scopes().find((scope) => scope.id === this.scopeId())?.label ?? + '' + ); + readonly densityIcon = computed(() => + this.density() === 'comfortable' ? 'density_medium' : 'density_small' + ); + readonly densityLabelKey = computed(() => + this.density() === 'comfortable' + ? 'EPG.GUIDE.DENSITY_COMPACT' + : 'EPG.GUIDE.DENSITY_COMFORTABLE' + ); + + toggleDensity(): void { + this.densityChange.emit( + this.density() === 'comfortable' ? 'compact' : 'comfortable' + ); + } + + onZoomInput(event: Event): void { + this.zoomChange.emit((event.target as HTMLInputElement).valueAsNumber); + } + + onFilterInput(event: Event): void { + this.filterChange.emit((event.target as HTMLInputElement).value); + } + + onSearchInput(event: Event): void { + this.searchQueryChange.emit((event.target as HTMLInputElement).value); + } +} +``` + +```html + +
+
+ + + {{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }} + @if (isToday()) { {{ 'EPG.GUIDE.TODAY' | translate }} } + + +
+ + + + + + @for (scope of scopes(); track scope.id) { + + } + + + + + + + + + + + + + @if (searchEnabled()) { + + } +
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss +@use '../epg-theme' as *; + +:host { display: block; } +.guide-toolbar { + display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 18px; + border-bottom: 1px solid $line; background: var(--app-content-bg); color: $text-primary; + button, label, input { font: inherit; } +} +.guide-toolbar__day { + display: inline-flex; align-items: center; border: 1px solid $line-strong; border-radius: 8px; height: 30px; overflow: hidden; + button { width: 30px; height: 30px; padding: 0; } +} +.guide-toolbar__date { + display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; font-weight: 500; white-space: nowrap; + border-left: 1px solid $line-strong; border-right: 1px solid $line-strong; height: 100%; + small { font: 10px $font-mono; letter-spacing: 0.1em; color: $text-tertiary; } +} +.guide-toolbar__now, .guide-toolbar__scope { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px; + border: 1px solid $line-strong; background: transparent; color: $text-primary; font-size: 12.5px; font-weight: 500; + cursor: pointer; white-space: nowrap; + &:hover { background: $surface-2; } + &:focus-visible { outline: 2px solid $accent-blue; outline-offset: 1px; } +} +.guide-toolbar__now { border-color: $accent-blue; color: $accent-text; background: var(--app-selection-surface); + &::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: $accent-live; box-shadow: 0 0 0 3px color-mix(in srgb, $accent-live 20%, transparent); } +} +.guide-toolbar__scope mat-icon { width: 18px; height: 18px; font-size: 18px; margin-right: -6px; } +.guide-toolbar__toggle { + display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; cursor: pointer; white-space: nowrap; + input { position: absolute; opacity: 0; width: 0; height: 0; } + i { width: 30px; height: 16px; border-radius: 99px; background: $surface-3; position: relative; transition: background 120ms; + &::after { content: ''; position: absolute; left: 2px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: $text-tertiary; transition: left 120ms, background 120ms; } } + &.is-on i { background: $accent-blue; &::after { left: 16px; background: var(--app-content-bg); } } + &:has(input:disabled) { opacity: 0.5; cursor: default; } + &:has(input:focus-visible) i { outline: 2px solid $accent-blue; outline-offset: 2px; } +} +.guide-toolbar__zoom { display: inline-flex; align-items: center; gap: 8px; color: $text-secondary; + input[type='range'] { width: 110px; accent-color: $accent-blue; } } +.guide-toolbar__spacer { flex: 1; min-width: 8px; } +.guide-toolbar__input { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 4px 0 10px; width: 190px; + border: 1px solid $line-strong; border-radius: 8px; background: $surface-1; color: $text-tertiary; + mat-icon { width: 16px; height: 16px; font-size: 16px; } + input { flex: 1; min-width: 0; border: 0; background: transparent; color: $text-primary; font-size: 12px; outline: none; + &::placeholder { color: $text-tertiary; } } + button { width: 24px; height: 24px; padding: 0; mat-icon { font-size: 14px; } } + &:focus-within { border-color: $accent-blue; } +} +.guide-toolbar__input--search { width: 230px; } +.guide-toolbar__count { font: 10.5px $font-mono; color: $text-tertiary; white-space: nowrap; } +``` + +Add to the same file, outside `:host` scoping, `::ng-deep .guide-toolbar__scope-menu { max-height: 60vh; .is-selected { color: var(--app-selection-color); font-weight: 600; } }` (CDK menus render outside the component). + +- [ ] **Step 4: Row component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslatePipe } from '@ngx-translate/core'; +import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; +import { + buildGuideRowBlocks, + EPG_GUIDE_ZOOM_DEFAULT, + EpgGuideDayAxis, + EpgGuideDensity, + guideTrackWidthPx, +} from './epg-guide-layout.util'; +import { EpgGuideRowStatus } from './epg-guide-programs.service'; +import { EpgGuideChannel } from './epg-guide-source'; + +/** + * One channel row: the sticky channel cell and the lane with positioned + * programme cards. Layout is recomputed only when its own inputs change, so a + * 2000-row guide re-lays-out one row per programme batch, not the grid. + */ +@Component({ + selector: 'app-epg-guide-row', + imports: [DatePipe, MatIconModule, MatTooltipModule, TranslatePipe], + templateUrl: './epg-guide-row.component.html', + styleUrl: './epg-guide-row.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'epg-guide-row', + '[class.is-active]': 'active()', + '[class.is-focused]': 'rowFocused()', + '[class.is-compact]': 'density() === "compact"', + }, +}) +export class EpgGuideRowComponent { + readonly channel = input.required(); + readonly programs = input([]); + readonly status = input('idle'); + readonly axis = input.required(); + readonly hourWidthPx = input(EPG_GUIDE_ZOOM_DEFAULT); + readonly nowMs = input(0); + readonly offsetMinutes = input(0); + readonly catchUpAvailable = input(false); + readonly active = input(false); + readonly density = input('comfortable'); + readonly rowFocused = input(false); + readonly focusedBlock = input(null); + + /** Single click on the channel cell or the on-now card. */ + readonly channelActivated = output(); + /** Double click: activate and close the guide. */ + readonly channelCommitted = output(); + readonly detailsRequested = output(); + readonly watchRequested = output(); + + readonly blocks = computed(() => + buildGuideRowBlocks(this.programs(), { + axis: this.axis(), + hourWidthPx: this.hourWidthPx(), + nowMs: this.nowMs(), + offsetMinutes: this.offsetMinutes(), + catchUpAvailable: this.catchUpAvailable(), + }) + ); + readonly nowBlock = computed( + () => this.blocks().find((item) => item.block.when === 'now') ?? null + ); + readonly trackWidthPx = computed(() => guideTrackWidthPx(this.hourWidthPx())); + readonly showEmpty = computed( + () => + this.status() === 'none' || + (this.status() === 'loaded' && this.blocks().length === 0) + ); + readonly isLoading = computed(() => this.status() === 'loading'); + + onBlockClick(item: TimelineRenderBlock): void { + if (item.block.when === 'now') { + this.channelActivated.emit(); + } else { + this.detailsRequested.emit(item); + } + } + + onBlockDoubleClick(item: TimelineRenderBlock): void { + if (item.block.when === 'now') { + this.channelCommitted.emit(); + } + } + + onWatchClick(event: Event, item: TimelineRenderBlock): void { + event.stopPropagation(); + this.watchRequested.emit(item); + } +} +``` + +```html + +
+ {{ channel().number }} + + + {{ channel().name }} + @if (density() === 'comfortable') { + + @if (active()) { + ▶ {{ 'EPG.GUIDE.PLAYING' | translate }} + } @else if (nowBlock(); as now) { + {{ now.block.program.title }} + } @else if (showEmpty()) { + {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }} + } + + } + + @if (active()) { + equalizer + } +
+ +
+ @if (showEmpty()) { + {{ 'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate }} + } @else if (isLoading()) { + + + + } + @for (item of blocks(); track item.key; let index = $index) { +
+ @if (item.block.when === 'now') { + + } + + {{ item.block.startMs | date: 'HH:mm' }} + @if (density() === 'comfortable') { – {{ item.block.stopMs | date: 'HH:mm' }} } + + {{ item.block.program.title }} + @if (item.block.when === 'now') { + {{ 'EPG.TIMELINE.ON_NOW' | translate }} + } + @if (item.canCatchUp && item.block.when === 'past') { + + } +
+ } +
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss +@use '../epg-theme' as *; + +:host { + display: flex; height: 100%; box-sizing: border-box; border-bottom: 1px solid $line; + background: var(--app-content-bg); + &:hover { background: $surface-3; } + &.is-active { background: var(--app-selection-surface); } + &.is-focused { box-shadow: inset 0 0 0 2px $accent-blue; } +} +.epg-guide-row__channel { + position: sticky; left: 0; z-index: 2; flex: 0 0 var(--epg-guide-channel-col, 232px); + display: flex; align-items: center; gap: 10px; padding: 0 12px 0 14px; box-sizing: border-box; + background: inherit; border-right: 1px solid $line-strong; cursor: pointer; color: $text-primary; + :host.is-active &::before { content: ''; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: $accent-blue; } +} +.epg-guide-row__num { width: 22px; text-align: right; font: 11px $font-mono; color: $text-tertiary; font-variant-numeric: tabular-nums; } +.epg-guide-row__logo { + flex: 0 0 auto; width: 34px; height: 34px; border-radius: 8px; overflow: hidden; display: grid; place-items: center; + background: $surface-1; border: 1px solid $line; color: $text-tertiary; + img { width: 100%; height: 100%; object-fit: contain; } + mat-icon { font-size: 18px; width: 18px; height: 18px; } + :host.is-compact & { width: 26px; height: 26px; border-radius: 6px; } +} +.epg-guide-row__name { + flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; + b { font-weight: 500; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + small { font-size: 10.5px; color: $text-tertiary; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + :host.is-active & small { color: $accent-text; } +} +.epg-guide-row__playing { color: $accent-blue; font-size: 18px; width: 18px; height: 18px; } +.epg-guide-row__lane { position: relative; flex: 0 0 auto; height: 100%; } +.epg-guide-row__empty { position: absolute; left: 14px; top: 0; bottom: 0; display: flex; align-items: center; font-size: 12px; font-style: italic; color: $text-tertiary; } +.epg-guide-row__skeleton { position: absolute; top: 9px; bottom: 9px; border-radius: 8px; background: $surface-2; opacity: 0.6; } +.epg-guide-row__block { + position: absolute; top: 7px; bottom: 7px; box-sizing: border-box; border-radius: 8px; padding: 6px 10px; + border: 1px solid $line-strong; background: $surface-1; color: $text-primary; cursor: pointer; overflow: hidden; + display: flex; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; + transition: border-color 120ms, transform 120ms; + &:hover { border-color: $accent-blue; transform: translateY(-1px); z-index: 3; } + &.is-focused { outline: 2px solid $accent-blue; outline-offset: 1px; z-index: 3; } + &.is-past { background: transparent; border-color: $line; .epg-guide-row__title { color: $text-secondary; font-weight: 400; } } + &.is-now { background: var(--app-selection-surface); border-color: $accent-blue; .epg-guide-row__title { font-weight: 600; } } + &.is-playing { background: var(--app-selection-surface-strong); } + &[data-tier='narrow'] { padding: 6px 8px; .epg-guide-row__time, .epg-guide-row__tag { display: none; } } + &[data-tier='micro'] { padding: 0; border-radius: 4px; .epg-guide-row__time, .epg-guide-row__title, .epg-guide-row__tag { display: none; } } + :host.is-compact & { top: 5px; bottom: 5px; flex-direction: row; align-items: center; gap: 8px; border-radius: 6px; padding: 0 9px; } +} +.epg-guide-row__fill { position: absolute; left: 0; top: 0; bottom: 0; background: color-mix(in srgb, $accent-blue 14%, transparent); pointer-events: none; } +.epg-guide-row__time { position: relative; font: 10.5px $font-mono; color: $text-secondary; white-space: nowrap; font-variant-numeric: tabular-nums; } +.epg-guide-row__title { position: relative; font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.epg-guide-row__tag { position: absolute; right: 8px; top: 7px; font: 600 9px $font-mono; letter-spacing: 0.1em; text-transform: uppercase; color: $accent-text; + &::before { content: ''; display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: $accent-live; margin-right: 4px; vertical-align: 1px; } + :host.is-compact & { position: static; margin-left: auto; } } +.epg-guide-row__watch { position: absolute; right: 6px; bottom: 5px; width: 20px; height: 20px; padding: 0; border: 0; background: transparent; color: $text-secondary; cursor: pointer; + mat-icon { font-size: 16px; width: 16px; height: 16px; } &:hover { color: $accent-text; } } +``` + +- [ ] **Step 5: Guide shell component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { DatePipe } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostListener, + inject, + OnDestroy, + OnInit, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { ListRange } from '@angular/cdk/collections'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgProgram, epgProviderClockMs } from '@iptvnator/shared/interfaces'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; +import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component'; +import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; +import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; +import { EpgGuideKeyboardController } from './epg-guide-keyboard.controller'; +import { + buildGuideDayAxis, + buildGuideRowBlocks, + buildGuideTicks, + EPG_GUIDE_CHANNEL_COLUMN_PX, + EPG_GUIDE_ROW_BUFFER, + EPG_GUIDE_ROW_HEIGHT_PX, + EpgGuideDensity, + guideNowLeftPx, + guideTrackWidthPx, +} from './epg-guide-layout.util'; +import { + clampGuideZoom, + persistEpgGuidePreferences, + restoreEpgGuidePreferences, +} from './epg-guide-preferences'; +import { EpgGuideProgramsService } from './epg-guide-programs.service'; +import { EpgGuideRowComponent } from './epg-guide-row.component'; +import { EPG_GUIDE_SOURCE, EpgGuideChannel } from './epg-guide-source'; +import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component'; + +const SEARCH_DEBOUNCE_MS = 300; +const SEARCH_MAX_RESULTS = 20; + +/** + * Multi-channel programme guide. Reads everything from `EPG_GUIDE_SOURCE`; + * owns the selected day, zoom, density, filters and keyboard navigation. + * Rows are virtualised; the channel column and the ruler are sticky. + */ +@Component({ + selector: 'app-epg-guide', + imports: [ + DatePipe, + MatButtonModule, + MatIconModule, + ScrollingModule, + TranslatePipe, + EpgGuideRowComponent, + EpgGuideToolbarComponent, + ], + providers: [EpgGuideProgramsService], + templateUrl: './epg-guide.component.html', + styleUrl: './epg-guide.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EpgGuideComponent implements OnInit, OnDestroy { + private readonly source = inject(EPG_GUIDE_SOURCE); + private readonly programsService = inject(EpgGuideProgramsService); + private readonly settingsStore = inject(SettingsStore); + private readonly programmeDialog = inject(EpgProgrammeDialogService); + private readonly dialog = inject(MatDialog); + private readonly translate = inject(TranslateService); + private readonly destroyRef = inject(DestroyRef); + + readonly close = output(); + readonly channelActivated = output(); + + readonly viewport = viewChild('viewport'); + + private readonly preferences = restoreEpgGuidePreferences(); + readonly dayKey = signal(getTodayEpgDateKey()); + readonly zoom = signal(this.preferences.zoom); + readonly density = signal(this.preferences.density); + readonly onlyWithEpg = signal(this.preferences.onlyWithEpg); + readonly filter = signal(''); + readonly searchQuery = signal(''); + readonly searchResults = signal([]); + readonly nowMs = signal(Date.now()); + readonly scrollLeft = signal(0); + + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + readonly currentLocale = computed(() => { + this.languageTick(); + return this.translate.currentLang || this.translate.defaultLang || 'en'; + }); + readonly offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; + readonly scopes = this.source.scopes; + readonly scopeId = this.source.scopeId; + readonly activeChannelId = this.source.activeChannelId; + readonly coverageLoaded = this.programsService.coverageLoaded; + readonly searchEnabled = typeof this.source.searchPrograms === 'function'; + readonly catchUpAvailable = this.source.catchUp !== undefined; + readonly channelColumnPx = EPG_GUIDE_CHANNEL_COLUMN_PX; + + readonly axis = computed(() => buildGuideDayAxis(this.dayKey())); + readonly isToday = computed(() => { + this.nowMs(); + return this.dayKey() === getTodayEpgDateKey(); + }); + readonly ticks = computed(() => buildGuideTicks(this.axis(), this.zoom())); + readonly trackWidthPx = computed(() => guideTrackWidthPx(this.zoom())); + readonly nowLeftPx = computed(() => + guideNowLeftPx(this.axis(), this.nowMs(), this.zoom()) + ); + readonly rowHeightPx = computed(() => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]); + readonly totalCount = computed(() => this.source.channels().length); + readonly rows = computed(() => { + const needle = this.filter().trim().toLowerCase(); + const onlyWithEpg = this.onlyWithEpg(); + return this.source + .channels() + .filter( + (channel) => + (!needle || channel.name.toLowerCase().includes(needle)) && + (!onlyWithEpg || this.programsService.isCovered(channel.id)) + ); + }); + readonly activeRowIndex = computed(() => { + const activeId = this.activeChannelId(); + return activeId === null + ? -1 + : this.rows().findIndex((channel) => channel.id === activeId); + }); + + private readonly keyboard = new EpgGuideKeyboardController({ + rowCount: () => this.rows().length, + blockCount: (row) => this.blocksFor(row).length, + activeRow: () => this.activeRowIndex(), + isBlocked: () => this.dialog.openDialogs.length > 0, + play: (row) => this.commitRow(this.rows()[row]), + details: (row, block) => + this.openDetails(this.rows()[row], this.blocksFor(row)[block]), + jumpNow: () => this.jumpNow(), + stepDay: (direction) => this.stepDay(direction), + close: () => this.close.emit(), + }); + readonly focus = this.keyboard.focus; + + private renderedRange: ListRange | null = null; + private minuteTimer?: number; + private searchTimer?: number; + + constructor() { + effect(() => { + const axis = this.axis(); + const offset = this.offsetMinutes(); + this.programsService.setWindow( + epgProviderClockMs(axis.startMs, offset), + epgProviderClockMs(axis.endMs, offset) + ); + untracked(() => this.loadRenderedRange()); + }); + effect(() => { + this.rows(); + untracked(() => this.loadRenderedRange()); + }); + effect(() => + persistEpgGuidePreferences({ + density: this.density(), + zoom: this.zoom(), + onlyWithEpg: this.onlyWithEpg(), + }) + ); + effect(() => { + const viewport = this.viewport(); + if (!viewport) { + return; + } + untracked(() => + viewport.renderedRangeStream + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((range) => { + this.renderedRange = range; + this.loadRenderedRange(); + }) + ); + }); + afterNextRender(() => this.jumpNow(false)); + } + + ngOnInit(): void { + this.minuteTimer = window.setInterval( + () => this.nowMs.set(Date.now()), + 60_000 + ); + } + + ngOnDestroy(): void { + window.clearInterval(this.minuteTimer); + window.clearTimeout(this.searchTimer); + } + + @HostListener('document:keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + if (this.keyboard.handle(event)) { + event.preventDefault(); + this.revealFocus(); + } + } + + trackRow(_index: number, channel: EpgGuideChannel): string { + return `${channel.number}:${channel.id}`; + } + + programsFor(channelId: string): EpgProgram[] { + return this.programsService.programsFor(channelId); + } + + statusFor(channelId: string) { + return this.programsService.statusFor(channelId); + } + + focusedBlockFor(row: number): number | null { + const focused = this.focus(); + return focused?.row === row ? focused.block : null; + } + + onViewportScroll(event: Event): void { + this.scrollLeft.set((event.target as HTMLElement).scrollLeft); + } + + stepDay(direction: EpgDateNavigationDirection): void { + this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction)); + this.focus.set(null); + } + + jumpNow(animate = true): void { + this.dayKey.set(getTodayEpgDateKey()); + this.nowMs.set(Date.now()); + const viewport = this.viewport(); + const nowX = this.nowLeftPx(); + if (!viewport || nowX === null) { + return; + } + const element = viewport.elementRef.nativeElement; + const visibleTrack = Math.max(0, element.clientWidth - this.channelColumnPx); + element.scrollTo({ + left: Math.max(0, nowX - visibleTrack / 3), + behavior: animate ? 'smooth' : 'auto', + }); + const activeRow = this.activeRowIndex(); + if (activeRow >= 0) { + viewport.scrollToIndex(Math.max(0, activeRow - 3), animate ? 'smooth' : 'auto'); + } + } + + setScope(scopeId: string): void { + this.source.setScope(scopeId); + this.focus.set(null); + } + + setOnlyWithEpg(value: boolean): void { + this.onlyWithEpg.set(value); + } + + setDensity(value: EpgGuideDensity): void { + this.density.set(value); + } + + setZoom(value: number): void { + this.zoom.set(clampGuideZoom(value)); + } + + setFilter(value: string): void { + this.filter.set(value); + this.focus.set(null); + } + + onSearchQueryChange(query: string): void { + this.searchQuery.set(query); + window.clearTimeout(this.searchTimer); + const term = query.trim(); + if (term.length < 2 || !this.source.searchPrograms) { + this.searchResults.set([]); + return; + } + this.searchTimer = window.setTimeout(async () => { + const results = await this.source.searchPrograms?.(term).catch(() => []); + if (this.searchQuery() === query) { + this.searchResults.set((results ?? []).slice(0, SEARCH_MAX_RESULTS)); + } + }, SEARCH_DEBOUNCE_MS); + } + + openSearchResult(program: EpgProgram): void { + this.programmeDialog.open({ ...program }).subscribe(); + } + + activateRow(channel: EpgGuideChannel | undefined): void { + if (!channel) { + return; + } + this.source.activate(channel.id); + this.channelActivated.emit(channel.id); + } + + commitRow(channel: EpgGuideChannel | undefined): void { + if (!channel) { + return; + } + this.activateRow(channel); + this.close.emit(); + } + + openDetails( + channel: EpgGuideChannel | undefined, + item: TimelineRenderBlock | undefined + ): void { + if (!channel || !item) { + return; + } + const when = item.block.when; + this.programmeDialog + .open({ + ...item.block.program, + channelName: channel.name, + channelLogo: channel.logoUrl, + primaryAction: + when === 'now' ? 'live' : item.canCatchUp ? 'timeshift' : null, + archiveUnavailableNote: when === 'past' && !item.canCatchUp, + }) + .subscribe((result: EpgItemDialogAction | undefined) => { + if (result === 'live') { + this.activateRow(channel); + } else if (result === 'timeshift') { + this.source.catchUp?.watch(channel, item.block.program); + } + }); + } + + watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void { + this.source.catchUp?.watch(channel, item.block.program); + } + + private blocksFor(row: number): TimelineRenderBlock[] { + const channel = this.rows()[row]; + if (!channel) { + return []; + } + return buildGuideRowBlocks(this.programsService.programsFor(channel.id), { + axis: this.axis(), + hourWidthPx: this.zoom(), + nowMs: this.nowMs(), + offsetMinutes: this.offsetMinutes(), + catchUpAvailable: this.catchUpAvailable, + }); + } + + private loadRenderedRange(): void { + const rows = this.rows(); + const range = this.renderedRange ?? { start: 0, end: Math.min(rows.length, 30) }; + const start = Math.max(0, range.start - EPG_GUIDE_ROW_BUFFER); + const end = Math.min(rows.length, range.end + EPG_GUIDE_ROW_BUFFER); + this.programsService.ensureLoaded(rows.slice(start, end)); + } + + /** Keep the keyboard focus target inside the viewport, both axes. */ + private revealFocus(): void { + const focused = this.focus(); + const viewport = this.viewport(); + if (!focused || !viewport) { + return; + } + const element = viewport.elementRef.nativeElement; + const rowTop = focused.row * this.rowHeightPx(); + const scrollTop = viewport.measureScrollOffset('top'); + if (rowTop < scrollTop || rowTop + this.rowHeightPx() > scrollTop + element.clientHeight) { + viewport.scrollToIndex(Math.max(0, focused.row - 2)); + } + if (focused.block !== null) { + const block = this.blocksFor(focused.row)[focused.block]; + if (!block) { + return; + } + const visibleLeft = element.scrollLeft; + const visibleWidth = element.clientWidth - this.channelColumnPx; + if (block.leftPx < visibleLeft || block.leftPx + block.widthPx > visibleLeft + visibleWidth) { + element.scrollTo({ left: Math.max(0, block.leftPx - 40), behavior: 'smooth' }); + } + } + } +} +``` + +If this file exceeds 300 lines after formatting, move `jumpNow`/`revealFocus` scrolling into `epg-guide-scroll.util.ts` as pure functions taking the viewport element and geometry — do not let it reach 400. + +```html + + + +@if (searchEnabled && searchQuery().trim().length >= 2) { +
+ @for (program of searchResults(); track program.channel + program.start) { + + } @empty { +
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
+ } +
+} + +
+
+
{{ 'EPG.GUIDE.CHANNEL' | translate }}
+
+
+ @for (tick of ticks(); track tick.ms) { +
+ {{ tick.ms | date: 'HH:mm' }} +
+ } + @if (nowLeftPx(); as nowX) { +
{{ nowMs() | date: 'HH:mm' }}
+ } +
+
+
+ + + + @if (rows().length === 0) { +
+ search_off +

{{ 'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() } }}

+ @if (filter()) { + + } +
+ } +
+ + @if (nowLeftPx(); as nowX) { +
+ } +
+ + +``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss +@use '../epg-theme' as *; + +:host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--app-content-bg); color: $text-primary; position: relative; } +.epg-guide__grid { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } +.epg-guide__ruler { display: flex; height: 34px; flex: 0 0 auto; border-bottom: 1px solid $line-strong; background: var(--app-content-bg); z-index: 4; } +.epg-guide__corner { flex: 0 0 var(--epg-guide-channel-col); display: flex; align-items: center; padding: 0 14px; font: 500 10px $font-mono; letter-spacing: 0.1em; text-transform: uppercase; color: $text-tertiary; border-right: 1px solid $line-strong; box-sizing: border-box; } +.epg-guide__ruler-clip { flex: 1; overflow: hidden; position: relative; } +.epg-guide__ruler-track { position: relative; height: 100%; will-change: transform; } +.epg-guide__tick { position: absolute; top: 0; bottom: 0; padding-left: 8px; display: flex; align-items: center; border-left: 1px solid $line-strong; font: 11px $font-mono; color: $text-secondary; font-variant-numeric: tabular-nums; + &.is-half { border-left-color: $line; color: $text-tertiary; font-size: 10px; } } +.epg-guide__now-badge { position: absolute; top: 6px; transform: translateX(-50%); background: $accent-live; color: #1a0d0d; font: 600 10.5px $font-mono; padding: 3px 7px; border-radius: 5px; z-index: 5; font-variant-numeric: tabular-nums; } +.epg-guide__viewport { flex: 1; min-height: 0; overflow: auto; + ::ng-deep .cdk-virtual-scroll-content-wrapper { min-width: 100%; } } +.epg-guide__now-line { position: absolute; top: 34px; bottom: 0; width: 2px; background: $accent-live; box-shadow: 0 0 10px color-mix(in srgb, $accent-live 60%, transparent); pointer-events: none; z-index: 3; } +.epg-guide__no-rows { position: sticky; left: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: $text-tertiary; + mat-icon { font-size: 32px; width: 32px; height: 32px; } } +.epg-guide__keys { display: flex; align-items: center; gap: 18px; height: 34px; padding: 0 18px; border-top: 1px solid $line; font-size: 11px; color: $text-tertiary; flex: 0 0 auto; + kbd { font: 10px $font-mono; color: $text-secondary; border: 1px solid $line-strong; border-radius: 4px; padding: 1px 5px; margin-right: 5px; } } +.epg-guide__search-results { position: absolute; top: 52px; right: 18px; width: 420px; max-height: 50vh; overflow: auto; z-index: 20; background: $surface-1; border: 1px solid $line-strong; border-radius: 10px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); padding: 6px; display: flex; flex-direction: column; gap: 2px; } +.epg-guide__search-result { display: flex; flex-direction: column; gap: 2px; text-align: left; padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: $text-primary; cursor: pointer; font: inherit; + &:hover, &:focus-visible { background: $surface-3; outline: none; } } +.epg-guide__search-title { font-size: 13px; font-weight: 500; } +.epg-guide__search-meta { font: 10.5px $font-mono; color: $text-tertiary; } +.epg-guide__search-empty { padding: 14px; text-align: center; color: $text-tertiary; font-size: 12.5px; } +``` + +- [ ] **Step 6: Export from the library** + +In `libs/ui/epg/src/index.ts` add (keep the two `multi-epg` lines for now; Task 10 removes them): + +```ts +export * from './lib/epg-guide/epg-guide-source'; +export * from './lib/epg-guide/epg-guide-layout.util'; +export * from './lib/epg-guide/epg-guide-preferences'; +export * from './lib/epg-guide/epg-guide.component'; +export * from './lib/epg-guide/epg-guide-now-playing.component'; +``` + +- [ ] **Step 7: Write the failing component spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; +import { EpgGuideComponent } from './epg-guide.component'; +import { + EPG_GUIDE_DENSITY_KEY, + EPG_GUIDE_ONLY_WITH_EPG_KEY, +} from './epg-guide-preferences'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideSource, +} from './epg-guide-source'; + +function channel(id: string, epgKey: string | null = id, number = 1): EpgGuideChannel { + return { id, number, name: `Channel ${id}`, logoUrl: null, epgKey }; +} + +function nowProgram(channelId: string): EpgProgram { + const start = new Date(Date.now() - 10 * 60_000); + const stop = new Date(Date.now() + 20 * 60_000); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: channelId, + title: `${channelId} now`, + desc: null, + category: null, + }; +} + +async function settle(fixture: ComponentFixture): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); +} + +describe('EpgGuideComponent', () => { + let fixture: ComponentFixture; + let component: EpgGuideComponent; + const channels = signal([]); + const activeChannelId = signal(null); + const activate = jest.fn(); + const setScope = jest.fn(); + const dialogOpen = jest.fn(() => of(undefined)); + + beforeEach(() => { + localStorage.clear(); + activate.mockReset(); + setScope.mockReset(); + dialogOpen.mockClear(); + channels.set([channel('a', 'a', 1), channel('b', null, 2), channel('c', 'c', 3)]); + activeChannelId.set('a'); + const source: EpgGuideSource = { + channels, + scopes: signal([{ id: 'all', label: 'All channels', kind: 'all' }]), + scopeId: signal('all'), + setScope, + loadPrograms: async (window) => + new Map(window.channels.map((item) => [item.id, item.id === 'a' ? [nowProgram('a')] : []])), + loadCoverage: async () => new Set(['a']), + activeChannelId, + activate, + }; + TestBed.configureTestingModule({ + imports: [EpgGuideComponent], + providers: [ + { provide: EPG_GUIDE_SOURCE, useValue: source }, + { provide: EpgProgrammeDialogService, useValue: { open: dialogOpen } }, + { provide: MatDialog, useValue: { openDialogs: [] } }, + { provide: SettingsStore, useValue: { resolvedEpgOffsetMinutes: signal(0) } }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + }, + }, + ], + }); + fixture = TestBed.createComponent(EpgGuideComponent); + component = fixture.componentInstance; + }); + + it('lists the scope channels in order and marks the active one', async () => { + await settle(fixture); + expect(component.rows().map((row) => row.id)).toEqual(['a', 'b', 'c']); + expect(component.activeRowIndex()).toBe(0); + }); + + it('emits activate on click and activate + close on double click / Enter', async () => { + await settle(fixture); + const close = jest.fn(); + component.close.subscribe(close); + component.activateRow(component.rows()[2]); + expect(activate).toHaveBeenCalledWith('c'); + expect(close).not.toHaveBeenCalled(); + component.commitRow(component.rows()[2]); + expect(close).toHaveBeenCalledTimes(1); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(activate).toHaveBeenLastCalledWith('a'); + expect(close).toHaveBeenCalledTimes(2); + }); + + it('hides only uncovered rows when "Only with EPG" is on, after coverage arrived', async () => { + await settle(fixture); + component.setOnlyWithEpg(true); + await settle(fixture); + expect(component.rows().map((row) => row.id)).toEqual(['a']); + expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1'); + }); + + it('filters by channel name and clears', async () => { + await settle(fixture); + component.setFilter('channel c'); + expect(component.rows().map((row) => row.id)).toEqual(['c']); + component.setFilter(''); + expect(component.rows()).toHaveLength(3); + }); + + it('persists density and zoom and restores them', async () => { + await settle(fixture); + component.setDensity('compact'); + component.setZoom(9_999); + await settle(fixture); + expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact'); + expect(component.zoom()).toBe(480); + expect(component.rowHeightPx()).toBe(44); + }); + + it('opens the programme dialog for a non-live card and activates on "live"', async () => { + await settle(fixture); + dialogOpen.mockReturnValueOnce(of('live')); + const row = component.rows()[0]; + const item = { + kind: 'block' as const, + key: 'k', + block: { + program: nowProgram('a'), + key: 'k', + startMs: 0, + stopMs: 1, + when: 'past' as const, + offsetMin: 0, + durationMin: 1, + }, + leftPx: 0, + widthPx: 10, + tier: 'wide' as const, + nowFillPercent: 0, + canCatchUp: false, + }; + component.openDetails(row, item); + expect(dialogOpen).toHaveBeenCalledWith( + expect.objectContaining({ channelName: 'Channel a', primaryAction: null, archiveUnavailableNote: true }) + ); + expect(activate).toHaveBeenCalledWith('a'); + }); + + it('closes on Escape and steps days with PageUp/PageDown', async () => { + await settle(fixture); + const close = jest.fn(); + component.close.subscribe(close); + const before = component.dayKey(); + component.onKeydown(new KeyboardEvent('keydown', { key: 'PageDown' })); + expect(component.dayKey()).not.toBe(before); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(close).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('app-epg-guide-toolbar'))).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 8: Run the spec** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts` +Expected: PASS (7 tests). If `TranslatePipe` needs more of `TranslateService`, extend the stub the way `epg-timeline.component.spec.ts` does rather than importing the real module. If `afterNextRender` throws in the test environment, wrap the `jumpNow(false)` call in `try/catch`-free guard `if (typeof window !== 'undefined')` — do not remove the auto-scroll. + +- [ ] **Step 9: Lint + run the whole ui-epg project** + +Run: `pnpm nx lint ui-epg && pnpm nx test ui-epg` +Expected: lint clean (every new file < 400 lines, no bare aliases), all specs PASS. + +- [ ] **Step 10: Commit** + +```bash +git add libs/ui/epg apps/web/src/assets/i18n/en.json +git commit -m "feat(epg): add the programme guide grid components + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 7: "Guide" button in the timeline toolbar + +**Files:** +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts:86-115` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html:90-125` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss:164-180` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts` + +- [ ] **Step 1: Write the failing spec** + +Append to `epg-timeline.component.spec.ts` inside the main `describe`: + +```ts + it('shows a Guide button only when the host offers a guide and emits openGuide', () => { + setInputs({ programs: [programAt(0, 120, 'Now')] }); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('.epg-timeline__guide') + ).toBeNull(); + + const openGuide = jest.fn(); + component.openGuide.subscribe(openGuide); + setInputs({ guideAvailable: true }); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector( + '.epg-timeline__guide' + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + button.click(); + expect(openGuide).toHaveBeenCalledTimes(1); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts` +Expected: FAIL — `guideAvailable` is not a known input. + +- [ ] **Step 3: Add input, output and button** + +In `epg-timeline.component.ts` after `readonly offsetMinutes = input(0);` add: + +```ts + /** The host can open a multi-channel guide; renders the Guide button. */ + readonly guideAvailable = input(false); +``` + +After `readonly collapsedChange = output();` add: + +```ts + readonly openGuide = output(); +``` + +In `epg-timeline.component.html`, inside `@if (showRibbonControls()) {` directly before the `epg-timeline__jump` button, add: + +```html + @if (guideAvailable()) { + + } +``` + +In `epg-timeline.component.scss` change the selector `.epg-timeline__jump {` to `.epg-timeline__jump,\n.epg-timeline__guide {` so both share the pill style, and add after that block: + +```scss +.epg-timeline__guide { + color: $accent-text; + border-color: $accent-blue; +} +``` + +In `video-player.spec-stubs.ts` add to `StubEpgTimelineComponent`: + +```ts + readonly guideAvailable = input(false); + readonly openGuide = output(); +``` + +- [ ] **Step 4: Run the timeline spec and the host spec** + +Run: +```bash +NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-timeline libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +git commit -m "feat(epg): add a Guide button to the timeline toolbar + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 8: M3U adapter for `EPG_GUIDE_SOURCE` + +**Files:** +- Create: `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts` +- Create: `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts` + +The host binds its signals into the adapter (`bind()`), so the adapter has +no dependency on the 1600-line host component and can be unit-tested alone. + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { ChannelActions } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service'; + +function makeChannel( + id: string, + overrides: Partial & { tvgId?: string; group?: string } = {} +): Channel { + return { + id, + url: `https://example.com/${id}.m3u8`, + name: overrides.name ?? `Channel ${id}`, + group: { title: overrides.group ?? 'News' }, + tvg: { + id: overrides.tvgId ?? '', + name: '', + url: '', + logo: overrides.tvg?.logo ?? '', + rec: '', + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: overrides.radio ?? 'false', + } as Channel; +} + +function program(channel: string): EpgProgram { + return { + start: '2026-09-06T16:00:00.000Z', + stop: '2026-09-06T17:00:00.000Z', + channel, + title: `${channel} show`, + desc: null, + category: null, + }; +} + +describe('M3uEpgGuideSourceService', () => { + const channels = signal([]); + const favoriteIds = signal([]); + const activeChannel = signal(null); + const dispatch = jest.fn(); + const getProgramsForChannels = jest.fn(); + const getProgramCoverage = jest.fn(); + const searchPrograms = jest.fn(); + let service: M3uEpgGuideSourceService; + + beforeEach(() => { + dispatch.mockReset(); + getProgramsForChannels.mockReset(); + getProgramCoverage.mockReset(); + searchPrograms.mockReset(); + channels.set([ + makeChannel('a', { tvgId: 'a.tv', group: 'News' }), + makeChannel('b', { name: 'Beta', group: 'Sports' }), + makeChannel('c', { name: ' ', group: 'Sports' }), + ]); + favoriteIds.set(['b']); + activeChannel.set(channels()[0]); + TestBed.configureTestingModule({ + providers: [ + M3uEpgGuideSourceService, + { provide: Store, useValue: { dispatch } }, + { + provide: EpgRuntimeBridgeService, + useValue: { getProgramsForChannels, getProgramCoverage, searchPrograms }, + }, + { provide: SettingsStore, useValue: { stripCountryPrefix: signal(false) } }, + ], + }); + service = TestBed.inject(M3uEpgGuideSourceService); + service.bind({ channels, favoriteIds, activeChannel }); + }); + + it('offers all / groups / favorites scopes and lists channels in playlist order', () => { + expect(service.scopes().map((scope) => scope.id)).toEqual([ + 'all', + 'favorites', + 'group:News', + 'group:Sports', + ]); + expect(service.channels().map((channel) => channel.number)).toEqual([1, 2, 3]); + service.setScope('group:Sports'); + expect(service.channels().map((channel) => channel.id)).toEqual(['b', 'c']); + expect(service.channels()[0].number).toBe(1); + service.setScope('favorites'); + expect(service.channels().map((channel) => channel.id)).toEqual(['b']); + }); + + it('derives the EPG key from tvg-id, then name, and null for blank names', () => { + const keys = service.channels().map((channel) => channel.epgKey); + expect(keys).toEqual(['a.tv', 'Beta', null]); + }); + + it('loads programmes and coverage through the bridge keyed back by channel id', async () => { + getProgramsForChannels.mockResolvedValue({ 'a.tv': [program('a.tv')], Beta: [] }); + getProgramCoverage.mockResolvedValue(['a.tv']); + const window = { channels: service.channels(), fromMs: 1, toMs: 2 }; + + const programs = await service.loadPrograms(window); + expect(getProgramsForChannels).toHaveBeenCalledWith({ + channelIds: ['a.tv', 'Beta'], + fromMs: 1, + toMs: 2, + }); + expect(programs.get('a')?.[0].title).toBe('a.tv show'); + expect(programs.get('b')).toEqual([]); + expect(programs.has('c')).toBe(false); + + const covered = await service.loadCoverage(window); + expect(covered).toEqual(new Set(['a'])); + }); + + it('answers empty results when the bridge is unavailable', async () => { + getProgramsForChannels.mockResolvedValue(null); + getProgramCoverage.mockResolvedValue(null); + const window = { channels: service.channels(), fromMs: 1, toMs: 2 }; + expect((await service.loadPrograms(window)).size).toBe(0); + expect((await service.loadCoverage(window)).size).toBe(0); + }); + + it('mirrors the active channel and dispatches playback on activate', () => { + expect(service.activeChannelId()).toBe('a'); + service.activate('b'); + expect(dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ channel: channels()[1], startPlayback: true }) + ); + service.activate('missing'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('seeds the initial scope from the sidebar view', () => { + service.applyInitialScope('favorites'); + expect(service.scopeId()).toBe('favorites'); + service.applyInitialScope('groups'); + expect(service.scopeId()).toBe('group:News'); + service.applyInitialScope('all'); + expect(service.scopeId()).toBe('all'); + }); + + it('forwards programme search to the bridge', async () => { + searchPrograms.mockResolvedValue([program('x')]); + await expect(service.searchPrograms('news')).resolves.toHaveLength(1); + expect(searchPrograms).toHaveBeenCalledWith('news', 20); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the adapter** + +```ts +// libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EpgGuideChannel, + EpgGuideScope, + EpgGuideSource, + EpgGuideWindow, +} from '@iptvnator/ui/epg'; +import { createM3uChannelPlaybackRequest } from '../video-player/m3u-channel-playback-actions'; + +export interface M3uEpgGuideInputs { + /** Guide-eligible channels (host excludes radio and recognised movies). */ + channels: Signal; + favoriteIds: Signal; + activeChannel: Signal; +} + +const SCOPE_ALL = 'all'; +const SCOPE_FAVORITES = 'favorites'; +const GROUP_PREFIX = 'group:'; +const SEARCH_LIMIT = 20; + +/** + * `EPG_GUIDE_SOURCE` for the M3U player. Channels, favorites and the active + * channel are bound by the host; programmes come from the XMLTV bridge + * keyed by the same lookup chain the sidebar uses (tvg-id → tvg-name → name). + * Queries are unscoped (all imported EPG sources), like the M3U timeline. + */ +@Injectable() +export class M3uEpgGuideSourceService implements EpgGuideSource { + private readonly store = inject(Store); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly settingsStore = inject(SettingsStore); + + private readonly inputs = signal(null); + private readonly scope = signal(SCOPE_ALL); + + readonly scopeId = this.scope.asReadonly(); + + private readonly allChannels = computed( + () => this.inputs()?.channels() ?? [] + ); + + readonly scopes = computed(() => { + const groups: string[] = []; + const seen = new Set(); + for (const channel of this.allChannels()) { + const title = channel.group?.title?.trim(); + if (title && !seen.has(title)) { + seen.add(title); + groups.push(title); + } + } + return [ + { id: SCOPE_ALL, label: 'All channels', kind: 'all' }, + { id: SCOPE_FAVORITES, label: 'Favorites', kind: 'favorites' }, + ...groups.map((title) => ({ + id: `${GROUP_PREFIX}${title}`, + label: title, + kind: 'group' as const, + })), + ]; + }); + + private readonly scopedChannels = computed(() => { + const scope = this.scope(); + const channels = this.allChannels(); + if (scope === SCOPE_FAVORITES) { + const favorites = new Set(this.inputs()?.favoriteIds() ?? []); + return channels.filter((channel) => favorites.has(channel.id)); + } + if (scope.startsWith(GROUP_PREFIX)) { + const title = scope.slice(GROUP_PREFIX.length); + return channels.filter( + (channel) => channel.group?.title?.trim() === title + ); + } + return channels; + }); + + readonly channels = computed(() => { + const strip = this.settingsStore.stripCountryPrefix?.(); + return this.scopedChannels().map((channel, index) => ({ + id: channel.id, + number: index + 1, + name: applyChannelNameStrip(channel.name, strip) || channel.name, + logoUrl: channel.tvg?.logo?.trim() || null, + epgKey: resolveChannelEpgLookupKey(channel) || null, + })); + }); + + readonly activeChannelId = computed( + () => this.inputs()?.activeChannel()?.id ?? null + ); + + bind(inputs: M3uEpgGuideInputs): void { + this.inputs.set(inputs); + } + + /** Called by the host when the guide opens: mirror the sidebar view. */ + applyInitialScope(view: string): void { + if (view === SCOPE_FAVORITES) { + this.scope.set(SCOPE_FAVORITES); + return; + } + const activeGroup = this.inputs()?.activeChannel()?.group?.title?.trim(); + if (view === 'groups' && activeGroup) { + this.scope.set(`${GROUP_PREFIX}${activeGroup}`); + return; + } + this.scope.set(SCOPE_ALL); + } + + setScope(id: string): void { + if (this.scopes().some((scope) => scope.id === id)) { + this.scope.set(id); + } + } + + async loadPrograms( + window: EpgGuideWindow + ): Promise> { + const keyed = this.keyedChannels(window.channels); + const result = new Map(); + if (keyed.length === 0) { + return result; + } + const response = await this.epgBridge.getProgramsForChannels({ + channelIds: Array.from(new Set(keyed.map(([, key]) => key))), + fromMs: window.fromMs, + toMs: window.toMs, + }); + if (!response) { + return result; + } + for (const [channel, key] of keyed) { + result.set(channel.id, response[key] ?? []); + } + return result; + } + + async loadCoverage(window: EpgGuideWindow): Promise> { + const keyed = this.keyedChannels(window.channels); + const covered = new Set(); + if (keyed.length === 0) { + return covered; + } + const response = await this.epgBridge.getProgramCoverage({ + channelIds: Array.from(new Set(keyed.map(([, key]) => key))), + fromMs: window.fromMs, + toMs: window.toMs, + }); + const keys = new Set(response ?? []); + for (const [channel, key] of keyed) { + if (keys.has(key)) { + covered.add(channel.id); + } + } + return covered; + } + + activate(channelId: string): void { + const channel = this.scopedChannels().find( + (candidate) => candidate.id === channelId + ); + if (channel) { + this.store.dispatch(createM3uChannelPlaybackRequest(channel)); + } + } + + async searchPrograms(query: string): Promise { + return (await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? []; + } + + private keyedChannels( + channels: EpgGuideChannel[] + ): Array<[EpgGuideChannel, string]> { + return channels + .filter((channel) => channel.epgKey !== null) + .map((channel) => [channel, channel.epgKey as string]); + } +} +``` + +Scope labels "All channels" / "Favorites" must be translated: import +`TranslateService` and use `this.translate.instant('CHANNELS.ALL_CHANNELS')` / +`'CHANNELS.FAVORITES'` if those keys exist (`grep -n '"ALL_CHANNELS"\|"FAVORITES"' apps/web/src/assets/i18n/en.json`); otherwise add +`EPG.GUIDE.SCOPE_ALL: "All channels"` and `EPG.GUIDE.SCOPE_FAVORITES: "Favorites"` to `en.json` (and Task 11's locale pass) and use those. Provide `TranslateService` in the spec as `{ instant: (key: string) => key }` and adjust the label assertions to the keys. + +- [ ] **Step 4: Run the spec** + +Same command as Step 2. Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/playlist/m3u/feature-player/src/lib/epg-guide +git commit -m "feat(m3u): adapt the playlist channel list to the guide contract + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 9: Host integration — guide mode, docked player, entry points + +**Files:** +- Modify: `libs/portal/shared/util/src/lib/workspace-header-context.service.ts` (optional `active`) +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html:118-129` and `.scss` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts:880-925` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts` (rename fixture id `multi-epg` → `epg-guide`, label `Open programme guide`, icon `grid_view`) + +`video-player.component.ts` is baselined (1590 lines). Add only thin glue +here; logic lives in `M3uEpgGuideSourceService` (Task 8) and the guide +components. Net growth must stay under ~80 lines. + +- [ ] **Step 1: Header action "active" state** + +`workspace-header-context.service.ts` — add to `WorkspaceHeaderAction`: + +```ts + /** Pressed/highlighted state for toggling actions (e.g. the guide). */ + active?: () => boolean; +``` + +`workspace-shell-header.component.html` — on the `.header-shortcut` button add: + +```html + [class.is-active]="action.active?.() ?? false" + [attr.aria-pressed]="action.active ? action.active() : null" +``` + +`workspace-shell-header.component.scss` — append: + +```scss +.header-shortcut.is-active { + background: var(--app-selection-surface); + color: var(--app-selection-color); +} +``` + +- [ ] **Step 2: Write the failing host specs** + +In `video-player.component.spec.ts`: + +1. Add stubs next to the other stubs: + +```ts +@Component({ + selector: 'app-epg-guide', + standalone: true, + template: '
', +}) +class StubEpgGuideComponent { + readonly close = output(); + readonly channelActivated = output(); +} + +@Component({ + selector: 'app-epg-guide-now-playing', + standalone: true, + template: '', +}) +class StubEpgGuideNowPlayingComponent { + readonly channelName = input(''); + readonly program = input(null); + readonly offsetMinutes = input(0); + readonly collapsed = input(false); + readonly closeRequested = output(); + readonly collapsedChange = output(); +} +``` + +and add both to the `overrideComponent(...).set.imports` list. + +2. Add `getEpgProgramsForChannels: jest.fn().mockResolvedValue({}), getEpgProgramCoverage: jest.fn().mockResolvedValue([]),` wherever the spec builds `window.electron` for the Electron branch (search for `window.electron = {`), and `EpgRuntimeBridgeService` mock is unnecessary because the adapter goes through the real bridge which reads `window.electron`. Add `selectFavorites` to the `selectSignal` switch returning `signal([])`. + +3. Update the existing header-shortcut test: + +```ts + it('registers and clears the workspace guide header shortcut', () => { + fixture.detectChanges(); + expect(headerContext.action()).toEqual( + expect.objectContaining({ id: 'm3u-epg-guide', icon: 'grid_view' }) + ); + fixture.destroy(); + expect(headerContext.action()).toBeNull(); + }); +``` + +and in the PWA test rename the title to `'hides EPG controls and the guide header action in browser/PWA playback'` (assertions unchanged). + +4. New cases (place after the header-shortcut test; `syncStoreState(sampleChannel)` and `player.set(...)` are the existing helpers that mount an inline player): + +```ts + it('opens the guide in place of sidebar and timeline without remounting the player', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + const playerBefore = fixture.nativeElement.querySelector('app-web-player-view'); + expect(playerBefore).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-timeline')).not.toBeNull(); + + component.openGuide(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-epg-guide')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-guide-now-playing')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-sidebar')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect(fixture.nativeElement.querySelector('.content-container').classList).toContain('is-guide'); + expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(playerBefore); + expect(headerContext.action()?.active?.()).toBe(true); + + component.closeGuide(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('app-epg-guide')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-sidebar')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(playerBefore); + }); + + it('toggles the guide with G, ignores typing, and lets the guide own other keys', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true })); + fixture.detectChanges(); + expect(component.guideOpen()).toBe(true); + + const pageDown = new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true, cancelable: true }); + document.dispatchEvent(pageDown); + expect(pageDown.defaultPrevented).toBe(false); + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: expect.stringContaining('setActiveChannel') }) + ); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'G', bubbles: true })); + expect(component.guideOpen()).toBe(false); + + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'g', bubbles: true })); + expect(component.guideOpen()).toBe(false); + input.remove(); + }); + + it('closes the guide when the live player enters fullscreen or the channel turns to radio', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + component.openGuide(); + fixture.detectChanges(); + + const playerView = fixture.nativeElement.querySelector('app-web-player-view') as HTMLElement; + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => playerView, + }); + document.dispatchEvent(new Event('fullscreenchange')); + expect(component.guideOpen()).toBe(false); + Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null }); + + component.openGuide(); + syncStoreState({ ...sampleChannel, radio: 'true' }); + fixture.detectChanges(); + expect(component.guideOpen()).toBe(false); + }); + + it('remembers the collapsed dock strip', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + fixture.detectChanges(); + component.openGuide(); + component.setGuideDockCollapsed(true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.content-container').classList).toContain('is-guide-collapsed'); + expect(localStorage.getItem('epg-guide:dock-collapsed')).toBe('1'); + localStorage.removeItem('epg-guide:dock-collapsed'); + }); +``` + +`storeMock.dispatch` is the existing jest mock on the store stub. If `syncStoreState` cannot express `radio: 'true'`, set `activeChannel.set({...sampleChannel, radio: 'true'})` directly (the signal the mock returns for `selectActive`). + +- [ ] **Step 3: Run to verify they fail** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts` +Expected: FAIL on `openGuide` / `guideOpen` not existing. + +- [ ] **Step 4: Host component changes** + +`video-player.component.ts`: + +Imports — remove `Overlay, OverlayRef`, `ComponentPortal`, `Injector`, and from `@iptvnator/ui/epg` remove `COMPONENT_OVERLAY_REF`, `MultiEpgContainerComponent`; add: + +```ts +import { + EPG_GUIDE_SOURCE, + EpgGuideComponent, + EpgGuideNowPlayingComponent, + persistEpgGuideDockCollapsed, + restoreEpgGuideDockCollapsed, +} from '@iptvnator/ui/epg'; +import { selectFavorites } from '@iptvnator/m3u-state'; // add to the existing m3u-state import list +import { M3uEpgGuideSourceService } from '../epg-guide/m3u-epg-guide-source.service'; +``` + +Constant rename: `const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';` → `const M3U_EPG_GUIDE_HEADER_ACTION_ID = 'm3u-epg-guide';` (update both usages). + +Decorator — add `EpgGuideComponent, EpgGuideNowPlayingComponent` to `imports`; add to `providers`: + +```ts + M3uEpgGuideSourceService, + { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService }, +``` + +Fields — delete `private readonly overlay = inject(Overlay);` and `private overlayRef!: OverlayRef;`. Add after `fullscreenPanelChannels`: + +```ts + private readonly guideSource = inject(M3uEpgGuideSourceService); + /** Guide mode: sidebar and timeline give way to the multi-channel grid. */ + readonly guideOpen = signal(false); + readonly guideDockCollapsed = signal(restoreEpgGuideDockCollapsed()); + /** Rows the guide may show: everything that keeps the live host mounted. */ + readonly guideChannels = computed(() => + this.channels().filter( + (channel) => + channel.radio !== 'true' && !this.opensMovieDetail(channel) + ) + ); + readonly canOpenGuide = computed(() => { + const channel = this.activeChannel(); + return ( + this.supportsEpg && + !!channel && + channel.radio !== 'true' && + !this.showMovieDetail() + ); + }); + /** Programme shown in the docked strip (catch-up selection wins). */ + readonly guideNowPlayingProgram = computed( + () => this.activeEpgProgramOrNull() ?? this.epgProgram() ?? null + ); +``` + +Constructor (add to the existing constructor body, or create one if the class has none): + +```ts + this.guideSource.bind({ + channels: this.guideChannels, + favoriteIds: this.store.selectSignal(selectFavorites), + activeChannel: this.activeChannel, + }); + effect(() => { + if (!this.canOpenGuide()) { + untracked(() => this.closeGuide()); + } + }); +``` + +Methods — replace `openMultiEpgView()` entirely with: + +```ts + openGuide(): void { + if (!this.canOpenGuide() || this.guideOpen()) { + return; + } + this.guideSource.applyInitialScope(this.activeView()); + this.guideOpen.set(true); + } + + closeGuide(): void { + this.guideOpen.set(false); + } + + toggleGuide(): void { + if (this.guideOpen()) { + this.closeGuide(); + } else { + this.openGuide(); + } + } + + setGuideDockCollapsed(collapsed: boolean): void { + this.guideDockCollapsed.set(collapsed); + persistEpgGuideDockCollapsed(collapsed); + } + + @HostListener('document:fullscreenchange') + onFullscreenChange(): void { + if (this.guideOpen() && this.isLivePlayerFullscreen()) { + this.closeGuide(); + } + } +``` + +`handleKeyPress` — after the `[inert]` check and before the Cmd/Ctrl+B branch insert: + +```ts + const isGuideKey = + event.key.toLowerCase() === 'g' && + !event.metaKey && + !event.ctrlKey && + !event.altKey; + if (this.guideOpen()) { + // The guide owns the keyboard while open; only G toggles it off. + if (isGuideKey) { + event.preventDefault(); + this.closeGuide(); + } + return; + } + if (isGuideKey && this.canOpenGuide()) { + event.preventDefault(); + this.openGuide(); + return; + } +``` + +`registerHeaderShortcut` — replace the `setAction` payload: + +```ts + this.workspaceHeaderContext.setAction({ + id: M3U_EPG_GUIDE_HEADER_ACTION_ID, + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + active: () => this.guideOpen(), + palette: { + labelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION', + keywords: ['epg', 'guide', 'schedule'], + priority: 10, + }, + run: () => this.toggleGuide(), + }); +``` + +`video-player.component.html`: + +- Wrap the whole `` in `@if (!guideOpen()) { … }`. +- `
` → `
`. +- The `sidebar-restore` `@if (isSidebarCollapsed())` → `@if (isSidebarCollapsed() && !guideOpen())`. +- Inside `
`, after the `@if (radio) … @else … app-web-player-view` block (still inside `.video-player`), add: + +```html + @if (guideOpen()) { + + } +``` + +- Directly after that `.video-player` block's closing `}` (the `@if (radio || inline)` branch), add the external-player variant of the strip: + +```html + @if (guideOpen() && !shouldShowInlinePlayer(activeChannel)) { +
+ +
+ } +``` + +- Replace `@if (supportsEpg && activeChannel.radio !== 'true') {` … `}` (the EPG block) with: + +```html + @if (guideOpen()) { + + } @else if (supportsEpg && activeChannel.radio !== 'true') { + … existing `.epg` block unchanged … + } +``` + +- On `` add `[guideAvailable]="canOpenGuide()"` and `(openGuide)="openGuide()"`. + +`video-player.component.scss` — append: + +```scss +// ─── Guide mode ───────────────────────────────────────────────────────────── +// The player keeps its DOM position and is only re-flowed into a docked strip; +// no remount, so playback (and native-view Embedded MPV bounds) survive. +.content-container.is-guide { + .video-player { + flex: 0 0 auto; + height: 128px; + display: flex; + align-items: stretch; + gap: 18px; + padding: 14px 18px; + box-sizing: border-box; + background: var(--app-widget-bg); + border-bottom: 1px solid var(--app-separator); + + > app-web-player-view { + flex: 0 0 auto; + height: 100%; + aspect-ratio: 16 / 9; + border-radius: 9px; + overflow: hidden; + background: #000; + } + + > app-epg-guide-now-playing { + flex: 1; + min-width: 0; + } + } + + .guide-dock--external { + height: 48px; + padding: 6px 18px; + } + + .epg-guide-host { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } + + &.is-guide-collapsed .video-player { + height: 48px; + padding: 6px 18px; + + > app-web-player-view { + display: none; + } + } +} +``` + +- [ ] **Step 5: Palette and facade spec renames** + +`workspace-shell.facade.spec.ts:880-925`: rename the test to `'includes M3U navigation, playlist actions, and the programme guide on playlist routes'`, replace `'m3u-multi-epg'` → `'m3u-epg-guide'` (3 places), `'view_list'` → `'grid_view'`, `'TOP_MENU.OPEN_MULTI_EPG'` → `'TOP_MENU.OPEN_EPG_GUIDE'` (2), `'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION'` → `'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION'`. + +`workspace-command-palette.component.spec.ts`: replace fixture id `'multi-epg'` → `'epg-guide'` (4 places), label `'Open Multi-EPG'` → `'Open programme guide'`, icon `'view_list'` → `'grid_view'`. + +- [ ] **Step 6: Run host, shell and lint** + +Run: +```bash +NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +pnpm nx test workspace-shell-feature +pnpm nx lint playlist-m3u-feature-player workspace-shell-feature portal-shared-util +pnpm run typecheck:ci +``` +(`pnpm nx show projects | grep shell` gives the exact shell project name if it differs.) Expected: all PASS; lint clean. + +- [ ] **Step 7: Commit** + +```bash +git add libs/portal/shared/util/src/lib/workspace-header-context.service.ts libs/workspace/shell libs/playlist/m3u/feature-player +git commit -m "feat(m3u): open the programme guide in place with a docked player + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 10: Remove the old multi-EPG overlay and its plumbing + +**Files:** +- Delete: `libs/ui/epg/src/lib/multi-epg/` (3 files) +- Modify: `libs/ui/epg/src/index.ts` (drop the two `multi-epg` exports) +- Modify: `apps/electron-backend/src/app/events/epg-query.service.ts:404-451` (delete `getChannelsByRange`) +- Modify: `apps/electron-backend/src/app/events/epg-query.service.spec.ts` (delete any `getChannelsByRange` case; `grep -n getChannelsByRange`) +- Modify: `apps/electron-backend-e2e/src/epg.e2e.ts:719-724` +- Modify: `apps/web/src/styles.scss:106-108` +- Modify: `libs/ui/components/src/lib/window-controls/window-controls.component.ts:17-24,91-95` +- Modify: `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts:81-83` +- Modify: `tools/eslint/max-lines-baseline.mjs` (regenerated) + +- [ ] **Step 1: Delete the component and its exports** + +```bash +git rm -r libs/ui/epg/src/lib/multi-epg +``` + +Remove from `libs/ui/epg/src/index.ts`: +```ts +export * from './lib/multi-epg/multi-epg-container.component'; +export * from './lib/multi-epg/overlay-ref.token'; +``` + +- [ ] **Step 2: Delete the backend range query** + +In `epg-query.service.ts` delete the whole `getChannelsByRange` method (the block starting `async getChannelsByRange(` through its closing brace, ~48 lines). Delete its spec cases if any exist. + +- [ ] **Step 3: E2E helper** + +In `apps/electron-backend-e2e/src/epg.e2e.ts` replace `getEpgChannelCount`: + +```ts +async function getEpgChannelCount(page: Page): Promise { + return page.evaluate(async () => { + const result = await window.electron?.getEpgChannels?.(); + return Array.isArray(result?.channels) ? result.channels.length : 0; + }); +} +``` + +- [ ] **Step 4: Comments and styles that described the overlay** + +`apps/web/src/styles.scss`: delete the `body.frameless-platform #epg-navigation { … }` rule (lines 106-108). + +`window-controls.component.ts:17-24`: replace "above full-window content such as the multi-EPG overlay and dialog backdrops" with "above full-window content such as dialog backdrops"; line 94: replace "(dialogs, multi-EPG)" with "(dialogs, menus)". + +`epg-item-description.component.ts:81-83`: replace "the multi-EPG grid and its search" with "the programme guide grid and its search". + +- [ ] **Step 5: Regenerate the max-lines baseline** + +```bash +node tools/eslint/generate-max-lines-baseline.mjs +git diff --stat tools/eslint/max-lines-baseline.mjs +``` +Expected: exactly one line removed (`libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts`); no additions. If the generator lists any new file, split that file instead of committing the baseline. + +- [ ] **Step 6: Verify nothing references the old names** + +```bash +grep -rn 'MultiEpg\|multi-epg\|COMPONENT_OVERLAY_REF\|getChannelsByRange\|EPG_GET_CHANNELS_BY_RANGE\|supportsChannelBrowser\|OPEN_MULTI_EPG\|epg-navigation' apps libs tools --include='*.ts' --include='*.html' --include='*.scss' --include='*.mjs' --include='*.json' | grep -v node_modules +``` +Expected: only i18n JSON hits for `OPEN_MULTI_EPG` in the non-English locales (removed in Task 11) and nothing else. + +- [ ] **Step 7: Typecheck, lint, tests** + +```bash +pnpm run typecheck:ci +pnpm nx lint ui-epg electron-backend web +pnpm nx test ui-epg +pnpm nx test electron-backend --testPathPatterns='epg' +``` +Expected: clean / PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(epg): remove the multi-EPG overlay and the channel-range IPC + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 11: Translations for all 19 locales + +**Files:** +- Modify: `apps/web/src/assets/i18n/*.json` (18 non-English locales) + +- [ ] **Step 1: Add the new keys to every locale** + +Invoke the repository skill `i18n-fill` (`Skill: i18n-fill`) and ask it to sync the gap for the keys added in Task 6 (and Task 8's scope labels if you added `EPG.GUIDE.SCOPE_ALL`/`SCOPE_FAVORITES`): `EPG.GUIDE.*`, `EPG.TIMELINE.GUIDE`, `EPG.TIMELINE.OPEN_GUIDE`, `TOP_MENU.OPEN_EPG_GUIDE`, `WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION`. The merger only ADDS keys, so existing ones are untouched. + +- [ ] **Step 2: Remove the two retired keys from every locale** + +Write the removal as a one-off script in the scratchpad and run it: + +```js +// scratchpad/remove-old-multi-epg-keys.mjs +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +const dir = 'apps/web/src/assets/i18n'; +for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) { + const path = `${dir}/${file}`; + const json = JSON.parse(readFileSync(path, 'utf8')); + delete json.TOP_MENU?.OPEN_MULTI_EPG; + delete json.WORKSPACE?.SHELL?.COMMANDS?.OPEN_MULTI_EPG_DESCRIPTION; + writeFileSync(path, JSON.stringify(json, null, 4) + '\n'); +} +``` + +Run: `node /remove-old-multi-epg-keys.mjs` and then `git diff --stat apps/web/src/assets/i18n` — every file should show only the intended key changes (compare the JSON indentation with `git diff` on `en.json`; if the repo uses a different indent, match it before committing). + +- [ ] **Step 3: Verify** + +```bash +grep -rn 'OPEN_MULTI_EPG' apps/web/src/assets/i18n | wc -l # expected: 0 +grep -c '"GUIDE": {' apps/web/src/assets/i18n/*.json # expected: 1 per file (19 lines) +pnpm nx test web --testPathPatterns=i18n 2>/dev/null || true # if an i18n consistency spec exists it must pass +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/assets/i18n +git commit -m "i18n(epg): translate the programme guide + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 12: Electron E2E for the guide + +**Files:** +- Create: `apps/electron-backend-e2e/src/epg-guide.e2e.ts` + +- [ ] **Step 1: Write the test** + +```ts +// apps/electron-backend-e2e/src/epg-guide.e2e.ts +import { + buildM3uContent, + channelItemByTitle, + closeElectronApp, + createMutableTextServer, + expect, + importM3uPlaylistFromUrl, + launchElectronApp, + openWorkspaceSection, + test, +} from './electron-test-fixtures'; + +function formatXmltvDate(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0'); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + ].join(''); +} + +function xmltvWithCurrentProgramme( + channelId: string, + channelName: string, + title: string +): string { + const start = new Date(Date.now() - 15 * 60 * 1000); + const stop = new Date(Date.now() + 45 * 60 * 1000); + return ` + + ${channelName} + + ${title} + Guide E2E programme. + + +`; +} + +test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({ + dataDir, +}) => { + test.setTimeout(120000); + const epgServer = await createMutableTextServer( + xmltvWithCurrentProgramme('guide-news', 'Guide News', 'Guide Bulletin Now'), + { contentType: 'application/xml; charset=utf-8', resourcePath: '/guide.xml' } + ); + const playlistServer = await createMutableTextServer( + buildM3uContent([ + { name: 'Guide News', tvgId: 'guide-news', url: 'https://example.com/live/guide-news.m3u8' }, + { name: 'Guide Silent', tvgId: 'guide-silent', url: 'https://example.com/live/guide-silent.m3u8' }, + ]).replace('#EXTM3U', `#EXTM3U x-tvg-url="${epgServer.resourceUrl}"`), + { contentType: 'application/x-mpegurl; charset=utf-8', resourcePath: '/guide-playlist.m3u' } + ); + const app = await launchElectronApp(dataDir); + + try { + await app.electronApp.evaluate(({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1600, 1000); + }); + // The demo stream URLs are unreachable; the player still mounts. + await app.mainWindow.route('https://example.com/**', (route) => route.abort()); + + await importM3uPlaylistFromUrl(app.mainWindow, playlistServer.resourceUrl); + await expect( + app.mainWindow.locator('.epg-progress-panel .import-item.status-complete') + ).toHaveCount(1, { timeout: 30000 }); + + await openWorkspaceSection(app.mainWindow, 'All channels'); + const newsRow = channelItemByTitle(app.mainWindow, 'Guide News'); + await expect(newsRow).toBeVisible(); + await newsRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + await expect(timeline).toBeVisible({ timeout: 20000 }); + await app.mainWindow.evaluate(() => + document.querySelector('app-web-player-view')?.setAttribute('data-e2e-guide-marker', 'kept') + ); + + // Entry point: the Guide button in the timeline toolbar. + await timeline.locator('.epg-timeline__guide').click(); + const guide = app.mainWindow.locator('app-epg-guide'); + await expect(guide).toBeVisible(); + await expect(app.mainWindow.locator('app-sidebar')).toHaveCount(0); + await expect(timeline).toHaveCount(0); + + // Rows are the playlist's channels, in order, with the playing row marked. + const rows = guide.locator('app-epg-guide-row'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0).locator('.epg-guide-row__name b')).toHaveText('Guide News'); + await expect(rows.nth(1).locator('.epg-guide-row__name b')).toHaveText('Guide Silent'); + await expect(rows.nth(0)).toHaveClass(/is-active/); + await expect(rows.nth(0).locator('.epg-guide-row__block.is-now')).toContainText('Guide Bulletin Now', { timeout: 20000 }); + await expect(rows.nth(1).locator('.epg-guide-row__empty')).toBeVisible({ timeout: 20000 }); + + // "Only with EPG" hides the silent channel once coverage is known. + const toggle = guide.locator('.guide-toolbar__toggle input'); + await expect(toggle).toBeEnabled({ timeout: 20000 }); + await guide.locator('.guide-toolbar__toggle').click(); + await expect(rows).toHaveCount(1); + await guide.locator('.guide-toolbar__toggle').click(); + await expect(rows).toHaveCount(2); + + // Clicking a channel switches playback without closing the guide. + await rows.nth(1).locator('.epg-guide-row__channel').click(); + await expect(rows.nth(1)).toHaveClass(/is-active/); + await expect(rows.nth(0)).not.toHaveClass(/is-active/); + await expect(guide).toBeVisible(); + await expect(app.mainWindow.locator('app-epg-guide-now-playing')).toContainText('Guide Silent'); + + // The player element survived both the mode switch and the channel switch. + await expect( + app.mainWindow.locator('app-web-player-view[data-e2e-guide-marker="kept"]') + ).toHaveCount(1); + + // Escape closes the guide and restores the sidebar and timeline. + await app.mainWindow.keyboard.press('Escape'); + await expect(guide).toHaveCount(0); + await expect(app.mainWindow.locator('app-sidebar')).toBeVisible(); + await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible(); + await expect( + app.mainWindow.locator('app-web-player-view[data-e2e-guide-marker="kept"]') + ).toHaveCount(1); + + // G reopens it; the header shortcut is highlighted while open. + await app.mainWindow.keyboard.press('g'); + await expect(guide).toBeVisible(); + await expect(app.mainWindow.locator('.header-shortcut.is-active')).toHaveCount(1); + } finally { + await closeElectronApp(app); + await playlistServer.close(); + await epgServer.close(); + } +}); +``` + +Note: a channel switch through `ChannelActions.setActiveChannel` with an +inline player keeps `app-web-player-view` mounted (the marker assertion +proves it); if the marker disappears, the host template moved the player +inside an `@if` that toggles — fix the template, not the test. + +- [ ] **Step 2: Run the test** + +Run: `pnpm nx run electron-backend-e2e:e2e-ci--src/epg-guide.e2e.ts` +(If the atomized target is not generated for the new file, run `pnpm nx reset` first, then retry; the target list comes from the file system.) +Expected: 1 passed. Ports 3210/3211/4200 are shared across worktrees — make sure no other E2E run is active. + +- [ ] **Step 3: Commit** + +```bash +git add apps/electron-backend-e2e/src/epg-guide.e2e.ts +git commit -m "test(e2e): cover the programme guide flow + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 13: Docs, CLAUDE.md, spec touch-ups and release note + +**Files:** +- Modify: `docs/architecture/m3u-playlist-module.md:700-712,1060-1075` +- Modify: `docs/architecture/workspace-shell.md:265-272,400-410` +- Modify: `docs/architecture/pwa-self-hosted.md:114-116` +- Modify: `CLAUDE.md` (`ui/epg` bullet in Monorepo Structure; the EPG display-offset bullet under "EPG (Electronic Program Guide)") +- Modify: `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md` (two deviations) +- Create: `.changes/epg-programme-guide.md` + +- [ ] **Step 1: m3u-playlist-module.md** + +Replace the bullet `- Multi-EPG modal view` (line ~1071) with `- Programme guide (multi-channel grid) in guide mode — see "Programme guide" below`, and add a section after "Archive / Catch-Up Playback": + +```markdown +### 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)`. +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. Density +(`epg-guide:density`, comfortable 60 px / compact 44 px), zoom +(`epg-guide:zoom`, 120–480 px per hour) 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`. +``` + +Line ~706: "the programme dialog and the multi-EPG overlay are opened imperatively" → "the programme dialog and the programme guide are opened imperatively". + +- [ ] **Step 2: workspace-shell.md and pwa-self-hosted.md** + +`workspace-shell.md:268`: "above full-window content such as the multi-EPG cdk overlay and Material dialog backdrops" → "above full-window content such as Material dialog backdrops". +`workspace-shell.md:405`: "top-aligned drag regions (`.workspace-header`, multi-EPG `#epg-navigation`)" → "the top-aligned drag region (`.workspace-header`)". +`pwa-self-hosted.md:115`: "multi-EPG shortcuts" → "programme-guide entry points". + +- [ ] **Step 3: CLAUDE.md** + +- Monorepo Structure: `- **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)`. +- EPG section bullet on the display offset: "(the programme dialog and multi-EPG overlay read the store themselves)" → "(the programme dialog and the programme guide read the store themselves)". +- Add to the EPG section a bullet: + `- 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").` +- Also grep `CLAUDE.md` for `getEpgChannelsByRange`/`multi-EPG` and fix every remaining mention. + +- [ ] **Step 4: Spec deviations** + +In `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md`, "M3U Host" bullet 3: replace "`loadPrograms`/`loadCoverage` call the new bridge methods with the playlist's `sourceUrls`, as `EpgService.getCurrentProgramsForChannels` does, so playlist-scoped EPG wins." with "`loadPrograms`/`loadCoverage` call the new bridge methods unscoped (every imported EPG source), exactly like the timeline's `getChannelPrograms`; `sourceUrls` stays in the IPC contract for portal hosts." and "`searchPrograms` uses `EPG_SEARCH_PROGRAMS` and filters results to the scope's resolved channel ids." with "`searchPrograms` uses `EPG_SEARCH_PROGRAMS` unfiltered (result click opens the programme dialog, as the previous overlay did)." + +- [ ] **Step 5: Release note** + +```markdown + +--- +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. Clicking a channel switches +playback while the player stays on screen above the grid; double-click plays +and closes. Open it from the new Guide button under the player, the header, the +command palette or the G key. Hide channels without EPG, pick comfortable or +compact rows, and navigate with the keyboard. +``` + +Run: `pnpm run release:notes:validate` — expected: OK. + +- [ ] **Step 6: Commit** + +```bash +git add docs CLAUDE.md .changes/epg-programme-guide.md +git commit -m "docs(epg): document the programme guide and its release note + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 14: Final validation + +- [ ] **Step 1: Unit suites and lint for every touched project** + +```bash +pnpm run typecheck:ci +pnpm nx run-many -t lint -p ui-epg playlist-m3u-feature-player epg-data-access services electron-backend web workspace-shell-feature portal-shared-util +pnpm nx run-many -t test -p ui-epg playlist-m3u-feature-player epg-data-access services workspace-shell-feature +pnpm nx test electron-backend +pnpm run release:notes:validate +``` +Expected: all green. `git status` must show no untracked files. + +- [ ] **Step 2: E2E** + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/epg-guide.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/epg.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/epg-timeline-interaction.e2e.ts +``` +Expected: all passed. + +- [ ] **Step 3: Manual CDP check (Embedded MPV native-view bounds)** + +```bash +pnpm run serve:backend +``` +In the app: Settings → Playback → Embedded MPV (native-view); open an M3U playlist with a working stream; press `G`; confirm the video is confined to the 16:9 thumb in the docked strip, that "Collapse player" hides it and audio continues, that resizing the window keeps the thumb aligned, and that Esc restores the full player. Capture `agent-browser --cdp 9222 screenshot` of the open guide for the PR. + +- [ ] **Step 4: Open the PR** + +Push the branch and open a PR against `master` titled `feat(epg): rebuild the programme guide for M3U playlists`, body: what changed (issue #171 items, guide mode, new IPCs, removals), test evidence (commands above), screenshots, and the follow-ups: portal hosts (sub-project 2), `multi-epg-view.webp` recapture at release, M3U catch-up wiring. End the body with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +--- + +## Self-review notes + +- Spec coverage: contract (T3), guide component incl. toolbar/row/now-playing/keyboard/density/zoom/persistence (T4–T6), timeline button (T7), M3U adapter + scopes (T8), host guide mode + four entry points + close rules (T9), backend IPCs + capability + removals (T1, T2, T10), i18n (T6, T11), E2E (T12), docs + release note (T13), validation (T14). +- Deviations from the spec are recorded in T13 Step 4 (unscoped queries; unfiltered search). +- Type consistency: `EpgGuideChannel/Scope/Window/Source` (T3) are used unchanged in T4, T6, T8; `EpgGuideRowStatus` comes from T4 and is consumed by T6's row; `TimelineRenderBlock` is the existing timeline type reused for guide blocks; `ElectronBridgeEpgGuideWindow` (T2) is the wire shape for both IPCs and matches `EpgGuideWindowRequest` (T1) field-for-field. 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..1c63cfb73 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md @@ -0,0 +1,337 @@ +# 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 hides the sidebar (it stays mounted, +`inert`, because destroying the channel list resets the active channel) and +drops 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: readonly 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 + // EpgGuideSearchHit = { channelId: string | null; program: EpgProgram } — the host + // supplies the row id when it can resolve one, so a hit can be highlighted later + 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 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 + 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 is hidden (`sidebar--guide-hidden` + + `inert`, still mounted) and the `.epg` block is 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; 48 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 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. + 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. +- 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 + `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 `
} @@ -163,7 +177,9 @@ {{ 'EPG.TIMELINE.ON_NOW' | translate }} - {{ now.program.title }} + {{ + now.program.title + }} @if (now.progress !== null) {
-
- - @for (item of filteredChannels(); track item.id; let i = $index) { - - - - - -
- @if (getChannelIcon(item)) { - - } - {{ getChannelName(item) }} -
-
-
-
- } -
-
- - - @for (hour of timeHeader; track hour) { - - - - {{ hour }}:00 - - - - - } - - - @for (item of filteredChannels(); track item.id; let i = $index) { - - @for (program of item.programs; track trackByProgram($index, program)) { - - - -
{{ program.title }}
-
-
- } -
- } - - - - - - {{ currentTimeLabel() }} - -
-
-
- - -@if (filteredChannels().length === 0 && channelFilter()) { -
- search_off -

No channels match "{{ channelFilter() }}"

- -
-} diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss deleted file mode 100644 index 94bf9af24..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ /dev/null @@ -1,916 +0,0 @@ -// Color palette — every value resolves through the app's theme tokens so -// Multi-EPG looks like part of IPTVnator instead of a separate broadcast -// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid -// switch to near-black while the rest of the app sat on --app-content-bg, -// which the UX audit called out as the screen "visually detaching" from -// the rest of the app. -$bg-deep: var(--app-content-bg); -$bg-surface: var(--app-widget-bg); -$bg-elevated: var(--app-widget-header-bg); -$bg-hover: var(--app-card-hover-bg); -$border-subtle: var(--app-widget-header-border); -$border-medium: var(--app-separator); - -// Accent colors — unified with the rest of the app: -// cyan stays on the "now" indicator only; blue is the primary; the old -// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit. -$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator -$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line -$accent-secondary: var(--app-selection-color); // Blue — programs -$accent-tertiary: var(--app-selection-color); // Blue — highlights -$accent-warning: #fbbf24; - -// Text colors — derived from app heading/body so light and dark themes -// stay in sync without a separate `.light-theme` override at the bottom -// (which was duplicating responsibility with the same token chain). -$text-primary: var(--app-heading-color); -$text-secondary: var(--app-body-color); -$text-muted: var(--app-muted-color); - -// Gradients -$gradient-program: linear-gradient( - 135deg, - rgba(79, 142, 255, 0.15) 0%, - rgba(79, 142, 255, 0.05) 100% -); -$gradient-program-hover: linear-gradient( - 135deg, - rgba(79, 142, 255, 0.3) 0%, - rgba(79, 142, 255, 0.15) 100% -); -$gradient-header: linear-gradient( - 180deg, - rgba(92, 214, 255, 0.08) 0%, - transparent 100% -); - -// Base container -.parent { - display: flex; - background: $bg-deep; - color: $text-primary; - font-family: Roboto, 'Helvetica Neue', sans-serif; - position: relative; - overflow-y: auto; - height: calc(100vh - 56px); - - // Custom scrollbar for vertical scroll - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background: $bg-surface; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.4); - border-radius: 4px; - - &:hover { - background: rgba(47, 123, 255, 0.6); - } - } -} - -// Navigation toolbar - Frosted glass effect -#epg-navigation { - display: flex; - align-items: center; - padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights - background: rgba(18, 18, 26, 0.85); - backdrop-filter: blur(20px); - border-bottom: 1px solid $border-medium; - gap: 8px; - position: relative; - z-index: 100; - -webkit-app-region: drag; - - // Subtle glow at bottom - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(92, 214, 255, 0.3) 20%, - rgba(47, 123, 255, 0.3) 80%, - transparent 100% - ); - } - - // Button styling - exclude from drag region - button { - font-family: Roboto, 'Helvetica Neue', sans-serif; - font-weight: 500; - letter-spacing: 0.02em; - transition: all 0.2s ease; - -webkit-app-region: no-drag; - - &:not([disabled]):hover { - background: rgba(47, 123, 255, 0.15); - } - - &[disabled] { - opacity: 0.3; - } - - mat-icon { - font-size: 20px; - } - } -} - -// Search container - exclude from drag region -.search-container { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; - -webkit-app-region: no-drag; - - .channel-search { - display: flex; - align-items: center; - gap: 8px; - width: 200px; - height: 36px; - padding: 0 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid $border-subtle; - border-radius: 8px; - transition: all 0.2s ease; - - &:focus-within { - border-color: rgba(47, 123, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - } - - .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: $text-muted; - flex-shrink: 0; - } - - input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 13px; - font-family: Roboto, 'Helvetica Neue', sans-serif; - color: $text-primary; - min-width: 0; - - &::placeholder { - color: $text-muted; - } - } - - .clear-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 50%; - cursor: pointer; - color: $text-muted; - transition: all 0.15s ease; - flex-shrink: 0; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: $text-primary; - } - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - } - - .filter-count { - font-size: 11px; - color: $text-muted; - font-family: 'JetBrains Mono', monospace; - white-space: nowrap; - } - - &.expanded { - .channel-search { - animation: slideIn 0.2s ease-out; - } - } -} - -@keyframes slideIn { - from { - opacity: 0; - width: 0; - transform: translateX(20px); - } - to { - opacity: 1; - width: 200px; - transform: translateX(0); - } -} - -// Program search container -.program-search-container { - display: flex; - align-items: center; - gap: 8px; - position: relative; - -webkit-app-region: no-drag; - - .program-search-input { - display: flex; - align-items: center; - gap: 8px; - width: 220px; - height: 36px; - padding: 0 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid $border-subtle; - border-radius: 8px; - transition: all 0.2s ease; - - &:focus-within { - border-color: rgba(47, 123, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - } - - .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: $accent-secondary; - flex-shrink: 0; - } - - input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 13px; - font-family: Roboto, 'Helvetica Neue', sans-serif; - color: $text-primary; - min-width: 0; - - &::placeholder { - color: $text-muted; - } - } - - .clear-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 50%; - cursor: pointer; - color: $text-muted; - transition: all 0.15s ease; - flex-shrink: 0; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: $text-primary; - } - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - - .search-spinner { - width: 16px; - height: 16px; - border: 2px solid $border-subtle; - border-top-color: $accent-secondary; - border-radius: 50%; - animation: spin 0.8s linear infinite; - flex-shrink: 0; - } - } - - .program-search-results { - position: fixed; - top: 60px; - right: 16px; - width: 320px; - max-height: 400px; - overflow-y: auto; - background: $bg-elevated; - border: 1px solid $border-medium; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 10000; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.3); - border-radius: 3px; - } - - .search-result-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 12px 16px; - background: transparent; - border: none; - border-bottom: 1px solid $border-subtle; - cursor: pointer; - text-align: left; - transition: background 0.15s ease; - - &:last-child { - border-bottom: none; - } - - &:hover { - background: $bg-hover; - - .result-arrow { - opacity: 1; - transform: translateX(0); - } - } - - .result-info { - display: flex; - flex-direction: column; - gap: 4px; - overflow: hidden; - } - - .result-title { - font-size: 13px; - font-weight: 500; - color: $text-primary; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .result-meta { - display: flex; - gap: 8px; - font-size: 11px; - color: $text-muted; - } - - .result-time { - color: $accent-primary; - font-family: 'JetBrains Mono', monospace; - } - - .result-date { - color: $text-muted; - } - - .result-arrow { - font-size: 18px; - width: 18px; - height: 18px; - color: $accent-secondary; - opacity: 0; - transform: translateX(-4px); - transition: all 0.15s ease; - } - } - } - - .program-search-empty { - position: fixed; - top: 60px; - right: 16px; - width: 220px; - padding: 24px 16px; - background: $bg-elevated; - border: 1px solid $border-medium; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 10000; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - color: $text-muted; - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - opacity: 0.5; - } - - span { - font-size: 12px; - } - } - - &.open { - .program-search-input { - animation: slideIn 0.2s ease-out; - } - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes highlightPulse { - 0%, 100% { - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)); - } - 50% { - filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9)) - drop-shadow(0 0 40px rgba(92, 214, 255, 0.4)); - } -} - -// No results state -.no-results { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - color: $text-muted; - text-align: center; - z-index: 100; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - opacity: 0.5; - } - - p { - margin: 0; - font-size: 14px; - } - - button { - color: $accent-primary; - } -} - -// Today's date display -.today-date { - flex: 1; - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 500; - color: $accent-primary; - text-align: center; - padding: 0 24px; - letter-spacing: 0.05em; - text-transform: uppercase; - - // Glowing text effect - text-shadow: 0 0 20px rgba(92, 214, 255, 0.3); -} - -// Channel column — bumped from 140px to 180px per the UX audit so long -// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being -// truncated to 5 characters. -#channels-column { - width: 180px; - flex: none; - border-right: 1px solid $border-medium; - background: $bg-surface; - position: relative; - z-index: 10; - min-height: calc(100vh - 6px); - - // Subtle shadow for depth - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3); - - .channel { - cursor: pointer; - transition: all 0.15s ease; - - rect { - fill: $bg-surface; - stroke: $border-subtle; - transition: fill 0.15s ease; - } - - &:hover rect { - fill: $bg-elevated; - } - - &.active rect { - fill: rgba(47, 123, 255, 0.2); - stroke: rgba(47, 123, 255, 0.4); - } - - &.active .channel-name { - color: $accent-secondary; - font-weight: 600; - } - } - - .channel-name { - color: $text-primary; - padding: 8px 12px; - font-size: 12.5px; - font-weight: 500; - display: flex; - align-items: center; - gap: 10px; - height: 100%; - overflow: hidden; - letter-spacing: 0.01em; - line-height: 1.2; - - img { - // Smaller logo (24px) per audit recommendation so the channel - // name has room to breathe in the 180px column. - width: 24px !important; - height: 24px !important; - margin-right: 0 !important; - flex: 0 0 auto; - border-radius: 4px; - background: rgba(255, 255, 255, 0.05); - padding: 2px; - object-fit: contain; - } - - span { - // Allow up to two lines so "13th Street Universal HD" and - // similar long names wrap instead of disappearing into an - // ellipsis after 5 characters. - min-width: 0; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - word-break: break-word; - } - } - - image { - object-fit: contain; - background: $bg-elevated; - } -} - -// EPG container -#epg-container { - width: calc(100vw - 180px); - overflow-x: scroll; - overflow-y: clip; - background: $bg-deep; - min-height: calc(100vh - 6px); - - // Custom scrollbar - &::-webkit-scrollbar { - height: 8px; - } - - &::-webkit-scrollbar-track { - background: $bg-surface; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.4); - border-radius: 4px; - - &:hover { - background: rgba(47, 123, 255, 0.6); - } - } -} - -// EPG SVG container -#epg-svg { - // Time header styling - g > rect[fill='#000'] { - fill: $bg-surface !important; - } - - g > text { - font-family: 'JetBrains Mono', monospace !important; - font-size: 11px !important; - font-weight: 500; - fill: $text-secondary !important; - letter-spacing: 0.1em; - } - - // Hour separator vertical lines - .hour-separator { - stroke: $border-subtle; - stroke-width: 1px; - pointer-events: none; - } - - // Program blocks - .program { - cursor: pointer; - - rect { - fill: $bg-elevated; - stroke: $border-subtle; - rx: 4; - ry: 4; - transition: all 0.15s ease; - } - - &:hover rect { - fill: $bg-hover; - stroke: rgba(47, 123, 255, 0.4); - filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2)); - } - - // Highlighted program (from search) - &.highlighted rect { - fill: rgba(92, 214, 255, 0.25); - stroke: $accent-primary; - stroke-width: 2px; - animation: highlightPulse 1s ease-in-out 3; - } - - &.highlighted .program-title { - color: $accent-primary; - font-weight: 600; - } - } - - // Active channel row highlighting - .active-row .program rect { - fill: rgba(47, 123, 255, 0.15); - stroke: rgba(47, 123, 255, 0.3); - } - - .active-row .program:hover rect { - fill: rgba(47, 123, 255, 0.25); - } - - .program-title { - color: $text-primary; - padding: 6px 10px; - font-size: 11px; - font-weight: 400; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - letter-spacing: 0.01em; - line-height: 1.4; - - // Subtle gradient text for depth - display: flex; - align-items: center; - height: 100%; - } - - // Current time line - glowing effect - line[stroke='red'] { - stroke: $accent-primary !important; - stroke-width: 2px; - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) - drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); - - // Animated pulse - animation: timePulse 2s ease-in-out infinite; - } - - // Now-line time badge — a small filled pill sitting on top of the time - // header that reads "HH:MM". Keeps the cyan visual language of the line - // itself so the relationship is unmistakable. - .now-label { - pointer-events: none; - - rect { - fill: $accent-primary; - stroke: none; - filter: drop-shadow(0 0 6px $accent-now-glow); - } - - text { - fill: var(--app-shell-bg, #0d0f12); - font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, - monospace; - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.04em; - } - } - - // Currently-airing program — a thin cyan border on every program block - // whose time window contains the now-line. Audit recommended this to - // make "what's airing right now" scannable without finding the line - // first then tracing it down each row. - .program.is-now { - rect { - stroke: $accent-primary; - stroke-width: 1.5px; - } - } -} - -// Pulse animation for time line -@keyframes timePulse { - 0%, - 100% { - opacity: 1; - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) - drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); - } - 50% { - opacity: 0.8; - filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8)) - drop-shadow(0 0 24px rgba(92, 214, 255, 0.4)); - } -} - -// Legacy classes cleanup -.program-item { - cursor: pointer; -} - -rect { - stroke: $border-subtle; - cursor: pointer; -} - -.channel > rect { - stroke: $border-subtle; -} - -.program-name, -.channel-name { - -webkit-line-clamp: 2; - height: 32px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-box-orient: vertical; - padding: 6px 8px; -} - -#current-time-line { - stroke-width: 2px; - stroke: $accent-primary; -} - -// Dark theme override (already dark, but ensure consistency) -::ng-deep { - .dark-theme { - #epg-navigation { - background: rgba(18, 18, 26, 0.85); - } - } - - // Light theme adjustments - .light-theme { - #epg-navigation { - background: rgba(255, 255, 255, 0.9); - border-bottom-color: rgba(0, 0, 0, 0.1); - - .today-date { - color: #059669; - text-shadow: none; - } - } - - .parent { - background: #f8fafc; - - &::before { - background-image: - linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px), - linear-gradient( - 90deg, - rgba(0, 0, 0, 0.03) 1px, - transparent 1px - ); - } - } - - #channels-column { - background: #ffffff; - border-right-color: rgba(0, 0, 0, 0.1); - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05); - - .channel rect { - fill: #ffffff; - stroke: rgba(0, 0, 0, 0.08); - } - - .channel:hover rect { - fill: #f1f5f9; - } - - .channel-name { - color: #1e293b; - } - } - - #epg-container { - background: #f8fafc; - } - - #epg-svg { - g > rect[fill='#000'] { - fill: #e2e8f0 !important; - } - - g > text { - fill: #64748b !important; - } - - .program { - rect { - fill: #ffffff; - stroke: rgba(0, 0, 0, 0.08); - } - - &:hover rect { - fill: #f1f5f9; - stroke: rgba(47, 123, 255, 0.4); - } - } - - .program-title { - color: #1e293b; - } - - line[stroke='red'] { - stroke: $accent-primary !important; - filter: drop-shadow(0 0 6px $accent-now-glow); - } - } - } -} - -// Responsive adjustments -@media (max-width: 768px) { - #epg-navigation { - padding-left: 70px; // Slightly reduced for smaller screens - } - - #channels-column { - width: 140px; - - .channel-name { - font-size: 11.5px; - padding: 6px 8px; - gap: 8px; - - img { - width: 20px !important; - height: 20px !important; - } - } - } - - #epg-container { - width: calc(100vw - 140px); - } - - .today-date { - font-size: 12px; - padding: 0 12px; - } -} diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts deleted file mode 100644 index 9535f69b6..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OverlayRef } from '@angular/cdk/overlay'; -import { MatDialog } from '@angular/material/dialog'; -import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; -import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { SettingsStore } from '@iptvnator/services'; -import { - ElectronBridgeEpgChannelWithPrograms, - EpgProgram, -} from '@iptvnator/shared/interfaces'; -import { - MultiEpgContainerComponent, - isSelectedEpgDayToday, -} from './multi-epg-container.component'; -import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; - -describe('isSelectedEpgDayToday', () => { - it('returns true only when the selected EPG day is the actual current day', () => { - const now = new Date('2026-05-21T20:00:00.000Z'); - - expect(isSelectedEpgDayToday('20260521', now)).toBe(true); - expect(isSelectedEpgDayToday('20260520', now)).toBe(false); - expect(isSelectedEpgDayToday('20260522', now)).toBe(false); - }); -}); - -function programAt(start: Date, minutes: number, title: string): EpgProgram { - return { - start: start.toISOString(), - stop: new Date(start.getTime() + minutes * 60_000).toISOString(), - channel: 'c1', - title, - desc: null, - category: null, - }; -} - -describe('MultiEpgContainerComponent runtime gates', () => { - let fixture: ComponentFixture; - let component: MultiEpgContainerComponent; - let epgBridge: Partial; - const epgOffsetMinutes = signal(0); - - beforeEach(async () => { - epgOffsetMinutes.set(0); - epgBridge = { - getChannelsByRange: jest.fn().mockResolvedValue([]), - searchPrograms: jest.fn().mockResolvedValue([]), - supportsChannelBrowser: false, - supportsProgramSearch: false, - }; - - await TestBed.configureTestingModule({ - imports: [MultiEpgContainerComponent], - providers: [ - { provide: MatDialog, useValue: { open: jest.fn() } }, - { - provide: COMPONENT_OVERLAY_REF, - useValue: { detach: jest.fn() }, - }, - { - provide: OverlayRef, - useValue: { detach: jest.fn() }, - }, - { - provide: EpgRuntimeBridgeService, - useValue: epgBridge, - }, - { - provide: SettingsStore, - useValue: { resolvedEpgOffsetMinutes: epgOffsetMinutes }, - }, - { - provide: TranslateService, - useValue: { - currentLang: 'en', - defaultLang: 'en', - onLangChange: of(null), - }, - }, - ], - }) - .overrideComponent(MultiEpgContainerComponent, { - set: { template: '' }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(MultiEpgContainerComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - fixture.destroy(); - jest.restoreAllMocks(); - jest.useRealTimers(); - }); - - it('lays programmes out in display time when an EPG offset is set', () => { - const morning = new Date(); - morning.setHours(10, 0, 0, 0); - const late = new Date(); - late.setHours(23, 30, 0, 0); - component.originalEpgData.set([ - { - id: 'c1', - displayName: 'Channel 1', - programs: [ - programAt(morning, 60, 'Morning'), - programAt(late, 60, 'Late'), - ], - } as unknown as ElectronBridgeEpgChannelWithPrograms, - ]); - - epgOffsetMinutes.set(90); - - // 23:30 + 90 min lands on the next day and leaves today's column; - // 10:00 + 90 min is drawn under 11:30. - const programs = component.channels()[0].programs; - expect(programs.map((program) => program.title)).toEqual(['Morning']); - expect(programs[0].startPosition).toBe(11.5 * component.hourWidth()); - expect(programs[0].width).toBe(component.hourWidth()); - }); - - it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => { - jest.spyOn(console, 'warn').mockImplementation(() => undefined); - - await component.requestPrograms(); - - expect(epgBridge.getChannelsByRange).not.toHaveBeenCalled(); - expect(component.isLoading()).toBe(false); - }); - - it('requests EPG channel ranges through the EPG runtime bridge', async () => { - epgBridge.getChannelsByRange = jest.fn().mockResolvedValue([ - { - channel_id: 'channel-1', - display_name: 'Channel One', - programs: [], - }, - ]); - epgBridge.supportsChannelBrowser = true; - - await component.requestPrograms(); - - expect(epgBridge.getChannelsByRange).toHaveBeenCalledWith(0, 20); - expect(component.isLoading()).toBe(false); - }); - - it('does not search EPG programs when the EPG bridge cannot search programs', () => { - jest.useFakeTimers(); - - component.onProgramSearchInput({ - target: { value: 'news' }, - } as unknown as Event); - jest.advanceTimersByTime(600); - - expect(epgBridge.searchPrograms).not.toHaveBeenCalled(); - expect(component.isSearchingPrograms()).toBe(false); - expect(component.programSearchResults()).toEqual([]); - }); - - it('searches EPG programs through the EPG runtime bridge', async () => { - jest.useFakeTimers(); - const results = [ - { - channelId: 'channel-1', - start: '2026-05-22T10:00:00.000Z', - stop: '2026-05-22T11:00:00.000Z', - title: 'News', - }, - ]; - epgBridge.searchPrograms = jest.fn().mockResolvedValue(results); - epgBridge.supportsProgramSearch = true; - - component.onProgramSearchInput({ - target: { value: 'news' }, - } as unknown as Event); - jest.advanceTimersByTime(500); - await Promise.resolve(); - - expect(epgBridge.searchPrograms).toHaveBeenCalledWith('news', 20); - expect(component.programSearchResults()).toEqual(results); - expect(component.isSearchingPrograms()).toBe(false); - }); -}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts deleted file mode 100644 index a43449ce8..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { DatePipe } from '@angular/common'; -import { OverlayRef } from '@angular/cdk/overlay'; - -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - inject, - Input, - OnDestroy, - OnInit, - signal, - viewChild, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; -import { normalizeDateLocale } from '@iptvnator/pipes'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; -import { Observable, Subscription, startWith } from 'rxjs'; -import { - Channel, - ElectronBridgeEpgChannelWithPrograms, - EpgChannel, - epgDisplayTimeMs, - EpgProgram, -} from '@iptvnator/shared/interfaces'; -import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { SettingsStore } from '@iptvnator/services'; -import { getProgramTimeMs } from '../epg-program.utils'; -import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component'; -import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; - -type MultiEpgChannel = ElectronBridgeEpgChannelWithPrograms; - -interface EnrichedProgram extends EpgProgram { - startDate: Date; - stopDate: Date; - startPosition: number; - width: number; -} - -interface EnrichedChannel extends MultiEpgChannel { - programs: EnrichedProgram[]; -} - -interface ProgramSearchResult extends EpgProgram { - channel_id?: string; - channelName?: string | null; - channel_name?: string | null; - display_name?: string | null; -} - -export function isSelectedEpgDayToday( - selectedDay: string, - now: Date = new Date() -): boolean { - return selectedDay === format(now, 'yyyyMMdd'); -} - -@Component({ - imports: [DatePipe, MatButtonModule, MatIcon, MatTooltip, TranslatePipe], - selector: 'app-multi-epg-container', - templateUrl: './multi-epg-container.component.html', - styleUrls: ['./multi-epg-container.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MultiEpgContainerComponent - implements OnInit, AfterViewInit, OnDestroy -{ - readonly epgContainer = viewChild.required('epgContainer'); - - @Input() set playlistChannels(value: Observable) { - if (this.playlistChannelsSubscription) { - this.playlistChannelsSubscription.unsubscribe(); - } - - if (value) { - this.playlistChannelsSubscription = value.subscribe(() => { - this.channelsLowerRange = 0; - this.originalEpgData.set([]); - this.isLastPage.set(false); - this.initializeVisibleChannels(); - this.requestPrograms(); - }); - } - } - - @Input() activeChannelId: string | null = null; - - // Signals - readonly hourWidth = signal(150); - readonly today = signal(format(new Date(), 'yyyyMMdd')); - readonly originalEpgData = signal([]); - readonly isLoading = signal(false); - readonly isLastPage = signal(false); - readonly channelFilter = signal(''); - readonly isSearchExpanded = signal(false); - - // Program search signals - readonly isProgramSearchOpen = signal(false); - readonly programSearchQuery = signal(''); - readonly programSearchResults = signal([]); - readonly isSearchingPrograms = signal(false); - readonly highlightedProgramKey = signal(null); - private searchDebounceTimer: ReturnType | null = null; - private readonly translate = inject(TranslateService); - private readonly languageTick = toSignal( - this.translate.onLangChange.pipe(startWith(null)), - { initialValue: null } - ); - readonly currentLocale = computed(() => { - this.languageTick(); - return normalizeDateLocale( - this.translate.currentLang || this.translate.defaultLang - ); - }); - readonly selectedDayDate = computed(() => - parse(this.today(), 'yyyyMMdd', new Date()) - ); - - // Computed signal for enriched channels - automatically updates when dependencies change - readonly channels = computed(() => this.enrichProgramData()); - - // Computed signal for filtered channels based on search term - readonly filteredChannels = computed(() => { - const filter = this.channelFilter().toLowerCase().trim(); - const allChannels = this.channels(); - - if (!filter) { - return allChannels; - } - - return allChannels.filter((channel) => { - const name = this.getChannelName(channel).toLowerCase(); - return name.includes(filter); - }); - }); - - // Computed signal for current time line position - readonly currentTimeLine = computed(() => { - const now = new Date(); - return (now.getHours() + now.getMinutes() / 60) * this.hourWidth(); - }); - - // "16:32"-style label rendered as a badge above the now-line. Recomputes - // on the same 60-second tick that drives `currentTimeLine` (both depend - // on hourWidth's update pulse from `ngOnInit`). - readonly currentTimeLabel = computed(() => { - // Subscribe to the same tick source as currentTimeLine. - this.hourWidth(); - const now = new Date(); - return `${now.getHours().toString().padStart(2, '0')}:${now - .getMinutes() - .toString() - .padStart(2, '0')}`; - }); - - // Constants - readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); - readonly barHeight = 50; - - // Pagination state - visibleChannels = 20; - channelsLowerRange = 0; - channelsUpperRange = this.visibleChannels; - totalChannels = 0; - - private dateCache = new Map(); - private interval!: ReturnType; - private playlistChannelsSubscription?: Subscription; - - private readonly dialog = inject(MatDialog); - private readonly overlayRef = inject(COMPONENT_OVERLAY_REF); - private readonly epgBridge = inject(EpgRuntimeBridgeService); - private readonly settingsStore = inject(SettingsStore); - /** - * Attached to an overlay without host inputs, so the grid reads the EPG - * display offset itself (`epg-display-offset.util.ts`, display form). - */ - private readonly epgOffsetMinutes = - this.settingsStore.resolvedEpgOffsetMinutes; - - ngOnInit() { - // Update current time line every minute - this.interval = setInterval(() => { - // Force recomputation by updating hourWidth to same value - // This triggers the computed signal to recalculate - this.hourWidth.update((v) => v); - }, 60000); - } - - ngAfterViewInit(): void { - this.initializeVisibleChannels(); - this.scrollToCurrentTime(); - } - - private scrollToCurrentTime(): void { - const timeNow = new Date(); - const scrollPosition = - (timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth(); - - requestAnimationFrame(() => { - const container = document.getElementById('epg-container'); - if (container) { - container.scrollTo( - scrollPosition < 1000 ? 0 : scrollPosition - 150, - 0 - ); - } - }); - } - - private initializeVisibleChannels(): void { - const epgContainer = this.epgContainer(); - if (epgContainer) { - const containerHeight = epgContainer.nativeElement.offsetHeight; - const calculatedVisibleChannels = Math.floor( - (containerHeight - this.barHeight) / this.barHeight - ); - - this.visibleChannels = Math.max( - 10, - Math.min(calculatedVisibleChannels, 20) - ); - this.channelsUpperRange = this.visibleChannels; - } - } - - ngOnDestroy(): void { - clearInterval(this.interval); - - if (this.playlistChannelsSubscription) { - this.playlistChannelsSubscription.unsubscribe(); - } - - if (this.searchDebounceTimer) { - clearTimeout(this.searchDebounceTimer); - } - } - - trackByIndex(index: number): number { - return index; - } - - trackByProgram(_: number, program: EnrichedProgram): string { - return `${program.start}|${program?.title?.toString() ?? ''}`; - } - - /** - * Returns true when the now-line falls within this program's rendered - * span — used to add a `.is-now` class so the cyan border lights up - * every airing program at once. The template reads `currentTimeLine()` - * (a signal) here so the highlight refreshes on the same 60-second tick - * as the line itself. - */ - isProgramAiringNow(program: EnrichedProgram): boolean { - const nowX = this.currentTimeLine(); - if (!isSelectedEpgDayToday(this.today())) { - return false; - } - return ( - nowX >= program.startPosition && - nowX <= program.startPosition + program.width - ); - } - - async requestPrograms(): Promise { - if (!this.epgBridge.supportsChannelBrowser) { - console.warn('Multi-EPG not available in this runtime'); - return; - } - - if (this.isLoading() || this.isLastPage()) { - return; - } - - this.isLoading.set(true); - - try { - const response = await this.epgBridge.getChannelsByRange( - this.channelsLowerRange, - this.visibleChannels - ); - - if (response && Array.isArray(response)) { - // Append new data to existing data - this.originalEpgData.update((data) => [...data, ...response]); - - // Update isLastPage based on the number of channels received - this.isLastPage.set(response.length < this.visibleChannels); - - // Update range for next fetch - this.channelsLowerRange += response.length; - } - } catch (error) { - console.error('Error fetching EPG data:', error); - } finally { - this.isLoading.set(false); - } - } - - onScroll(event: Event): void { - const target = event.target as HTMLElement; - const scrollTop = target.scrollTop; - const scrollHeight = target.scrollHeight; - const clientHeight = target.clientHeight; - - // Load more when user scrolls to within 200px of the bottom - if (scrollHeight - scrollTop - clientHeight < 200) { - this.requestPrograms(); - } - } - - private getCachedDate(dateStr: string): Date { - let date = this.dateCache.get(dateStr); - if (!date) { - date = new Date(dateStr); - this.dateCache.set(dateStr, date); - } - return date; - } - - /** The cached raw date shifted into display time. */ - private getDisplayDate(dateStr: string, offsetMinutes: number): Date { - const raw = this.getCachedDate(dateStr); - return offsetMinutes === 0 - ? raw - : new Date(epgDisplayTimeMs(raw.getTime(), offsetMinutes)); - } - - /** Display-time epoch ms of a search-result boundary for the date pipe. */ - programDisplayMs(program: EpgProgram, side: 'start' | 'stop'): number { - return side === 'start' - ? getProgramTimeMs( - program.start, - program.startTimestamp, - this.epgOffsetMinutes() - ) - : getProgramTimeMs( - program.stop, - program.stopTimestamp, - this.epgOffsetMinutes() - ); - } - - private enrichProgramData(): EnrichedChannel[] { - const hourWidthValue = this.hourWidth(); - const todayValue = this.today(); - const data = this.originalEpgData(); - // Day filter and x-position both work in display time, so a shifted - // programme lands in the right day column under the right hour. - const offsetMinutes = this.epgOffsetMinutes(); - - return data.map((channel) => { - const filteredPrograms = (channel.programs || []) - .filter((item: EpgProgram) => { - const itemDate = format( - this.getDisplayDate(item.start, offsetMinutes), - 'yyyyMMdd' - ); - return itemDate === todayValue; - }) - .map((program: EpgProgram) => { - const startDate = this.getDisplayDate( - program.start, - offsetMinutes - ); - const stopDate = this.getDisplayDate( - program.stop, - offsetMinutes - ); - const startPosition = - (startDate.getHours() + startDate.getMinutes() / 60) * - hourWidthValue; - const duration = differenceInMinutes(stopDate, startDate); - const width = (duration * hourWidthValue) / 60; - - return { - ...program, - startDate, - stopDate, - startPosition, - width, - }; - }); - - return { - ...channel, - programs: filteredPrograms, - }; - }); - } - - /** - * Get display name from EpgChannel - */ - getChannelName(channel: EpgChannel | MultiEpgChannel): string { - if (typeof channel.displayName === 'string') { - return channel.displayName; - } - if ( - Array.isArray(channel.displayName) && - channel.displayName.length > 0 - ) { - return channel.displayName[0].value; - } - return ''; - } - - /** - * Get icon from EpgChannel - */ - getChannelIcon(channel: EpgChannel | MultiEpgChannel): string { - if ('iconUrl' in channel && channel.iconUrl) { - return channel.iconUrl; - } - if ( - 'icon' in channel && - Array.isArray(channel.icon) && - channel.icon.length > 0 - ) { - return channel.icon[0].src; - } - return ''; - } - - zoomIn(): void { - if (this.hourWidth() >= 800) return; - this.hourWidth.update((v) => v + 50); - } - - zoomOut(): void { - if (this.hourWidth() <= 50) return; - this.hourWidth.update((v) => v - 50); - } - - toggleSearch(): void { - this.isSearchExpanded.update((v) => !v); - if (!this.isSearchExpanded()) { - this.channelFilter.set(''); - } - } - - clearFilter(): void { - this.channelFilter.set(''); - } - - onFilterInput(event: Event): void { - const input = event.target as HTMLInputElement; - this.channelFilter.set(input.value); - } - - switchDay(direction: 'prev' | 'next'): void { - const currentDate = parse(this.today(), 'yyyyMMdd', new Date()); - this.today.set( - direction === 'prev' - ? format(subDays(currentDate, 1), 'yyyyMMdd') - : format(addDays(currentDate, 1), 'yyyyMMdd') - ); - } - - showDescription(program: EpgProgram): void { - this.dialog.open(EpgItemDescriptionComponent, { - width: '600px', - data: program, - }); - } - - // Program search methods - toggleProgramSearch(): void { - this.isProgramSearchOpen.update((v) => !v); - if (!this.isProgramSearchOpen()) { - this.programSearchQuery.set(''); - this.programSearchResults.set([]); - } - } - - onProgramSearchInput(event: Event): void { - const input = event.target as HTMLInputElement; - const query = input.value.trim(); - this.programSearchQuery.set(query); - - // Clear previous debounce timer - if (this.searchDebounceTimer) { - clearTimeout(this.searchDebounceTimer); - } - - if (query.length < 2) { - this.programSearchResults.set([]); - this.isSearchingPrograms.set(false); - return; - } - - if (!this.epgBridge.supportsProgramSearch) { - this.programSearchResults.set([]); - this.isSearchingPrograms.set(false); - return; - } - - // Debounce search by 500ms - show spinner only when search actually starts - this.searchDebounceTimer = setTimeout(async () => { - this.isSearchingPrograms.set(true); - try { - const results = await this.epgBridge.searchPrograms(query, 20); - this.programSearchResults.set( - (results as ProgramSearchResult[]) || [] - ); - } catch (error) { - console.error('Error searching programs:', error); - this.programSearchResults.set([]); - } finally { - this.isSearchingPrograms.set(false); - } - }, 500); - } - - clearProgramSearch(): void { - this.programSearchQuery.set(''); - this.programSearchResults.set([]); - } - - showProgramDetails(program: ProgramSearchResult): void { - // Get channel id (DB returns channel_id, EPG uses channel) - const channelId = program.channel_id || program.channel; - - // Find channel name - const channel = this.channels().find((ch) => ch.id === channelId); - const channelName = channel ? this.getChannelName(channel) : null; - - // Open the program details dialog with channel name - this.dialog.open(EpgItemDescriptionComponent, { - width: '800px', - data: { ...program, channelName }, - }); - } - - getProgramKey(program: ProgramSearchResult): string { - const channelId = program.channel_id || program.channel; - return `${channelId}|${program.start}`; - } - - close() { - this.overlayRef.detach(); - } -} diff --git a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts b/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts deleted file mode 100644 index db768fed1..000000000 --- a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { OverlayRef } from '@angular/cdk/overlay'; -import { InjectionToken } from '@angular/core'; - -/** - * Injection token for providing OverlayRef to dynamically created overlay components - */ -export const COMPONENT_OVERLAY_REF = new InjectionToken( - 'COMPONENT_OVERLAY_REF' -); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts index 638b34c89..92d46ddf2 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts @@ -81,6 +81,34 @@ describe('ControlsShortcuts', () => { } }); + it('ignores every shortcut while the host surface suspends player shortcuts', () => { + const suspendedRegion = document.createElement('div'); + suspendedRegion.setAttribute('data-player-shortcuts-suspended', ''); + const host = document.createElement('div'); + suspendedRegion.appendChild(host); + document.body.appendChild(suspendedRegion); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('ArrowDown')).toBe(false); + expect(dispatchKey('f')).toBe(false); + expect(dispatchKey('m')).toBe(false); + dispatchKey('Escape'); + + expect(handlers.togglePaused).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.toggleFullscreen).not.toHaveBeenCalled(); + expect(handlers.toggleMute).not.toHaveBeenCalled(); + expect(handlers.onEscape).not.toHaveBeenCalled(); + } finally { + suspendedRegion.remove(); + } + }); + it('handles shortcuts when the host is not inside an inert region', () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts index 058745b1e..0b2749d6d 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts @@ -3,8 +3,11 @@ export interface ControlsShortcutHandlers { /** * The player's host element, used to opt out of global shortcuts while * an ancestor is `inert` (e.g. behind the workspace's phone context - * drawer): inert strips pointer and Tab access, but document-level - * keydown listeners still fire, so shortcuts must check it themselves. + * drawer) or marked `data-player-shortcuts-suspended` (a host surface + * that owns the keyboard itself, such as the programme guide with the + * player docked): both strip the player of keyboard ownership, but + * document-level keydown listeners still fire, so shortcuts must check + * it themselves. */ hostElement?: () => HTMLElement | null; canTogglePaused: () => boolean; @@ -86,10 +89,17 @@ export class ControlsShortcuts { } // A player inside an inert region is outside the interaction model - // entirely — a modal surface above it owns the keyboard. This also - // covers Escape: the modal decides what Escape means, not the - // obscured player. - if (handlers.hostElement?.()?.closest('[inert]')) { + // entirely — a modal surface above it owns the keyboard. The same + // applies to a host surface that marks its player container + // `data-player-shortcuts-suspended` (the programme guide docks the + // player but keeps the arrows, Space and F for itself). This also + // covers Escape: the surface above decides what Escape means, not + // the player it obscures or demotes. + if ( + handlers + .hostElement?.() + ?.closest('[inert], [data-player-shortcuts-suspended]') + ) { return; } diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index d32411fd9..2484b133d 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -26,4 +26,5 @@ (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" + (selectedGroupChange)="selectedGroupChange.emit($event)" /> diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 24c79202c..b7821f4e5 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -32,6 +32,8 @@ export class SidebarComponent { readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); readonly sidebarToggleRequested = output(); + /** Groups view only: the group whose channels are currently listed. */ + readonly selectedGroupChange = output(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly translate = inject(TranslateService); diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 27a81e5d8..42c1d6022 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -41,11 +41,11 @@ describe('WorkspaceCommandPaletteComponent', () => { run: () => undefined, }, { - id: 'multi-epg', - label: 'Open Multi-EPG', + id: 'epg-guide', + label: 'Open programme guide', description: '', group: 'view', - icon: 'view_list', + icon: 'grid_view', keywords: ['epg', 'guide'], priority: 0, visible: true, @@ -126,7 +126,7 @@ describe('WorkspaceCommandPaletteComponent', () => { component.query.set('guide'); expect(component.flatCommands().map((command) => command.id)).toEqual([ - 'multi-epg', + 'epg-guide', ]); }); @@ -135,7 +135,7 @@ describe('WorkspaceCommandPaletteComponent', () => { fixture.detectChanges(); expect(component.flatCommands().map((command) => command.id)).toEqual([ - 'multi-epg', + 'epg-guide', 'playlist-search', 'global-search', ]); @@ -158,7 +158,7 @@ describe('WorkspaceCommandPaletteComponent', () => { component.onCommandClick(command); expect(dialogRef.close).toHaveBeenCalledWith({ - commandId: 'multi-epg', + commandId: 'epg-guide', query: '', }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 7f80772df..cd7d38991 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -136,6 +136,9 @@ type="button" class="header-shortcut" mat-icon-button + [class.is-active]="action.active?.() ?? false" + [disabled]="action.disabled?.() ?? false" + [attr.aria-pressed]="action.active ? action.active() : null" (click)="onHeaderShortcutRequested()" [matTooltip]="action.tooltipKey | translate" [attr.aria-label]="action.ariaLabelKey | translate" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 64d98a8bb..5e9ee3780 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -407,3 +407,8 @@ } } } + +.header-shortcut.is-active { + background: var(--app-selection-surface); + color: var(--app-selection-color); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 29cbb46aa..e0b056a7a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -877,7 +877,7 @@ describe('WorkspaceShellFacade', () => { ); }); - it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => { + it('includes M3U navigation, playlist actions, and the programme guide on playlist routes', () => { const headerContext = TestBed.inject(WorkspaceHeaderContextService); activePlaylistSignal.set({ @@ -889,14 +889,14 @@ describe('WorkspaceShellFacade', () => { }); facade.currentUrl.set('/workspace/playlists/pl-m3u/groups'); headerContext.setAction({ - id: 'm3u-multi-epg', - icon: 'view_list', - tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG', - ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG', + id: 'm3u-epg-guide', + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', palette: { - labelKey: 'TOP_MENU.OPEN_MULTI_EPG', + labelKey: 'TOP_MENU.OPEN_EPG_GUIDE', descriptionKey: - 'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION', + 'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION', keywords: ['epg', 'guide', 'schedule'], priority: 10, }, @@ -907,7 +907,7 @@ describe('WorkspaceShellFacade', () => { expect(commands.map((command) => command.id)).toEqual( expect.arrayContaining([ - 'm3u-multi-epg', + 'm3u-epg-guide', 'go-to-all', 'go-to-favorites', 'go-to-recent', @@ -915,13 +915,46 @@ describe('WorkspaceShellFacade', () => { ]) ); expect( - commands.find((command) => command.id === 'm3u-multi-epg')?.group + commands.find((command) => command.id === 'm3u-epg-guide')?.group ).toBe('view'); expect(commands.some((command) => command.id === 'account-info')).toBe( false ); }); + it('disables the programme guide command while the header action reports disabled', () => { + const headerContext = TestBed.inject(WorkspaceHeaderContextService); + + activePlaylistSignal.set({ + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + }); + facade.currentUrl.set('/workspace/playlists/pl-m3u/groups'); + const guideCommandEnabled = () => + facade + .commandPaletteCommands() + .find((command) => command.id === 'm3u-epg-guide')?.enabled; + const registerGuideAction = (disabled: () => boolean) => + headerContext.setAction({ + id: 'm3u-epg-guide', + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + disabled, + palette: { labelKey: 'TOP_MENU.OPEN_EPG_GUIDE' }, + run: jest.fn(), + }); + + registerGuideAction(() => true); + expect(guideCommandEnabled()).toBe(false); + + registerGuideAction(() => false); + expect(guideCommandEnabled()).toBe(true); + }); + it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => { activePlaylistSignal.set({ _id: 'pl-xtream', diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index c609d81dc..1a44727aa 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -60,7 +60,6 @@ export const maxLinesBaseline = [ 'libs/shared/interfaces/src/lib/electron-api.interface.ts', 'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts', 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts', - 'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts', 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts', 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts', 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',