From 845e94b7265ed70c9963b7b00078dd703a1840ac Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 23:13:34 +0000 Subject: [PATCH] fix(player): ref-count secondary-device audio guard lifecycle --- .../player/player-shell.component.spec.ts | 30 ++++++++++++++++ .../features/player/player-shell.component.ts | 35 ++++++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) 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 1024766..13d2f7e 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 @@ -375,4 +375,34 @@ describe('PlayerShellComponent gameplay wiring', () => { await expect(mediaPrototype.play()).rejects.toThrow('original play'); }); + + it('keeps audio guard active until the last mounted player shell is destroyed', async () => { + const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); + const mediaPrototype = { play: originalPlay }; + + 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: mediaPrototype }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); + + const firstComponent = new PlayerShellComponent(); + const secondComponent = new PlayerShellComponent(); + + firstComponent.ngOnInit(); + secondComponent.ngOnInit(); + + await expect(mediaPrototype.play()).resolves.toBeUndefined(); + + firstComponent.ngOnDestroy(); + await expect(mediaPrototype.play()).resolves.toBeUndefined(); + + secondComponent.ngOnDestroy(); + await expect(mediaPrototype.play()).rejects.toThrow('original play'); + }); }); 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 1d5f16c..94f8fa0 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -16,6 +16,14 @@ interface SessionDetail { type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; +type MediaPrototypeWithGuardState = { + play?: () => Promise; + __wppSecondaryDeviceAudioGuard__?: { + originalPlay: () => Promise; + installs: number; + }; +}; + function resolveLocalStorage(): Storage | undefined { if (typeof window === 'undefined') { return undefined; @@ -194,17 +202,36 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } - const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: { play?: () => Promise } } }).HTMLMediaElement + const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement ?.prototype; if (!mediaPrototype || typeof mediaPrototype.play !== 'function') { return; } - const originalPlay = mediaPrototype.play; - mediaPrototype.play = () => Promise.resolve(); + const guardState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; + if (guardState) { + guardState.installs += 1; + } else { + const originalPlay = mediaPrototype.play; + mediaPrototype.play = () => Promise.resolve(); + mediaPrototype.__wppSecondaryDeviceAudioGuard__ = { + originalPlay, + installs: 1, + }; + } + this.restoreAudioGuard = () => { - mediaPrototype.play = originalPlay; + const currentState = mediaPrototype.__wppSecondaryDeviceAudioGuard__; + if (!currentState) { + return; + } + + currentState.installs -= 1; + if (currentState.installs <= 0) { + mediaPrototype.play = currentState.originalPlay; + delete mediaPrototype.__wppSecondaryDeviceAudioGuard__; + } }; }