From 4ca2b6852efba55b5531ff1625186e8dabc28710 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:16:09 +0200 Subject: [PATCH] feat(playback): Up Next episode rail for the inline series player (#1231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- CLAUDE.md | 1 + .../src/app/settings/settings-form.utils.ts | 2 + .../settings-playback-section.component.html | 18 ++ ...ettings-playback-section.component.spec.ts | 1 + .../app/settings/settings.component.spec.ts | 1 + apps/web/src/assets/i18n/ar.json | 3 + apps/web/src/assets/i18n/ary.json | 3 + apps/web/src/assets/i18n/by.json | 3 + apps/web/src/assets/i18n/de.json | 3 + apps/web/src/assets/i18n/el.json | 3 + apps/web/src/assets/i18n/en.json | 3 + apps/web/src/assets/i18n/es.json | 3 + apps/web/src/assets/i18n/fr.json | 3 + apps/web/src/assets/i18n/it.json | 3 + apps/web/src/assets/i18n/ja.json | 3 + apps/web/src/assets/i18n/ko.json | 3 + apps/web/src/assets/i18n/nl.json | 3 + apps/web/src/assets/i18n/pl.json | 3 + apps/web/src/assets/i18n/pt.json | 3 + apps/web/src/assets/i18n/ru.json | 3 + apps/web/src/assets/i18n/tr.json | 3 + apps/web/src/assets/i18n/zh.json | 3 + apps/web/src/assets/i18n/zhtw.json | 3 + docs/architecture/embedded-inline-playback.md | 49 ++++ .../stalker-series-view.component.html | 2 + .../stalker-series-view.component.spec.ts | 255 ++++++++++++++++++ .../stalker-series-view.component.ts | 94 ++++++- .../serial-details.component.html | 2 + .../serial-details.component.spec.ts | 2 + .../serial-details.component.ts | 14 + .../src/lib/settings-store.service.spec.ts | 18 ++ .../src/lib/settings-store.service.ts | 4 + .../interfaces/src/lib/settings.interface.ts | 7 + libs/ui/playback/src/index.ts | 2 + .../portal-inline-player-up-next.spec.ts | 235 ++++++++++++++++ .../portal-inline-player.component.html | 13 +- .../portal-inline-player.component.scss | 25 ++ .../portal-inline-player.component.ts | 120 ++++++++- .../up-next-rail.component.html | 57 ++++ .../up-next-rail.component.scss | 151 +++++++++++ .../up-next-rail.component.ts | 34 +++ .../up-next-rail.util.spec.ts | 155 +++++++++++ .../portal-inline-player/up-next-rail.util.ts | 161 +++++++++++ 43 files changed, 1466 insertions(+), 11 deletions(-) create mode 100644 libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts create mode 100644 libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.html create mode 100644 libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss create mode 100644 libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts create mode 100644 libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.spec.ts create mode 100644 libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.ts diff --git a/CLAUDE.md b/CLAUDE.md index b836ded00..2935ced70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 594c2e951..7cde29550 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -33,6 +33,7 @@ export function createSettingsForm( player: [VideoPlayer.VideoJs], webPlayerSharedControls: false, playerAmbientMode: false, + playerUpNextRail: true, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -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, diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 36a44043d..72f64e43f 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -113,6 +113,24 @@ > + +
+
+

+ {{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }} +

+

+ {{ 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION' | translate }} +

+
+
+ +
+
} @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) { diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 51aaf30b8..909d55fb4 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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), diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index ca40a5840..473326b72 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -86,6 +86,7 @@ const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, webPlayerSharedControls: false, playerAmbientMode: false, + playerUpNextRail: true, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 57b4f2ae7..7608a7e62 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "المواسم والحلقات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f683db23c..60c4714c5 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "المواسم والحلقات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4dede0808..601d3cb44 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Сезоны і эпізоды", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2c9ff8d68..0a2af0752 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dd7c97679..eafaa6404 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Σεζόν και επεισόδια", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7f1b76c4e..c7d090fb6 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c5e6706e7..441a2b579 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 563ce05bd..37024977b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c1088fff3..242958c72 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 3fa837903..51ef44cec 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "シーズンとエピソード", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d3fcfecdd..a7b0ed26c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "시즌 및 에피소드", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index f80fb9b60..3723ffd8e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1d826a919..f0bb39c07 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index bfc926c3e..3e56a1d62 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8fde1f4ae..2098932a9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Сезоны и эпизоды", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index c3f76c633..6131836af 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 715d12e4f..f50e930c8 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "季与集", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 9f53e46d1..2ceeb8071 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "季數與集數", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 4e16c2e4b..3acd31898 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 5f2f152cf..8754f10b7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -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) " diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index d7ef8a0b5..dbccdd37a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -55,6 +55,7 @@ class StubPortalInlinePlayerComponent { readonly episodeMetadata = input(null); readonly seriesTitle = input(null); readonly seriesNavigation = input(null); + readonly upNextEpisodes = input(null); readonly timeUpdate = output(); readonly closed = output(); readonly streamUrlCopied = output(); @@ -62,6 +63,7 @@ class StubPortalInlinePlayerComponent { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly upNextEpisodeSelected = output(); } @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({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 0c0a0a02f..2038c58cd 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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(() => + 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(); + /** + * 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 { // 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) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 4ad4a1ae7..05a2df569 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -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) " diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index c0decadb2..78c2a02b6 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -56,6 +56,7 @@ class StubPortalInlinePlayerComponent { readonly episodeMetadata = input(null); readonly seriesTitle = input(null); readonly seriesNavigation = input(null); + readonly upNextEpisodes = input(null); readonly timeUpdate = output(); readonly closed = output(); readonly streamUrlCopied = output(); @@ -63,6 +64,7 @@ class StubPortalInlinePlayerComponent { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly upNextEpisodeSelected = output(); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index d84a83be2..86d4dc37e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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(() => + 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(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(); } diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index 1f43e8940..b3362edb7 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -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, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 8999ba85e..447db58a2 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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(), diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index f9ff0f632..83daa7116 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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; diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index 243a8d5cb..3f8e6fcb3 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts new file mode 100644 index 000000000..b002bc430 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -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: '
', +}) +class StubWebPlayerViewComponent { + readonly streamUrl = input.required(); + readonly title = input(''); + readonly mediaTitle = input(null); + readonly playback = input(null); + readonly startTime = input(0); + readonly seriesNavigation = input(null); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly externalFallbackRequested = output(); + readonly playbackEnded = output(); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); +} + +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; + 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; + buttons[0].click(); + expect(selected).toEqual([]); + buttons[1].click(); + expect(selected).toEqual([upNextItems[1]]); + }); +}); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index b935d10e9..2cdd6ff4a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -49,7 +49,11 @@ -
+
@if (ambientEnabled()) {
+ @if (upNextRailVisible()) { + + }
} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss index d28d00016..56e7a7aab 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss @@ -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 { diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index aeeca9d7a..2b9290056 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -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(null); + /** "Up Next" entries built by the series host; null for movies/live. */ + readonly upNextEpisodes = input(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(() => { - // 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(() => { const player = this.settingsStore.player?.(); - const isWebPlayer = + return ( player === VideoPlayer.VideoJs || player === VideoPlayer.Html5Player || - player === VideoPlayer.ArtPlayer; - + player === VideoPlayer.ArtPlayer + ); + }); + readonly ambientEnabled = computed(() => { 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>( + '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(() => { + 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(() => { + 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( + () => this.upNextEpisodes() ?? [] + ); + readonly closed = output(); /** Back arrow in the now-playing bar: route-level back, not just close. */ readonly backClicked = output(); @@ -139,6 +204,41 @@ export class PortalInlinePlayerComponent { readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); + readonly upNextEpisodeSelected = output(); + + 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); + } } diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.html b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.html new file mode 100644 index 000000000..f50be373f --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.html @@ -0,0 +1,57 @@ + diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss new file mode 100644 index 000000000..b6c695c58 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.scss @@ -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; +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts new file mode 100644 index 000000000..ed25a13ad --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts @@ -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(); + + readonly episodeSelected = output(); + + onSelect(item: UpNextRailItem): void { + if (item.isPlaying) { + return; + } + this.episodeSelected.emit(item); + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.spec.ts new file mode 100644 index 000000000..a2ded7693 --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.spec.ts @@ -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 { + 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([ + [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]); + }); +}); diff --git a/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.ts b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.ts new file mode 100644 index 000000000..eb6581d9c --- /dev/null +++ b/libs/ui/playback/src/lib/portal-inline-player/up-next-rail.util.ts @@ -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 { + 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 | null | undefined; + /** Id of the episode currently playing inline; no rail without one. */ + currentEpisodeId: string | number | null | undefined; + playbackPositions?: ReadonlyMap | 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({ + episodesBySeason, + currentEpisodeId, + playbackPositions, + limit = DEFAULT_LIMIT, +}: BuildUpNextRailItemsOptions): UpNextRailItem[] { + 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 { + 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( + episodesBySeason: Record +): FlattenedEpisode[] { + 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[] = []; + 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; +}