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 839025f..1024766 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 @@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => { 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 }; + + 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 component = new PlayerShellComponent(); + component.ngOnInit(); + + await expect(mediaPrototype.play()).resolves.toBeUndefined(); + + component.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 3d4a099..8f0015a 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -113,6 +113,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private reconnectTimer: ReturnType | null = null; private stateSyncTimer: ReturnType | null = null; private unsubscribeLocale: (() => void) | null = null; + private restoreAudioGuard: (() => void) | null = null; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { this.locale = locale; }); + this.installSecondaryDeviceAudioGuard(); const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); @@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.clearStateSyncTimer(); this.unsubscribeLocale?.(); this.unsubscribeLocale = null; + this.restoreAudioGuard?.(); + this.restoreAudioGuard = null; } private readonly handleOnline = (): void => { @@ -185,6 +189,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + private installSecondaryDeviceAudioGuard(): void { + if (!this.clientHasNoAudioOutput || typeof window === 'undefined') { + return; + } + + const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: { play?: () => Promise } } }).HTMLMediaElement + ?.prototype; + + if (!mediaPrototype || typeof mediaPrototype.play !== 'function') { + return; + } + + const originalPlay = mediaPrototype.play; + mediaPrototype.play = () => Promise.resolve(); + this.restoreAudioGuard = () => { + mediaPrototype.play = originalPlay; + }; + } + private scheduleStateSync(): void { this.clearStateSyncTimer();