From 51347fc2266a5c6a9b73cbe264e3f70d3ae66541 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Apr 2026 19:56:41 +0200 Subject: [PATCH] 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; }