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; +}