[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device #235

Merged
integrator-bot merged 1 commits from feature/issue-223-player-audio-guard into main 2026-03-02 00:21:38 +01:00
2 changed files with 61 additions and 4 deletions

View File

@@ -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');
});
});

View File

@@ -16,6 +16,14 @@ interface SessionDetail {
type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
type MediaPrototypeWithGuardState = {
play?: () => Promise<void>;
__wppSecondaryDeviceAudioGuard__?: {
originalPlay: () => Promise<void>;
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<void> } } }).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__;
}
};
}