From 5c9a12fa796f9d6d7ee5c41d55ef0f242b299aea Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 16:15:54 +0200 Subject: [PATCH] fix(ui): the trailer backdrop stops while the trailer modal is open Opening the modal neither blurs the window nor hides the document, so an unmuted backdrop kept sounding under the modal's own copy. The dialog service now tracks open modals and the backdrop unmounts meanwhile, waiting idle again, muted, once the modal closed. Co-Authored-By: Claude Fable 5.1 --- libs/ui/components/src/index.ts | 1 + .../hero-trailer-backdrop.component.spec.ts | 23 ++++++++++++++++ .../hero-trailer-backdrop.component.ts | 12 ++++++--- .../src/lib/detail-ui/trailer-dialog-state.ts | 21 +++++++++++++++ .../detail-ui/trailer-dialog.service.spec.ts | 26 +++++++++++++++++++ .../lib/detail-ui/trailer-dialog.service.ts | 7 ++++- 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog-state.ts create mode 100644 libs/ui/components/src/lib/detail-ui/trailer-dialog.service.spec.ts 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; } }