fix: Fix multiple bugs with new authentication flow
This commit is contained in:
+271
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
||||
ɵEffectScheduler as EffectScheduler
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ScreenShareFacade } from '../../../screen-share';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { VoicePlaybackService } from './voice-playback.service';
|
||||
|
||||
let audioContextCount = 0;
|
||||
|
||||
describe('VoicePlaybackService', () => {
|
||||
beforeEach(() => {
|
||||
audioContextCount = 0;
|
||||
installAudioDomMocks();
|
||||
installLocalStorageMock();
|
||||
});
|
||||
|
||||
it('creates one audio pipeline per peer on first connect', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the existing pipeline when the same voice stream is handled again', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const track = createMockAudioTrack('track-a');
|
||||
const firstStream = createMockAudioStreamFromTracks([track]);
|
||||
const secondStream = createMockAudioStreamFromTracks([track]);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rebuilds the pipeline when the live audio track set changes', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const firstStream = createMockAudioStream(['track-a']);
|
||||
const secondStream = createMockAudioStream(['track-b']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not recreate pipelines for unrelated remote stream notifications', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const voiceStream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
|
||||
context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());
|
||||
|
||||
context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('creates pipelines for each peer when multiple friends join voice', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
|
||||
context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('still applies updated playback options when reusing an existing pipeline', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
expect(context.service.getUserVolume('peer-1')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContext {
|
||||
service: VoicePlaybackService;
|
||||
voiceConnection: {
|
||||
isVoiceConnected: ReturnType<typeof signal<boolean>>;
|
||||
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
onVoiceConnected: Subject<void>;
|
||||
onPeerDisconnected: Subject<string>;
|
||||
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
}
|
||||
|
||||
function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
|
||||
const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||
const voiceConnection = {
|
||||
isVoiceConnected: signal(options.isVoiceConnected ?? false),
|
||||
onRemoteStream: remoteStream$,
|
||||
onVoiceConnected: new Subject<void>(),
|
||||
onPeerDisconnected: new Subject<string>(),
|
||||
getRemoteVoiceStream: vi.fn(() => null),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
syncOutgoingVoiceRouting: vi.fn()
|
||||
};
|
||||
const screenShare = {
|
||||
isScreenShareRemotePlaybackSuppressed: signal(false),
|
||||
forceDefaultRemotePlaybackOutput: signal(false)
|
||||
};
|
||||
const store = {
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return signal(null);
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return signal([]);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
|
||||
})
|
||||
};
|
||||
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
|
||||
const effectScheduler = {
|
||||
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
}),
|
||||
flush: vi.fn(() => {
|
||||
for (const scheduledEffect of scheduledEffects) {
|
||||
if (scheduledEffect.dirty) {
|
||||
scheduledEffect.run();
|
||||
}
|
||||
}
|
||||
}),
|
||||
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.delete(scheduledEffect);
|
||||
}),
|
||||
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
})
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
VoicePlaybackService,
|
||||
{
|
||||
provide: ChangeDetectionScheduler,
|
||||
useValue: { notify: vi.fn() }
|
||||
},
|
||||
{ provide: EffectScheduler, useValue: effectScheduler },
|
||||
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
|
||||
{ provide: ScreenShareFacade, useValue: screenShare },
|
||||
{ provide: Store, useValue: store }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
|
||||
voiceConnection,
|
||||
remoteStream$
|
||||
};
|
||||
}
|
||||
|
||||
function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
outputVolume: overrides.outputVolume ?? 1,
|
||||
isDeafened: overrides.isDeafened ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
|
||||
return {
|
||||
id,
|
||||
kind: 'audio',
|
||||
readyState,
|
||||
enabled: true
|
||||
} as MediaStreamTrack;
|
||||
}
|
||||
|
||||
function createMockAudioStream(trackIds: string[]): MediaStream {
|
||||
return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
|
||||
}
|
||||
|
||||
function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
|
||||
return {
|
||||
getAudioTracks: () => tracks,
|
||||
getTracks: () => tracks
|
||||
} as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function installAudioDomMocks(): void {
|
||||
vi.stubGlobal('MediaStream', class MediaStream {
|
||||
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
|
||||
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('Audio', class {
|
||||
muted = false;
|
||||
volume = 1;
|
||||
srcObject: MediaStream | null = null;
|
||||
|
||||
play = vi.fn().mockResolvedValue(undefined);
|
||||
remove = vi.fn();
|
||||
});
|
||||
|
||||
vi.stubGlobal('AudioContext', class {
|
||||
state = 'running';
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor() {
|
||||
audioContextCount += 1;
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return {
|
||||
gain: { value: 0 },
|
||||
connect: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn() };
|
||||
}
|
||||
|
||||
createMediaStreamDestination() {
|
||||
return { stream: createMockAudioStream(['destination']) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
+58
-8
@@ -112,10 +112,17 @@ export class VoicePlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.createPipeline(peerId, stream);
|
||||
}
|
||||
|
||||
@@ -142,13 +149,19 @@ export class VoicePlaybackService {
|
||||
for (const peerId of peers) {
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
if (!stream || !this.hasAudio(stream)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,4 +446,41 @@ export class VoicePlaybackService {
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
return stream.getAudioTracks().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote composite-stream notifications (camera, screen share, renegotiation)
|
||||
* can arrive without any change to the underlying voice audio tracks.
|
||||
* Rebuilding the Web Audio graph in that case only churns AudioContexts.
|
||||
*/
|
||||
private shouldReusePipeline(peerId: string, stream: MediaStream): boolean {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
return this.peerPipelines.has(peerId)
|
||||
&& !!trackedRaw
|
||||
&& this.streamsShareLiveAudioTracks(trackedRaw, stream);
|
||||
}
|
||||
|
||||
private streamsShareLiveAudioTracks(previous: MediaStream, next: MediaStream): boolean {
|
||||
const previousTrackIds = this.getLiveAudioTrackIds(previous);
|
||||
const nextTrackIds = this.getLiveAudioTrackIds(next);
|
||||
|
||||
if (previousTrackIds.length === 0 || nextTrackIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousTrackIds.length !== nextTrackIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousIds = new Set(previousTrackIds);
|
||||
|
||||
return nextTrackIds.every((trackId) => previousIds.has(trackId));
|
||||
}
|
||||
|
||||
private getLiveAudioTrackIds(stream: MediaStream): string[] {
|
||||
return stream
|
||||
.getAudioTracks()
|
||||
.filter((track) => track.readyState === 'live')
|
||||
.map((track) => track.id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user