diff --git a/.changes/portals-trailer-backdrop.md b/.changes/portals-trailer-backdrop.md new file mode 100644 index 000000000..b24d3a1c4 --- /dev/null +++ b/.changes/portals-trailer-backdrop.md @@ -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. diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index ddf8baa27..ed3765f0e 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -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, diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts index a43bfe1ad..e4faba996 100644 --- a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -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), diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index c3f312fd3..810c05b29 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -114,6 +114,33 @@ +
+
+

+ {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }} +

+

+ {{ + 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+
(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(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 2bd6569c3..0d7f72c7a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -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()" diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts index 10157b114..6527d994b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-hero.presenter.ts @@ -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(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(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 007ca6892..7f00dc2b0 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -8,6 +8,7 @@ [description]="info.plot" [posterUrl]="info.cover" [backdropUrl]="getBackdropUrl(info)" + [trailerBackdropUrl]="heroPresenter.trailerBackdropUrl()" [playbackActive]="!!inlinePlayback()" (backClicked)="goBack()" (closePlayerRequested)="closeInlinePlayer()" diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 18c9814b8..e58f63793 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -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()" diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 9d82f656f..1b1e814dc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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; diff --git a/libs/services/src/lib/settings-store.defaults.ts b/libs/services/src/lib/settings-store.defaults.ts index 794437f24..75d4da6c2 100644 --- a/libs/services/src/lib/settings-store.defaults.ts +++ b/libs/services/src/lib/settings-store.defaults.ts @@ -15,6 +15,7 @@ export const DEFAULT_SETTINGS: Settings = { player: VideoPlayer.VideoJs, webPlayerSharedControls: true, playerAmbientMode: false, + detailTrailerBackdrop: false, playerUpNextRail: true, playerUpNextCard: true, fullscreenChannelPanel: true, diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 71800a36e..a517f1fcb 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -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, diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index dfbd71a10..9ff5aa3ad 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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 diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 6397bfac3..01bd424ef 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index 22c78ec0f..6bccf4046 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -104,6 +104,14 @@ }
+ @if (trailerBackdropUrl(); as trailerUrl) { + + } +
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 8bd7a9c2d..629dfd144 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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 { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 674ed101a..2084a665a 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -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(); /** 0–100 watched share; renders the resume bar above the actions. */ readonly progress = input(null); + /** With the setting on, this trailer plays muted behind the details. */ + readonly trailerBackdropUrl = input(null); readonly isLoading = input(false); readonly errorMessage = input(); diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html new file mode 100644 index 000000000..830ebd50d --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.html @@ -0,0 +1,35 @@ +@if (playing()) { + + +} diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss new file mode 100644 index 000000000..fccacc654 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.scss @@ -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; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts new file mode 100644 index 000000000..fe9263089 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.spec.ts @@ -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; + 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( + '[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; + } + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts new file mode 100644 index 000000000..d96128a5b --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/hero-trailer-backdrop.component.ts @@ -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(); + 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); + private readonly frame = viewChild>('frame'); + private readonly inView = signal(true); + private readonly windowFocused = signal(true); + private idleTimer: ReturnType | null = null; + + readonly frameUrl = computed(() => + 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; + } +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html index 54af0c14c..c83eb682e 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.html @@ -27,6 +27,7 @@ [posterUrl]="posterUrl()" [backdropUrl]="backdropUrl()" [progress]="progress()" + [trailerBackdropUrl]="trailerBackdropUrl()" [isLoading]="isLoading()" [errorMessage]="errorMessage()" > diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index c748c007d..dada488a8 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -90,6 +90,8 @@ export class PortalDetailShellComponent { readonly backdropUrl = input(); /** 0–100 watched share shown as the hero's resume bar. */ readonly progress = input(null); + /** Trailer embed to play muted behind the hero, when the setting is on. */ + readonly trailerBackdropUrl = input(null); readonly isLoading = input(false); readonly errorMessage = input(); readonly backLabel = input(); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index e7c77e6ba..7bdb4af17 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -7,6 +7,7 @@ [posterUrl]="meta.posterUrl" [backdropUrl]="meta.backdropUrl" [progress]="hero.progress()" + [trailerBackdropUrl]="trailerBackdropUrl()" [playbackActive]="!!inlinePlayback()" (backClicked)="goBack()" (closePlayerRequested)="closeInlinePlayback()" diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 6d45154d8..6b16594a4 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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( diff --git a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts index 30613b6f2..4c623cee0 100644 --- a/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts +++ b/libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts @@ -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',