mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
93523a1d3c
commit
5c9a12fa79
6 files changed
+86
-4
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLElement>>(ElementRef);
|
||||
private readonly frame = viewChild<ElementRef<HTMLIFrameElement>>('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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<void>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<TrailerDialogComponent> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user