mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
aac8021553
commit
ff7b340e87
29 files changed
+498
-9
No files matched your search
@@ -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,
|
||||
|
||||
+11
-1
@@ -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();
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user