From f50f6a08ae61b86281deff44b0aee3cf14ed504b Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 23:29:15 +0000 Subject: [PATCH] fix(player): silence active media on secondary-device guard install --- .../player/player-shell.component.spec.ts | 31 +++++++++++++++++++ .../features/player/player-shell.component.ts | 27 ++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 13d2f7e..df15bf6 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -351,6 +351,37 @@ describe('PlayerShellComponent gameplay wiring', () => { component.ngOnDestroy(); }); + it('silences active media elements when secondary-device audio guard is installed', () => { + const pauseAudio = vi.fn(); + const pauseVideo = vi.fn(); + const audioElement = { muted: false, pause: pauseAudio }; + const videoElement = { muted: false, pause: pauseVideo }; + const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]); + + vi.stubGlobal('document', { querySelectorAll }); + vi.stubGlobal('window', { + location: { hash: '', search: '' }, + history: { state: null, replaceState: vi.fn() }, + localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, + sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, + HTMLMediaElement: { prototype: { play: vi.fn().mockResolvedValue(undefined) } }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); + + const component = new PlayerShellComponent(); + component.ngOnInit(); + + expect(querySelectorAll).toHaveBeenCalledWith('audio,video'); + expect(audioElement.muted).toBe(true); + expect(videoElement.muted).toBe(true); + expect(pauseAudio).toHaveBeenCalledTimes(1); + expect(pauseVideo).toHaveBeenCalledTimes(1); + + component.ngOnDestroy(); + }); + it('installs secondary-device audio guard while player shell is mounted', async () => { const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); const mediaPrototype = { play: originalPlay }; diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 94f8fa0..2bbc9f4 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -24,6 +24,11 @@ type MediaPrototypeWithGuardState = { }; }; +type GuardableMediaElement = { + muted?: boolean; + pause?: () => void; +}; + function resolveLocalStorage(): Storage | undefined { if (typeof window === 'undefined') { return undefined; @@ -202,6 +207,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } + this.silenceExistingMediaElements(); + const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement ?.prototype; @@ -235,6 +242,26 @@ export class PlayerShellComponent implements OnInit, OnDestroy { }; } + private silenceExistingMediaElements(): void { + if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') { + return; + } + + const activeElements = document.querySelectorAll('audio,video') as + | NodeListOf + | GuardableMediaElement[] + | undefined; + + if (!activeElements || typeof (activeElements as { forEach?: unknown }).forEach !== 'function') { + return; + } + + activeElements.forEach((element) => { + element.muted = true; + element.pause?.(); + }); + } + private scheduleStateSync(): void { this.clearStateSyncTimer(); -- 2.39.5