feat(portals): optional trailer backdrop behind the details hero

Settings → Playback → "Play trailers in details background" (off by
default) hands the trailer embed URL to the detail shell; the new
app-hero-trailer-backdrop plays it muted and looping under the scrim
after three idle seconds with a corner mute toggle. It never starts under
prefers-reduced-motion or with saveData, and stops while the hero is off
screen or the window is unfocused. Xtream and Stalker movies and series
are wired; M3U has no trailer source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-02 09:04:36 +02:00
1 parent aac8021553
commit ff7b340e87
29 files changed
+498 -9

No files matched your search

+6
View File
@@ -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.
@@ -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,
@@ -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),
@@ -114,6 +114,33 @@
</div>
</div>
<div
class="setting-item"
data-setting-id="detail-trailer-backdrop"
data-test-id="detail-trailer-backdrop-setting"
>
<div class="setting-item__meta">
<h4>
{{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }}
</h4>
<p>
{{
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION'
| translate
}}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
[aria-label]="
'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate
"
formControlName="detailTrailerBackdrop"
data-test-id="detail-trailer-backdrop-toggle"
></mat-checkbox>
</div>
</div>
<div
class="setting-item"
data-setting-id="player-up-next-rail"
@@ -457,6 +457,7 @@ function createForm(player = VideoPlayer.VideoJs): 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),
@@ -86,6 +86,7 @@ export const DEFAULT_SETTINGS = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
@@ -178,6 +178,13 @@ and chip. Series titles drop their season marker (`splitSeasonSuffix`) into a
disabled. The page-level Sass mixin (`libs/ui/styles/_detail-view.scss`)
only carries the page shell, meta items and the episodes section.
With `detailTrailerBackdrop` on (Settings → Playback → "Play trailers in
details background", default off) the hosts hand the trailer embed URL to the
shell and `app-hero-trailer-backdrop` plays it muted and looping under the
scrim after three idle seconds, with a 32px mute toggle in the corner. It
never starts under `prefers-reduced-motion` or with `saveData`, and stops
while the hero is off screen or the window is unfocused.
## Electron Drag Regions
Every interactive descendant of a drag region—including buttons, links,
@@ -5,7 +5,10 @@ import {
StalkerStore,
type StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
import type { CrossPortalSimilarItem } from '@iptvnator/services';
import {
SettingsStore,
type CrossPortalSimilarItem,
} from '@iptvnator/services';
import { youtubeEmbedUrl } from '@iptvnator/shared/interfaces';
import {
createSeriesHeroState,
@@ -29,6 +32,7 @@ export class StalkerSeriesHeroPresenter {
private readonly translate = inject(TranslateService);
private readonly stalkerStore = inject(StalkerStore);
private readonly trailerDialog = inject(TrailerDialogService);
private readonly settingsStore = inject(SettingsStore);
private readonly bindings = signal<StalkerSeriesHeroBindings | null>(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();
@@ -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()"
@@ -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<SerialDetailsHeroBindings | null>(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();
@@ -8,6 +8,7 @@
[description]="info.plot"
[posterUrl]="info.cover"
[backdropUrl]="getBackdropUrl(info)"
[trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayer()"
@@ -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()"
@@ -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;
@@ -15,6 +15,7 @@ export const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
webPlayerSharedControls: true,
playerAmbientMode: false,
detailTrailerBackdrop: false,
playerUpNextRail: true,
playerUpNextCard: true,
fullscreenChannelPanel: true,
@@ -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,
@@ -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
+1
View File
@@ -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';
@@ -104,6 +104,14 @@
}
</div>
@if (trailerBackdropUrl(); as trailerUrl) {
<app-hero-trailer-backdrop
class="hero__trailer"
[embedUrl]="trailerUrl"
[title]="formattedTitle()"
/>
}
<!-- Scrim: strong on the text side, fading into the page below -->
<div class="hero__vignette"></div>
@@ -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 {
@@ -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<string>();
/** 0–100 watched share; renders the resume bar above the actions. */
readonly progress = input<number | null>(null);
/** With the setting on, this trailer plays muted behind the details. */
readonly trailerBackdropUrl = input<string | null>(null);
readonly isLoading = input(false);
readonly errorMessage = input<string>();
@@ -0,0 +1,35 @@
@if (playing()) {
<iframe
#frame
class="trailer-backdrop__frame"
[src]="frameUrl()"
[title]="title()"
allow="autoplay; encrypted-media"
referrerpolicy="strict-origin-when-cross-origin"
tabindex="-1"
aria-hidden="true"
></iframe>
<button
type="button"
class="trailer-backdrop__mute"
[matTooltip]="
(muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-label]="
(muted()
? 'PORTALS.DETAIL.TRAILER_UNMUTE'
: 'PORTALS.DETAIL.TRAILER_MUTE'
) | translate
"
[attr.aria-pressed]="!muted()"
data-test-id="trailer-backdrop-mute"
(click)="toggleMute()"
>
<mat-icon aria-hidden="true">{{
muted() ? 'volume_off' : 'volume_up'
}}</mat-icon>
</button>
}
@@ -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;
}
}
@@ -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<HeroTrailerBackdropComponent>;
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<HTMLButtonElement>(
'[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;
}
});
});
@@ -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<string>();
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<HTMLElement>>(ElementRef);
private readonly frame = viewChild<ElementRef<HTMLIFrameElement>>('frame');
private readonly inView = signal(true);
private readonly windowFocused = signal(true);
private idleTimer: ReturnType<typeof setTimeout> | null = null;
readonly frameUrl = computed<SafeResourceUrl>(() =>
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;
}
}
@@ -27,6 +27,7 @@
[posterUrl]="posterUrl()"
[backdropUrl]="backdropUrl()"
[progress]="progress()"
[trailerBackdropUrl]="trailerBackdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
>
@@ -90,6 +90,8 @@ export class PortalDetailShellComponent {
readonly backdropUrl = input<string>();
/** 0–100 watched share shown as the hero's resume bar. */
readonly progress = input<number | null>(null);
/** Trailer embed to play muted behind the hero, when the setting is on. */
readonly trailerBackdropUrl = input<string | null>(null);
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
@@ -7,6 +7,7 @@
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
[progress]="hero.progress()"
[trailerBackdropUrl]="trailerBackdropUrl()"
[playbackActive]="!!inlinePlayback()"
(backClicked)="goBack()"
(closePlayerRequested)="closeInlinePlayback()"
@@ -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(
@@ -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',