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:
4grayandClaude Opus 4.8 authored and GitHub committed 2026-07-24 08:02:11 +02:00
1 parent b3e130aa65
commit db70b07093
34 files changed
+426 -23

No files matched your search

+2 -1
View File
@@ -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,
+2
View File
@@ -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": "اختر السمة البصرية للتطبيق",
+2
View File
@@ -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": "اختار المظهر المرئي للتطبيق",
+2
View File
@@ -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": "Выбраць візуальную тэму афармлення",
+2
View File
@@ -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",
+2
View File
@@ -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": "Επιλέξτε οπτικό θέμα της εφαρμογής",
+2
View File
@@ -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",
+2
View File
@@ -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.",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "アプリケーションの視覚テーマを選択",
+2
View File
@@ -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": "애플리케이션의 시각적 테마 선택",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "Выбрать визуальную тему оформления",
+2
View File
@@ -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",
+2
View File
@@ -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": "选择应用程序的视觉主题",
+2
View File
@@ -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
+21 -15
View File
@@ -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
);