feat(playback): Up Next episode rail for the inline series player (#1231)

* feat(playback): Up Next episode rail for the inline series player

On wide windows the inline series player now docks left and fills the
leftover stage column with a Netflix-style "Up Next" rail: the rest of the
current season plus next-season spillover, the playing episode highlighted,
and watch-progress bars from playback positions. Clicking an episode plays
it inline through the host's existing episode flow (Xtream serial-details
and Stalker series view).

- New app-up-next-rail component + buildUpNextRailItems() util in
  ui/playback; entries carry the host's raw episode object so selection
  needs no id lookup.
- PortalInlinePlayerComponent measures the theater stage with a
  ResizeObserver and docks the rail only when the leftover beside the 16:9
  player is >= 320px; narrower stages keep the centered theater/ambient
  behavior from #1223. Movies and live never show the rail.
- New playerUpNextRail setting (Settings > Playback, default on, built-in
  web players only), mirroring playerAmbientMode; enforced at runtime for
  non-web engines.
- i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in
  all 18 locales.
- The rail renders as an opaque panel on top of the stage, so the ambient
  fill stays behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): address Greptile review on the Up Next rail

- Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo
  has no global reset), so the docked-rail modifier's 12px padding widened the
  stage past its container and the right edge was clipped.
- Width gate: compute the width the rail actually receives (stage minus the
  docked layout's padding, the height-driven 16:9 player, and the flex gap)
  instead of raw stage slack, and observe the stage's border box so the
  modifier's own padding cannot feed back into the measurement.
- Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until
  opened, so the rail's next-season spillover stopped at the current season.
  Prefetch the following season while an episode plays inline.

Adds regression coverage for the gate boundary, gate stability across the
padding toggle, and the lazy-season prefetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): stop the rail spillover prefetch from retrying forever

A failed or genuinely empty Ministra season resets isLoading while leaving
episodes empty, so the prefetch effect re-requested the same season on every
emission for as long as inline playback continued. Remember which seasons this
view already requested and ask at most once each.

Regression test asserts the empty-response case fetches exactly once and does
not retrigger on further playback in the same season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(stalker): let a failed spillover prefetch recover on the next episode

The previous guard was permanent, so a transient network or authorization
failure disabled the rail's next-season prefetch for the component's lifetime.
Distinguish the two outcomes instead:

- Answered (even with zero episodes) — a real answer, never asked again.
- Failed — the claim is released, but pinned to the episode that triggered it,
  so the retry waits for the next playback change. Retrying immediately would
  loop, since the failure itself flips isLoading and re-runs the effect.

The claim is taken synchronously; awaiting first let the isLoading flip re-run
the effect and fire a duplicate request before the answer arrived.

`loadEpisodesForSeason` now reports whether the portal answered; existing
callers ignore the result and are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 authored and GitHub committed 2026-07-25 13:16:09 +02:00
1 parent b94f40dc74
commit 4ca2b6852e
43 files changed
+1466 -11

No files matched your search

+1
View File
@@ -787,6 +787,7 @@ engine` (restart required) or
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items: quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
@@ -33,6 +33,7 @@ export function createSettingsForm(
player: [VideoPlayer.VideoJs],
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
...(supportsEpg
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
: {}),
@@ -119,6 +120,7 @@ export function createSettingsFromFormValue(
player: value.player ?? VideoPlayer.VideoJs,
webPlayerSharedControls: value.webPlayerSharedControls ?? false,
playerAmbientMode: value.playerAmbientMode ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
language: value.language ?? Language.ENGLISH,
@@ -113,6 +113,24 @@
></mat-checkbox>
</div>
</div>
<div class="setting-item" data-test-id="player-up-next-rail-setting">
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }}
</h4>
<p>
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate"
formControlName="playerUpNextRail"
data-test-id="player-up-next-rail-toggle"
></mat-checkbox>
</div>
</div>
}
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
@@ -382,6 +382,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
@@ -86,6 +86,7 @@ const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.",
"PLAYER_AMBIENT_MODE": "ملء الخلفية المحيطة",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات التالية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "في النوافذ العريضة، تثبيت مشغل المسلسل على اليسار وعرض الحلقات القادمة بجانبه.",
"STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)",
"LANGUAGE_DESCRIPTION": "اختر لغة التطبيق",
"THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "تم نسخ رابط البث إلى الحافظة",
"COPY_STREAM_URL": "نسخ رابط البث",
"NOW_PLAYING": "قيد التشغيل",
"UP_NEXT": "التالي",
"CLOSE_PLAYER": "إغلاق المشغل",
"ABOUT": "نبذة",
"SEASONS_AND_EPISODES": "المواسم والحلقات",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.",
"PLAYER_AMBIENT_MODE": "خلفية محيطة",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.",
"PLAYER_UP_NEXT_RAIL": "شريط الحلقات الجاية",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "فالنوافذ العريضة، حط مشغل السلسلة على اليسار وبيّن الحلقات الجاية بحداه.",
"STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)",
"LANGUAGE_DESCRIPTION": "اختار لغة التطبيق",
"THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "رابط البث تنقل للحافظة",
"COPY_STREAM_URL": "نسخ رابط البث",
"NOW_PLAYING": "كيتشغل دابا",
"UP_NEXT": "اللي جاي",
"CLOSE_PLAYER": "سد المشغل",
"ABOUT": "حول",
"SEASONS_AND_EPISODES": "المواسم والحلقات",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Атмасфернае запаўненне",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.",
"PLAYER_UP_NEXT_RAIL": "Панэль наступных эпізодаў",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "На шырокіх вокнах замацоўваць прайгравальнік серыяла злева і паказваць наступныя эпізоды побач з ім.",
"STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)",
"LANGUAGE_DESCRIPTION": "Выбраць мову праграмы",
"THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL-адрас патоку быў скапіяваны ў буфер абмену.",
"COPY_STREAM_URL": "Капіраваць URL-адрас патоку",
"NOW_PLAYING": "Зараз прайграецца",
"UP_NEXT": "Далей",
"CLOSE_PLAYER": "Закрыць прайгравальнік",
"ABOUT": "Пра фільм",
"SEASONS_AND_EPISODES": "Сезоны і эпізоды",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Verwendet die gemeinsamen IPTVnator-Steuerelemente in HTML5, Video.js und ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Umgebungs-Hintergrund",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Füllt den Bereich um den Player mit einem unscharfen, gedimmten Poster auf, anstelle von einfachen schwarzen Balken.",
"PLAYER_UP_NEXT_RAIL": "Folgenleiste „Als Nächstes“",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "Stream-URL in die Zwischenablage kopiert",
"COPY_STREAM_URL": "Stream-URL kopieren",
"NOW_PLAYING": "Wird wiedergegeben",
"UP_NEXT": "Als Nächstes",
"CLOSE_PLAYER": "Player schließen",
"ABOUT": "Info",
"SEASONS_AND_EPISODES": "Staffeln und Folgen",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Περιβαλλοντικό γέμισμα",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.",
"PLAYER_UP_NEXT_RAIL": "Λίστα επόμενων επεισοδίων",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Σε πλατιά παράθυρα, ο αναπαραγωγός σειρών αγκυρώνεται στα αριστερά και τα επόμενα επεισόδια εμφανίζονται δίπλα του.",
"STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)",
"LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής",
"THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "Η διεύθυνση URL ροής αντιγράφηκε στο πρόχειρο",
"COPY_STREAM_URL": "Αντιγραφή διεύθηνσης URL ροής",
"NOW_PLAYING": "Τώρα παίζει",
"UP_NEXT": "Επόμενα",
"CLOSE_PLAYER": "Κλείσιμο προγράμματος αναπαραγωγής",
"ABOUT": "Σχετικά",
"SEASONS_AND_EPISODES": "Σεζόν και επεισόδια",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Use IPTVnator's shared controls in HTML5, Video.js, and ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Ambient background fill",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Fill the space around the player with a blurred, dimmed poster instead of plain black bars.",
"PLAYER_UP_NEXT_RAIL": "Up Next episode rail",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
"LANGUAGE_DESCRIPTION": "Select language of the application",
"THEME_DESCRIPTION": "Select visual theme of the application",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "Stream URL copied to clipboard",
"COPY_STREAM_URL": "Copy Stream URL",
"NOW_PLAYING": "Now playing",
"UP_NEXT": "Up next",
"CLOSE_PLAYER": "Close player",
"ABOUT": "About",
"SEASONS_AND_EPISODES": "Seasons and Episodes",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa los controles compartidos de IPTVnator en HTML5, Video.js y ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Relleno de fondo ambiental",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Rellena el espacio alrededor del reproductor con una imagen de póster desenfocada y oscurecida en lugar de barras negras simples.",
"PLAYER_UP_NEXT_RAIL": "Panel de próximos episodios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "En ventanas anchas, ancla el reproductor de series a la izquierda y muestra los próximos episodios a su lado.",
"STREAM_FORMAT_DESCRIPTION": "Selecciona el formato de transmision predeterminado (Xtream)",
"LANGUAGE_DESCRIPTION": "Seleccione el idioma de la aplicación",
"THEME_DESCRIPTION": "Seleccione el tema visual de la aplicación.",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL de transmisión copiada al portapapeles",
"COPY_STREAM_URL": "Copiar URL de transmisión",
"NOW_PLAYING": "Reproduciendo ahora",
"UP_NEXT": "A continuación",
"CLOSE_PLAYER": "Cerrar reproductor",
"ABOUT": "Acerca de",
"SEASONS_AND_EPISODES": "Temporadas y episodios",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Utilise les commandes partagées d’IPTVnator dans HTML5, Video.js et ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Mode ambiant",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Remplit l'espace autour du lecteur avec une affiche floue et assombrie au lieu de barres noires simples.",
"PLAYER_UP_NEXT_RAIL": "Rail d'épisodes « À suivre »",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Sur les fenêtres larges, ancre le lecteur de séries à gauche et affiche les épisodes à venir à côté.",
"STREAM_FORMAT_DESCRIPTION": "Sélectionnez le format de flux par défaut (xtream)",
"LANGUAGE_DESCRIPTION": "Sélectionnez la langue de l'application",
"THEME_DESCRIPTION": "Sélectionnez le thème visuel de l'application",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL du flux copiée dans le presse-papiers",
"COPY_STREAM_URL": "Copier l'URL du flux",
"NOW_PLAYING": "En cours de lecture",
"UP_NEXT": "À suivre",
"CLOSE_PLAYER": "Fermer le lecteur",
"ABOUT": "À propos",
"SEASONS_AND_EPISODES": "Saisons et épisodes",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa i controlli condivisi di IPTVnator in HTML5, Video.js e ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Riempimento ambientale dello sfondo",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Riempie lo spazio intorno al lettore con un poster sfocato e scurito invece delle semplici barre nere.",
"PLAYER_UP_NEXT_RAIL": "Pannello \"A seguire\" con i prossimi episodi",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Nelle finestre ampie, ancora il lettore delle serie a sinistra e mostra i prossimi episodi accanto.",
"STREAM_FORMAT_DESCRIPTION": "Seleziona il formato di streaming predefinito (xtream)",
"LANGUAGE_DESCRIPTION": "Seleziona la lingua dell'applicazione",
"THEME_DESCRIPTION": "Seleziona il tema visivo dell'applicazione",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL streaming copiato negli appunti",
"COPY_STREAM_URL": "Copia URL streaming",
"NOW_PLAYING": "In riproduzione",
"UP_NEXT": "A seguire",
"CLOSE_PLAYER": "Chiudi lettore",
"ABOUT": "Info",
"SEASONS_AND_EPISODES": "Stagioni ed episodi",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。",
"PLAYER_AMBIENT_MODE": "プレーヤー周囲のぼかし背景",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。",
"PLAYER_UP_NEXT_RAIL": "「次に再生」エピソードレール",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "ウィンドウ幅が広い場合、シリーズのプレーヤーを左側に配置し、その横に次のエピソードの一覧を表示します。",
"STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)",
"LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択",
"THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "ストリームURLがクリップボードにコピーされました",
"COPY_STREAM_URL": "ストリームURLをコピー",
"NOW_PLAYING": "再生中",
"UP_NEXT": "次に再生",
"CLOSE_PLAYER": "プレーヤーを閉じる",
"ABOUT": "概要",
"SEASONS_AND_EPISODES": "シーズンとエピソード",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.",
"PLAYER_AMBIENT_MODE": "주변 배경 채우기",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.",
"PLAYER_UP_NEXT_RAIL": "다음 에피소드 레일",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "넓은 창에서 시리즈 플레이어를 왼쪽에 고정하고 그 옆에 다음 에피소드를 표시합니다.",
"STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)",
"LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택",
"THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "스트림 URL이 클립보드에 복사되었습니다.",
"COPY_STREAM_URL": "스트림 URL 복사",
"NOW_PLAYING": "지금 재생 중",
"UP_NEXT": "다음 에피소드",
"CLOSE_PLAYER": "플레이어 닫기",
"ABOUT": "정보",
"SEASONS_AND_EPISODES": "시즌 및 에피소드",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Gebruik de gedeelde IPTVnator-bediening in HTML5, Video.js en ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Ambient achtergrond",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Vult de ruimte rond de speler met een vervaagde, gedimde posterafbeelding in plaats van zwarte balken.",
"PLAYER_UP_NEXT_RAIL": "Hierna-paneel met afleveringen",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Zet de seriespeler in brede vensters links vast en toont de komende afleveringen ernaast.",
"STREAM_FORMAT_DESCRIPTION": "Selecteer het standaard streamformaat (xtream)",
"LANGUAGE_DESCRIPTION": "Selecteer de taal van de applicatie",
"THEME_DESCRIPTION": "Kies het visuele thema van de applicatie",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "Stream URL Gekopieërd",
"COPY_STREAM_URL": "Kopieër Stream URL",
"NOW_PLAYING": "Nu bezig",
"UP_NEXT": "Hierna",
"CLOSE_PLAYER": "Speler sluiten",
"ABOUT": "Over",
"SEASONS_AND_EPISODES": "Seizoenen en afleveringen",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Używaj wspólnych elementów sterowania IPTVnator w HTML5, Video.js i ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Rozmyte tło",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Wypełnij przestrzeń wokół odtwarzacza rozmytym, przyciemnianym plakatem zamiast zwykłych czarnych pasków.",
"PLAYER_UP_NEXT_RAIL": "Panel następnych odcinków",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "W szerokich oknach zadokuj odtwarzacz serialu po lewej stronie i pokaż obok kolejne odcinki.",
"STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)",
"LANGUAGE_DESCRIPTION": "Wybierz język aplikacji",
"THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL strumienia skopiowany do schowka",
"COPY_STREAM_URL": "Kopiuj URL strumienia",
"NOW_PLAYING": "Teraz odtwarzane",
"UP_NEXT": "Następne",
"CLOSE_PLAYER": "Zamknij odtwarzacz",
"ABOUT": "Informacje",
"SEASONS_AND_EPISODES": "Sezony i odcinki",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Usa os controlos partilhados do IPTVnator em HTML5, Video.js e ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Preenchimento de fundo ambiente",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Preenche o espaço ao redor do reprodutor com um pôster desfocado e escurecido em vez de barras pretas simples.",
"PLAYER_UP_NEXT_RAIL": "Painel de próximos episódios",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Em janelas largas, ancora o reprodutor de séries à esquerda e mostra os próximos episódios ao lado.",
"STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)",
"LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo",
"THEME_DESCRIPTION": "Selecionar tema visual do aplicativo",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL do stream copiado para a área de transferência",
"COPY_STREAM_URL": "Copiar URL do Stream",
"NOW_PLAYING": "Reproduzindo agora",
"UP_NEXT": "A seguir",
"CLOSE_PLAYER": "Fechar reprodutor",
"ABOUT": "Sobre",
"SEASONS_AND_EPISODES": "Temporadas e episódios",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.",
"PLAYER_AMBIENT_MODE": "Заливка фона за плеером",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.",
"PLAYER_UP_NEXT_RAIL": "Панель следующих серий",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "URL-адрес потока был скопирован в буфер обмена.",
"COPY_STREAM_URL": "Копировать URL-адрес потока",
"NOW_PLAYING": "Сейчас воспроизводится",
"UP_NEXT": "Далее",
"CLOSE_PLAYER": "Закрыть плеер",
"ABOUT": "Об этом",
"SEASONS_AND_EPISODES": "Сезоны и эпизоды",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js ve ArtPlayer'da IPTVnator'ın ortak kontrollerini kullan.",
"PLAYER_AMBIENT_MODE": "Ortam ışığı dolgusu",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "Oynatıcının çevresindeki boşluğu siyah çubuklar yerine bulanık ve karartılmış bir poster ile doldurur.",
"PLAYER_UP_NEXT_RAIL": "Sıradaki bölümler paneli",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Geniş pencerelerde dizi oynatıcısını sola yaslar ve sıradaki bölümleri yanında gösterir.",
"STREAM_FORMAT_DESCRIPTION": "Varsayılan yayın formatını seçin (xtream)",
"LANGUAGE_DESCRIPTION": "Uygulamanın dilini seçin",
"THEME_DESCRIPTION": "Uygulamanın görünüm temasını seçin",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "Yayın URL'si panoya kopyalandı",
"COPY_STREAM_URL": "Yayın URL'sini kopyala",
"NOW_PLAYING": "Şimdi oynatılıyor",
"UP_NEXT": "Sıradaki",
"CLOSE_PLAYER": "Oynatıcıyı kapat",
"ABOUT": "Hakkında",
"SEASONS_AND_EPISODES": "Sezonlar ve Bölümler",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。",
"PLAYER_AMBIENT_MODE": "环境背景填充",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。",
"PLAYER_UP_NEXT_RAIL": "「接下来播放」剧集栏",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在宽窗口下,将剧集播放器停靠在左侧,并在旁边显示后续剧集。",
"STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)",
"LANGUAGE_DESCRIPTION": "选择应用程序的语言",
"THEME_DESCRIPTION": "选择应用程序的视觉主题",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "流 URL 已复制到剪贴板",
"COPY_STREAM_URL": "复制流 URL",
"NOW_PLAYING": "正在播放",
"UP_NEXT": "接下来播放",
"CLOSE_PLAYER": "关闭播放器",
"ABOUT": "简介",
"SEASONS_AND_EPISODES": "季与集",
+3
View File
@@ -304,6 +304,8 @@
"WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。",
"PLAYER_AMBIENT_MODE": "環境背景填充",
"PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。",
"PLAYER_UP_NEXT_RAIL": "即將播放集數側欄",
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "在較寬的視窗中,將影集播放器停靠在左側,並在旁邊顯示即將播放的集數。",
"STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)",
"LANGUAGE_DESCRIPTION": "選擇應用程式語言",
"THEME_DESCRIPTION": "選擇應用程式視覺主題",
@@ -960,6 +962,7 @@
"STREAM_URL_COPIED": "串流網址已複製到剪貼簿",
"COPY_STREAM_URL": "複製串流網址",
"NOW_PLAYING": "現正播放",
"UP_NEXT": "即將播放",
"CLOSE_PLAYER": "關閉播放器",
"ABOUT": "簡介",
"SEASONS_AND_EPISODES": "季數與集數",
@@ -144,6 +144,55 @@ layer, keeping extra DOM out of the native-video compositing path. Live
channels are excluded (their `thumbnail` is a logo), and only
`http(s):`/`data:` poster URLs are accepted to avoid CSS `url()` breakout.
### Up Next side rail (series)
For inline **series** playback the stage can trade its centered letterbox for
a Netflix/Plex-style layout: the player docks left and the leftover column
becomes an "Up Next" episode rail (`app-up-next-rail`,
`libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts`).
The rail lists the currently playing episode (highlighted, click-inert)
followed by the rest of its season and a spillover into the following
seasons, with per-episode watch-progress bars from playback positions.
- Data flow: the hosts (Xtream `SerialDetailsComponent`, Stalker
`StalkerSeriesViewComponent`) build the entries with
`buildUpNextRailItems()` (`up-next-rail.util.ts`) from their
season→episodes map, the inline episode state, and the playback-position
map, and pass them into `PortalInlinePlayerComponent` via the
`upNextEpisodes` input. Selection comes back through
`upNextEpisodeSelected`, carrying the host's raw episode object, and is
routed into the host's existing episode-play flow — the same path the
season container uses.
- Width gating: a ResizeObserver on `.player-shell__viewport` feeds the
component's `stageSize` with the stage's **border-box** size, and the gate
computes the width the rail would actually receive — stage minus the docked
layout's padding, minus the 16:9 player sized to the remaining height, minus
the flex gap (`RAIL_STAGE_PADDING` / `RAIL_STAGE_GAP`, kept in sync with the
stylesheet). The rail docks in only when that is ≥ 320px
(`UP_NEXT_RAIL_MIN_WIDTH`). Measuring the border box matters: the docked
modifier adds padding to the same element being observed, so a content-box
measurement would change the input the moment the rail appears and could
oscillate around the threshold. On near-16:9 or taller windows the rail
auto-hides and the centered theater/ambient behavior above remains.
- Lazy Stalker seasons: Ministra VOD-series seasons carry no episodes until
their tab is opened, so `StalkerSeriesViewComponent` prefetches the season
after the playing one while inline playback is active — otherwise the rail's
next-season spillover would silently stop at the current season's end. The
prefetch is claimed synchronously and answered seasons (including genuinely
empty ones) are never re-requested: a failed request leaves `episodes` empty
with `isLoading` back to false, which would otherwise re-run the effect that
issued it and loop. A *failed* request releases the claim but is pinned to
the episode that triggered it, so a transient portal error retries on the
next playback change instead of either looping or giving up permanently.
- Gating mirrors ambient mode: the `playerUpNextRail` setting (Settings →
Playback, **default on**, shown only for the built-in web players) plus a
runtime web-engine check; the rail also requires
`contentInfo.contentType === 'episode'` and non-live playback, so movies
and live channels always keep the centered stage.
- Layering: the rail is an opaque panel rendered on top of the stage, so the
ambient fill stays behind it and shows in the flexible gap between the
docked player and the rail on very wide stages.
Season navigation inside `SeasonContainerComponent` uses season tabs
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
seasons-grid + "Back to seasons" level. A season is auto-selected
@@ -158,6 +158,7 @@
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="serial.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
[upNextEpisodes]="upNextEpisodes()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
@@ -165,6 +166,7 @@
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
(nextEpisodeRequested)="playNextEpisode()"
(upNextEpisodeSelected)="playUpNextEpisode($event)"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
@@ -55,6 +55,7 @@ class StubPortalInlinePlayerComponent {
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly upNextEpisodes = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
@@ -62,6 +63,7 @@ class StubPortalInlinePlayerComponent {
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly upNextEpisodeSelected = output<unknown>();
}
@Component({
@@ -683,6 +685,259 @@ describe('StalkerSeriesViewComponent', () => {
);
});
it('prefetches the next unopened season so the Up Next rail can spill over', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
// Season 1 loaded, season 2 still empty — the state a user is in
// right after opening the series and starting the first episode.
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
const firstEpisode = fixture.componentInstance.mappedSeasons()['1'][0];
fixture.componentInstance.onEpisodeClicked(firstEpisode);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledWith(
'50001',
'season-2'
);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E01',
'S02E01',
]);
});
it('does not re-request a spillover season that came back empty', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
// Failed or genuinely empty season: isLoading returns to false while
// episodes stays empty — the retry trap.
fetchVodSeriesEpisodes.mockResolvedValue([]);
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Further playback activity in the same season must not retrigger it.
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
});
it('retries a failed spillover prefetch on the next episode', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
is_series: true,
info: {
name: 'VOD Flagged Series',
description: 'Lazy seasons',
movie_image: 'vod-series.jpg',
},
});
serialSeasonsResource.set([]);
vodSeriesSeasonsResource.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
},
]);
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season-1',
video_id: '50001',
season_number: '1',
name: 'Season 1',
episodes: [
{ id: 'episode-1', series_number: 1, name: 'Pilot' },
{ id: 'episode-2', series_number: 2, name: 'Second' },
],
isLoading: false,
isExpanded: false,
},
{
id: 'season-2',
video_id: '50001',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
fetchVodSeriesEpisodes.mockClear();
fetchVodSeriesEpisodes.mockRejectedValueOnce(new Error('network'));
const seasonOne = fixture.componentInstance.mappedSeasons()['1'];
fixture.componentInstance.onEpisodeClicked(seasonOne[0]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
// The failure must not loop while the same episode keeps playing.
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1);
// Moving to the next episode gives the transient failure a new chance.
fetchVodSeriesEpisodes.mockResolvedValue([
{ id: 'episode-3', series_number: 1, name: 'Next Season' },
]);
fixture.componentInstance.onEpisodeClicked(seasonOne[1]);
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(2);
const inlinePlayer = fixture.debugElement.query(
By.directive(StubPortalInlinePlayerComponent)
).componentInstance as StubPortalInlinePlayerComponent;
const railItems = inlinePlayer.upNextEpisodes() as Array<{
label: string;
}>;
expect(railItems.map((item) => item.label)).toEqual([
'S01E02',
'S02E01',
]);
});
it('loads the next unloaded VOD-series season after the loaded season is watched', async () => {
selectedContentType.set('vod');
selectedItem.set({
@@ -55,12 +55,14 @@ import {
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import {
buildUpNextRailItems,
getSeriesEpisodeMetadata,
getSeriesPlaybackNavigation,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
type UpNextRailItem,
} from '@iptvnator/ui/playback';
import {
CrossPortalSimilarItem,
@@ -419,6 +421,87 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly inlineSeriesNavigation = computed(() =>
getSeriesPlaybackNavigation(this.inlineEpisodeState())
);
/** "Up Next" rail entries for the inline player (series only). */
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
buildUpNextRailItems({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: this.inlineEpisodeState()?.episode.id,
playbackPositions: this.episodePlaybackPositions(),
})
);
/**
* Seasons the portal has already answered for on the rail's behalf. An
* answered-but-empty season is a real answer, so it is recorded here and
* never asked for again — otherwise the effect below would re-request it
* on every emission for as long as playback continues.
*/
private readonly prefetchedSpilloverSeasonIds = new Set<string>();
/**
* The last spillover request that *failed*, tagged with the episode that
* triggered it. A failure is not permanent (it may be a transient network
* or authorization error), but retrying immediately would loop: the
* failure itself flips `isLoading` and re-runs the effect. Pinning it to
* the episode defers the retry to the next playback change instead.
*/
private spilloverPrefetchFailure: {
key: string;
episodeId: string | number;
} | null = null;
/**
* Ministra VOD-series seasons hold no episodes until their tab is opened,
* so the rail's next-season spillover would silently stop at the end of
* the playing season. While an episode plays inline, fetch the following
* season's episodes so the spillover is actually there.
*/
private readonly prefetchRailSpilloverSeason = effect(() => {
const episodeState = this.inlineEpisodeState();
const seasonKey = episodeState?.seasonKey;
const seasons = this.vodSeriesSeasons();
if (!this.isVodSeries() || !seasonKey) {
return;
}
const currentIndex = seasons.findIndex(
(season) => getVodSeriesSeasonKey(season) === seasonKey
);
const nextSeason =
currentIndex >= 0 ? seasons[currentIndex + 1] : undefined;
if (!nextSeason || nextSeason.isLoading || nextSeason.episodes.length) {
return;
}
const prefetchKey = `${nextSeason.video_id}:${nextSeason.id}`;
const episodeId = episodeState.episode.id;
if (
this.prefetchedSpilloverSeasonIds.has(prefetchKey) ||
(this.spilloverPrefetchFailure?.key === prefetchKey &&
this.spilloverPrefetchFailure.episodeId === episodeId)
) {
return;
}
// Claim the season synchronously: awaiting first would let the
// `isLoading` flip re-run this effect and fire a duplicate request
// before the answer arrives. A failure releases the claim below.
this.prefetchedSpilloverSeasonIds.add(prefetchKey);
untracked(() =>
void this.loadEpisodesForSeason(nextSeason).then((answered) => {
if (answered) {
this.spilloverPrefetchFailure = null;
return;
}
this.spilloverPrefetchFailure = {
key: prefetchKey,
episodeId,
};
this.prefetchedSpilloverSeasonIds.delete(prefetchKey);
})
);
});
/**
* Handles season selection from the container.
@@ -444,11 +527,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
/**
* Loads episodes for a specific VOD season
*/
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
/** Resolves true when the portal answered, false when the request failed. */
async loadEpisodesForSeason(season: VodSeriesSeasonVm): Promise<boolean> {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
if (index === -1) return;
if (index === -1) return false;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
@@ -471,6 +555,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
};
this.vodSeriesSeasons.set(newSeasons);
}
return true;
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
@@ -482,6 +567,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
};
this.vodSeriesSeasons.set(newSeasons);
}
return false;
}
}
@@ -700,6 +786,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.onEpisodeClicked(next);
}
playUpNextEpisode(item: UpNextRailItem): void {
this.onEpisodeClicked(item.episode as XtreamSerieEpisode);
}
handleInlinePlaybackEnded(): void {
const navigation = this.inlineSeriesNavigation();
if (!navigation?.autoplayEnabled || !navigation.canNext) {
@@ -167,6 +167,7 @@
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="item.info.name"
[seriesNavigation]="inlineSeriesNavigation()"
[upNextEpisodes]="upNextEpisodes()"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(backClicked)="goBack()"
@@ -174,6 +175,7 @@
(playbackEnded)="handleInlinePlaybackEnded()"
(previousEpisodeRequested)="playPreviousEpisode()"
(nextEpisodeRequested)="playNextEpisode()"
(upNextEpisodeSelected)="playUpNextEpisode($event)"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
@@ -56,6 +56,7 @@ class StubPortalInlinePlayerComponent {
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
readonly seriesNavigation = input<unknown>(null);
readonly upNextEpisodes = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
@@ -63,6 +64,7 @@ class StubPortalInlinePlayerComponent {
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly upNextEpisodeSelected = output<unknown>();
}
@Component({
@@ -25,8 +25,10 @@ import {
} from '@iptvnator/ui/components';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
buildUpNextRailItems,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
type UpNextRailItem,
} from '@iptvnator/ui/playback';
import {
TmdbEnrichedCastMember,
@@ -115,6 +117,14 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly quickStartAction = this.playback.quickStartAction;
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
/** "Up Next" rail entries for the inline player (series only). */
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
buildUpNextRailItems({
episodesBySeason: this.selectedItem()?.episodes,
currentEpisodeId: this.playback.inlineEpisodeState()?.episode.id,
playbackPositions: this.episodePlaybackPositions(),
})
);
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
@@ -337,6 +347,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.playback.playNextEpisode();
}
playUpNextEpisode(item: UpNextRailItem): void {
this.playback.playEpisode(item.episode as XtreamSerieEpisode);
}
handleInlinePlaybackEnded(): void {
this.playback.handleInlinePlaybackEnded();
}
@@ -163,6 +163,24 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().playerAmbientMode).toBe(true);
});
it('defaults the up next rail to true when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerUpNextRail).toBe(true);
});
it('restores a persisted false up next rail preference', async () => {
storedSettings = { playerUpNextRail: false };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().playerUpNextRail).toBe(false);
});
it('restores a persisted true strip country prefix preference', async () => {
storedSettings = {
stripCountryPrefix: true,
@@ -28,6 +28,7 @@ const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: false,
playerAmbientMode: false,
playerUpNextRail: true,
streamFormat: StreamFormat.AutoStreamFormat,
openStreamOnDoubleClick: false,
language: Language.ENGLISH,
@@ -192,6 +193,9 @@ export const SettingsStore = signalStore(
playerAmbientMode:
store.playerAmbientMode?.() ??
DEFAULT_SETTINGS.playerAmbientMode,
playerUpNextRail:
store.playerUpNextRail?.() ??
DEFAULT_SETTINGS.playerUpNextRail,
streamFormat: store.streamFormat(),
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
language: store.language(),
@@ -89,6 +89,13 @@ export interface Settings {
* black bars. Off by default; only affects the built-in web players.
*/
playerAmbientMode?: boolean;
/**
* Dock the inline series player to the left and show an "Up Next"
* episode rail in the leftover stage column on wide windows. On by
* default (the rail only appears when there is genuinely unused space);
* missing values mean enabled. Only affects the built-in web players.
*/
playerUpNextRail?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
+2
View File
@@ -9,6 +9,8 @@ export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from './lib/player-controls';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
export * from './lib/portal-inline-player/up-next-rail.component';
export * from './lib/portal-inline-player/up-next-rail.util';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/vod-details/vod-details.component';
@@ -0,0 +1,235 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import type { PortalInlinePlayerComponent as PortalInlinePlayerComponentInstance } from './portal-inline-player.component';
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-web-player-view',
standalone: true,
template: '<div data-test-id="stub-web-player-view"></div>',
})
class StubWebPlayerViewComponent {
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
readonly externalFallbackRequested = output<unknown>();
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
}
describe('PortalInlinePlayerComponent up next rail', () => {
let PortalInlinePlayerComponent: typeof import('./portal-inline-player.component').PortalInlinePlayerComponent;
let WebPlayerViewComponent: typeof import('../web-player-view/web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<PortalInlinePlayerComponentInstance>;
let component: PortalInlinePlayerComponentInstance;
beforeAll(async () => {
({ PortalInlinePlayerComponent } = await import(
'./portal-inline-player.component'
));
({ WebPlayerViewComponent } = await import(
'../web-player-view/web-player-view.component'
));
});
afterEach(() => {
fixture?.destroy();
});
async function setup(railEnabled: boolean, player = 'videojs') {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [PortalInlinePlayerComponent, TranslateModule.forRoot()],
providers: [
{
provide: SettingsStore,
useValue: {
player: signal(player),
playerAmbientMode: signal(false),
playerUpNextRail: signal(railEnabled),
stripCountryPrefix: signal(false),
},
},
],
})
.overrideComponent(PortalInlinePlayerComponent, {
remove: { imports: [WebPlayerViewComponent] },
add: { imports: [StubWebPlayerViewComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(PortalInlinePlayerComponent);
component = fixture.componentInstance;
}
const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail');
const seriesPlayback = {
streamUrl: 'https://example.com/episode.mp4',
title: 'Some Show',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 12,
contentType: 'episode',
},
};
const upNextItems = [
{
id: 12,
label: 'S01E02',
title: 'Episode 2',
thumbnailUrl: null,
progressPercent: 40,
isPlaying: true,
episode: { id: '12' },
},
{
id: 13,
label: 'S01E03',
title: 'Episode 3',
thumbnailUrl: null,
progressPercent: null,
isPlaying: false,
episode: { id: '13' },
},
];
/** Renders, then feeds a stage size as the ResizeObserver would. */
function renderWithStage(width: number, height: number): void {
fixture.detectChanges();
component.stageSize.set({ width, height });
fixture.detectChanges();
}
it('docks the rail for series playback on a wide stage', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
// 1600 - 500 * 16/9 = ~711px leftover — plenty for the rail.
renderWithStage(1600, 500);
expect(component.upNextRailVisible()).toBe(true);
expect(railEl()).toBeTruthy();
expect(
fixture.nativeElement.querySelectorAll(
'[data-testid="up-next-episode"]'
).length
).toBe(2);
});
it('gates on the width the rail actually gets, after padding and gap', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
// At 500px tall the docked layout spends 24px padding + 18px gap, so
// the rail clears 320px only above a ~1208px stage.
renderWithStage(1180, 500);
expect(component.upNextRailVisible()).toBe(false);
expect(railEl()).toBeNull();
renderWithStage(1230, 500);
expect(component.upNextRailVisible()).toBe(true);
expect(railEl()).toBeTruthy();
});
it('stays docked once shown — the modifier padding cannot flip the gate', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
renderWithStage(1600, 500);
expect(component.upNextRailVisible()).toBe(true);
// Border-box measurement: the observer reports the same size after
// the rail modifier adds its padding, so visibility does not oscillate.
component.stageSize.set({ width: 1600, height: 500 });
fixture.detectChanges();
expect(component.upNextRailVisible()).toBe(true);
});
it('keeps the centered theater layout when the stage is near 16:9', async () => {
await setup(true);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
renderWithStage(1280, 720);
expect(component.upNextRailVisible()).toBe(false);
expect(railEl()).toBeNull();
});
it('never shows the rail for movies (no episode content info)', async () => {
await setup(true);
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/movie.mp4',
title: 'Some Movie',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'movie',
},
});
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
renderWithStage(1600, 500);
expect(component.upNextRailVisible()).toBe(false);
expect(railEl()).toBeNull();
});
it('stays hidden when the setting is toggled off', async () => {
await setup(false);
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
renderWithStage(1600, 500);
expect(component.upNextRailVisible()).toBe(false);
expect(railEl()).toBeNull();
});
it('stays hidden for non-web engines like embedded MPV', async () => {
await setup(true, 'embedded-mpv');
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
renderWithStage(1600, 500);
expect(component.upNextRailVisible()).toBe(false);
expect(railEl()).toBeNull();
});
it('emits the selected episode but ignores clicks on the playing one', async () => {
await setup(true);
const selected: unknown[] = [];
fixture.componentRef.setInput('playback', seriesPlayback);
fixture.componentRef.setInput('upNextEpisodes', upNextItems);
(
component as unknown as {
upNextEpisodeSelected: {
subscribe: (fn: (item: unknown) => void) => void;
};
}
).upNextEpisodeSelected.subscribe((item) => selected.push(item));
renderWithStage(1600, 500);
const buttons = fixture.nativeElement.querySelectorAll(
'[data-testid="up-next-episode"]'
) as NodeListOf<HTMLButtonElement>;
buttons[0].click();
expect(selected).toEqual([]);
buttons[1].click();
expect(selected).toEqual([upNextItems[1]]);
});
});
@@ -49,7 +49,11 @@
</div>
</header>
<div class="player-shell__viewport">
<div
class="player-shell__viewport"
#stageViewport
[class.player-shell__viewport--with-rail]="upNextRailVisible()"
>
@if (ambientEnabled()) {
<div
class="player-shell__ambient"
@@ -72,6 +76,13 @@
onExternalFallbackRequested($event)
"
/>
@if (upNextRailVisible()) {
<app-up-next-rail
class="player-shell__rail"
[items]="upNextRailItems()"
(episodeSelected)="onUpNextEpisodeSelected($event)"
/>
}
</div>
</section>
}
@@ -91,6 +91,9 @@
// transfer through `aspect-ratio` into a max-width and re-clamp the stage
// to 16:9 — recreating the app-surface gap on wide-short windows.
width: 100%;
// There is no global border-box reset, so the rail modifier's padding
// would otherwise widen the stage past its container and get clipped.
box-sizing: border-box;
min-height: 320px;
max-height: min(70vh, 720px);
aspect-ratio: 16 / 9;
@@ -111,6 +114,28 @@
aspect-ratio: 16 / 9;
}
// "Up Next" rail: instead of centering, the player docks left and the
// leftover column becomes the episode rail. The rail is an opaque panel on
// top of the stage, so the ambient fill stays behind it (and shows in the
// flexible gap between player and rail on very wide stages).
.player-shell__viewport--with-rail {
justify-content: space-between;
gap: 18px;
// Insets the docked player and the rail from the stage edges; the player
// keeps sizing itself as the largest 16:9 box fitting the (now smaller)
// content height.
padding: 12px;
}
.player-shell__rail {
position: relative;
z-index: 1;
flex: 1 1 auto;
align-self: stretch;
min-width: 0;
max-width: 440px;
}
// "Ambient mode": a blurred, dimmed copy of the poster fills the whole stage
// behind the player, turning the letterbox margins into atmosphere.
.player-shell__ambient {
@@ -3,9 +3,13 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@@ -25,6 +29,14 @@ import type {
SeriesEpisodeMetadata,
SeriesPlaybackNavigation,
} from './series-playback-navigation';
import { UpNextRailComponent } from './up-next-rail.component';
import type { UpNextRailItem } from './up-next-rail.util';
/** Narrowest useful "Up Next" rail; below this the stage stays centered. */
const UP_NEXT_RAIL_MIN_WIDTH = 320;
/** Keep in sync with `.player-shell__viewport--with-rail` in the stylesheet. */
const RAIL_STAGE_PADDING = 12;
const RAIL_STAGE_GAP = 18;
@Component({
selector: 'app-portal-inline-player',
@@ -36,6 +48,7 @@ import type {
MatIconModule,
MatTooltipModule,
TranslateModule,
UpNextRailComponent,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -51,6 +64,8 @@ export class PortalInlinePlayerComponent {
/** Series name for the fullscreen title overlay. Xtream episode playback
* carries the episode title, so the series name must come from the host. */
readonly seriesTitle = input<string | null>(null);
/** "Up Next" entries built by the series host; null for movies/live. */
readonly upNextEpisodes = input<UpNextRailItem[] | null>(null);
private readonly settingsStore = inject(SettingsStore);
// Strip only live-channel titles — VOD/series titles ("Mission:
// Impossible - Fallout") must never lose their leading segment.
@@ -85,18 +100,20 @@ export class PortalInlinePlayerComponent {
const safe = url.replace(/"/g, '%22').replace(/\\/g, '%5C');
return `url("${safe}")`;
});
readonly ambientEnabled = computed<boolean>(() => {
// Web players only — mirrors the settings UI, which offers the toggle
// for HTML5, Video.js, and ArtPlayer. Embedded MPV composites a native
// video layer, so an extra DOM layer behind it stays out of the mix.
// Web players only — mirrors the settings UI, which offers the ambient
// and Up Next toggles for HTML5, Video.js, and ArtPlayer. Embedded MPV
// composites a native video layer, so extra DOM stays out of the mix.
private readonly isWebPlayerEngine = computed<boolean>(() => {
const player = this.settingsStore.player?.();
const isWebPlayer =
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer;
player === VideoPlayer.ArtPlayer
);
});
readonly ambientEnabled = computed<boolean>(() => {
return (
isWebPlayer &&
this.isWebPlayerEngine() &&
this.settingsStore.playerAmbientMode?.() === true &&
!!this.ambientImageStyle()
);
@@ -127,6 +144,54 @@ export class PortalInlinePlayerComponent {
return { primary, secondary: metadata?.label ?? null };
});
private readonly stageViewport = viewChild<ElementRef<HTMLElement>>(
'stageViewport'
);
/**
* Border-box size of the theater stage, written by a ResizeObserver.
* Measuring the border box (not the content box) keeps the value stable
* when the rail modifier toggles the stage's own padding, so the gate
* below cannot oscillate around its threshold.
*/
readonly stageSize = signal<{ width: number; height: number } | null>(
null
);
/**
* Width the rail would actually get: the stage minus its docked-mode
* padding, the 16:9 player sized to the remaining height, and the flex
* gap. Computed for the docked layout even while centered, so the gate
* answers "would the rail fit?" rather than "is there slack right now?".
*/
private readonly upNextRailAvailableWidth = computed<number>(() => {
const size = this.stageSize();
if (!size || size.height <= 0) {
return 0;
}
const innerWidth = size.width - RAIL_STAGE_PADDING * 2;
const innerHeight = size.height - RAIL_STAGE_PADDING * 2;
return innerWidth - (innerHeight * 16) / 9 - RAIL_STAGE_GAP;
});
/**
* The rail docks in only for inline series playback on web engines, when
* the setting (default on) is enabled and the stage is wide enough; on
* near-16:9 or taller windows the theater/ambient centering remains.
*/
readonly upNextRailVisible = computed<boolean>(() => {
const playback = this.playback();
return (
this.settingsStore.playerUpNextRail?.() !== false &&
this.isWebPlayerEngine() &&
!!this.upNextEpisodes()?.length &&
!playback?.isLive &&
playback?.contentInfo?.contentType === 'episode' &&
this.upNextRailAvailableWidth() >= UP_NEXT_RAIL_MIN_WIDTH
);
});
readonly upNextRailItems = computed<UpNextRailItem[]>(
() => this.upNextEpisodes() ?? []
);
readonly closed = output<void>();
/** Back arrow in the now-playing bar: route-level back, not just close. */
readonly backClicked = output<void>();
@@ -139,6 +204,41 @@ export class PortalInlinePlayerComponent {
readonly playbackEnded = output<void>();
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly upNextEpisodeSelected = output<UpNextRailItem>();
constructor() {
effect((onCleanup) => {
const element = this.stageViewport()?.nativeElement;
if (!element || typeof ResizeObserver === 'undefined') {
this.stageSize.set(null);
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
// `borderBoxSize` is the padding-independent measurement; the
// `contentRect` fallback covers engines that omit it.
const borderBox = entry.borderBoxSize?.[0];
this.stageSize.set(
borderBox
? {
width: borderBox.inlineSize,
height: borderBox.blockSize,
}
: {
width: entry.contentRect.width,
height: entry.contentRect.height,
}
);
});
observer.observe(element);
onCleanup(() => observer.disconnect());
});
}
onClose(): void {
this.closed.emit();
@@ -171,4 +271,8 @@ export class PortalInlinePlayerComponent {
onNextEpisodeRequested(): void {
this.nextEpisodeRequested.emit();
}
onUpNextEpisodeSelected(item: UpNextRailItem): void {
this.upNextEpisodeSelected.emit(item);
}
}
@@ -0,0 +1,57 @@
<aside class="up-next" [attr.aria-label]="'PORTALS.UP_NEXT' | translate">
<h4 class="up-next__heading">{{ 'PORTALS.UP_NEXT' | translate }}</h4>
<div class="up-next__list" role="list">
@for (item of items(); track item.id) {
<button
type="button"
role="listitem"
class="up-next__item"
data-testid="up-next-episode"
[class.up-next__item--playing]="item.isPlaying"
[attr.aria-current]="item.isPlaying ? 'true' : null"
(click)="onSelect(item)"
>
<span class="up-next__thumb" aria-hidden="true">
@if (item.thumbnailUrl) {
<img
class="up-next__thumb-image"
[src]="item.thumbnailUrl"
alt=""
loading="lazy"
/>
} @else {
<mat-icon class="up-next__thumb-fallback"
>smart_display</mat-icon
>
}
@if (item.isPlaying) {
<span class="up-next__playing-badge">
<mat-icon>equalizer</mat-icon>
</span>
}
@if (item.progressPercent !== null) {
<span class="up-next__progress">
<span
class="up-next__progress-bar"
[style.width.%]="item.progressPercent"
></span>
</span>
}
</span>
<span class="up-next__meta">
<span class="up-next__label">
{{ item.label }}
@if (item.isPlaying) {
<span class="up-next__now-playing">
{{ 'PORTALS.NOW_PLAYING' | translate }}
</span>
}
</span>
@if (item.title) {
<span class="up-next__title">{{ item.title }}</span>
}
</span>
</button>
}
</div>
</aside>
@@ -0,0 +1,151 @@
// Opaque panel docked on the stage's right column — it sits on top of the
// black stage (or the ambient fill), so its own dark surface works for both
// app themes without :host-context branching.
:host {
display: block;
height: 100%;
min-height: 0;
}
.up-next {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 14px 6px 14px 14px;
border-radius: 12px;
background: rgba(16, 16, 18, 0.86);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
}
.up-next__heading {
margin: 0 8px 10px 4px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.62);
}
.up-next__list {
display: grid;
gap: 6px;
align-content: start;
overflow-y: auto;
min-height: 0;
padding-right: 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.up-next__item {
display: flex;
gap: 10px;
align-items: center;
padding: 6px;
border: 0;
border-radius: 10px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
&:hover,
&:focus-visible {
background: rgba(255, 255, 255, 0.1);
}
}
.up-next__item--playing {
background: rgba(255, 255, 255, 0.14);
cursor: default;
}
.up-next__thumb {
position: relative;
flex: none;
width: 104px;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.up-next__thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.up-next__thumb-fallback {
color: rgba(255, 255, 255, 0.38);
}
.up-next__playing-badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
mat-icon {
color: #fff;
}
}
.up-next__progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: rgba(255, 255, 255, 0.28);
}
.up-next__progress-bar {
display: block;
height: 100%;
background: #e50914;
}
.up-next__meta {
display: grid;
gap: 2px;
min-width: 0;
}
.up-next__label {
display: flex;
gap: 8px;
align-items: baseline;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.72);
}
.up-next__now-playing {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #7dd87d;
}
.up-next__title {
font-size: 0.9rem;
font-weight: 600;
line-height: 1.25;
color: #fff;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -0,0 +1,34 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import type { UpNextRailItem } from './up-next-rail.util';
/**
* "Up Next" side rail shown next to the inline series player when the
* theater stage is wider than 16:9 (Netflix/Plex pattern). Purely
* presentational: the host builds the items and handles selection.
*/
@Component({
selector: 'app-up-next-rail',
templateUrl: './up-next-rail.component.html',
styleUrl: './up-next-rail.component.scss',
imports: [MatIconModule, TranslateModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UpNextRailComponent {
readonly items = input.required<UpNextRailItem[]>();
readonly episodeSelected = output<UpNextRailItem>();
onSelect(item: UpNextRailItem): void {
if (item.isPlaying) {
return;
}
this.episodeSelected.emit(item);
}
}
@@ -0,0 +1,155 @@
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { buildUpNextRailItems } from './up-next-rail.util';
interface TestEpisode {
id: string;
episode_num: number;
season: number;
title?: string;
info?: { movie_image?: string } | unknown[];
}
function episode(
id: number,
season: number,
episodeNum: number,
overrides: Partial<TestEpisode> = {}
): TestEpisode {
return {
id: String(id),
episode_num: episodeNum,
season,
title: `Episode ${episodeNum}`,
...overrides,
};
}
function position(
contentXtreamId: number,
positionSeconds: number,
durationSeconds: number
): PlaybackPositionData {
return {
playlistId: 'playlist-1',
contentXtreamId,
contentType: 'episode',
positionSeconds,
durationSeconds,
};
}
const EPISODES_BY_SEASON = {
'1': [episode(11, 1, 1), episode(12, 1, 2), episode(13, 1, 3)],
'2': [episode(21, 2, 1), episode(22, 2, 2)],
};
describe('buildUpNextRailItems', () => {
it('starts at the playing episode and spills over into the next season', () => {
const items = buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: 12,
});
expect(items.map((item) => item.id)).toEqual([12, 13, 21, 22]);
expect(items.map((item) => item.label)).toEqual([
'S01E02',
'S01E03',
'S02E01',
'S02E02',
]);
expect(items[0].isPlaying).toBe(true);
expect(items.slice(1).every((item) => !item.isPlaying)).toBe(true);
});
it('orders numeric season keys numerically, not lexicographically', () => {
const items = buildUpNextRailItems({
episodesBySeason: {
'10': [episode(101, 10, 1)],
'2': [episode(21, 2, 1)],
'1': [episode(11, 1, 1)],
},
currentEpisodeId: 11,
});
expect(items.map((item) => item.id)).toEqual([11, 21, 101]);
});
it('returns an empty list when the playing episode is not in the seasons', () => {
expect(
buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: 999,
})
).toEqual([]);
});
it('returns an empty list without seasons or a current episode', () => {
expect(
buildUpNextRailItems({
episodesBySeason: null,
currentEpisodeId: 12,
})
).toEqual([]);
expect(
buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: null,
})
).toEqual([]);
});
it('caps the list at the given limit', () => {
const items = buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: 11,
limit: 2,
});
expect(items.map((item) => item.id)).toEqual([11, 12]);
});
it('maps watch progress from playback positions and null when unstarted', () => {
const positions = new Map<number, PlaybackPositionData>([
[12, position(12, 600, 1200)],
]);
const items = buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: 12,
playbackPositions: positions,
});
expect(items[0].progressPercent).toBe(50);
expect(items[1].progressPercent).toBeNull();
});
it('uses http(s) episode thumbnails and rejects other schemes', () => {
const items = buildUpNextRailItems({
episodesBySeason: {
'1': [
episode(11, 1, 1, {
info: { movie_image: 'https://cdn.example.com/a.jpg' },
}),
episode(12, 1, 2, {
info: { movie_image: 'javascript:alert(1)' },
}),
episode(13, 1, 3, { info: [] }),
],
},
currentEpisodeId: 11,
});
expect(items[0].thumbnailUrl).toBe('https://cdn.example.com/a.jpg');
expect(items[1].thumbnailUrl).toBeNull();
expect(items[2].thumbnailUrl).toBeNull();
});
it('carries the raw episode object through for host playback', () => {
const items = buildUpNextRailItems({
episodesBySeason: EPISODES_BY_SEASON,
currentEpisodeId: 13,
});
expect(items[1].episode).toBe(EPISODES_BY_SEASON['2'][0]);
});
});
@@ -0,0 +1,161 @@
import { getPortalPlaybackProgressPercent } from '@iptvnator/portal/shared/util';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import {
formatSeriesEpisodeLabel,
type SeriesPlaybackEpisodeLike,
} from './series-playback-navigation';
/**
* Episode entry rendered by the "Up Next" side rail next to the inline
* series player. Carries the host's raw episode object so selection can be
* routed back through the host's existing episode-play flow without an
* id lookup.
*/
export interface UpNextRailItem<TEpisode = unknown> {
id: number;
/** `SxxEyy` label. */
label: string;
title: string;
thumbnailUrl: string | null;
/** Watch progress in percent (0–100); null when never started. */
progressPercent: number | null;
isPlaying: boolean;
episode: TEpisode;
}
interface UpNextEpisodeLike extends SeriesPlaybackEpisodeLike {
info?: { movie_image?: string } | unknown[];
}
export interface BuildUpNextRailItemsOptions<
TEpisode extends UpNextEpisodeLike,
> {
episodesBySeason: Record<string, readonly TEpisode[]> | null | undefined;
/** Id of the episode currently playing inline; no rail without one. */
currentEpisodeId: string | number | null | undefined;
playbackPositions?: ReadonlyMap<number, PlaybackPositionData> | null;
/** Max entries including the playing episode. */
limit?: number;
}
const DEFAULT_LIMIT = 12;
/**
* Builds the "Up Next" list for the inline series player: the currently
* playing episode (highlighted) followed by the rest of its season and a
* spillover into the following seasons, with per-episode watch progress.
*/
export function buildUpNextRailItems<TEpisode extends UpNextEpisodeLike>({
episodesBySeason,
currentEpisodeId,
playbackPositions,
limit = DEFAULT_LIMIT,
}: BuildUpNextRailItemsOptions<TEpisode>): UpNextRailItem<TEpisode>[] {
if (
!episodesBySeason ||
currentEpisodeId === null ||
currentEpisodeId === undefined
) {
return [];
}
const flattened = flattenSeasons(episodesBySeason);
const currentIndex = flattened.findIndex(
({ episode }) => Number(episode.id) === Number(currentEpisodeId)
);
if (currentIndex < 0) {
return [];
}
return flattened
.slice(currentIndex, currentIndex + limit)
.map(({ episode, seasonNumber, episodeNumber }, index) => ({
id: Number(episode.id),
label: formatSeriesEpisodeLabel(seasonNumber, episodeNumber),
title: episode.title ?? '',
thumbnailUrl: getEpisodeThumbnail(episode),
progressPercent: getProgressPercent(
playbackPositions?.get(Number(episode.id))
),
isPlaying: index === 0,
episode,
}));
}
interface FlattenedEpisode<TEpisode> {
episode: TEpisode;
seasonNumber: number;
episodeNumber: number;
}
/**
* Flattens the season map into playback order. Season keys are sorted
* numerically when possible (object key order is not reliable for numeric
* strings mixed with names); non-numeric keys keep their insertion order
* after the numeric ones.
*/
function flattenSeasons<TEpisode extends UpNextEpisodeLike>(
episodesBySeason: Record<string, readonly TEpisode[]>
): FlattenedEpisode<TEpisode>[] {
interface SeasonEntry {
seasonKey: string;
episodes: readonly TEpisode[];
entryIndex: number;
}
const entries: SeasonEntry[] = Object.entries(episodesBySeason).map(
([seasonKey, episodes], entryIndex) => ({
seasonKey,
episodes,
entryIndex,
})
);
entries.sort((a, b) => {
const aNumber = Number(a.seasonKey);
const bNumber = Number(b.seasonKey);
const aNumeric = Number.isFinite(aNumber);
const bNumeric = Number.isFinite(bNumber);
if (aNumeric && bNumeric) {
return aNumber - bNumber;
}
if (aNumeric !== bNumeric) {
return aNumeric ? -1 : 1;
}
return a.entryIndex - b.entryIndex;
});
// No Array.flatMap: the e2e build config targets a lib without it.
const flattened: FlattenedEpisode<TEpisode>[] = [];
for (const { seasonKey, episodes } of entries) {
episodes.forEach((episode: TEpisode, episodeIndex: number) => {
flattened.push({
episode,
seasonNumber: Number(episode.season) || Number(seasonKey) || 0,
episodeNumber: Number(episode.episode_num) || episodeIndex + 1,
});
});
}
return flattened;
}
function getEpisodeThumbnail(episode: UpNextEpisodeLike): string | null {
const info = episode.info;
if (!info || Array.isArray(info)) {
return null;
}
const url = info.movie_image;
return url && /^https?:/i.test(url) ? url : null;
}
function getProgressPercent(
position: PlaybackPositionData | undefined
): number | null {
if (!position || !position.positionSeconds) {
return null;
}
const percent = getPortalPlaybackProgressPercent(position);
return percent > 0 ? percent : null;
}