Files
iptvnator/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts
T
4grayandClaude Fable 5.1 ff7b340e87 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>
2026-10-02 09:04:36 +02:00

161 lines
5.6 KiB
TypeScript

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;
}
}