diff --git a/.changes/portals-trailer-backdrop.md b/.changes/portals-trailer-backdrop.md
new file mode 100644
index 000000000..b24d3a1c4
--- /dev/null
+++ b/.changes/portals-trailer-backdrop.md
@@ -0,0 +1,6 @@
+---
+type: feature
+area: portals
+---
+
+A new Playback setting, "Play trailers in details background" (off by default), plays a movie's or series' trailer muted behind the details a few seconds after the page opens, with a corner button to unmute. It stays off under reduced motion and on metered connections, and stops when the page scrolls away or the window loses focus.
diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts
index ddf8baa27..ed3765f0e 100644
--- a/apps/web/src/app/settings/settings-form.utils.ts
+++ b/apps/web/src/app/settings/settings-form.utils.ts
@@ -64,6 +64,7 @@ export function createSettingsForm(
player: [VideoPlayer.VideoJs],
webPlayerSharedControls: true,
playerAmbientMode: false,
+ detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
@@ -176,6 +177,7 @@ export function createSettingsFromFormValue(
player: value.player ?? VideoPlayer.VideoJs,
webPlayerSharedControls: value.webPlayerSharedControls ?? true,
playerAmbientMode: value.playerAmbientMode ?? false,
+ detailTrailerBackdrop: value.detailTrailerBackdrop ?? false,
playerUpNextRail: value.playerUpNextRail ?? true,
playerUpNextCard: value.playerUpNextCard ?? true,
fullscreenChannelPanel: value.fullscreenChannelPanel ?? true,
diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts
index a43bfe1ad..e4faba996 100644
--- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts
+++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts
@@ -73,6 +73,7 @@ function createForm(player: VideoPlayer): FormGroup {
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
+ detailTrailerBackdrop: new FormControl(false),
playerUpNextRail: new FormControl(true),
playerUpNextCard: new FormControl(true),
fullscreenChannelPanel: new FormControl(true),
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 c3f312fd3..810c05b29 100644
--- a/apps/web/src/app/settings/settings-playback-section.component.html
+++ b/apps/web/src/app/settings/settings-playback-section.component.html
@@ -114,6 +114,33 @@
+
+
(null);
private readonly info = computed(
() => this.bindings()?.displayItem()?.info ?? null
@@ -96,6 +100,12 @@ export class StalkerSeriesHeroPresenter {
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.info()?.tmdb_trailer)
);
+ /** Settings → Playback → Play trailers in details background. */
+ readonly trailerBackdropUrl = computed(() =>
+ this.settingsStore.detailTrailerBackdrop?.() === true
+ ? this.trailerEmbedUrl()
+ : null
+ );
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html
index 2bd6569c3..0d7f72c7a 100644
--- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html
+++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html
@@ -6,6 +6,7 @@
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
[backdropUrl]="serial.info.tmdb_backdrop"
+ [trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts
index 10157b114..6527d994b 100644
--- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts
+++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts
@@ -5,6 +5,7 @@ import {
type SeriesQuickStartAction,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
+import { SettingsStore } from '@iptvnator/services';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import { youtubeEmbedUrl } from '@iptvnator/shared/interfaces';
import {
@@ -32,6 +33,7 @@ export class SerialDetailsHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly xtreamStore = inject(XtreamStore);
private readonly trailerDialog = inject(TrailerDialogService);
+ private readonly settingsStore = inject(SettingsStore);
private readonly bindings = signal(null);
private readonly info = computed(
() => this.bindings()?.selectedItem()?.info ?? null
@@ -101,6 +103,12 @@ export class SerialDetailsHeroPresenter {
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.info()?.youtube_trailer)
);
+ /** Settings → Playback → Play trailers in details background. */
+ readonly trailerBackdropUrl = computed(() =>
+ this.settingsStore.detailTrailerBackdrop?.() === true
+ ? this.trailerEmbedUrl()
+ : null
+ );
openTrailer(): void {
const embedUrl = this.trailerEmbedUrl();
diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html
index 007ca6892..7f00dc2b0 100644
--- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html
+++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html
@@ -8,6 +8,7 @@
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="getBackdropUrl(info)"
+ [trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html
index 18c9814b8..e58f63793 100644
--- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html
+++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html
@@ -235,6 +235,7 @@
[posterUrl]="info.movie_image || info.cover_big"
[backdropUrl]="getBackdropUrl(info)"
[progress]="hero.progress()"
+ [trailerBackdropUrl]="trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
index 9d82f656f..1b1e814dc 100644
--- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
+++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
@@ -381,6 +381,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
+ /** Settings → Playback → Play trailers in details background. */
+ readonly trailerBackdropUrl = computed(() =>
+ this.settingsStore.detailTrailerBackdrop?.() === true
+ ? this.trailerEmbedUrl()
+ : null
+ );
readonly similarItems = this.similar.similarItems;
readonly similarInPortals = this.similar.similarInPortals;
diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts
index 794437f24..75d4da6c2 100644
--- a/libs/services/src/lib/settings-store.defaults.ts
+++ b/libs/services/src/lib/settings-store.defaults.ts
@@ -15,6 +15,7 @@ export const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
+ detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts
index 71800a36e..a517f1fcb 100644
--- a/libs/services/src/lib/settings-store.service.ts
+++ b/libs/services/src/lib/settings-store.service.ts
@@ -198,6 +198,9 @@ export const SettingsStore = signalStore(
playerAmbientMode:
store.playerAmbientMode?.() ??
DEFAULT_SETTINGS.playerAmbientMode,
+ detailTrailerBackdrop:
+ store.detailTrailerBackdrop?.() ??
+ DEFAULT_SETTINGS.detailTrailerBackdrop,
playerUpNextRail:
store.playerUpNextRail?.() ??
DEFAULT_SETTINGS.playerUpNextRail,
diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts
index dfbd71a10..9ff5aa3ad 100644
--- a/libs/shared/interfaces/src/lib/settings.interface.ts
+++ b/libs/shared/interfaces/src/lib/settings.interface.ts
@@ -144,6 +144,11 @@ export interface Settings {
* black bars. Off by default; only affects the built-in web players.
*/
playerAmbientMode?: boolean;
+ /**
+ * Play a title's trailer, muted, behind the movie or series details a
+ * few seconds after the page opens. Off by default.
+ */
+ detailTrailerBackdrop?: boolean;
/**
* Dock the inline series player to the left and show an "Up Next"
* episode rail in the leftover stage column on wide windows. On by
diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts
index 6397bfac3..01bd424ef 100644
--- a/libs/ui/components/src/index.ts
+++ b/libs/ui/components/src/index.ts
@@ -47,3 +47,4 @@ export * from './lib/detail-ui/trailer-dialog.service';
export * from './lib/detail-ui/vod-more-menu.component';
export * from './lib/detail-ui/vod-more-menu.model';
export * from './lib/detail-ui/series-hero.state';
+export * from './lib/detail-ui/hero-trailer-backdrop.component';
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html
index 22c78ec0f..6bccf4046 100644
--- a/libs/ui/components/src/lib/content-hero/content-hero.component.html
+++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html
@@ -104,6 +104,14 @@
}
+ @if (trailerBackdropUrl(); as trailerUrl) {
+
+ }
+
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss
index 8bd7a9c2d..629dfd144 100644
--- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss
+++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss
@@ -72,6 +72,11 @@
}
}
+ // The muted trailer sits over the still backdrop and under the scrim.
+ &__trailer {
+ z-index: 0;
+ }
+
// Two scrim layers: a horizontal one for the text column, a vertical one
// that dissolves the artwork into the page.
&__vignette {
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts
index 674ed101a..2084a665a 100644
--- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts
+++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts
@@ -15,11 +15,13 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
+import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [
+ HeroTrailerBackdropComponent,
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent,
@@ -41,6 +43,8 @@ export class ContentHeroComponent {
readonly backdropUrl = input();
/** 0–100 watched share; renders the resume bar above the actions. */
readonly progress = input(null);
+ /** With the setting on, this trailer plays muted behind the details. */
+ readonly trailerBackdropUrl = input(null);
readonly isLoading = input(false);
readonly errorMessage = input();
diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html
new file mode 100644
index 000000000..830ebd50d
--- /dev/null
+++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html
@@ -0,0 +1,35 @@
+@if (playing()) {
+
+
+}
diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss
new file mode 100644
index 000000000..fccacc654
--- /dev/null
+++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss
@@ -0,0 +1,59 @@
+:host {
+ position: absolute;
+ inset: 0;
+ display: block;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 600ms ease;
+
+ &.is-playing {
+ opacity: 1;
+ }
+}
+
+// A 16:9 frame that covers the hero whatever its aspect: oversized and
+// centred, like `object-fit: cover` for an iframe.
+.trailer-backdrop__frame {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: max(100%, calc(100vh * 16 / 9));
+ height: max(100%, calc(100vw * 9 / 16));
+ transform: translate(-50%, -50%);
+ border: 0;
+}
+
+.trailer-backdrop__mute {
+ position: absolute;
+ right: 16px;
+ bottom: 16px;
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 1px solid color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--app-content-bg, #161a22) 70%, transparent);
+ color: var(--app-heading-color, #e7ecf3);
+ cursor: pointer;
+ pointer-events: auto;
+
+ mat-icon {
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
+ line-height: 18px;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--app-selection-color, #78adff);
+ outline-offset: 2px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :host {
+ transition: none;
+ }
+}
diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts
new file mode 100644
index 000000000..fe9263089
--- /dev/null
+++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts
@@ -0,0 +1,123 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ buildLoopingEmbedUrl,
+ canAutoplayTrailer,
+ HeroTrailerBackdropComponent,
+ TRAILER_BACKDROP_IDLE_MS,
+} from './hero-trailer-backdrop.component';
+
+const EMBED = 'https://www.youtube-nocookie.com/embed/abc123def';
+
+describe('HeroTrailerBackdropComponent', () => {
+ let fixture: ComponentFixture;
+ let reducedMotion: boolean;
+ const originalMatchMedia = window.matchMedia;
+
+ beforeEach(async () => {
+ jest.useFakeTimers();
+ reducedMotion = false;
+ window.matchMedia = jest.fn().mockImplementation(() => ({
+ matches: reducedMotion,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ await TestBed.configureTestingModule({
+ imports: [HeroTrailerBackdropComponent, TranslateModule.forRoot()],
+ }).compileComponents();
+ fixture = TestBed.createComponent(HeroTrailerBackdropComponent);
+ fixture.componentRef.setInput('embedUrl', EMBED);
+ fixture.componentRef.setInput('title', 'Black Harbor');
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ window.matchMedia = originalMatchMedia;
+ });
+
+ function host(): HTMLElement {
+ return fixture.nativeElement as HTMLElement;
+ }
+
+ /** jsdom reports an unfocused document until something is focused. */
+ function renderFocused(): void {
+ fixture.detectChanges();
+ window.dispatchEvent(new Event('focus'));
+ fixture.detectChanges();
+ }
+
+ it('starts the muted loop after the idle delay and toggles the sound', () => {
+ renderFocused();
+ expect(host().querySelector('iframe')).toBeNull();
+
+ jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
+ fixture.detectChanges();
+ const frame = host().querySelector('iframe');
+ expect(frame).not.toBeNull();
+ expect(frame?.getAttribute('src')).toContain('autoplay=1');
+ expect(frame?.getAttribute('src')).toContain('mute=1');
+ expect(frame?.getAttribute('src')).toContain('playlist=abc123def');
+
+ const postMessage = jest.fn();
+ Object.defineProperty(frame, 'contentWindow', {
+ value: { postMessage },
+ configurable: true,
+ });
+ const mute = host().querySelector(
+ '[data-test-id="trailer-backdrop-mute"]'
+ );
+ mute?.click();
+ fixture.detectChanges();
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.stringContaining('"unMute"'),
+ '*'
+ );
+ expect(mute?.getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('never starts under reduced motion', () => {
+ reducedMotion = true;
+ renderFocused();
+ jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS * 2);
+ fixture.detectChanges();
+ expect(host().querySelector('iframe')).toBeNull();
+ });
+
+ it('stops when the window loses focus', () => {
+ renderFocused();
+ jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
+ fixture.detectChanges();
+ expect(host().querySelector('iframe')).not.toBeNull();
+
+ window.dispatchEvent(new Event('blur'));
+ fixture.detectChanges();
+ expect(host().querySelector('iframe')).toBeNull();
+ });
+});
+
+describe('trailer backdrop helpers', () => {
+ it('builds a looping, muted, controls-free embed with the JS API', () => {
+ const url = new URL(buildLoopingEmbedUrl(EMBED));
+ expect(url.searchParams.get('loop')).toBe('1');
+ expect(url.searchParams.get('controls')).toBe('0');
+ expect(url.searchParams.get('enablejsapi')).toBe('1');
+ expect(url.searchParams.get('playlist')).toBe('abc123def');
+ });
+
+ it('respects a metered connection', () => {
+ const original = Object.getOwnPropertyDescriptor(
+ navigator,
+ 'connection'
+ );
+ Object.defineProperty(navigator, 'connection', {
+ value: { saveData: true },
+ configurable: true,
+ });
+ expect(canAutoplayTrailer()).toBe(false);
+ if (original) {
+ Object.defineProperty(navigator, 'connection', original);
+ } else {
+ delete (navigator as { connection?: unknown }).connection;
+ }
+ });
+});
diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts
new file mode 100644
index 000000000..d96128a5b
--- /dev/null
+++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts
@@ -0,0 +1,160 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ ElementRef,
+ afterNextRender,
+ computed,
+ effect,
+ inject,
+ input,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
+import { MatIcon } from '@angular/material/icon';
+import { MatTooltip } from '@angular/material/tooltip';
+import { TranslatePipe } from '@ngx-translate/core';
+
+/** Idle time on the page before the trailer takes over the backdrop. */
+export const TRAILER_BACKDROP_IDLE_MS = 3000;
+
+/**
+ * Plays a title's trailer, muted and looping, as the hero backdrop after a
+ * few idle seconds (`Settings → Playback → Play trailers in details
+ * background`). It stops when the hero scrolls out of view or the window
+ * loses focus, and never starts under `prefers-reduced-motion` or on a
+ * metered connection. The 32px button in the corner toggles the sound
+ * through the YouTube IFrame API (`enablejsapi`).
+ */
+@Component({
+ selector: 'app-hero-trailer-backdrop',
+ imports: [MatIcon, MatTooltip, TranslatePipe],
+ templateUrl: './hero-trailer-backdrop.component.html',
+ styleUrl: './hero-trailer-backdrop.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { '[class.is-playing]': 'playing()' },
+})
+export class HeroTrailerBackdropComponent {
+ /** A `youtube-nocookie.com/embed/...` URL, see `youtubeEmbedUrl`. */
+ readonly embedUrl = input.required();
+ readonly title = input('');
+
+ readonly playing = signal(false);
+ readonly muted = signal(true);
+
+ private readonly sanitizer = inject(DomSanitizer);
+ private readonly destroyRef = inject(DestroyRef);
+ private readonly host = inject>(ElementRef);
+ private readonly frame = viewChild>('frame');
+ private readonly inView = signal(true);
+ private readonly windowFocused = signal(true);
+ private idleTimer: ReturnType | null = null;
+
+ readonly frameUrl = computed(() =>
+ this.sanitizer.bypassSecurityTrustResourceUrl(
+ buildLoopingEmbedUrl(this.embedUrl())
+ )
+ );
+
+ constructor() {
+ effect(() => {
+ // A new trailer or a hero that left the screen resets the idle wait.
+ this.embedUrl();
+ const visible = this.inView() && this.windowFocused();
+ untracked(() => {
+ this.clearIdleTimer();
+ if (!visible) {
+ this.playing.set(false);
+ return;
+ }
+ if (canAutoplayTrailer()) {
+ this.idleTimer = setTimeout(() => {
+ this.playing.set(true);
+ this.idleTimer = null;
+ }, TRAILER_BACKDROP_IDLE_MS);
+ }
+ });
+ });
+ afterNextRender(() => this.observe());
+ this.destroyRef.onDestroy(() => this.clearIdleTimer());
+ }
+
+ toggleMute(): void {
+ const nextMuted = !this.muted();
+ this.muted.set(nextMuted);
+ this.frame()?.nativeElement.contentWindow?.postMessage(
+ JSON.stringify({
+ event: 'command',
+ func: nextMuted ? 'mute' : 'unMute',
+ args: [],
+ }),
+ '*'
+ );
+ }
+
+ private observe(): void {
+ const element = this.host.nativeElement;
+ if (typeof IntersectionObserver !== 'undefined') {
+ const observer = new IntersectionObserver(
+ (entries) =>
+ this.inView.set(
+ entries.some((entry) => entry.intersectionRatio > 0.4)
+ ),
+ { threshold: [0, 0.4, 1] }
+ );
+ observer.observe(element);
+ this.destroyRef.onDestroy(() => observer.disconnect());
+ }
+ const onFocus = () => this.windowFocused.set(true);
+ const onBlur = () => this.windowFocused.set(false);
+ this.windowFocused.set(document.hasFocus());
+ window.addEventListener('focus', onFocus);
+ window.addEventListener('blur', onBlur);
+ this.destroyRef.onDestroy(() => {
+ window.removeEventListener('focus', onFocus);
+ window.removeEventListener('blur', onBlur);
+ });
+ }
+
+ private clearIdleTimer(): void {
+ if (this.idleTimer) {
+ clearTimeout(this.idleTimer);
+ this.idleTimer = null;
+ }
+ }
+}
+
+/** Reduced motion and metered connections keep the backdrop still. */
+export function canAutoplayTrailer(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
+ return false;
+ }
+ const connection = (
+ navigator as Navigator & { connection?: { saveData?: boolean } }
+ ).connection;
+ return connection?.saveData !== true;
+}
+
+/** Autoplay muted, loop the single video, no controls, JS API for the mute toggle. */
+export function buildLoopingEmbedUrl(embedUrl: string): string {
+ try {
+ const url = new URL(embedUrl);
+ const videoId = url.pathname.split('/').pop() ?? '';
+ url.searchParams.set('autoplay', '1');
+ url.searchParams.set('mute', '1');
+ url.searchParams.set('controls', '0');
+ url.searchParams.set('loop', '1');
+ url.searchParams.set('playlist', videoId);
+ url.searchParams.set('rel', '0');
+ url.searchParams.set('playsinline', '1');
+ url.searchParams.set('enablejsapi', '1');
+ return url.toString();
+ } catch {
+ return embedUrl;
+ }
+}
diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html
index 54af0c14c..c83eb682e 100644
--- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html
+++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html
@@ -27,6 +27,7 @@
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
[progress]="progress()"
+ [trailerBackdropUrl]="trailerBackdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
>
diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts
index c748c007d..dada488a8 100644
--- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts
+++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts
@@ -90,6 +90,8 @@ export class PortalDetailShellComponent {
readonly backdropUrl = input();
/** 0–100 watched share shown as the hero's resume bar. */
readonly progress = input(null);
+ /** Trailer embed to play muted behind the hero, when the setting is on. */
+ readonly trailerBackdropUrl = input(null);
readonly isLoading = input(false);
readonly errorMessage = input();
readonly backLabel = input();
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html
index e7c77e6ba..7bdb4af17 100644
--- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html
+++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html
@@ -7,6 +7,7 @@
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
[progress]="hero.progress()"
+ [trailerBackdropUrl]="trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayback()"
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
index 6d45154d8..6b16594a4 100644
--- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
+++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
@@ -29,6 +29,7 @@ import {
TrailerDialogService,
ViewInPortalActionComponent,
VodMoreMenuComponent,
+ scrollToCastCrewRow,
type SimilarRailItem,
} from '@iptvnator/ui/components';
import { Router } from '@angular/router';
@@ -206,13 +207,17 @@ export class VodDetailsComponent {
readonly isElectron = computed(() => this.downloadsService.isAvailable());
/** Normalized metadata for display */
- readonly normalizedMeta = computed(() => {
- return normalizeVodDetails(this.item());
- });
+ readonly normalizedMeta = computed(() => normalizeVodDetails(this.item()));
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.normalizedMeta().youtubeTrailer)
);
+ /** Settings → Playback → Play trailers in details background. */
+ readonly trailerBackdropUrl = computed(() =>
+ this.settingsStore.detailTrailerBackdrop?.() === true
+ ? this.trailerEmbedUrl()
+ : null
+ );
/**
* TMDB recommendations found in the user's OTHER portals (batched DB
@@ -360,11 +365,7 @@ export class VodDetailsComponent {
}
}
- scrollToCast(): void {
- document
- .getElementById('detail-cast-crew')
- ?.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
+ readonly scrollToCast = scrollToCastCrewRow;
openSimilarRailItem(item: SimilarRailItem): void {
const match = this.similarInPortals().find(
diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts
index 30613b6f2..4c623cee0 100644
--- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts
+++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts
@@ -44,6 +44,14 @@ export const PLAYBACK_SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] =
keywords: ['ambient', 'glow', 'backlight'],
fallbackId: 'video-player',
},
+ {
+ id: 'detail-trailer-backdrop',
+ section: 'playback',
+ labelKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND',
+ descriptionKey: 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION',
+ keywords: ['trailer', 'youtube', 'backdrop', 'details'],
+ fallbackId: 'video-player',
+ },
{
id: 'player-up-next-rail',
section: 'playback',