From 51347fc2266a5c6a9b73cbe264e3f70d3ae66541 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Apr 2026 19:56:41 +0200 Subject: [PATCH 01/54] feat: add collapsible live epg panel Entire-Checkpoint: 0518c49b948d --- .plans/2026-04-28-live-epg-panel-collapse.md | 60 ++++++++ apps/web/src/assets/i18n/ar.json | 5 +- apps/web/src/assets/i18n/ary.json | 5 +- apps/web/src/assets/i18n/by.json | 5 +- apps/web/src/assets/i18n/de.json | 5 +- apps/web/src/assets/i18n/el.json | 5 +- apps/web/src/assets/i18n/en.json | 5 +- apps/web/src/assets/i18n/es.json | 5 +- apps/web/src/assets/i18n/fr.json | 5 +- apps/web/src/assets/i18n/it.json | 5 +- apps/web/src/assets/i18n/ja.json | 5 +- apps/web/src/assets/i18n/ko.json | 5 +- apps/web/src/assets/i18n/nl.json | 5 +- apps/web/src/assets/i18n/pl.json | 5 +- apps/web/src/assets/i18n/pt.json | 5 +- apps/web/src/assets/i18n/ru.json | 5 +- apps/web/src/assets/i18n/tr.json | 5 +- apps/web/src/assets/i18n/zh.json | 5 +- apps/web/src/assets/i18n/zhtw.json | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 13 ++ docs/architecture/m3u-playlist-module.md | 123 ++++++++++------- docs/architecture/stalker-epg.md | 100 +++++++------- .../video-player/video-player.component.html | 51 +++++-- .../video-player.component.spec.ts | 116 ++++++++++++++-- .../video-player/video-player.component.ts | 43 +++++- libs/portal/shared/util/src/index.ts | 1 + .../util/src/lib/live-epg-panel-state.spec.ts | 50 +++++++ .../util/src/lib/live-epg-panel-state.ts | 25 ++++ .../stalker-live-stream-layout.component.html | 49 +++++-- ...alker-live-stream-layout.component.spec.ts | 98 ++++++++++++- .../stalker-live-stream-layout.component.ts | 81 +++++++---- .../live-stream-layout.component.html | 100 ++++++++------ .../live-stream-layout.component.spec.ts | 95 ++++++++++++- .../live-stream-layout.component.ts | 42 +++++- .../src/lib/settings-store.service.ts | 3 +- libs/ui/shared-portals/src/index.ts | 5 +- .../live-epg-panel.component.html | 76 ++++++++++ .../live-epg-panel.component.scss | 130 ++++++++++++++++++ .../live-epg-panel.component.spec.ts | 110 +++++++++++++++ .../live-epg-panel.component.ts | 102 ++++++++++++++ .../live-stream-layout.component.html | 33 ++++- .../live-stream-layout.component.scss | 21 ++- .../live-stream-layout.component.ts | 78 +++++++++-- libs/ui/shared-portals/tsconfig.spec.json | 35 ++--- libs/ui/styles/_portal-layout.scss | 18 ++- 45 files changed, 1465 insertions(+), 283 deletions(-) create mode 100644 .plans/2026-04-28-live-epg-panel-collapse.md create mode 100644 libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts create mode 100644 libs/portal/shared/util/src/lib/live-epg-panel-state.ts create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts create mode 100644 libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts diff --git a/.plans/2026-04-28-live-epg-panel-collapse.md b/.plans/2026-04-28-live-epg-panel-collapse.md new file mode 100644 index 000000000..2333eb471 --- /dev/null +++ b/.plans/2026-04-28-live-epg-panel-collapse.md @@ -0,0 +1,60 @@ +# Live EPG Panel Collapse + +## Summary + +Add a shared, persisted live-TV EPG collapse state across M3U, Xtream, and Stalker. Default remains expanded. When an internal player is active, the EPG can collapse to a slim bottom line showing the current program and an expand button; the player grows to use the freed space. External MPV/VLC playback keeps the existing full EPG-only layout. + +## Key Changes + +- Add shared state helpers in `@iptvnator/portal/shared/util`: + - storage key: `live-epg-panel-state` + - states: `expanded | collapsed` + - restore invalid/missing values to `expanded` +- Add a shared `LiveEpgPanelComponent` in `shared-portals`: + - inputs: `collapsed`, `summary`, `loading` + - output: `collapsedChange` + - keeps projected EPG content mounted while collapsed, but visually hides and inerts it so existing EPG effects still update current-program state. +- Update the shared live layout SCSS mixin so `.epg.epg-collapsed` has a fixed slim height and `.video-player` fills the remaining space. +- Wire the wrapper into: + - M3U `VideoPlayerComponent` + - Xtream `LiveStreamLayoutComponent` + - Stalker `StalkerLiveStreamLayoutComponent` + - legacy shared `LiveStreamLayoutComponent`, including ArtPlayer as an internal player. +- Add i18n keys for collapse/expand/current-program labels to all locale JSON files, using English fallback where no translation is available. + +## Behavior Rules + +- Collapse is available only when an internal player is shown: Video.js, HTML5, or ArtPlayer. +- Radio playback still hides EPG entirely. +- External MPV/VLC playback does not render the collapse strip and keeps the full EPG panel. +- The slim line shows current program title, start/end time, and progress when available; otherwise it shows the existing no-program/EPG-unavailable message. +- The shared persisted state applies across all three live-TV modules. + +## Tests + +- Add unit coverage for restore/persist state validation. +- Add shared `LiveEpgPanelComponent` tests for summary rendering, toggle output, hidden-but-mounted body, and fallback text. +- Extend M3U, Xtream, and Stalker component specs for: + - collapsed state from `localStorage` + - toggle persistence + - collapse only for internal players + - external player keeps full EPG + - current-program summary renders from each module's data source. +- Run: + - `pnpm nx test shared-portals` + - `pnpm nx test ui-epg` + - `pnpm nx test portal-xtream-feature` + - `pnpm nx test portal-stalker-feature` + - `pnpm nx lint playlist-m3u-feature-player` + - `pnpm nx build web` +- Verify in the running Electron app through CDP/agent-browser and Computer Use: + - M3U, Xtream, Stalker live pages + - Video.js, HTML5, ArtPlayer sizing + - collapsed state survives reload/navigation + - light and dark themes do not overlap or clip text. + +## Docs And Plan Artifact + +- Update `docs/architecture/iptvnator-ui-guidelines.md` with the collapsible live EPG pattern. +- Update module docs where they describe active live EPG behavior, especially M3U and Stalker EPG docs. +- If `IPTVNATOR_WIKI_VAULT` is configured after docs change, run `pnpm wiki:export --mode changed`. diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 6f5feb8ac..d789c192b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -269,7 +269,10 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "عرض تفاصيل البرنامج" }, - "FETCH_SUCCESS": "تم جلب الدليل بنجاح." + "FETCH_SUCCESS": "تم جلب الدليل بنجاح.", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e81849c85..fac318e97 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -261,7 +261,10 @@ "LANGUAGE": "اللغة", "SHOW_PROGRAM_DETAILS": "وري تفاصيل حول هاد البرنامج" }, - "FETCH_SUCCESS": "EPG تجلب بنجاح" + "FETCH_SUCCESS": "EPG تجلب بنجاح", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 36d6fcf1e..99cbdc583 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -257,7 +257,10 @@ "DESCRIPTION": "Апісанне", "LANGUAGE": "Мова", "SHOW_PROGRAM_DETAILS": "Паказаць падрабязнасці" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "NO": "Не", "YES": "Так", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8c070385e..257ca3c48 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -54,7 +54,10 @@ "DESCRIPTION": "Beschreibung", "LANGUAGE": "Sprache", "SHOW_PROGRAM_DETAILS": "Details anzeigen" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "HOME": { "SUBTITLE": "Wählen Sie eine Playlist aus oder laden Sie eine neue Playlist hoch", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 67baeb50e..4265223c1 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -327,7 +327,10 @@ "TOTAL_CHANNELS": "Σύνολο καναλιών", "TOTAL_PROGRAMS": "Σύνολο προγραμμάτων", "UP_TO_DATE": "Το EPG είναι ενημερωμένο, δεν απαιτείται συγχρονισμός", - "RETRY": "Επανάληψη" + "RETRY": "Επανάληψη", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index cd0bb0b05..ac4ec6fd9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -459,7 +459,10 @@ "TOTAL_CHANNELS": "Total channels", "TOTAL_PROGRAMS": "Total programs", "UP_TO_DATE": "EPG is up-to-date, no sync needed", - "RETRY": "Retry" + "RETRY": "Retry", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 5e9b3d21b..e3ab248c7 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -285,7 +285,10 @@ "DESCRIPTION": "Descripción", "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalles del programa" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d5197301d..0e397629c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -283,7 +283,10 @@ "LANGUAGE": "Langue", "SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme" }, - "FETCH_SUCCESS": "L'EPG a été récupéré avec succès" + "FETCH_SUCCESS": "L'EPG a été récupéré avec succès", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 51e9e9bfb..86d6aa8ec 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -303,7 +303,10 @@ "LANGUAGE": "Lingua", "SHOW_PROGRAM_DETAILS": "Visualizza dettagli su questo programma" }, - "FETCH_SUCCESS": "EPG scaricato correttamente" + "FETCH_SUCCESS": "EPG scaricato correttamente", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index f27658448..ba1ad5d47 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -249,7 +249,10 @@ "LANGUAGE": "言語", "SHOW_PROGRAM_DETAILS": "この番組の詳細を表示" }, - "FETCH_SUCCESS": "EPGの取得に成功しました" + "FETCH_SUCCESS": "EPGの取得に成功しました", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 12905d9ca..aa93ee9a5 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -214,7 +214,10 @@ "DESCRIPTION": "설명", "LANGUAGE": "언어", "SHOW_PROGRAM_DETAILS": "이 프로그램에 대한 세부 정보 표시" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c76caf139..e905bca05 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -261,7 +261,10 @@ "LANGUAGE": "Taal", "SHOW_PROGRAM_DETAILS": "Toon details over dit programma" }, - "FETCH_SUCCESS": "TV-Gids bijgewerkt" + "FETCH_SUCCESS": "TV-Gids bijgewerkt", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d82010bf6..aa7e89a6a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -261,7 +261,10 @@ "LANGUAGE": "Język", "SHOW_PROGRAM_DETAILS": "Pokaż szczegóły programu" }, - "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane" + "FETCH_SUCCESS": "EPG zostało pomyślnie pobrane", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1849d94cb..6c43c5654 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -280,7 +280,10 @@ "LANGUAGE": "Idioma", "SHOW_PROGRAM_DETAILS": "Mostrar detalhes sobre este programa" }, - "FETCH_SUCCESS": "EPG foi buscado com sucesso" + "FETCH_SUCCESS": "EPG foi buscado com sucesso", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 34b3cd82d..57de5dc1d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -360,7 +360,10 @@ "DESCRIPTION": "Описание", "LANGUAGE": "Язык", "SHOW_PROGRAM_DETAILS": "Показать подробности" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7226bcebd..243946df7 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -335,7 +335,10 @@ "TOTAL_CHANNELS": "Toplam kanal", "TOTAL_PROGRAMS": "Toplam program", "UP_TO_DATE": "EPG güncel, senkronizasyona gerek yok", - "RETRY": "Tekrar Dene" + "RETRY": "Tekrar Dene", + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 553a6cbe7..4029926cf 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -214,7 +214,10 @@ "DESCRIPTION": "描述", "LANGUAGE": "语言", "SHOW_PROGRAM_DETAILS": "显示有关此程序的详细信息" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3fa68346a..0153f36cd 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -260,7 +260,10 @@ "DESCRIPTION": "說明", "LANGUAGE": "語言", "SHOW_PROGRAM_DETAILS": "顯示關於該節目的詳細資訊" - } + }, + "CURRENT_PROGRAM": "Current program", + "COLLAPSE_PANEL": "Collapse EPG panel", + "EXPAND_PANEL": "Expand EPG panel" }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1ead04c24..1bedd4071 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -126,6 +126,19 @@ The shared row should be reused instead of rebuilding channel markup per view. - Add bottom padding so the last program is not clipped - Current program card uses the same selection treatment as selected channels +### Collapsible Live EPG + +- Live TV layouts with an internal player use `app-live-epg-panel` around the + EPG content. +- Collapsed state is shared across M3U, Xtream, and Stalker with + `live-epg-panel-state`; missing or invalid values restore to expanded. +- The collapsed panel is a slim current-program strip with a trailing progress + line and an expand button. +- Do not render the collapsed strip for external MPV/VLC playback; those + layouts keep the full EPG-only panel. +- Keep the EPG content mounted while collapsed so current-program state can + continue updating. + ### EPG Card - Radius: diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index aa85a41df..6c66de2aa 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -5,6 +5,7 @@ This document describes the M3U playlist module architecture, which handles trad ## Overview The M3U playlist module provides: + - Channel list display with virtual scrolling (90,000+ channels support) - EPG (Electronic Program Guide) integration - Favorites management with drag-and-drop reordering @@ -80,32 +81,32 @@ used by the groups view to remember which group titles the user has hidden. ### Actions -| Action Group | Actions | Purpose | -|--------------|---------|---------| -| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | -| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | -| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | -| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | -| **FilterActions** | `setSelectedFilters` | Playlist type filtering | +| Action Group | Actions | Purpose | +| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------ | +| **PlaylistActions** | `loadPlaylists`, `addPlaylist`, `removePlaylist`, `parsePlaylist`, `setActivePlaylist` | Playlist CRUD | +| **ChannelActions** | `setChannels`, `setActiveChannel`, `setAdjacentChannelAsActive` | Channel selection & navigation | +| **EpgActions** | `setActiveEpgProgram`, `setCurrentEpgProgram`, `setEpgAvailableFlag` | EPG state | +| **FavoritesActions** | `updateFavorites`, `setFavorites` | Favorites management | +| **FilterActions** | `setSelectedFilters` | Playlist type filtering | ### Key Selectors ```typescript // Channel selectors -selectActive // Current playing channel -selectChannelsLoading // Channel list loading flag -selectChannels // All channels array -selectFavorites // Favorite channel URLs +selectActive; // Current playing channel +selectChannelsLoading; // Channel list loading flag +selectChannels; // All channels array +selectFavorites; // Favorite channel URLs // Playlist selectors -selectAllPlaylistsMeta // All playlists -selectActivePlaylistId // Selected playlist ID -selectCurrentPlaylist // Active playlist object -selectPlaylistTitle // Title with "Global favorites" fallback +selectAllPlaylistsMeta; // All playlists +selectActivePlaylistId; // Selected playlist ID +selectCurrentPlaylist; // Active playlist object +selectPlaylistTitle; // Title with "Global favorites" fallback // EPG selectors -selectIsEpgAvailable // EPG data available flag -selectCurrentEpgProgram // Current playing program +selectIsEpgAvailable; // EPG data available flag +selectCurrentEpgProgram; // Current playing program ``` ## Channel List Container @@ -182,9 +183,9 @@ channel-list-container/ - `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation. - The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope. - Empty playlists and empty search results are no longer conflated: - - loading: skeletons - - empty source: no channels in the playlist after loading completes - - empty search: no matches within an already loaded playlist + - loading: skeletons + - empty source: no channels in the playlist after loading completes + - empty search: no matches within an already loaded playlist ### Group Visibility Management @@ -213,8 +214,8 @@ For performance optimization, channels are pre-enriched with EPG data: ```typescript interface EnrichedChannel extends Channel { epgProgram: EpgProgram | null | undefined; - logo: string; // Playlist tvg-logo first, XMLTV icon fallback second - progressPercentage: number; // Pre-computed by parent + logo: string; // Playlist tvg-logo first, XMLTV icon fallback second + progressPercentage: number; // Pre-computed by parent } ``` @@ -237,29 +238,32 @@ EPG lookup keys use the same precedence in both program and icon paths: ### Performance Optimizations -| Optimization | Implementation | -|--------------|----------------| -| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels | -| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe | -| **Debounced Search** | 300ms debounce on search input | -| **Global Progress Tick** | Single 30s interval instead of per-item intervals | -| **OnPush Change Detection** | All components use OnPush | -| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time | -| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal | +| Optimization | Implementation | +| ----------------------------- | --------------------------------------------------------- | +| **Virtual Scroll** | CDK virtual scroll for 90,000+ channels | +| **Computed Signals** | `enrichedChannels` computed signal replaces template pipe | +| **Debounced Search** | 300ms debounce on search input | +| **Global Progress Tick** | Single 30s interval instead of per-item intervals | +| **OnPush Change Detection** | All components use OnPush | +| **Infinite Scroll in Groups** | IntersectionObserver loads 50 channels at a time | +| **Memoized Group Enrichment** | `enrichedGroupChannelsMap` computed signal | ### Tab Components #### AllChannelsTabComponent + - **Inputs**: `channels`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `itemSize`, `activeChannelUrl`, `favoriteIds` - **Outputs**: `channelSelected`, `favoriteToggled` - **Features**: Search with 300ms debounce, virtual scrolling, no-results placeholder #### GroupsTabComponent + - **Inputs**: Same as AllChannelsTab + `groupedChannels` - **Outputs**: `channelSelected`, `favoriteToggled` - **Features**: Expansion panels, infinite scroll with IntersectionObserver, lazy loading #### FavoritesTabComponent + - **Inputs**: `favorites`, `channelEpgMap`, `channelIconMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` - **Outputs**: `channelSelected`, `favoriteToggled`, `favoritesReordered` - **Features**: Drag-and-drop reordering with CDK DragDrop @@ -277,10 +281,14 @@ class EpgService { getChannelPrograms(channelId: string): void; // Batch fetch current programs - getCurrentProgramsForChannels(channelIds: string[]): Observable>; + getCurrentProgramsForChannels( + channelIds: string[] + ): Observable>; // Batch fetch XMLTV channel metadata for logo fallback - getChannelMetadataForChannels(channelIds: string[]): Observable>; + getChannelMetadataForChannels( + channelIds: string[] + ): Observable>; // Observables epgAvailable$: Observable; @@ -290,27 +298,31 @@ class EpgService { ### EPG Components -| Component | Purpose | -|-----------|---------| -| `EpgListComponent` | Timeline view for single channel | -| `EpgListItemComponent` | Individual program in timeline | -| `EpgItemDescriptionComponent` | Program details dialog | -| `MultiEpgContainerComponent` | Grid view of all channels' schedules | +| Component | Purpose | +| ----------------------------- | ------------------------------------ | +| `EpgListComponent` | Timeline view for single channel | +| `EpgListItemComponent` | Individual program in timeline | +| `EpgItemDescriptionComponent` | Program details dialog | +| `MultiEpgContainerComponent` | Grid view of all channels' schedules | ## Video Player **Location**: `libs/playlist/m3u/feature-player/src/lib/video-player/` ### Supported Players + - **ArtPlayer** (default) - Modern player with plugins - **Video.js** - Fallback with HLS support - **HTML5** - Basic video element - **Audio** - For radio streams ### Player Features + - Channel navigation (prev/next) - Favorites toggle - EPG sidebar +- Collapsible inline EPG panel for internal players, persisted through the + shared `live-epg-panel-state` preference - Multi-EPG modal view - Channel info overlay - External player support (MPV, VLC) in Electron @@ -324,16 +336,16 @@ class EpgService { - M3U catch-up support is resolved in `m3u-utils` from channel metadata and the archived program start time. - Supported replay precedence: - 1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends - standard `utc` and `lutc` query params on that URL. - 2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In - that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`. - 3. Legacy same-stream shift fallback when no explicit catch-up mode is - declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or - `catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This - covers providers that only advertise archive retention such as - `tvg-rec="7"` but still expect standard `utc` and `lutc` query params on - the live URL. + 1. `catchup.source` if it is an HTTP(S) URL. IPTVNator rewrites or appends + standard `utc` and `lutc` query params on that URL. + 2. Legacy same-stream shift playback when `catchup.type === 'shift'`. In + that case IPTVNator rewrites or appends `utc` and `lutc` on `channel.url`. + 3. Legacy same-stream shift fallback when no explicit catch-up mode is + declared, archive-day metadata exists (`tvg.rec`, `timeshift`, or + `catchup.days`), and `channel.url` itself is an HTTP(S) stream URL. This + covers providers that only advertise archive retention such as + `tvg-rec="7"` but still expect standard `utc` and `lutc` query params on + the live URL. - `tvg.rec`, `timeshift`, and `catchup.days` still define the archive window shown in the EPG, but replay remains unavailable when the provider declares a different explicit catch-up scheme that IPTVNator does not understand or when @@ -346,6 +358,7 @@ class EpgService { ## Interfaces ### Channel Interface + ```typescript interface Channel { id: string; @@ -353,7 +366,7 @@ interface Channel { name: string; group: { title: string }; tvg: { - id: string; // For EPG matching + id: string; // For EPG matching name: string; url: string; logo: string; @@ -384,11 +397,12 @@ interface PlaylistState { ``` ### EpgProgram Interface + ```typescript interface EpgProgram { - start: string; // ISO string - stop: string; // ISO string - channel: string; // TVG ID + start: string; // ISO string + stop: string; // ISO string + channel: string; // TVG ID title: string; desc: string | null; category: string | null; @@ -408,17 +422,20 @@ interface EpgProgram { ## Adding New Features ### To add a new tab to channel list: + 1. Create component in `channel-list-container/new-tab/` 2. Accept inputs: `channels`, `channelEpgMap`, `progressTick`, `shouldShowEpg`, `activeChannelUrl` 3. Emit `channelSelected` output 4. Add to parent template and imports ### To add EPG-related features: + 1. Use `EpgService` for data fetching 2. Subscribe to `channelEpgMap` signal for current programs 3. Dispatch `EpgActions` for state updates ### To modify favorites behavior: + 1. Dispatch `FavoritesActions.updateFavorites` for toggle 2. Dispatch `FavoritesActions.setFavorites` for reordering 3. Effects automatically persist to database diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index 26a6ab775..a1b7e8914 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -78,21 +78,21 @@ GET load.php?type=itv&action=get_short_epg&ch_id={channel_id}&size={n}&JsHttpReq ```json { - "js": { - "data": [ - { - "id": "123", - "ch_id": "45", - "name": "Program Title", - "descr": "Program description", - "time": "2025-01-15 14:00:00", - "time_to": "2025-01-15 14:30:00", - "duration": "1800", - "start_timestamp": "1736949600", - "stop_timestamp": "1736951400" - } - ] - } + "js": { + "data": [ + { + "id": "123", + "ch_id": "45", + "name": "Program Title", + "descr": "Program description", + "time": "2025-01-15 14:00:00", + "time_to": "2025-01-15 14:30:00", + "duration": "1800", + "start_timestamp": "1736949600", + "stop_timestamp": "1736951400" + } + ] + } } ``` @@ -120,21 +120,21 @@ GET load.php?type=itv&action=get_epg_info&period={hours}&JsHttpRequest=1-xml ```json { - "js": { - "data": { - "45": [ - { - "id": "1", - "name": "Program Title", - "descr": "Program description", - "time": "2025-01-15 14:00:00", - "time_to": "2025-01-15 16:00:00", - "start_timestamp": "1736949600", - "stop_timestamp": "1736956800" + "js": { + "data": { + "45": [ + { + "id": "1", + "name": "Program Title", + "descr": "Program description", + "time": "2025-01-15 14:00:00", + "time_to": "2025-01-15 16:00:00", + "start_timestamp": "1736949600", + "stop_timestamp": "1736956800" + } + ] } - ] } - } } ``` @@ -153,16 +153,16 @@ The short EPG path now exists only for the active-panel fallback flow. Key mapped fields: -| Stalker field | `EpgItem` field | -| --- | --- | -| `id` | `id` | -| `ch_id` | `channel_id` | -| `name` | `title` | -| `descr` | `description` | -| `time` | `start` | -| `time_to` | `end`, `stop` | +| Stalker field | `EpgItem` field | +| ----------------- | ----------------- | +| `id` | `id` | +| `ch_id` | `channel_id` | +| `name` | `title` | +| `descr` | `description` | +| `time` | `start` | +| `time_to` | `end`, `stop` | | `start_timestamp` | `start_timestamp` | -| `stop_timestamp` | `stop_timestamp` | +| `stop_timestamp` | `stop_timestamp` | ### Active panel data (`get_epg_info` / fallback) → `EpgProgram` @@ -180,13 +180,13 @@ Normalization rules: ### Key files -| File | Purpose | -| --- | --- | -| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling | -| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring | -| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template | -| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading | -| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator | +| File | Purpose | +| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-epg.feature.ts` | bulk cache and fallback handling | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` | active-channel EPG loading and controlled `app-epg-list` wiring | +| `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` | active panel template | +| `libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts` | row preview loading | +| `libs/ui/shared-portals/src/lib/epg-list/epg-list.component.ts` | shared controlled EPG list with date navigator | ### Store API @@ -215,6 +215,10 @@ playlists. `get_short_epg` The active panel no longer uses local EPG pagination or a "Load more" button. +When Stalker live TV is playing through an internal player, the active panel is +wrapped in the shared collapsible live EPG panel. The collapsed/expanded state +uses the shared `live-epg-panel-state` preference and is only applied after a +stream URL has been resolved; external playback keeps the full EPG-only panel. ### Channel row preview flow @@ -238,10 +242,10 @@ derived locally from `bulkItvEpgByChannel`: EPG requests follow the standard Stalker request path: -| Portal type | Auth path | -| --- | --- | -| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` | -| Simple Stalker portal | generic IPC request path via Electron | +| Portal type | Auth path | +| --------------------- | -------------------------------------------------- | +| Full Stalker portal | `StalkerSessionService.makeAuthenticatedRequest()` | +| Simple Stalker portal | generic IPC request path via Electron | No EPG-specific backend transport was needed; the Electron Stalker request handler forwards portal params directly. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e2a5c0271..e330aa962 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -17,7 +17,9 @@ [activeView]="activeView()" [sidebarWidth]="sidebarWidth()" (sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)" - (sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)" + (sidebarWidthRequestEnded)=" + onGroupedSidebarWidthRequestEnded($event) + " /> } @loading { @@ -29,13 +31,18 @@
@if (activeChannel(); as activeChannel) { @let playbackTarget = playbackChannel(); - @if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) { + @if ( + activeChannel.radio === 'true' || + shouldShowInlinePlayer(activeChannel) + ) {
@if (activeChannel.radio === 'true') { } @else { @if (playerSettings.player === 'videojs') { @@ -73,12 +80,38 @@ } @if (activeChannel.radio !== 'true') { -
-
- -
+ @let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel); +
+ @if (canCollapseEpgPanel) { + +
+ +
+
+ } @else { +
+ +
+ }
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index a7c1062de..6787ce449 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -1,5 +1,11 @@ import { AsyncPipe } from '@angular/common'; -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { + Component, + NO_ERRORS_SCHEMA, + input, + output, + signal, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -18,17 +24,30 @@ import { import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PORTAL_EXTERNAL_PLAYBACK, + LIVE_EPG_PANEL_STATE_STORAGE_KEY, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService, SettingsStore } from 'services'; -import { - Channel, - Settings, - VideoPlayer, -} from 'shared-interfaces'; +import { Channel, EpgProgram, Settings, VideoPlayer } from 'shared-interfaces'; +import { LiveEpgPanelSummary } from 'shared-portals'; import { Overlay } from '@angular/cdk/overlay'; import { VideoPlayerComponent } from './video-player.component'; +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + describe('VideoPlayerComponent', () => { let fixture: ComponentFixture; let component: VideoPlayerComponent; @@ -39,11 +58,11 @@ describe('VideoPlayerComponent', () => { const activePlaybackUrl = signal(null); const channels = signal([]); const channelsLoading = signal(false); - const currentEpgProgram = signal(null); + const currentEpgProgram = signal(null); const channels$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject(null); - const currentEpgProgram$ = new BehaviorSubject(null); + const currentEpgProgram$ = new BehaviorSubject(null); const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); @@ -149,6 +168,7 @@ describe('VideoPlayerComponent', () => { beforeEach(async () => { syncStoreState(null); localStorage.removeItem('m3u-sidebar-width'); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); player.set(VideoPlayer.VideoJs); showCaptions.set(false); activePlaybackUrl.set(null); @@ -233,6 +253,7 @@ describe('VideoPlayerComponent', () => { set: { imports: [ AsyncPipe, + StubLiveEpgPanelComponent, MockPipe( TranslatePipe, (value: string | null | undefined) => value ?? '' @@ -249,6 +270,7 @@ describe('VideoPlayerComponent', () => { afterEach(() => { fixture.destroy(); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); }); it('registers and clears the workspace multi-EPG header shortcut', () => { @@ -288,20 +310,73 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.video-player') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('.video-player')).toBeNull(); expect( fixture.nativeElement.querySelector('app-epg-list') ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + ?.classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('restores the collapsed live EPG panel state for inline playback', () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + headerContext = TestBed.inject(WorkspaceHeaderContextService); + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('renders the current EPG program summary for the inline panel', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + currentEpgProgram.set(buildProgram('Current Show')); + currentEpgProgram$.next(buildProgram('Current Show')); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); }); it('uses the active playback override url when archive playback is active', () => { syncStoreState(sampleChannel); player.set(VideoPlayer.VideoJs); - activePlaybackUrl.set( - 'http://localhost/archive.m3u8?utc=123&lutc=456' - ); + activePlaybackUrl.set('http://localhost/archive.m3u8?utc=123&lutc=456'); fixture.detectChanges(); @@ -411,3 +486,16 @@ describe('VideoPlayerComponent', () => { ); }); }); + +function buildProgram(title: string): EpgProgram { + return { + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', + channel: 'sample-tvg-id', + title, + desc: null, + category: null, + startTimestamp: 1775388600, + stopTimestamp: 1775392200, + }; +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 96501d4da..0b154baf0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -39,19 +39,19 @@ import { Observable, Subscription, combineLatest, - combineLatestWith, - distinctUntilChanged, filter, map, startWith, - switchMap, take, } from 'rxjs'; import { getAdjacentChannelItem, getChannelItemByNumber, isWorkspaceLayoutRoute, + persistLiveEpgPanelState, PORTAL_EXTERNAL_PLAYBACK, + restoreLiveEpgPanelState, + LiveEpgPanelState, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; @@ -62,6 +62,7 @@ import { SidebarComponent, VjsPlayerComponent, } from '@iptvnator/ui/playback'; +import { LiveEpgPanelComponent, LiveEpgPanelSummary } from 'shared-portals'; import { ChannelListLoadingStateComponent } from 'components'; import { DataService, PlaylistsService, SettingsStore } from 'services'; import { @@ -93,6 +94,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; CommonModule, EpgListComponent, HtmlVideoPlayerComponent, + LiveEpgPanelComponent, PortalEmptyStateComponent, ResizableDirective, SidebarComponent, @@ -153,6 +155,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH); readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH; readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH; + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); /** Channels list */ readonly channels$: Observable = this.store.select( @@ -161,6 +169,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Current epg program */ readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.epgProgram()) + ); /** Active M3U view (all, groups, favorites, recent) */ readonly activeView = toSignal( @@ -427,6 +438,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.persistSidebarWidth(this.sidebarStorageKey(), width); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... @@ -472,9 +489,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); return this.clampSidebarWidth( - Number.isNaN(storedWidth) - ? M3U_SIDEBAR_DEFAULT_WIDTH - : storedWidth + Number.isNaN(storedWidth) ? M3U_SIDEBAR_DEFAULT_WIDTH : storedWidth ); } @@ -522,7 +537,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { _id: playlistId, recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], } as PlaylistMeta, - }) as any + }) ); } @@ -726,6 +741,20 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { return !this.isExternalPlayer(this.playerSettings.player); } + private toLiveEpgPanelSummary( + program: EpgProgram | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop, + }; + } + private getExternalSessionStateKey( session: ExternalPlayerSession | null | undefined ): string | null { diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 00a61df5a..2ada08585 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/portal-navigation-actions'; export * from './lib/portal-collection-context'; export * from './lib/portal-collection-context.service'; export * from './lib/portal-collection-items'; +export * from './lib/live-epg-panel-state'; export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; diff --git a/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts b/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts new file mode 100644 index 000000000..590b5d41b --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-epg-panel-state.spec.ts @@ -0,0 +1,50 @@ +import { + DEFAULT_LIVE_EPG_PANEL_STATE, + LIVE_EPG_PANEL_STATE_STORAGE_KEY, + isLiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, +} from './live-epg-panel-state'; + +describe('live EPG panel state', () => { + afterEach(() => { + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); + localStorage.removeItem('custom-live-epg-panel-state'); + }); + + it('accepts only known panel states', () => { + expect(isLiveEpgPanelState('expanded')).toBe(true); + expect(isLiveEpgPanelState('collapsed')).toBe(true); + expect(isLiveEpgPanelState('hidden')).toBe(false); + expect(isLiveEpgPanelState(null)).toBe(false); + }); + + it('restores expanded as the default for missing or invalid storage', () => { + expect(restoreLiveEpgPanelState()).toBe(DEFAULT_LIVE_EPG_PANEL_STATE); + + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'hidden'); + + expect(restoreLiveEpgPanelState()).toBe(DEFAULT_LIVE_EPG_PANEL_STATE); + }); + + it('restores and persists collapsed state', () => { + persistLiveEpgPanelState('collapsed'); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + expect(restoreLiveEpgPanelState()).toBe('collapsed'); + }); + + it('supports custom storage keys and fallback values', () => { + expect( + restoreLiveEpgPanelState('custom-live-epg-panel-state', 'collapsed') + ).toBe('collapsed'); + + persistLiveEpgPanelState('expanded', 'custom-live-epg-panel-state'); + + expect(restoreLiveEpgPanelState('custom-live-epg-panel-state')).toBe( + 'expanded' + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/live-epg-panel-state.ts b/libs/portal/shared/util/src/lib/live-epg-panel-state.ts new file mode 100644 index 000000000..325f91544 --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-epg-panel-state.ts @@ -0,0 +1,25 @@ +export type LiveEpgPanelState = 'expanded' | 'collapsed'; + +export const LIVE_EPG_PANEL_STATE_STORAGE_KEY = 'live-epg-panel-state'; +export const DEFAULT_LIVE_EPG_PANEL_STATE: LiveEpgPanelState = 'expanded'; + +export function isLiveEpgPanelState( + value: unknown +): value is LiveEpgPanelState { + return value === 'expanded' || value === 'collapsed'; +} + +export function restoreLiveEpgPanelState( + storageKey: string = LIVE_EPG_PANEL_STATE_STORAGE_KEY, + fallback: LiveEpgPanelState = DEFAULT_LIVE_EPG_PANEL_STATE +): LiveEpgPanelState { + const storedValue = localStorage.getItem(storageKey); + return isLiveEpgPanelState(storedValue) ? storedValue : fallback; +} + +export function persistLiveEpgPanelState( + state: LiveEpgPanelState, + storageKey: string = LIVE_EPG_PANEL_STATE_STORAGE_KEY +): void { + localStorage.setItem(storageKey, state); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 6073d211e..a2cb22c2e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -82,20 +82,25 @@
} -
-
- @if (isLoadingEpg()) { -
- -
- } @else { - - } -
+ @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer; +
+ @if (canCollapseEpgPanel) { + + + + } @else { + + }
} @else { }
+ + +
+ @if (isLoadingEpg()) { +
+ +
+ } @else { + + } +
+
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 643411369..e2aa8bbe7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { + LIVE_EPG_PANEL_STATE_STORAGE_KEY, PORTAL_PLAYER, ResizableDirective, } from '@iptvnator/portal/shared/util'; @@ -15,7 +16,11 @@ import { MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { PlaylistsService } from 'services'; import { EpgProgram } from 'shared-interfaces'; -import { WebPlayerViewComponent } from 'shared-portals'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; @Component({ @@ -57,6 +62,21 @@ class StubEpgListComponent { readonly archivePlaybackAvailable = input(null); } +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + @Component({ selector: 'app-portal-empty-state', standalone: true, @@ -184,11 +204,15 @@ describe('StalkerLiveStreamLayoutComponent', () => { isLoadingBulkItvEpg.set(false); hasMoreChannels.set(false); page.set(0); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); resolveItvPlayback.mockReset(); resolveItvPlayback.mockResolvedValue({ streamUrl: 'https://example.com/alpha.m3u8', }); + portalPlayer.isEmbeddedPlayer.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + portalPlayer.openResolvedPlayback.mockClear(); fetchChannelEpg.mockReset(); fetchChannelEpg.mockResolvedValue([]); ensureBulkItvEpg.mockReset(); @@ -242,6 +266,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ ChannelListItemComponent, EpgListComponent, + LiveEpgPanelComponent, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -252,6 +277,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ StubChannelListItemComponent, StubEpgListComponent, + StubLiveEpgPanelComponent, StubPortalEmptyStateComponent, StubResizableDirective, MockPipe( @@ -270,6 +296,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { afterEach(() => { fixture?.destroy(); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); }); it('renders the controlled epg list and removes the load-more button', () => { @@ -283,10 +310,75 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBeNull(); }); - it('does not reset live channels when loading the next lazy page', async () => { - hasMoreChannels.set(true); + it('restores the collapsed live EPG panel state after embedded playback starts', async () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('does not render the collapsible panel for external playback', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('renders the current EPG program in the collapsible panel summary', async () => { fixture.detectChanges(); await fixture.whenStable(); + + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); + }); + + it('does not reset live channels when loading the next lazy page', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + page.set(0); + hasMoreChannels.set(true); stalkerStore.setItvChannels.mockClear(); stalkerStore.setPage.mockClear(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 715122ed3..6e509bb7c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -15,10 +16,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { - ChannelListItemComponent, - ResizableDirective, -} from 'components'; +import { ChannelListItemComponent, ResizableDirective } from 'components'; import { PlaylistsService } from 'services'; import { Channel, @@ -27,12 +25,19 @@ import { ResolvedPortalPlayback, } from 'shared-interfaces'; import { EpgListComponent } from '@iptvnator/ui/epg'; -import { WebPlayerViewComponent } from 'shared-portals'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { PORTAL_PLAYER, createLogger, getAdjacentChannelItem, getChannelItemByNumber, + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; import { @@ -50,6 +55,8 @@ import { ChannelListItemComponent, EpgListComponent, MatProgressSpinnerModule, + LiveEpgPanelComponent, + NgTemplateOutlet, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -96,9 +103,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); - readonly streamUrl = computed( - () => this.activePlayback()?.streamUrl ?? '' - ); + readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? ''); /** EPG */ readonly fallbackEpgPrograms = signal([]); @@ -112,6 +117,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly currentProgram = computed(() => this.findCurrentProgram(this.activeEpgPrograms()) ); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.currentProgram()) + ); readonly controlledChannel = computed(() => { const selectedType = this.stalkerStore.selectedContentType(); const selectedItem = this.stalkerStore.selectedItem(); @@ -343,6 +357,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.setPage(nextPage); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + private async loadEpgForChannel(item: StalkerItvChannel) { const requestId = ++this.epgLoadRequestId; const normalizedChannelId = normalizeStalkerEntityId(item.id); @@ -358,12 +378,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { try { if (shouldEnsureBulk) { await this.stalkerStore.ensureBulkItvEpg(168); - if ( - !this.isCurrentEpgRequest( - requestId, - normalizedChannelId - ) - ) { + if (!this.isCurrentEpgRequest(requestId, normalizedChannelId)) { return; } } @@ -376,9 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const fallbackItems = await this.stalkerStore.fetchChannelEpg( item.id ); - if ( - !this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (!this.isCurrentEpgRequest(requestId, normalizedChannelId)) { return; } @@ -389,15 +402,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ); } catch (error) { this.logger.warn('Failed to load Stalker live EPG', error); - if ( - this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (this.isCurrentEpgRequest(requestId, normalizedChannelId)) { this.fallbackEpgPrograms.set([]); } } finally { - if ( - this.isCurrentEpgRequest(requestId, normalizedChannelId) - ) { + if (this.isCurrentEpgRequest(requestId, normalizedChannelId)) { this.isLoadingFallbackEpg.set(false); } } @@ -508,10 +517,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } } - private toProgram( - item: EpgItem, - channelId: string | number - ): EpgProgram { + private toProgram(item: EpgItem, channelId: string | number): EpgProgram { return { start: item.start, stop: item.stop || item.end, @@ -527,6 +533,20 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }; } + private toLiveEpgPanelSummary( + program: EpgProgram | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop, + }; + } + private toTimestamp( rawTimestamp: string | number | null | undefined, rawDate: string @@ -554,7 +574,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { program.stop, program.stopTimestamp ); - return start !== null && stop !== null && now >= start && now < stop; + return ( + start !== null && + stop !== null && + now >= start && + now < stop + ); }) ?? null ); } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index e8bd8c2e9..b4db74d23 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,6 +1,5 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); @let streamUrl = activeStreamUrl(); -@let epgPrograms = controlledEpgPrograms(); @if (selectedCategoryId()) {
} -
-
- @if (isLoadingEpg()) { -
- -
- } @else if (isElectron && epgPrograms.length > 0) { - @if (showArchiveUnavailableNotice()) { -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' - | translate - }} -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' - | translate - }} -
-
- } - - } @else if (!isElectron && epgItems().length > 0) { - - } @else { - - } -
+
+ @if (isEmbeddedPlayer) { + + + + } @else { + + }
} @else { }
+ + +
+ @if (isLoadingEpg()) { +
+ +
+ } @else if (isElectron && controlledEpgPrograms().length > 0) { + @if (showArchiveUnavailableNotice()) { +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' | translate + }} +
+
+ {{ + 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' + | translate + }} +
+
+ } + + } @else if (!isElectron && epgItems().length > 0) { + + } @else { + + } +
+
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index a0e08ea33..b1922d72c 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -6,6 +6,7 @@ import { MockPipe } from 'ng-mocks'; import { TranslatePipe } from '@ngx-translate/core'; import { of } from 'rxjs'; import { + LIVE_EPG_PANEL_STATE_STORAGE_KEY, PORTAL_PLAYER, ResizableDirective, } from '@iptvnator/portal/shared/util'; @@ -15,7 +16,12 @@ import { XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; -import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { + EpgViewComponent, + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { LiveStreamLayoutComponent } from './live-stream-layout.component'; @@ -63,6 +69,21 @@ class StubEpgListComponent { readonly programActivated = output(); } +@Component({ + selector: 'app-live-epg-panel', + standalone: true, + template: ` +
{{ summary()?.title }}
+ + `, +}) +class StubLiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); +} + @Directive({ selector: '[appResizable]', standalone: true, @@ -130,6 +151,7 @@ describe('LiveStreamLayoutComponent', () => { jest.useFakeTimers(); jest.setSystemTime(fixedNow); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); window.electron = { updateRemoteControlStatus: jest.fn(), @@ -184,6 +206,7 @@ describe('LiveStreamLayoutComponent', () => { imports: [ EpgListComponent, EpgViewComponent, + LiveEpgPanelComponent, PortalChannelsListComponent, ResizableDirective, TranslatePipe, @@ -194,6 +217,7 @@ describe('LiveStreamLayoutComponent', () => { imports: [ StubEpgListComponent, StubEpgViewComponent, + StubLiveEpgPanelComponent, StubPortalChannelsListComponent, StubResizableDirective, MockPipe( @@ -214,6 +238,7 @@ describe('LiveStreamLayoutComponent', () => { fixture.destroy(); jest.useRealTimers(); localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY); + localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); window.electron = originalElectron; }); @@ -236,6 +261,74 @@ describe('LiveStreamLayoutComponent', () => { expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); + it('restores the collapsed live EPG panel state for embedded playback', () => { + fixture.destroy(); + localStorage.setItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY, 'collapsed'); + + fixture = TestBed.createComponent(LiveStreamLayoutComponent); + component = fixture.componentInstance; + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(true); + }); + + it('persists live EPG panel toggle changes', () => { + component.onLiveEpgPanelCollapsedChange(true); + + expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'collapsed' + ); + + component.onLiveEpgPanelCollapsedChange(false); + + expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('does not render the collapsible panel for external playback', () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('app-live-epg-panel') + ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.epg') + .classList.contains('epg-collapsed') + ).toBe(false); + }); + + it('renders the current EPG program in the collapsible panel summary', () => { + epgItems.set([ + buildEpgItem( + 'current', + 'Current Show', + '2026-04-05T11:30:00.000Z', + '2026-04-05T12:30:00.000Z' + ), + ]); + currentEpgItem.set(epgItems()[0]); + + component.playLive(sampleChannel); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel-summary') + .textContent + ).toContain('Current Show'); + }); + it('uses currentEpgItem instead of assuming the first schedule item is current', () => { epgItems.set([ buildEpgItem( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index bfc6b8a08..ffce599f3 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -22,8 +23,11 @@ import { getAdjacentChannelItem, getChannelItemByNumber, isWorkspaceLayoutRoute, + LiveEpgPanelState, + persistLiveEpgPanelState, persistPortalChannelSortMode, queryParamSignal, + restoreLiveEpgPanelState, restorePortalChannelSortMode, } from '@iptvnator/portal/shared/util'; import { @@ -34,7 +38,12 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; +import { + EpgViewComponent, + LiveEpgPanelComponent, + LiveEpgPanelSummary, + WebPlayerViewComponent, +} from 'shared-portals'; import { EpgItem, EpgProgram } from 'shared-interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { ActivatedRoute } from '@angular/router'; @@ -58,11 +67,13 @@ interface XtreamLiveChannelItem { imports: [ EpgListComponent, EpgViewComponent, + LiveEpgPanelComponent, MatIcon, MatIconButton, MatMenuModule, MatProgressSpinnerModule, MatTooltipModule, + NgTemplateOutlet, PortalChannelsListComponent, PortalEmptyStateComponent, ResizableDirective, @@ -142,6 +153,15 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.hasPastPrograms() && !this.archivePlaybackAvailable() ); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); + readonly liveEpgPanelSummary = computed(() => + this.toLiveEpgPanelSummary(this.currentEpgItem()) + ); readonly liveChannelSortLabel = computed(() => getPortalChannelSortModeLabel(this.liveChannelSortMode()) ); @@ -321,6 +341,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistPortalChannelSortMode(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); @@ -437,6 +463,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }; } + private toLiveEpgPanelSummary( + program: EpgItem | null | undefined + ): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop ?? program.end, + }; + } + private getProgramTimestampSeconds( dateValue: string, unixTimestampValue?: number | string | null diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index cc2f9e6a9..44b655113 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -102,7 +102,8 @@ export const SettingsStore = signalStore( isEmbeddedPlayer() { return ( store.player() === VideoPlayer.VideoJs || - store.player() === VideoPlayer.Html5Player + store.player() === VideoPlayer.Html5Player || + store.player() === VideoPlayer.ArtPlayer ); }, })), diff --git a/libs/ui/shared-portals/src/index.ts b/libs/ui/shared-portals/src/index.ts index f578cba7d..361627ae0 100644 --- a/libs/ui/shared-portals/src/index.ts +++ b/libs/ui/shared-portals/src/index.ts @@ -1,3 +1,4 @@ -export * from './lib/epg-view/epg-view.component'; -export * from './lib/live-stream-layout/live-stream-layout.component'; +export * from './lib/epg-view/epg-view.component'; +export * from './lib/live-epg-panel/live-epg-panel.component'; +export * from './lib/live-stream-layout/live-stream-layout.component'; export { WebPlayerViewComponent } from '@iptvnator/ui/playback'; diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html new file mode 100644 index 000000000..d2dfec81d --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html @@ -0,0 +1,76 @@ +
+
+ + +
+ + {{ 'EPG.CURRENT_PROGRAM' | translate }} + + + @if (loading()) { + + {{ 'EPG.LOADING' | translate }} + + } @else if (hasSummary()) { + + {{ summary()?.title }} + + + @if (hasTimeRange()) { + + @if (summary()?.start) { + {{ summary()?.start | date: 'HH:mm' }} + } + @if (summary()?.start && summary()?.stop) { + - + } + @if (summary()?.stop) { + {{ summary()?.stop | date: 'HH:mm' }} + } + + } + } @else { + + {{ 'EPG.NO_PROGRAM_INFO' | translate }} + + } +
+ + @if (progress() !== null) { + + } +
+ +
+ +
+
diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss new file mode 100644 index 000000000..45849c282 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.scss @@ -0,0 +1,130 @@ +:host { + display: block; + height: 100%; + min-height: 0; +} + +.live-epg-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--mat-sys-surface); +} + +.live-epg-panel__summary-bar { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + column-gap: 8px; + min-height: 56px; + padding: 7px 12px 9px 8px; + flex-shrink: 0; + border-bottom: 1px solid var(--app-separator); + background: var( + --app-widget-header-bg, + var(--mat-sys-surface-container-high) + ); + box-sizing: border-box; +} + +.live-epg-panel__toggle { + width: 40px; + height: 40px; + border-radius: 10px; + color: var(--mat-sys-on-surface-variant); + app-region: no-drag; + + &:hover { + color: var(--mat-sys-on-surface); + background: color-mix(in srgb, currentColor 10%, transparent); + } +} + +.live-epg-panel__summary { + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + +.live-epg-panel__label { + flex-shrink: 0; + font-size: 0.7rem; + line-height: 1; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--app-selection-color); +} + +.live-epg-panel__title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9rem; + line-height: 1.25; + font-weight: 600; + color: var(--mat-sys-on-surface); +} + +.live-epg-panel__title--muted { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); +} + +.live-epg-panel__time { + flex-shrink: 0; + font-size: 0.78rem; + line-height: 1; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--mat-sys-on-surface-variant); +} + +.live-epg-panel__progress { + position: absolute; + inset-inline: 8px 12px; + bottom: 5px; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--mat-sys-on-surface) 14%, transparent); +} + +.live-epg-panel__progress-fill { + height: 100%; + border-radius: inherit; + background: var(--app-selection-color); + box-shadow: 0 0 8px -2px var(--app-selection-glow); + transition: width 0.35s ease; +} + +.live-epg-panel__body { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + transition: + opacity 0.16s ease, + block-size 0.16s ease; +} + +.live-epg-panel--collapsed { + .live-epg-panel__summary-bar { + border-bottom-color: transparent; + } + + .live-epg-panel__body { + flex-basis: 0; + block-size: 0; + min-block-size: 0; + opacity: 0; + visibility: hidden; + pointer-events: none; + } +} diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts new file mode 100644 index 000000000..d13af57c1 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.spec.ts @@ -0,0 +1,110 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, +} from './live-epg-panel.component'; + +@Component({ + standalone: true, + imports: [LiveEpgPanelComponent], + template: ` + +
Projected EPG
+
+ `, +}) +class HostComponent { + collapsed = false; + loading = false; + summary: LiveEpgPanelSummary | null = { + title: 'Current Show', + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', + }; +} + +describe('LiveEpgPanelComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-04-05T12:00:00.000Z')); + + await TestBed.configureTestingModule({ + imports: [ + HostComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + }); + + it('renders the current program summary and progress', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel__title') + .textContent + ).toContain('Current Show'); + expect( + fixture.nativeElement.querySelector( + '.live-epg-panel__progress-fill' + ).style.width + ).toBe('50%'); + }); + + it('emits and applies collapsed state from the toggle button', () => { + fixture.detectChanges(); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.collapsed).toBe(true); + expect( + fixture.nativeElement + .querySelector('.live-epg-panel') + .classList.contains('live-epg-panel--collapsed') + ).toBe(true); + }); + + it('keeps projected EPG content mounted while collapsed and makes it inert', () => { + fixture.componentInstance.collapsed = true; + fixture.detectChanges(); + + const body = fixture.nativeElement.querySelector( + '.live-epg-panel__body' + ); + + expect( + fixture.nativeElement.querySelector('.projected-content') + .textContent + ).toContain('Projected EPG'); + expect(body.getAttribute('aria-hidden')).toBe('true'); + expect(body.hasAttribute('inert')).toBe(true); + }); + + it('renders the fallback text when there is no current program', () => { + fixture.componentInstance.summary = null; + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.live-epg-panel__title') + .textContent + ).toContain('EPG.NO_PROGRAM_INFO'); + }); +}); diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts new file mode 100644 index 000000000..870717612 --- /dev/null +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.ts @@ -0,0 +1,102 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, +} from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +export interface LiveEpgPanelSummary { + readonly title?: string | null; + readonly start?: string | number | Date | null; + readonly stop?: string | number | Date | null; + readonly progress?: number | null; +} + +@Component({ + selector: 'app-live-epg-panel', + imports: [DatePipe, MatIcon, MatIconButton, MatTooltip, TranslatePipe], + templateUrl: './live-epg-panel.component.html', + styleUrl: './live-epg-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LiveEpgPanelComponent { + readonly collapsed = input(false); + readonly summary = input(null); + readonly loading = input(false); + readonly collapsedChange = output(); + + private readonly currentTimeMs = signal(Date.now()); + + readonly hasSummary = computed(() => { + const title = this.summary()?.title; + return typeof title === 'string' && title.trim().length > 0; + }); + + readonly hasTimeRange = computed(() => { + const summary = this.summary(); + return !!summary?.start || !!summary?.stop; + }); + + readonly progress = computed(() => { + const summary = this.summary(); + if (!summary) { + return null; + } + + const explicitProgress = Number(summary.progress); + if (Number.isFinite(explicitProgress)) { + return clampProgress(explicitProgress); + } + + const startMs = toTimeMs(summary.start); + const stopMs = toTimeMs(summary.stop); + if (startMs === null || stopMs === null || stopMs <= startMs) { + return null; + } + + const elapsed = this.currentTimeMs() - startMs; + return clampProgress((elapsed / (stopMs - startMs)) * 100); + }); + + constructor() { + effect((onCleanup) => { + const intervalId = window.setInterval(() => { + this.currentTimeMs.set(Date.now()); + }, 30_000); + + onCleanup(() => clearInterval(intervalId)); + }); + } + + toggleCollapsed(): void { + this.collapsedChange.emit(!this.collapsed()); + } +} + +function toTimeMs( + value: string | number | Date | null | undefined +): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = + value instanceof Date + ? value.getTime() + : typeof value === 'number' + ? value + : Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function clampProgress(value: number): number { + return Math.min(100, Math.max(0, value)); +} diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html index 71982145d..7408ff411 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,7 +1,9 @@
@@ -58,7 +60,7 @@
- @if (player === 'videojs' || player === 'html5') { + @if (usesInternalPlayer()) { @if (streamUrl) {
@@ -66,10 +68,27 @@ } } @if (streamUrl) { -
-
- -
+
+ @if (usesInternalPlayer()) { + +
+ +
+
+ } @else { +
+ +
+ }
} @else {
diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss index 4a9e1a447..519e8e7d5 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -20,6 +20,10 @@ height: 100%; } +.itv-container--epg-collapsed { + grid-template-rows: minmax(0, 1fr) 56px; +} + .itv-container-epg { display: grid; // Use auto for sidebar to respect the directive's width @@ -63,6 +67,12 @@ grid-area: epg; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; + + &.epg-collapsed { + min-height: 56px; + } } .search-panel { @@ -91,6 +101,11 @@ min-height: 0; } +.epg > app-live-epg-panel { + flex: 1; + min-height: 0; +} + .sidebar-content { height: 100%; overflow-y: auto; @@ -149,7 +164,11 @@ border-radius: 4px; object-fit: cover; flex-shrink: 0; - background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-on-surface) 5%, + transparent + ); } span, diff --git a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts index f2fcc0c79..a861e3ecc 100644 --- a/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/ui/shared-portals/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -3,6 +3,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling'; import { ChangeDetectionStrategy, Component, + computed, EventEmitter, Input, Output, @@ -15,28 +16,38 @@ import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { FilterPipe } from '@iptvnator/pipes'; import { TranslateModule } from '@ngx-translate/core'; +import { + LiveEpgPanelState, + persistLiveEpgPanelState, + restoreLiveEpgPanelState, +} from '@iptvnator/portal/shared/util'; import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { ResizableDirective } from 'components'; import { EpgItem, VideoPlayer, XtreamItem } from 'shared-interfaces'; import { EpgViewComponent } from '../epg-view/epg-view.component'; +import { + LiveEpgPanelComponent, + LiveEpgPanelSummary, +} from '../live-epg-panel/live-epg-panel.component'; @Component({ selector: 'app-live-stream-layout', templateUrl: './live-stream-layout.component.html', styleUrls: ['./live-stream-layout.component.scss'], imports: [ - EpgViewComponent, - FilterPipe, - FormsModule, - MatListModule, - MatIconModule, - MatInputModule, - MatFormFieldModule, - ResizableDirective, - ScrollingModule, - WebPlayerViewComponent, - TranslateModule -], + EpgViewComponent, + FilterPipe, + FormsModule, + LiveEpgPanelComponent, + MatListModule, + MatIconModule, + MatInputModule, + MatFormFieldModule, + ResizableDirective, + ScrollingModule, + WebPlayerViewComponent, + TranslateModule, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class LiveStreamLayoutComponent { @@ -49,8 +60,51 @@ export class LiveStreamLayoutComponent { @Output() itemClicked = new EventEmitter(); searchString = signal(''); + readonly liveEpgPanelState = signal( + restoreLiveEpgPanelState() + ); + readonly isLiveEpgPanelCollapsed = computed( + () => this.liveEpgPanelState() === 'collapsed' + ); trackBy(_index: number, item: XtreamItem) { return item.stream_id; } + + usesInternalPlayer(): boolean { + return ( + this.player === VideoPlayer.VideoJs || + this.player === VideoPlayer.Html5Player || + this.player === VideoPlayer.ArtPlayer + ); + } + + getLiveEpgPanelSummary(): LiveEpgPanelSummary | null { + const currentProgram = + this.epgItems?.find((item) => this.isCurrentProgram(item)) ?? null; + + if (!currentProgram) { + return null; + } + + return { + title: currentProgram.title, + start: currentProgram.start, + stop: currentProgram.stop ?? currentProgram.end, + }; + } + + onLiveEpgPanelCollapsedChange(collapsed: boolean): void { + const state: LiveEpgPanelState = collapsed ? 'collapsed' : 'expanded'; + this.liveEpgPanelState.set(state); + persistLiveEpgPanelState(state); + } + + private isCurrentProgram(item: EpgItem): boolean { + const end = item.stop ?? item.end; + const now = Date.now(); + const start = new Date(item.start).getTime(); + const stop = new Date(end).getTime(); + return now >= start && now <= stop; + } } diff --git a/libs/ui/shared-portals/tsconfig.spec.json b/libs/ui/shared-portals/tsconfig.spec.json index 553f8af7c..65a0eb8ce 100644 --- a/libs/ui/shared-portals/tsconfig.spec.json +++ b/libs/ui/shared-portals/tsconfig.spec.json @@ -1,22 +1,17 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "module": "commonjs", - "target": "es2016", - "types": [ - "jest", - "node" - ], - "moduleResolution": "node10" - }, - "files": [ - "src/test-setup.ts" - ], - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "ESNext", + "target": "ESNext", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index b79d904bf..051a34713 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -65,6 +65,21 @@ flex: 1 1 0; min-height: 0; overflow: hidden; + display: flex; + flex-direction: column; + transition: + flex-basis 0.18s ease, + min-height 0.18s ease; + + &.epg-collapsed { + flex: 0 0 56px; + min-height: 56px; + } + + > app-live-epg-panel { + flex: 1; + min-height: 0; + } } .epg-content { @@ -74,7 +89,8 @@ display: flex; flex-direction: column; - > app-epg-view { + > app-epg-view, + > app-live-epg-panel { flex: 1; min-height: 0; } From d163a1fbf23cd50fa67a6e01434e45717a97fdd2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Apr 2026 20:14:04 +0200 Subject: [PATCH 02/54] feat(dashboard): contained logo + blurred backdrop for live channels Render live TV channel logos with object-fit: contain on a soft blurred wash sampled from the logo itself, instead of stretching small (100-256px) logos to fill 2:3 portrait cards with object-fit: cover. Mirrors the treatment used by Apple Podcasts and Plex Live TV. VOD/series posters still get the full-bleed cover treatment. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 6bb6f5796090 --- .../lib/rails/dashboard-rail.component.html | 12 ++++++++ .../lib/rails/dashboard-rail.component.scss | 28 +++++++++++++++++++ .../src/lib/rails/dashboard-rail.component.ts | 1 + .../workspace-dashboard-rails.component.html | 1 + .../workspace-dashboard-rails.component.scss | 21 ++++++++++++++ .../workspace-dashboard-rails.component.ts | 5 ++++ 6 files changed, 68 insertions(+) diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 60dd77c2f..07878677b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -49,9 +49,21 @@ >
@if (card.imageUrl && !failedImages()[card.id]) { + @if (card.contentType === 'live') { + + } ; actions?: DashboardRailAction[]; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 054be0621..abc1c3a7a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -54,6 +54,7 @@ [class.hero--has-default-backdrop]=" item.backdropSource === 'fallback' " + [class.hero--live]="item.contentType === 'live'" [routerLink]="item.link" [state]="item.state ?? {}" > diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index 0e25da815..a7f1f8645 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -223,6 +223,27 @@ } } +// Live channel logos (small square images) get destroyed by the default +// `object-fit: cover` poster treatment. The `.hero--live` modifier centers +// the logo with breathing room over a tinted backdrop. The blurred +// `.hero__backdrop` already provides the ambient color wash for the wider hero. +.hero--live .hero__poster { + background: + radial-gradient( + circle at 50% 35%, + var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%, + transparent 65% + ), + var(--app-widget-bg, #1e2330); + + img { + box-sizing: border-box; + object-fit: contain; + padding: 22% 18%; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45)); + } +} + .hero__body { position: relative; z-index: 2; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index abb12ce69..84e7163a9 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -52,6 +52,7 @@ const SKELETON_RAILS = [1, 2, 3] as const; interface DashboardHeroModel { readonly backdropUrl?: string; readonly backdropSource: DashboardHeroBackdropSource; + readonly contentType?: 'live' | 'movie' | 'series'; readonly fallbackBackdropBackground: string; readonly fallbackPosterBackground: string; readonly hasBackdrop: boolean; @@ -260,6 +261,7 @@ export class WorkspaceDashboardRailsComponent { return { ...artwork, + contentType: item.type, icon: this.typeIcon(item.type), link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), @@ -379,6 +381,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getRecentItemLink(item), state: this.data.getRecentItemNavigationState(item), }; @@ -391,6 +394,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`, imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getGlobalFavoriteLink(item), state: this.data.getGlobalFavoriteNavigationState(item), }; @@ -409,6 +413,7 @@ export class WorkspaceDashboardRailsComponent { subtitle: subtitleParts.join(' · '), imageUrl: item.poster_url, icon: this.typeIcon(item.type), + contentType: item.type, link: this.data.getRecentlyAddedLink(item), state: this.data.getRecentlyAddedNavigationState(item), }; From 31258624b572faa00a7ef1d29cedbd25b5c9c2a2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Apr 2026 00:20:34 +0200 Subject: [PATCH 03/54] feat: unify live epg toolbar Entire-Checkpoint: 978eaff2e478 --- .plans/2026-04-28-live-epg-unified-toolbar.md | 49 +++++++ .../src/xtream-epg.e2e.ts | 67 +++++---- apps/web-e2e/src/stalker.e2e.ts | 8 +- docs/architecture/iptvnator-ui-guidelines.md | 5 +- .../video-player/video-player.component.html | 8 ++ .../video-player.component.spec.ts | 3 + .../video-player/video-player.component.ts | 14 ++ .../stalker-live-stream-layout.component.html | 26 +++- ...alker-live-stream-layout.component.spec.ts | 6 + .../stalker-live-stream-layout.component.ts | 18 ++- .../live-stream-layout.component.html | 26 +++- .../live-stream-layout.component.spec.ts | 6 + .../live-stream-layout.component.ts | 19 ++- libs/ui/epg/src/index.ts | 1 + libs/ui/epg/src/lib/epg-date.ts | 39 ++++++ .../src/lib/epg-list/epg-list.component.html | 54 ++++---- .../lib/epg-list/epg-list.component.spec.ts | 34 ++++- .../src/lib/epg-list/epg-list.component.ts | 61 +++++---- .../live-epg-panel.component.html | 129 ++++++++++++++---- .../live-epg-panel.component.scss | 121 +++++++++++++++- .../live-epg-panel.component.spec.ts | 32 +++++ .../live-epg-panel.component.ts | 44 +++++- tsconfig.base.json | 1 + 23 files changed, 648 insertions(+), 123 deletions(-) create mode 100644 .plans/2026-04-28-live-epg-unified-toolbar.md create mode 100644 libs/ui/epg/src/lib/epg-date.ts diff --git a/.plans/2026-04-28-live-epg-unified-toolbar.md b/.plans/2026-04-28-live-epg-unified-toolbar.md new file mode 100644 index 000000000..c0b45efff --- /dev/null +++ b/.plans/2026-04-28-live-epg-unified-toolbar.md @@ -0,0 +1,49 @@ +# Unified Live EPG Toolbar + +## Summary + +Merge the current-program strip and date navigator into one shared `app-live-epg-panel` toolbar for M3U, Xtream, and Stalker live playback. The EPG list starts directly below the unified toolbar when the panel is expanded. In collapsed mode, keep the slim current-program strip and show a compact date chip. + +## Key Changes + +- Update `EpgListComponent` to support controlled date state: + - Add `[selectedDate]`, `(selectedDateChange)`, and `[showDateNavigator]`. + - Rename its internal date signal to avoid API collision. + - Keep existing standalone behavior by default, so non-live or external-player layouts still show the internal date row. +- Add small shared EPG date helpers in `@iptvnator/ui/epg`: + - Today date key. + - Parse date key. + - Shift date key by previous/next day. +- Extend `LiveEpgPanelComponent` with optional date navigation: + - Inputs: `[showDateNavigator]`, `[selectedDate]`. + - Output: `(dateNavigation)` emitting `prev | next`. + - Expanded toolbar: collapse button, current-program label/title/time, previous-day button, date label, next-day button, progress line. + - Collapsed toolbar: current-program summary plus compact date chip; the chip opens a small menu for previous/next day on narrow or collapsed states. +- Wire the unified toolbar into M3U, Xtream, and Stalker live layouts. +- Update focused unit specs and e2e selectors that currently assume `.selected-date` / `.next-day` live inside `app-epg-list`. +- Update `docs/architecture/iptvnator-ui-guidelines.md` to document that live inline-player layouts use the unified current-program/date toolbar. + +## Test Plan + +- Run unit tests: + - `pnpm nx test ui-epg` + - `pnpm nx test shared-portals` + - `pnpm nx test portal-xtream-feature` + - `pnpm nx test portal-stalker-feature` + - `pnpm nx test web` +- Run build/type validation through Nx: + - `pnpm nx build web --configuration=development` +- Use the running Electron app via CDP `127.0.0.1:9222` with `agent-browser`: + - Verify the IPTVnator page target is selected. + - Confirm the old `app-epg-list #channel-header` date row is absent when wrapped by `app-live-epg-panel`. + - Confirm the program list starts immediately below the unified toolbar. + - Click previous/next day in the unified toolbar and verify visible EPG programs change. + - Collapse the panel and verify the slim strip remains, body is inert, and the date chip stays visible. + - Capture a screenshot for visual confirmation. +- If docs changed and `IPTVNATOR_WIKI_VAULT` is configured, run `pnpm wiki:export --mode changed`. + +## Assumptions + +- Keep external MPV/VLC and non-inline layouts on the existing full EPG list behavior. +- Use existing translation keys for current program, loading, no program info, previous day, and next day. +- Leave unrelated dirty worktree changes untouched. diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 59876cc51..25b6f139d 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -31,7 +31,9 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { const fixture = await fetchXtreamEpgFixture(request, epgCredentials); const currentProgram = fixture.shortEpg[0]; if (!currentProgram) { - throw new Error('Expected the Xtream EPG fixture to include a current program.'); + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); } const app = await launchElectronApp(dataDir, { env: { TZ: timeZone }, @@ -45,7 +47,10 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); - await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await clickCategoryByNameExact( + app.mainWindow, + fixture.categoryName + ); const channelRow = channelItemByTitle( app.mainWindow, @@ -55,14 +60,18 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { await expect .poll(async () => - ((await channelRow.locator('.epg-title').textContent()) ?? '').trim() + ( + (await channelRow + .locator('.epg-title') + .textContent()) ?? '' + ).trim() ) .toBe(currentProgram.title); await expect .poll(async () => { - return (await channelRow.locator('.epg-time').allInnerTexts()).map( - (value) => value.trim() - ); + return ( + await channelRow.locator('.epg-time').allInnerTexts() + ).map((value) => value.trim()); }) .toEqual([ formatTimeInZone(currentProgram.startTimestamp, timeZone), @@ -84,17 +93,17 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { timeZone ); - await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( - currentDayTitles - ); + await expect + .poll(() => visibleProgramTitles(app.mainWindow)) + .toEqual(currentDayTitles); const currentProgramRow = app.mainWindow .locator('app-epg-list .program-item.current-program') .first(); await expect(currentProgramRow).toBeVisible(); - await expect(currentProgramRow.locator('.program-title')).toHaveText( - currentProgram.title - ); + await expect( + currentProgramRow.locator('.program-title') + ).toHaveText(currentProgram.title); await expect(currentProgramRow.locator('.time')).toHaveText( `${formatTimeInZone( currentProgram.startTimestamp, @@ -114,25 +123,29 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { firstFutureDateKey!, timeZone ); - const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!); + const nextDayClicks = dayDifference( + currentDateKey, + firstFutureDateKey! + ); for (let index = 0; index < nextDayClicks; index += 1) { - await app.mainWindow.locator('app-epg-list .next-day').click(); + await app.mainWindow + .locator( + 'app-live-epg-panel .next-day, app-epg-list .next-day' + ) + .click(); } - await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual( - futureDayTitles - ); + await expect + .poll(() => visibleProgramTitles(app.mainWindow)) + .toEqual(futureDayTitles); } finally { await closeElectronApp(app); } }); } -function formatTimeInZone( - timestampSeconds: number, - timeZone: string -): string { +function formatTimeInZone(timestampSeconds: number, timeZone: string): string { return new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', @@ -157,7 +170,9 @@ function formatDateKeyInZone( const day = parts.find((part) => part.type === 'day')?.value; if (!year || !month || !day) { - throw new Error(`Failed to format a date key for timezone ${timeZone}.`); + throw new Error( + `Failed to format a date key for timezone ${timeZone}.` + ); } return `${year}-${month}-${day}`; @@ -215,14 +230,18 @@ function dayDifference(fromDateKey: string, toDateKey: string): number { return Math.round((to - from) / (24 * 60 * 60 * 1000)); } -async function visibleProgramTitles(page: Parameters[0]) { +async function visibleProgramTitles( + page: Parameters[0] +) { return page .locator('app-epg-list .program-title') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } -async function getProgressWidthPercent(row: ReturnType) { +async function getProgressWidthPercent( + row: ReturnType +) { const style = await row.locator('.epg-progress-fill').getAttribute('style'); const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; return Number.parseFloat(width); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 57e6e543f..7747bb1b7 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -201,9 +201,11 @@ test('@stalker EPG data loads for ITV channel', async ({ page }) => { await expect(page.locator('app-epg-list')).toBeVisible({ timeout: 20_000, }); - await expect(page.locator('app-epg-list .selected-date')).toBeVisible({ - timeout: 20_000, - }); + await expect( + page.locator( + 'app-live-epg-panel .selected-date, app-epg-list .selected-date' + ) + ).toBeVisible({ timeout: 20_000 }); }); test('@stalker bulk EPG is fetched once and reused across channel switches', async ({ diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1bedd4071..da6637338 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -130,10 +130,13 @@ The shared row should be reused instead of rebuilding channel markup per view. - Live TV layouts with an internal player use `app-live-epg-panel` around the EPG content. +- The live panel toolbar owns the current-program summary and live date + navigation together, so `app-epg-list` hides its internal date navigator when + projected inside that panel. - Collapsed state is shared across M3U, Xtream, and Stalker with `live-epg-panel-state`; missing or invalid values restore to expanded. - The collapsed panel is a slim current-program strip with a trailing progress - line and an expand button. + line, an expand button, and a compact date chip. - Do not render the collapsed strip for external MPV/VLC playback; those layouts keep the full EPG-only panel. - Keep the EPG content mounted while collapsed so current-program state can diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index e330aa962..3dfbf95f7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -91,15 +91,23 @@
diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 6787ce449..8c5b897e2 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -45,7 +45,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } describe('VideoPlayerComponent', () => { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 0b154baf0..fb3b3c035 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -22,8 +22,11 @@ import { isM3uCatchupPlaybackSupported } from 'm3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { COMPONENT_OVERLAY_REF, + EpgDateNavigationDirection, EpgListComponent, + getTodayEpgDateKey, MultiEpgContainerComponent, + shiftEpgDateKey, } from '@iptvnator/ui/epg'; import { ChannelActions, @@ -158,6 +161,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -444,6 +448,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a2cb22c2e..e1732252a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -94,12 +94,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + }
} @else { @@ -110,7 +123,7 @@ }
- +
@if (isLoadingEpg()) {
@@ -121,6 +134,13 @@ [controlledChannel]="controlledChannel()" [controlledPrograms]="activeEpgPrograms()" [controlledArchiveDays]="0" + [selectedDate]=" + unifiedDateNavigator ? selectedLiveEpgDate() : null + " + [showDateNavigator]="!unifiedDateNavigator" + (selectedDateChange)=" + unifiedDateNavigator && onLiveEpgSelectedDateChange($event) + " /> }
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index e2aa8bbe7..4f29d769d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -60,6 +60,9 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); + readonly selectedDateChange = output(); } @Component({ @@ -74,7 +77,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Component({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 6e509bb7c..4fc195897 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -24,7 +24,12 @@ import { EpgProgram, ResolvedPortalPlayback, } from 'shared-interfaces'; -import { EpgListComponent } from '@iptvnator/ui/epg'; +import { + EpgDateNavigationDirection, + EpgListComponent, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; import { LiveEpgPanelComponent, LiveEpgPanelSummary, @@ -120,6 +125,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -363,6 +369,16 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + private async loadEpgForChannel(item: StalkerItvChannel) { const requestId = ++this.epgLoadRequestId; const normalizedChannelId = normalizeStalkerEntityId(item.id); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index b4db74d23..3497dfc3b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -89,12 +89,25 @@ [collapsed]="isLiveEpgPanelCollapsed()" [summary]="liveEpgPanelSummary()" [loading]="isLoadingEpg()" + [showDateNavigator]="true" + [selectedDate]="selectedLiveEpgDate()" (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + (dateNavigation)="onLiveEpgDateNavigation($event)" > - + } @else { - + }
} @else { @@ -105,7 +118,7 @@ }
- +
@if (isLoadingEpg()) {
@@ -131,7 +144,14 @@ [controlledPrograms]="controlledEpgPrograms()" [controlledArchiveDays]="controlledArchiveDays()" [archivePlaybackAvailable]="archivePlaybackAvailable()" + [selectedDate]=" + unifiedDateNavigator ? selectedLiveEpgDate() : null + " + [showDateNavigator]="!unifiedDateNavigator" (programActivated)="onProgramActivated($event)" + (selectedDateChange)=" + unifiedDateNavigator && onLiveEpgSelectedDateChange($event) + " /> } @else if (!isElectron && epgItems().length > 0) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index b1922d72c..4112ab276 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -66,7 +66,10 @@ class StubEpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDate = input(null); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); } @Component({ @@ -81,7 +84,10 @@ class StubLiveEpgPanelComponent { readonly collapsed = input(false); readonly summary = input(null); readonly loading = input(false); + readonly showDateNavigator = input(false); + readonly selectedDate = input(null); readonly collapsedChange = output(); + readonly dateNavigation = output<'next' | 'prev'>(); } @Directive({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ffce599f3..dee7d6815 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -36,7 +36,13 @@ import { XtreamUrlService, XtreamStore, } from '@iptvnator/portal/xtream/data-access'; -import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; +import { + EpgDateNavigationDirection, + EpgListComponent, + EpgProgramActivationEvent, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '@iptvnator/ui/epg'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { EpgViewComponent, @@ -156,6 +162,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { readonly liveEpgPanelState = signal( restoreLiveEpgPanelState() ); + readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); @@ -347,6 +354,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { persistLiveEpgPanelState(state); } + onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { + this.selectedLiveEpgDate.set( + shiftEpgDateKey(this.selectedLiveEpgDate(), direction) + ); + } + + onLiveEpgSelectedDateChange(selectedDate: string): void { + this.selectedLiveEpgDate.set(selectedDate); + } + ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index 1020292cc..aa9f3a7b7 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,5 +1,6 @@ export * from './lib/epg-list/epg-list.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; +export * from './lib/epg-date'; export * from './lib/epg-progress-panel/epg-progress-panel.component'; export * from './lib/epg-source-status/epg-source-status.component'; export * from './lib/multi-epg/multi-epg-container.component'; diff --git a/libs/ui/epg/src/lib/epg-date.ts b/libs/ui/epg/src/lib/epg-date.ts new file mode 100644 index 000000000..c984f94be --- /dev/null +++ b/libs/ui/epg/src/lib/epg-date.ts @@ -0,0 +1,39 @@ +import { addDays, format, subDays } from 'date-fns'; + +export const EPG_DATE_KEY_FORMAT = 'yyyy-MM-dd'; + +export type EpgDateNavigationDirection = 'next' | 'prev'; + +export function getTodayEpgDateKey(): string { + return format(new Date(), EPG_DATE_KEY_FORMAT); +} + +export function parseEpgDateKey( + dateKey: string | null | undefined, + fallbackDateKey = getTodayEpgDateKey() +): Date { + const normalizedDateKey = + typeof dateKey === 'string' && dateKey.trim().length > 0 + ? dateKey + : fallbackDateKey; + const parsedDate = new Date(`${normalizedDateKey}T00:00:00`); + + if (Number.isFinite(parsedDate.getTime())) { + return parsedDate; + } + + return new Date(`${fallbackDateKey}T00:00:00`); +} + +export function shiftEpgDateKey( + dateKey: string | null | undefined, + direction: EpgDateNavigationDirection +): string { + const selectedDate = parseEpgDateKey(dateKey); + return format( + direction === 'next' + ? addDays(selectedDate, 1) + : subDays(selectedDate, 1), + EPG_DATE_KEY_FORMAT + ); +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.html b/libs/ui/epg/src/lib/epg-list/epg-list.component.html index 1b27b847a..00d6cee08 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.html @@ -1,33 +1,35 @@ @let items = filteredItems(); @let timeshiftLimit = timeshiftUntil(); - -
-
- - - {{ - selectedDate() - | date: 'MMMM d, EEEE' : undefined : currentLocale() - }} - +@if (showDateNavigator()) { + +
+
+ + + {{ + selectedDateKey() + | date: 'MMMM d, EEEE' : undefined : currentLocale() + }} + +
-
+} @if (timeshiftLimit) { diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts index 801d758b7..5c2c1d71c 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.spec.ts @@ -243,6 +243,34 @@ describe('EpgListComponent', () => { .textContent.trim() ).toContain('Sonntag'); }); + + it('can hide its internal date navigator for a parent-owned toolbar', () => { + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + fixture.componentRef.setInput('showDateNavigator', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('#channel-header') + ).toBeNull(); + expect( + fixture.nativeElement.querySelector('#program-list') + ).not.toBeNull(); + }); + + it('emits date changes when the selected date is controlled', () => { + const emittedDates: string[] = []; + component.selectedDateChange.subscribe((date) => + emittedDates.push(date) + ); + fixture.componentRef.setInput('selectedDate', '2026-04-04'); + fixture.componentRef.setInput('controlledPrograms', buildPrograms()); + fixture.detectChanges(); + + component.changeDate('next'); + + expect(emittedDates).toEqual(['2026-04-05']); + expect(component.selectedDateKey()).toBe('2026-04-04'); + }); }); function buildPrograms(overrides?: { @@ -253,11 +281,9 @@ function buildPrograms(overrides?: { }): EpgProgram[] { const pastStart = '2026-04-05T09:00:00.000Z'; const pastStop = '2026-04-05T10:00:00.000Z'; - const currentStart = - overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; + const currentStart = overrides?.currentStart ?? '2026-04-05T11:30:00.000Z'; const currentStop = overrides?.currentStop ?? '2026-04-05T12:30:00.000Z'; - const futureStart = - overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; + const futureStart = overrides?.futureStart ?? '2026-04-05T12:30:00.000Z'; const futureStop = overrides?.futureStop ?? '2026-04-05T13:30:00.000Z'; return [ diff --git a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts index 0979fb2a4..b92072fbf 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-list.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-list.component.ts @@ -20,13 +20,17 @@ import { normalizeDateLocale } from '@iptvnator/pipes'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgActions, selectActive } from 'm3u-state'; -import { addDays, format, subDays } from 'date-fns'; +import { format, subDays } from 'date-fns'; import { startWith } from 'rxjs'; import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces'; +import { + EPG_DATE_KEY_FORMAT, + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; import { EpgListItemComponent } from './epg-list-item/epg-list-item.component'; -const DATE_FORMAT = 'yyyy-MM-dd'; - export interface EpgProgramActivationEvent { program: EpgProgram; type: 'live' | 'timeshift'; @@ -51,7 +55,12 @@ export class EpgListComponent { readonly controlledPrograms = input(null); readonly controlledArchiveDays = input(null); readonly archivePlaybackAvailable = input(null); + readonly selectedDateInput = input(null, { + alias: 'selectedDate', + }); + readonly showDateNavigator = input(true); readonly programActivated = output(); + readonly selectedDateChange = output(); private readonly store = inject(Store); private readonly epgService = inject(EpgService); @@ -70,7 +79,10 @@ export class EpgListComponent { ); readonly programList = viewChild>('programList'); - readonly selectedDate = signal(format(new Date(), DATE_FORMAT)); + readonly internalSelectedDate = signal(getTodayEpgDateKey()); + readonly selectedDateKey = computed( + () => this.selectedDateInput() ?? this.internalSelectedDate() + ); readonly timeNow = signal(new Date().toISOString()); readonly currentTimeMs = computed(() => Date.parse(this.timeNow())); readonly currentLocale = computed(() => { @@ -85,8 +97,8 @@ export class EpgListComponent { this.controlledChannel() !== null || this.controlledPrograms() !== null ); - readonly displayChannel = computed( - () => this.controlledChannel() ?? this.activeChannel() + readonly displayChannel = computed( + () => this.controlledChannel() ?? this.activeChannel() ?? null ); readonly channel = computed(() => { const channel = this.displayChannel(); @@ -96,7 +108,9 @@ export class EpgListComponent { return { id: channel.tvg?.id || '', - displayName: channel.name ? [{ lang: '', value: channel.name }] : [], + displayName: channel.name + ? [{ lang: '', value: channel.name }] + : [], url: channel.url ? [channel.url] : [], icon: channel.tvg?.logo ? [{ src: channel.tvg.logo }] : [], }; @@ -119,14 +133,14 @@ export class EpgListComponent { subDays(new Date(), this.archiveDays()).toISOString() ); readonly archivePlaybackEnabled = computed( - () => this.archivePlaybackAvailable() ?? (this.archiveDays() > 0) + () => this.archivePlaybackAvailable() ?? this.archiveDays() > 0 ); readonly filteredItems = computed(() => [...this.items()] .filter( (item) => getProgramDateKey(item.start, item.startTimestamp) === - this.selectedDate() + this.selectedDateKey() ) .sort( (left, right) => @@ -185,16 +199,14 @@ export class EpgListComponent { }); } - changeDate(direction: 'next' | 'prev'): void { - const selectedDate = new Date(`${this.selectedDate()}T00:00:00`); - this.selectedDate.set( - format( - direction === 'next' - ? addDays(selectedDate, 1) - : subDays(selectedDate, 1), - DATE_FORMAT - ) - ); + changeDate(direction: EpgDateNavigationDirection): void { + const nextDate = shiftEpgDateKey(this.selectedDateKey(), direction); + + if (this.selectedDateInput() == null) { + this.internalSelectedDate.set(nextDate); + } + + this.selectedDateChange.emit(nextDate); } activateProgram(program: EpgProgram): void { @@ -225,7 +237,8 @@ export class EpgListComponent { canActivateProgram(program: EpgProgram): boolean { return ( - this.isProgramPlaying(program) || this.canPlayArchivedProgram(program) + this.isProgramPlaying(program) || + this.canPlayArchivedProgram(program) ); } @@ -240,7 +253,9 @@ export class EpgListComponent { const total = stop - start; const elapsed = now - start; - return total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0; + return total > 0 + ? Math.min(100, Math.max(0, (elapsed / total) * 100)) + : 0; } isProgramPlaying(program: EpgProgram): boolean { @@ -289,7 +304,7 @@ export class EpgListComponent { } private scheduleScrollToCurrentProgram(): void { - if (this.selectedDate() !== format(new Date(), DATE_FORMAT)) { + if (this.selectedDateKey() !== getTodayEpgDateKey()) { return; } @@ -385,5 +400,5 @@ function getProgramDateKey( return ''; } - return format(new Date(programTimeMs), DATE_FORMAT); + return format(new Date(programTimeMs), EPG_DATE_KEY_FORMAT); } diff --git a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html index d2dfec81d..98bf21751 100644 --- a/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html +++ b/libs/ui/shared-portals/src/lib/live-epg-panel/live-epg-panel.component.html @@ -25,37 +25,114 @@ {{ 'EPG.CURRENT_PROGRAM' | translate }} - @if (loading()) { - - {{ 'EPG.LOADING' | translate }} - - } @else if (hasSummary()) { - - {{ summary()?.title }} - +
+ @if (loading()) { + + {{ 'EPG.LOADING' | translate }} + + } @else if (hasSummary()) { + + {{ summary()?.title }} + - @if (hasTimeRange()) { - - @if (summary()?.start) { - {{ summary()?.start | date: 'HH:mm' }} - } - @if (summary()?.start && summary()?.stop) { - - - } - @if (summary()?.stop) { - {{ summary()?.stop | date: 'HH:mm' }} - } + @if (hasTimeRange()) { + + @if (summary()?.start) { + {{ summary()?.start | date: 'HH:mm' }} + } + @if (summary()?.start && summary()?.stop) { + - + } + @if (summary()?.stop) { + {{ summary()?.stop | date: 'HH:mm' }} + } + + } + } @else { + + {{ 'EPG.NO_PROGRAM_INFO' | translate }} } - } @else { - - {{ 'EPG.NO_PROGRAM_INFO' | translate }} - - } +
+ @if (showDateNavigator()) { + @if (!collapsed()) { +
+ + + {{ + selectedDateValue() + | date + : 'MMMM d, EEEE' + : undefined + : currentLocale() + }} + + +
+ + + + + + + + } + } + @if (progress() !== null) {