mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat: add collapsible live epg panel
Entire-Checkpoint: 0518c49b948d
This commit is contained in:
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`.
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "Так",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
+102
-14
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+35
-14
@@ -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>
|
||||
+95
-3
@@ -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();
|
||||
|
||||
|
||||
+53
-28
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+57
-43
@@ -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>
|
||||
+94
-1
@@ -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(
|
||||
|
||||
+41
-1
@@ -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
|
||||
);
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+26
-7
@@ -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
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user