feat(player): guard against audio playback on secondary device

This commit is contained in:
2026-03-01 21:51:54 +00:00
parent 5fe9939057
commit 4e300e4631
2 changed files with 48 additions and 0 deletions

View File

@@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.ngOnDestroy(); 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');
});
}); });

View File

@@ -113,6 +113,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null; private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null; private unsubscribeLocale: (() => void) | null = null;
private restoreAudioGuard: (() => void) | null = null;
constructor() { constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) { if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale; this.locale = locale;
}); });
this.installSecondaryDeviceAudioGuard();
const hashRoute = window.location.hash.replace(/^#\/?/, ''); const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
@@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.clearStateSyncTimer(); this.clearStateSyncTimer();
this.unsubscribeLocale?.(); this.unsubscribeLocale?.();
this.unsubscribeLocale = null; this.unsubscribeLocale = null;
this.restoreAudioGuard?.();
this.restoreAudioGuard = null;
} }
private readonly handleOnline = (): void => { 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<void> } } }).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 { private scheduleStateSync(): void {
this.clearStateSyncTimer(); this.clearStateSyncTimer();