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

+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>();