mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
161 lines
5.6 KiB
TypeScript
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;
|
|
}
|
|
}
|