diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 01bd424ef..fe29ab474 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -44,6 +44,7 @@ export * from './lib/detail-ui/meta-chip.component'; export * from './lib/detail-ui/similar-rail.component'; export * from './lib/detail-ui/trailer-dialog.component'; export * from './lib/detail-ui/trailer-dialog.service'; +export * from './lib/detail-ui/trailer-dialog-state'; 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'; 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 index 71888df66..2b0e41f47 100644 --- 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 @@ -6,6 +6,7 @@ import { HeroTrailerBackdropComponent, TRAILER_BACKDROP_IDLE_MS, } from './hero-trailer-backdrop.component'; +import { TrailerDialogState } from './trailer-dialog-state'; const EMBED = 'https://www.youtube-nocookie.com/embed/abc123def'; @@ -119,6 +120,28 @@ describe('HeroTrailerBackdropComponent', () => { visibility.mockRestore(); }); + it('stops while the trailer modal is open and waits again afterwards', () => { + renderFocused(); + jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS); + fixture.detectChanges(); + expect(host().querySelector('iframe')).not.toBeNull(); + + // The modal plays its own copy; an unmuted backdrop would sound + // underneath it. + const dialogs = TestBed.inject(TrailerDialogState); + dialogs.opened(); + fixture.detectChanges(); + expect(host().querySelector('iframe')).toBeNull(); + + dialogs.closed(); + fixture.detectChanges(); + expect(host().querySelector('iframe')).toBeNull(); + jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS); + fixture.detectChanges(); + expect(host().querySelector('iframe')).not.toBeNull(); + expect(fixture.componentInstance.muted()).toBe(true); + }); + it('stops when the window loses focus', () => { renderFocused(); jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS); 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 index ea8735f43..942770c1c 100644 --- 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 @@ -13,6 +13,7 @@ import { viewChild, } from '@angular/core'; import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser'; +import { TrailerDialogState } from './trailer-dialog-state'; /** Idle time on the page before the trailer takes over the backdrop. */ export const TRAILER_BACKDROP_IDLE_MS = 3000; @@ -21,8 +22,9 @@ 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, the window - * loses focus or the document is hidden, and never starts under - * `prefers-reduced-motion` or on a metered connection. `toggleMute()` + * loses focus, the document is hidden or the trailer modal opens (which + * plays its own copy), and never starts under `prefers-reduced-motion` or + * on a metered connection. `toggleMute()` * drives the sound through the YouTube IFrame API (`enablejsapi`); the hero * renders that control itself, above its content layer. */ @@ -43,6 +45,7 @@ export class HeroTrailerBackdropComponent { private readonly sanitizer = inject(DomSanitizer); private readonly destroyRef = inject(DestroyRef); + private readonly trailerDialog = inject(TrailerDialogState); private readonly host = inject>(ElementRef); private readonly frame = viewChild>('frame'); private readonly inView = signal(true); @@ -63,7 +66,10 @@ export class HeroTrailerBackdropComponent { // autoplay the next URL at once). this.embedUrl(); const visible = - this.inView() && this.windowFocused() && this.documentVisible(); + this.inView() && + this.windowFocused() && + this.documentVisible() && + !this.trailerDialog.dialogOpen(); untracked(() => { this.clearIdleTimer(); this.playing.set(false); diff --git a/libs/ui/components/src/lib/detail-ui/trailer-dialog-state.ts b/libs/ui/components/src/lib/detail-ui/trailer-dialog-state.ts new file mode 100644 index 000000000..640ebc984 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/trailer-dialog-state.ts @@ -0,0 +1,21 @@ +import { computed, Injectable, signal } from '@angular/core'; + +/** + * Whether a trailer modal is open. The hero's trailer backdrop reads it and + * unmounts meanwhile: the modal plays its own copy, and opening it neither + * blurs the window nor hides the document, so nothing else would stop the + * backdrop's sound. + */ +@Injectable({ providedIn: 'root' }) +export class TrailerDialogState { + private readonly openDialogs = signal(0); + readonly dialogOpen = computed(() => this.openDialogs() > 0); + + opened(): void { + this.openDialogs.update((count) => count + 1); + } + + closed(): void { + this.openDialogs.update((count) => Math.max(0, count - 1)); + } +} diff --git a/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.spec.ts b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.spec.ts new file mode 100644 index 000000000..860538ef3 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.spec.ts @@ -0,0 +1,26 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { TrailerDialogState } from './trailer-dialog-state'; +import { TrailerDialogService } from './trailer-dialog.service'; + +describe('TrailerDialogService', () => { + it('reports the modal as open until it closed', () => { + const closed = new Subject(); + const open = jest.fn().mockReturnValue({ afterClosed: () => closed }); + TestBed.configureTestingModule({ + providers: [{ provide: MatDialog, useValue: { open } }], + }); + const state = TestBed.inject(TrailerDialogState); + + TestBed.inject(TrailerDialogService).open({ + embedUrl: 'https://www.youtube-nocookie.com/embed/abc', + title: 'Black Harbor', + }); + expect(open).toHaveBeenCalledTimes(1); + expect(state.dialogOpen()).toBe(true); + + closed.next(); + expect(state.dialogOpen()).toBe(false); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts index 26ec5f4e3..8fb7bd322 100644 --- a/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts +++ b/libs/ui/components/src/lib/detail-ui/trailer-dialog.service.ts @@ -1,5 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { MatDialog, type MatDialogRef } from '@angular/material/dialog'; +import { TrailerDialogState } from './trailer-dialog-state'; import { TrailerDialogComponent, type TrailerDialogData, @@ -11,14 +12,18 @@ export const TRAILER_DIALOG_PANEL_CLASS = 'app-trailer-dialog-panel'; @Injectable({ providedIn: 'root' }) export class TrailerDialogService { private readonly dialog = inject(MatDialog); + private readonly state = inject(TrailerDialogState); open(data: TrailerDialogData): MatDialogRef { - return this.dialog.open(TrailerDialogComponent, { + const ref = this.dialog.open(TrailerDialogComponent, { data, width: 'min(880px, calc(100vw - 48px))', maxWidth: '880px', autoFocus: 'dialog', panelClass: TRAILER_DIALOG_PANEL_CLASS, }); + this.state.opened(); + ref.afterClosed().subscribe(() => this.state.closed()); + return ref; } }