mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(playback): theater stage and opt-in ambient fill for the inline portal player (#1223)
* fix(tmdb): purge obsolete search cache rows * feat(playback): theater stage and opt-in ambient fill for the inline portal player On wide-short windows the VOD/series inline player left a strip of app surface next to the video: with `width: auto`, the viewport's `max-height` transferred through `aspect-ratio` into a max-width (CSS transferred size constraints), re-clamping the stage to 16:9 and leaving the leftover outside it. - Theater stage: give `.player-shell__viewport` a definite `width: 100%` so it always fills the content row; the player renders as the largest 16:9 box that fits the stage height, centered — the leftover is always the stage's black background, never app surface (YouTube-style letterbox). Applies to every inline engine. - Ambient fill: new `playerAmbientMode` setting (default off, Settings > Playback, web players only) renders a blurred, dimmed copy of the poster behind the player, filling the letterbox margins. Enforced at runtime too: Embedded MPV never gets the extra DOM layer. Live channels and non-http(s) poster URLs are excluded. Verified live via CDP at 1720x760 (stage 1362x532, player 946x532 with symmetric 208px margins) and 1280x950 (stage exactly 16:9, no bars). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(i18n): add ambient-mode setting keys to all remaining locales The i18n drift gate requires SETTINGS.PLAYER_AMBIENT_MODE and its description in every locale; the feature commit only covered en and ru. Translated via the i18n-fill workflow (per-locale patch + mechanical merge, glossary-matched against each existing file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(settings): include playerAmbientMode in expected default settings settings.component.spec asserts the persisted settings object with toEqual; the new default-off field has to be part of the fixture. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3e130aa65
commit
db70b07093
34 files changed
+426
-23
No files matched your search
@@ -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`
|
||||
|
||||
@@ -32,6 +32,7 @@ export function createSettingsForm(
|
||||
return formBuilder.group({
|
||||
player: [VideoPlayer.VideoJs],
|
||||
webPlayerSharedControls: false,
|
||||
playerAmbientMode: false,
|
||||
...(supportsEpg
|
||||
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
|
||||
: {}),
|
||||
@@ -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,
|
||||
|
||||
@@ -95,6 +95,24 @@
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" data-test-id="player-ambient-mode-setting">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[aria-label]="'SETTINGS.PLAYER_AMBIENT_MODE' | translate"
|
||||
formControlName="playerAmbientMode"
|
||||
data-test-id="player-ambient-mode-toggle"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "اختر السمة البصرية للتطبيق",
|
||||
|
||||
@@ -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": "اختار المظهر المرئي للتطبيق",
|
||||
|
||||
@@ -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": "Выбраць візуальную тэму афармлення",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Επιλέξτε οπτικό θέμα της εφαρμογής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アプリケーションの視覚テーマを選択",
|
||||
|
||||
@@ -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": "애플리케이션의 시각적 테마 선택",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Выбрать визуальную тему оформления",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "选择应用程序的视觉主题",
|
||||
|
||||
@@ -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": "選擇應用程式視覺主題",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<id>` rows) |
|
||||
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` 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:<id>` rows) |
|
||||
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` 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:<tmdbId>' -- details payload row
|
||||
'title:<normalized>|year:<y>' -- search resolution row
|
||||
'title:<normalized>|year:<y>|v2' -- search resolution row
|
||||
'person:<personId>' -- 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)):
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
createTableStatements,
|
||||
indexMigrationStatements,
|
||||
runMigrations,
|
||||
cleanupLegacyTmdbSearchCache,
|
||||
} = __databaseConnectionTestHooks;
|
||||
|
||||
type SqliteHandle = Parameters<typeof runMigrations>[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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
</header>
|
||||
|
||||
<div class="player-shell__viewport">
|
||||
@if (ambientEnabled()) {
|
||||
<div
|
||||
class="player-shell__ambient"
|
||||
aria-hidden="true"
|
||||
[style.--ambient-image]="ambientImageStyle()"
|
||||
></div>
|
||||
}
|
||||
<app-web-player-view
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="title()"
|
||||
|
||||
@@ -77,8 +77,20 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// The viewport is the "theater stage": it spans the full content width and,
|
||||
// on wide-short windows, becomes wider than 16:9. The player is centered and
|
||||
// letterboxed inside it, so the leftover is always the black stage (or the
|
||||
// ambient fill) — never a stray strip of app background.
|
||||
.player-shell__viewport {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
// Definite width is load-bearing: with `width: auto`, `max-height` would
|
||||
// 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%;
|
||||
min-height: 320px;
|
||||
max-height: min(70vh, 720px);
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -86,10 +98,31 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
// Player: largest 16:9 box that fits the stage height, horizontally centered.
|
||||
// The stage is never narrower than 16:9, so height-driven sizing never overflows.
|
||||
.player-shell__viewport > 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 {
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(() => {
|
||||
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<string | null>(() => {
|
||||
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<boolean>(() => {
|
||||
// Web players only — mirrors the settings UI, which offers the toggle
|
||||
// for HTML5, Video.js, and ArtPlayer. Embedded MPV composites a native
|
||||
// video layer, so an extra DOM layer behind it stays out of the mix.
|
||||
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<PlayerContentInfo | undefined>(
|
||||
() => this.playback()?.contentInfo
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user