diff --git a/CLAUDE.md b/CLAUDE.md index d8c1f2c9a..659ab4a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -778,6 +778,7 @@ engine` (restart required) or **VOD/Series Detail Pages (two-state layout)**: - 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) - 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. @@ -814,7 +815,7 @@ engine` (restart required) or - Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 47ec38bb9..594c2e951 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -32,6 +32,7 @@ export function createSettingsForm( return formBuilder.group({ player: [VideoPlayer.VideoJs], webPlayerSharedControls: false, + playerAmbientMode: false, ...(supportsEpg ? { epgUrl: new FormArray>([]) } : {}), @@ -117,6 +118,7 @@ export function createSettingsFromFormValue( return { player: value.player ?? VideoPlayer.VideoJs, webPlayerSharedControls: value.webPlayerSharedControls ?? false, + playerAmbientMode: value.playerAmbientMode ?? false, 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 75551c14a..36a44043d 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -95,6 +95,24 @@ > + +
+
+

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

+

+ {{ 'SETTINGS.PLAYER_AMBIENT_MODE_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 5c6ce2662..51aaf30b8 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 @@ -381,6 +381,7 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup { return new FormGroup({ player: new FormControl(player), webPlayerSharedControls: new FormControl(false), + playerAmbientMode: new FormControl(false), 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 86c86339f..ca40a5840 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -85,6 +85,7 @@ export class MockRouter { const DEFAULT_SETTINGS = { player: VideoPlayer.VideoJs, webPlayerSharedControls: false, + playerAmbientMode: false, 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 833bd2075..7c9cde711 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "اختر مشغل الفيديو الافتراضي", "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحّدة لمشغلات الويب (تجريبي)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استخدم عناصر التحكم المشتركة في IPTVnator مع HTML5 وVideo.js وArtPlayer.", + "PLAYER_AMBIENT_MODE": "ملء الخلفية المحيطة", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "تعبئة المساحة حول المشغل بنسخة مشوشة ومعتمة من الملصق بدلاً من الخطوط السوداء.", "STREAM_FORMAT_DESCRIPTION": "اختر تنسيق البث الافتراضي (xtream)", "LANGUAGE_DESCRIPTION": "اختر لغة التطبيق", "THEME_DESCRIPTION": "اختر السمة البصرية للتطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 27188b4e8..f83fe2f02 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "اختار مشغل الفيديو الافتراضي", "WEB_PLAYER_SHARED_CONTROLS": "عناصر تحكم موحدة لمشغلات الويب (تجريبية)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "استعمل عناصر التحكم المشتركة ديال IPTVnator مع HTML5 وVideo.js وArtPlayer.", + "PLAYER_AMBIENT_MODE": "خلفية محيطة", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "ملي المساحة حول المشغل بغلاف معتم وطابت بلاصة الشرائط السوداء.", "STREAM_FORMAT_DESCRIPTION": "اختار صيغة البث الافتراضية (xtream)", "LANGUAGE_DESCRIPTION": "اختار لغة التطبيق", "THEME_DESCRIPTION": "اختار المظهر المرئي للتطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ac6cd8081..bfc239cc5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Выбраць стандартны відэапрайгравальнік", "WEB_PLAYER_SHARED_CONTROLS": "Адзіныя элементы кіравання для вэб-прайгравальнікаў (эксперыментальна)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Выкарыстоўваць агульныя элементы кіравання IPTVnator у HTML5, Video.js і ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Атмасфернае запаўненне", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Запаўніць прастор вакол плэйра размытай, затемнёнай вокладкай замяст чорных палос.", "STREAM_FORMAT_DESCRIPTION": "Выберыце фармат патоку па змаўчанні (xtream)", "LANGUAGE_DESCRIPTION": "Выбраць мову праграмы", "THEME_DESCRIPTION": "Выбраць візуальную тэму афармлення", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b9a3c2d9f..8b3a4b2cc 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Wählen Sie den Standard-Videoplayer aus", "WEB_PLAYER_SHARED_CONTROLS": "Einheitliche Steuerung für Web-Player (experimentell)", "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.", "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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 335cc47bb..414ea0d4b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Επιλογή προεπιλεγμένου προγράμματος αναπαραγωγής βίντεο", "WEB_PLAYER_SHARED_CONTROLS": "Ενοποιημένα στοιχεία ελέγχου για web players (πειραματικό)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Χρησιμοποιήστε τα κοινά στοιχεία ελέγχου του IPTVnator σε HTML5, Video.js και ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Περιβαλλοντικό γέμισμα", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Γεμίστε το χώρο γύρω από τον αναπαραγωγό με θολωμένη, σκοτεινή αφίσα αντί για απλές μαύρες ράβδους.", "STREAM_FORMAT_DESCRIPTION": "Επιλογή προεπιλεγμένης μορφής ροής (xtream)", "LANGUAGE_DESCRIPTION": "Επιλέξτε τη γλώσσα της εφαρμογής", "THEME_DESCRIPTION": "Επιλέξτε οπτικό θέμα της εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 92d396baa..aca191d45 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Select default video player", "WEB_PLAYER_SHARED_CONTROLS": "Unified controls for web players (experimental)", "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.", "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c7cef836c..81b329133 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Seleccionar reproductor de vídeo predeterminado", "WEB_PLAYER_SHARED_CONTROLS": "Controles unificados para reproductores web (experimental)", "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.", "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.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 79e523fff..eb5e74cb1 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Sélectionnez le lecteur vidéo par défaut", "WEB_PLAYER_SHARED_CONTROLS": "Commandes unifiées pour les lecteurs web (expérimental)", "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.", "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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 0421f61d8..5e790f94b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Seleziona lettore video predefinito", "WEB_PLAYER_SHARED_CONTROLS": "Controlli unificati per i player web (sperimentale)", "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.", "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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 73ea0138e..179db53b3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "デフォルトのビデオプレーヤーを選択", "WEB_PLAYER_SHARED_CONTROLS": "Webプレーヤーの統一コントロール(試験的)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5、Video.js、ArtPlayerでIPTVnator共通のコントロールを使用します。", + "PLAYER_AMBIENT_MODE": "プレーヤー周囲のぼかし背景", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "プレーヤーの周囲をぼかしたポスター画像で埋め、通常の黒い枠の代わりに表示します。", "STREAM_FORMAT_DESCRIPTION": "デフォルトのストリーム形式を選択(Xtream)", "LANGUAGE_DESCRIPTION": "アプリケーションの言語を選択", "THEME_DESCRIPTION": "アプリケーションの視覚テーマを選択", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e43ddc6b3..f861aab78 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "기본 비디오 플레이어 선택", "WEB_PLAYER_SHARED_CONTROLS": "웹 플레이어 통합 컨트롤(실험적)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "HTML5, Video.js 및 ArtPlayer에서 IPTVnator 공통 컨트롤을 사용합니다.", + "PLAYER_AMBIENT_MODE": "주변 배경 채우기", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "플레이어 주변을 검은 막대 대신 흐려지고 어두워진 포스터로 채웁니다.", "STREAM_FORMAT_DESCRIPTION": "기본 스트림 형식 선택 (xtream)", "LANGUAGE_DESCRIPTION": "애플리케이션 언어 선택", "THEME_DESCRIPTION": "애플리케이션의 시각적 테마 선택", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 6d05aab79..15b75ce94 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Selecteer de standaard videospeler", "WEB_PLAYER_SHARED_CONTROLS": "Uniforme bediening voor webspelers (experimenteel)", "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.", "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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 14758b632..e629135ed 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Wybierz domyślny odtwarzacz wideo", "WEB_PLAYER_SHARED_CONTROLS": "Ujednolicone sterowanie odtwarzaczami internetowymi (eksperymentalne)", "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.", "STREAM_FORMAT_DESCRIPTION": "Wybierz domyślny format strumienia (xtream)", "LANGUAGE_DESCRIPTION": "Wybierz język aplikacji", "THEME_DESCRIPTION": "Wybierz motyw wizualny aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 89f58e2ec..8572320aa 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Selecionar reprodutor de vídeo padrão", "WEB_PLAYER_SHARED_CONTROLS": "Controlos unificados para leitores web (experimental)", "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.", "STREAM_FORMAT_DESCRIPTION": "Selecionar formato de stream padrão (xtream)", "LANGUAGE_DESCRIPTION": "Selecionar idioma do aplicativo", "THEME_DESCRIPTION": "Selecionar tema visual do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index f0c007b9d..dc3377518 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Выбрать стандартный видео плеер", "WEB_PLAYER_SHARED_CONTROLS": "Единые элементы управления для веб-плееров (экспериментально)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "Использовать общие элементы управления IPTVnator в HTML5, Video.js и ArtPlayer.", + "PLAYER_AMBIENT_MODE": "Заливка фона за плеером", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "Заполнять пространство вокруг плеера размытым затемнённым постером вместо чёрных полос.", "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d89dbad45..ac4a86246 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "Varsayılan video oynatıcıyı seçin", "WEB_PLAYER_SHARED_CONTROLS": "Web oynatıcıları için birleşik kontroller (deneysel)", "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.", "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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 17ccb0b36..d40d29f9a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "选择默认视频播放器", "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器统一控件(实验性)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共享控件。", + "PLAYER_AMBIENT_MODE": "环境背景填充", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、暗淡的海报填充播放器周围的空白,而不是黑条纹。", "STREAM_FORMAT_DESCRIPTION": "选择默认的流格式(xtream)", "LANGUAGE_DESCRIPTION": "选择应用程序的语言", "THEME_DESCRIPTION": "选择应用程序的视觉主题", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 87d93aa43..2e048b4dd 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -298,6 +298,8 @@ "VIDEO_PLAYER_DESCRIPTION": "選擇預設影片播放器", "WEB_PLAYER_SHARED_CONTROLS": "Web 播放器統一控制項(實驗性)", "WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION": "在 HTML5、Video.js 和 ArtPlayer 中使用 IPTVnator 的共用控制項。", + "PLAYER_AMBIENT_MODE": "環境背景填充", + "PLAYER_AMBIENT_MODE_DESCRIPTION": "用模糊、昏暗的海報填充播放器周圍的空間,取代純黑色邊框。", "STREAM_FORMAT_DESCRIPTION": "選擇預設串流格式(xtream)", "LANGUAGE_DESCRIPTION": "選擇應用程式語言", "THEME_DESCRIPTION": "選擇應用程式視覺主題", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 0348653e0..4e16c2e4b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -121,6 +121,29 @@ Contracts: - Entering watch scrolls the shell to the top; leaving keeps the scroll position. +### Inline player stage (theater + ambient fill) + +`PortalInlinePlayerComponent` +(`libs/ui/playback/src/lib/portal-inline-player/`) wraps the projected +`WebPlayerViewComponent` in a `.player-shell__viewport` "theater stage". +The stage spans the full content width and is capped at +`min(70vh, 720px)`; on wide-short windows it becomes wider than 16:9. The +player is sized as the largest 16:9 box that fits the stage height and is +centered, so the leftover is always the stage's own black background — never +a stray strip of app surface. This is the YouTube-style letterbox and is the +default behavior for every inline engine. + +The optional `playerAmbientMode` setting (Settings → Playback, default off, +shown only for the built-in web players) renders a blurred, dimmed copy of the +poster (`ResolvedPortalPlayback.thumbnail`) behind the player via the +`--ambient-image` custom property, turning the letterbox margins into +atmosphere (YouTube "Ambient mode" / Netflix backdrops). The component also +enforces the web-player scope at runtime (HTML5, Video.js, ArtPlayer): with +Embedded MPV selected, a persisted `playerAmbientMode=true` never renders the +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. + 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/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index cac28e6ba..b28de8208 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -34,19 +34,19 @@ without creating dependency cycles (`portal/shared/data-access` already depends on `portal/xtream/data-access`, so it cannot host code the Xtream store imports): -| File | Responsibility | -| --- | --- | -| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping | -| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) | -| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) | -| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | -| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | -| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | -| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | -| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | -| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | -| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` rows) | -| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) | +| File | Responsibility | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping | +| `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) | +| `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) | +| `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | +| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | +| `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | +| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | +| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | +| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | +| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` rows) | +| `tmdb-trending.service.ts` | Weekly trending (movie + tv merged by popularity, `trending:week` rows, 1-day TTL) | Integration glue per portal: @@ -101,7 +101,7 @@ The year filter is applied client-side rather than via TMDB's strict when the provider's year is off by one. **Non-Latin titles**: TMDB matches translated titles but returns `title` in -the *request* language, so a Cyrillic query issued with `en-US` would come +the _request_ language, so a Cyrillic query issued with `en-US` would come back with an English title and fail the exact-match gate. `tmdbSearchLanguageForTitle` detects Cyrillic queries and issues the search with `ru-RU` (unless the app language is already Cyrillic-based); details @@ -216,7 +216,7 @@ Single table with two row kinds discriminated by `lookup_key` prefix: tmdb_metadata ( media_type 'movie' | 'tv' | 'person', lookup_key 'id:' -- details payload row - 'title:|year:' -- search resolution row + 'title:|year:|v2' -- search resolution row 'person:' -- person payload row language TEXT, -- TMDB language code tmdb_id INTEGER, -- NULL on a search row = negative cache @@ -229,6 +229,12 @@ tmdb_metadata ( TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and positive matches 30 days, negative matches 7 days. +Search keys carry a version suffix so normalization changes cannot reuse stale +positive or negative resolutions. Database startup deletes the obsolete +unversioned search rows once and records +`migration:tmdb-search-lookup-v2-cache-cleanup:v1` in `app_state`; details and +person cache rows are unaffected. + Electron IPC path (follows the standard DB worker contract, see [SQLite DB Worker](./sqlite-db-worker.md)): diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index ef5eb5fe0..1f43e8940 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -145,6 +145,24 @@ describe('SettingsStore dashboard rail settings', () => { expect(store.getSettings().stripCountryPrefix).toBe(false); }); + it('defaults ambient player mode to false when the stored field is missing', async () => { + storedSettings = {}; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().playerAmbientMode).toBe(false); + }); + + it('restores a persisted true ambient player mode preference', async () => { + storedSettings = { playerAmbientMode: true }; + const store = injector.get(SettingsStore); + + await store.loadSettings(); + + expect(store.getSettings().playerAmbientMode).toBe(true); + }); + 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 9ed1187dc..8999ba85e 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -27,6 +27,7 @@ import { const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, webPlayerSharedControls: false, + playerAmbientMode: false, streamFormat: StreamFormat.AutoStreamFormat, openStreamOnDoubleClick: false, language: Language.ENGLISH, @@ -188,6 +189,9 @@ export const SettingsStore = signalStore( player: store.player(), webPlayerSharedControls: store.webPlayerSharedControls?.() === true, + playerAmbientMode: + store.playerAmbientMode?.() ?? + DEFAULT_SETTINGS.playerAmbientMode, streamFormat: store.streamFormat(), openStreamOnDoubleClick: store.openStreamOnDoubleClick(), language: store.language(), diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index 07a0485fc..ed067e7a9 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -6,6 +6,7 @@ const { createTableStatements, indexMigrationStatements, runMigrations, + cleanupLegacyTmdbSearchCache, } = __databaseConnectionTestHooks; type SqliteHandle = Parameters[0]; @@ -22,10 +23,7 @@ type StatementHandler = { type HandlerRule = [pattern: string, handler: StatementHandler]; -function createSqliteMock( - rules: HandlerRule[], - exec: jest.Mock = jest.fn() -) { +function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) { const prepare = jest.fn((statement: string) => { const compact = compactSql(statement); const rule = rules.find(([pattern]) => compact.includes(pattern)); @@ -129,6 +127,45 @@ describe('runMigrations error tolerance', () => { }); }); +describe('TMDB search lookup v2 cache cleanup', () => { + it('deletes legacy search rows and records the migration atomically', () => { + const deleteRun = jest.fn(); + const markerRun = jest.fn(); + const { sqlite, transaction } = createSqliteMock([ + ['SELECT value FROM app_state', { get: () => undefined }], + ['DELETE FROM tmdb_metadata', { run: deleteRun }], + ['INSERT INTO app_state', { run: markerRun }], + ]); + + cleanupLegacyTmdbSearchCache(sqlite); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(markerRun).toHaveBeenCalledWith( + 'migration:tmdb-search-lookup-v2-cache-cleanup:v1' + ); + const deleteSql = compactSql( + (sqlite.prepare as jest.Mock).mock.calls.find(([statement]) => + statement.includes('DELETE FROM tmdb_metadata') + )?.[0] + ); + expect(deleteSql).toContain( + "lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'" + ); + }); + + it('does nothing after the migration has completed', () => { + const { sqlite, prepare, transaction } = createSqliteMock([ + completedMigrationStateRule, + ]); + + cleanupLegacyTmdbSearchCache(sqlite); + + expect(transaction).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledTimes(1); + }); +}); + describe('runMigrations Xtream cache deduplication', () => { it('re-points content to the canonical duplicate category before deleting the rest', () => { const candidatesAll = jest.fn(() => [ @@ -141,7 +178,11 @@ describe('runMigrations Xtream cache deduplication', () => { completedMigrationStateRule, [ 'FROM categories GROUP BY playlist_id, type, xtream_id', - { all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] }, + { + all: () => [ + { playlistId: 'p1', type: 'live', xtreamId: 5 }, + ], + }, ], ['LEFT JOIN content', { all: candidatesAll }], [ @@ -149,7 +190,10 @@ describe('runMigrations Xtream cache deduplication', () => { { run: updateContentRun }, ], ['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }], - ['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }], + [ + 'FROM content GROUP BY category_id, type, xtream_id', + { all: () => [] }, + ], ]); runMigrations(sqlite); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 7bc8ca437..ed0c854e7 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -30,6 +30,8 @@ const CONTENT_TITLE_FTS_MIGRATION_KEY = 'migration:content-title-fts-trigram:v1'; const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY = 'migration:epg-program-source-url-backfill:v1'; +const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY = + 'migration:tmdb-search-lookup-v2-cache-cleanup:v1'; const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000; function readTraceFlag(name: string): boolean { @@ -373,6 +375,7 @@ export const __databaseConnectionTestHooks = { normalizeXtreamContentAddedEpochs, ensureContentTitleFts, backfillEpgProgramSourceUrls, + cleanupLegacyTmdbSearchCache, runMigrations, } as const; @@ -768,11 +771,61 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void { } } +/** + * Search-match cache keys gained a v2 suffix when title normalization changed. + * Remove the now-unreachable unversioned rows once rather than leaving negative + * resolutions and other legacy search matches in long-lived installations. + */ +function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const executeCleanup = sqliteDb.transaction(() => { + sqliteDb + .prepare( + `DELETE FROM tmdb_metadata + WHERE lookup_key LIKE 'title:%|year:%' + AND lookup_key NOT LIKE 'title:%|year:%|v%'` + ) + .run(); + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY); + }); + + executeCleanup(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Legacy TMDB search cache cleanup failed (continuing): ${message}` + ); + } +} + /** * Run migrations that may fail if already applied */ function runMigrations(sqliteDb: Database.Database): void { widenTmdbMetadataMediaTypeCheck(sqliteDb); + cleanupLegacyTmdbSearchCache(sqliteDb); runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); ensureContentTitleFts(sqliteDb); deduplicateXtreamCache(sqliteDb); diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 2a86df369..f9ff0f632 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -83,6 +83,12 @@ export interface Settings { * Missing values remain off for compatibility with older saved settings. */ webPlayerSharedControls?: boolean; + /** + * Fill the empty space around the inline VOD/series player with a blurred, + * dimmed copy of the poster (YouTube "Ambient mode" style) instead of plain + * black bars. Off by default; only affects the built-in web players. + */ + playerAmbientMode?: boolean; epgUrl: string[]; streamFormat: StreamFormat; openStreamOnDoubleClick: boolean; 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 8b9c597ed..868f571c9 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 @@ -50,6 +50,13 @@
+ @if (ambientEnabled()) { + + } app-web-player-view { + position: relative; + z-index: 1; display: block; - width: 100%; + flex: 0 0 auto; height: 100%; + width: auto; + max-width: 100%; + aspect-ratio: 16 / 9; +} + +// "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 { + position: absolute; + inset: 0; + z-index: 0; + background-image: var(--ambient-image); + background-position: center; + background-size: cover; + filter: blur(34px) brightness(0.5) saturate(1.15); + transform: scale(1.18); + pointer-events: none; } :host-context(.dark-theme) .player-shell { diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index cd31802ac..7912f5613 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -202,4 +202,115 @@ describe('PortalInlinePlayerComponent', () => { expect(component.title()).toBe('US | Some Movie'); }); }); + + describe('ambient background fill', () => { + async function setup( + ambientEnabled: boolean, + player = 'videojs' + ): Promise { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + PortalInlinePlayerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: SettingsStore, + useValue: { + player: signal(player), + playerAmbientMode: signal(ambientEnabled), + stripCountryPrefix: signal(false), + }, + }, + ], + }) + .overrideComponent(PortalInlinePlayerComponent, { + remove: { imports: [WebPlayerViewComponent] }, + add: { imports: [StubWebPlayerViewComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(PortalInlinePlayerComponent); + component = fixture.componentInstance; + } + + const ambientEl = () => + fixture.nativeElement.querySelector('.player-shell__ambient'); + + it('renders a poster-backed ambient layer for VOD when enabled', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + const layer = ambientEl() as HTMLElement | null; + expect(component.ambientEnabled()).toBe(true); + expect(layer).toBeTruthy(); + expect(layer?.style.getPropertyValue('--ambient-image')).toBe( + 'url("https://cdn.example.com/poster.jpg")' + ); + }); + + it('does not render the ambient layer when the setting is off', async () => { + await setup(false); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + expect(component.ambientEnabled()).toBe(false); + expect(ambientEl()).toBeNull(); + }); + + it('skips the ambient layer for live channels and without a poster', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'CNN', + thumbnail: 'https://cdn.example.com/logo.png', + isLive: true, + }); + fixture.detectChanges(); + expect(ambientEl()).toBeNull(); + + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'No Poster Movie', + }); + fixture.detectChanges(); + expect(ambientEl()).toBeNull(); + }); + + it('keeps the ambient layer off for non-web engines like embedded MPV', async () => { + await setup(true, 'embedded-mpv'); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Some Movie', + thumbnail: 'https://cdn.example.com/poster.jpg', + }); + fixture.detectChanges(); + + expect(component.ambientEnabled()).toBe(false); + expect(ambientEl()).toBeNull(); + }); + + it('rejects non-http(s) poster URLs to avoid CSS breakout', async () => { + await setup(true); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/movie.mp4', + title: 'Sneaky', + thumbnail: 'javascript:alert(1)', + }); + fixture.detectChanges(); + + expect(component.ambientImageStyle()).toBeNull(); + expect(ambientEl()).toBeNull(); + }); + }); }); 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 8a2c72158..5a1ba0c15 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 @@ -14,6 +14,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { PlayerContentInfo, ResolvedPortalPlayback, + VideoPlayer, } from '@iptvnator/shared/interfaces'; import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; import { SettingsStore } from '@iptvnator/services'; @@ -58,6 +59,44 @@ export class PortalInlinePlayerComponent { ); readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); readonly startTime = computed(() => this.playback()?.startTime ?? 0); + /** + * Poster used for the "Ambient mode" fill behind the player. Live channels + * carry logos rather than posters, so they are excluded. + */ + private readonly ambientImageUrl = computed(() => { + const playback = this.playback(); + if (!playback || playback.isLive) { + return null; + } + + return playback.thumbnail ?? null; + }); + /** Safe `url(...)` value, or null when the poster URL is not a plain http/data URL. */ + readonly ambientImageStyle = computed(() => { + const url = this.ambientImageUrl(); + if (!url || !/^(https?:|data:)/i.test(url)) { + return null; + } + + 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. + const player = this.settingsStore.player?.(); + const isWebPlayer = + player === VideoPlayer.VideoJs || + player === VideoPlayer.Html5Player || + player === VideoPlayer.ArtPlayer; + + return ( + isWebPlayer && + this.settingsStore.playerAmbientMode?.() === true && + !!this.ambientImageStyle() + ); + }); readonly contentInfo = computed( () => this.playback()?.contentInfo );