feat: add collapsible live epg panel

Entire-Checkpoint: 0518c49b948d
This commit is contained in:
4gray committed 2026-04-28 19:56:41 +02:00
1 parent 10139955e7
commit 51347fc226
45 files changed
+1465 -283

No files matched your search

@@ -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`.
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "Так",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
+4 -1
View File
@@ -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": "العربية",
@@ -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:
+70 -53
View File
@@ -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<Map<string, EpgProgram>>;
getCurrentProgramsForChannels(
channelIds: string[]
): Observable<Map<string, EpgProgram>>;
// Batch fetch XMLTV channel metadata for logo fallback
getChannelMetadataForChannels(channelIds: string[]): Observable<Map<string, EpgChannelMetadata | null>>;
getChannelMetadataForChannels(
channelIds: string[]
): Observable<Map<string, EpgChannelMetadata | null>>;
// Observables
epgAvailable$: Observable<boolean>;
@@ -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
+52 -48
View File
@@ -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.
@@ -17,7 +17,9 @@
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
/>
} @loading {
<app-channel-list-loading-state [view]="activeView()" />
@@ -29,13 +31,18 @@
<div class="content-container">
@if (activeChannel(); as activeChannel) {
@let playbackTarget = playbackChannel();
@if (activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel)) {
@if (
activeChannel.radio === 'true' ||
shouldShowInlinePlayer(activeChannel)
) {
<div class="video-player">
@if (activeChannel.radio === 'true') {
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="activeChannel.name || activeChannel?.tvg?.name || ''"
[channelName]="
activeChannel.name || activeChannel?.tvg?.name || ''
"
/>
} @else {
@if (playerSettings.player === 'videojs') {
@@ -73,12 +80,38 @@
}
@if (activeChannel.radio !== 'true') {
<div class="epg">
<div class="epg-content">
<app-epg-list
[archivePlaybackAvailable]="archivePlaybackAvailable()"
/>
</div>
@let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
<div
class="epg"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
>
<div class="epg-content">
<app-epg-list
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
/>
</div>
</app-live-epg-panel>
} @else {
<div class="epg-content">
<app-epg-list
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
/>
</div>
}
</div>
}
@@ -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: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly collapsedChange = output<boolean>();
}
describe('VideoPlayerComponent', () => {
let fixture: ComponentFixture<VideoPlayerComponent>;
let component: VideoPlayerComponent;
@@ -39,11 +58,11 @@ describe('VideoPlayerComponent', () => {
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal(null);
const currentEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const player = signal<VideoPlayer>(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,
};
}
@@ -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<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
/** Channels list */
readonly channels$: Observable<Channel[]> = 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 {
+1
View File
@@ -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';
@@ -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'
);
});
});
@@ -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);
}
@@ -82,20 +82,25 @@
<app-web-player-view [streamUrl]="streamUrl()" />
</div>
}
<div class="epg">
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else {
<app-epg-list
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
/>
}
</div>
@let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
<div
class="epg"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
>
<ng-container *ngTemplateOutlet="epgPanelContent" />
</app-live-epg-panel>
} @else {
<ng-container *ngTemplateOutlet="epgPanelContent" />
}
</div>
} @else {
<app-portal-empty-state
@@ -104,3 +109,19 @@
/>
}
</div>
<ng-template #epgPanelContent>
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else {
<app-epg-list
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
/>
}
</div>
</ng-template>
@@ -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<boolean | null>(null);
}
@Component({
selector: 'app-live-epg-panel',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly collapsedChange = output<boolean>();
}
@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();
@@ -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<ResolvedPortalPlayback | null>(null);
readonly streamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
/** EPG */
readonly fallbackEpgPrograms = signal<EpgProgram[]>([]);
@@ -112,6 +117,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly currentProgram = computed(() =>
this.findCurrentProgram(this.activeEpgPrograms())
);
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
restoreLiveEpgPanelState()
);
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
readonly controlledChannel = computed<Channel | null>(() => {
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
);
}
@@ -1,6 +1,5 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@let streamUrl = activeStreamUrl();
@let epgPrograms = controlledEpgPrograms();
@if (selectedCategoryId()) {
<div
@@ -79,48 +78,24 @@
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
<div class="epg">
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else if (isElectron && epgPrograms.length > 0) {
@if (showArchiveUnavailableNotice()) {
<div class="archive-unavailable-banner" role="status">
<div class="archive-unavailable-banner__title">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE'
| translate
}}
</div>
<div
class="archive-unavailable-banner__description"
>
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION'
| translate
}}
</div>
</div>
}
<app-epg-list
[controlledPrograms]="epgPrograms"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
(programActivated)="onProgramActivated($event)"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
} @else {
<app-portal-empty-state
icon="event_busy"
[message]="'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate"
/>
}
</div>
<div
class="epg"
[class.epg-collapsed]="
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
"
>
@if (isEmbeddedPlayer) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
>
<ng-container *ngTemplateOutlet="epgPanelContent" />
</app-live-epg-panel>
} @else {
<ng-container *ngTemplateOutlet="epgPanelContent" />
}
</div>
} @else {
<app-portal-empty-state
@@ -129,3 +104,42 @@
/>
}
</div>
<ng-template #epgPanelContent>
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else if (isElectron && controlledEpgPrograms().length > 0) {
@if (showArchiveUnavailableNotice()) {
<div class="archive-unavailable-banner" role="status">
<div class="archive-unavailable-banner__title">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' | translate
}}
</div>
<div class="archive-unavailable-banner__description">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION'
| translate
}}
</div>
</div>
}
<app-epg-list
[controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
(programActivated)="onProgramActivated($event)"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
} @else {
<app-portal-empty-state
icon="event_busy"
[message]="'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate"
/>
}
</div>
</ng-template>
@@ -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<EpgProgramActivationEvent>();
}
@Component({
selector: 'app-live-epg-panel',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly collapsedChange = output<boolean>();
}
@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(
@@ -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<LiveEpgPanelState>(
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
@@ -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
);
},
})),
+3 -2
View File
@@ -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';
@@ -0,0 +1,76 @@
<section class="live-epg-panel" [class.live-epg-panel--collapsed]="collapsed()">
<div class="live-epg-panel__summary-bar">
<button
mat-icon-button
class="live-epg-panel__toggle"
type="button"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
[matTooltip]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<mat-icon>{{
collapsed() ? 'keyboard_arrow_up' : 'keyboard_arrow_down'
}}</mat-icon>
</button>
<div class="live-epg-panel__summary" role="status">
<span class="live-epg-panel__label">
{{ 'EPG.CURRENT_PROGRAM' | translate }}
</span>
@if (loading()) {
<span class="live-epg-panel__title">
{{ 'EPG.LOADING' | translate }}
</span>
} @else if (hasSummary()) {
<span class="live-epg-panel__title">
{{ summary()?.title }}
</span>
@if (hasTimeRange()) {
<span class="live-epg-panel__time">
@if (summary()?.start) {
{{ summary()?.start | date: 'HH:mm' }}
}
@if (summary()?.start && summary()?.stop) {
-
}
@if (summary()?.stop) {
{{ summary()?.stop | date: 'HH:mm' }}
}
</span>
}
} @else {
<span
class="live-epg-panel__title live-epg-panel__title--muted"
>
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</span>
}
</div>
@if (progress() !== null) {
<div class="live-epg-panel__progress" aria-hidden="true">
<div
class="live-epg-panel__progress-fill"
[style.width.%]="progress()"
></div>
</div>
}
</div>
<div
class="live-epg-panel__body"
[attr.aria-hidden]="collapsed()"
[attr.inert]="collapsed() ? '' : null"
>
<ng-content />
</div>
</section>
@@ -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;
}
}
@@ -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: `
<app-live-epg-panel
[collapsed]="collapsed"
[summary]="summary"
[loading]="loading"
(collapsedChange)="collapsed = $event"
>
<div class="projected-content">Projected EPG</div>
</app-live-epg-panel>
`,
})
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<HostComponent>;
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');
});
});
@@ -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<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly collapsedChange = output<boolean>();
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));
}
@@ -1,7 +1,9 @@
<div
[class]="
player === 'videojs' || player === 'html5'
? 'itv-container'
usesInternalPlayer()
? isLiveEpgPanelCollapsed()
? 'itv-container itv-container--epg-collapsed'
: 'itv-container'
: 'itv-container-epg'
"
>
@@ -58,7 +60,7 @@
</div>
</div>
</div>
@if (player === 'videojs' || player === 'html5') {
@if (usesInternalPlayer()) {
@if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
@@ -66,10 +68,27 @@
}
}
@if (streamUrl) {
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
</div>
<div
class="epg"
[class.epg-collapsed]="
usesInternalPlayer() && isLiveEpgPanelCollapsed()
"
>
@if (usesInternalPlayer()) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="getLiveEpgPanelSummary()"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
>
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
</div>
</app-live-epg-panel>
} @else {
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
</div>
}
</div>
} @else {
<div class="no-channel-selected">
@@ -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,
@@ -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<XtreamItem>();
searchString = signal<string>('');
readonly liveEpgPanelState = signal<LiveEpgPanelState>(
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;
}
}
+15 -20
View File
@@ -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"
]
}
+17 -1
View File
@@ -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;
}