feat(player): guard against audio playback on secondary device
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user