fix(voice): route media on evidence and switch devices without dropping the call
Outgoing voice was gated on the observer's roster copy of the remote user's voice state, which is signaling gossip. The signal server broadcasts `user_left` for any socket it declares dead, so a suspended laptop or a flaky hop wiped that copy and the observer detached its microphone from a peer that never left the channel - a silent member with no way back through the UI. `decideVoicePathRouting` now closes a path only on positive evidence: we left voice, the peer itself reported another channel or none, or the connection is gone. Missing gossip holds an established path instead. Opening still needs confirmation, so a guess never starts sending; the same rule gates playback, camera video, and the microphone a new connection puts in its first offer. Peers are also asked for their voice state when a connection or data channel comes up, so a rebuilt path re-confirms itself. Alongside it, the microphone can be switched mid-call: capture moves to a device service and rules, the live track is swapped with `replaceTrack` so the session is never renegotiated, and the speaking indicator follows the new stream.
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
* This file wires them together and exposes a public API that is
|
||||
* identical to the old monolithic service so consumers don't change.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
@@ -37,8 +37,12 @@ import { MediaManager } from './media/media.manager';
|
||||
import { ScreenShareManager } from './media/screen-share.manager';
|
||||
import { VoiceSessionController } from './media/voice-session-controller';
|
||||
import { IceServerSettingsService } from './ice-server-settings.service';
|
||||
import type { PeerData, VoiceStateSnapshot } from './realtime.types';
|
||||
import { LatencyProfile } from './realtime.constants';
|
||||
import type {
|
||||
PeerData,
|
||||
PeerRecoveryStatusEvent,
|
||||
VoiceStateSnapshot
|
||||
} from './realtime.types';
|
||||
import { LatencyProfile, P2P_TYPE_VOICE_STATE } from './realtime.constants';
|
||||
import { ScreenShareStartOptions } from './screen-share.config';
|
||||
import { WebRTCLogger } from './logging/webrtc-logger';
|
||||
import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager';
|
||||
@@ -114,6 +118,10 @@ export class WebRTCService implements OnDestroy {
|
||||
get onVoiceConnected(): Observable<void> {
|
||||
return this.peerMediaFacade.onVoiceConnected;
|
||||
}
|
||||
/** Peer recovery gave up on a peer, or re-armed it after signaling returned. */
|
||||
get onPeerRecoveryStatus(): Observable<PeerRecoveryStatusEvent> {
|
||||
return this.peerManager.peerRecoveryStatus$.asObservable();
|
||||
}
|
||||
|
||||
private readonly peerManager: PeerConnectionManager;
|
||||
private readonly mediaManager: MediaManager;
|
||||
@@ -199,9 +207,13 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerManager.setCallbacks({
|
||||
sendRawMessage: (msg: Record<string, unknown>) => this.signalingTransportHandler.sendRawMessage(msg),
|
||||
getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(),
|
||||
mayOpenVoicePathToPeer: (peerId: string): boolean =>
|
||||
this.peerMediaFacade.mayOpenVoicePathToPeer(peerId),
|
||||
isSignalingConnected: (): boolean => this.state.isSignalingConnected(),
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(),
|
||||
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
|
||||
getIdentifyCredentialsForPeer: (peerId: string) =>
|
||||
this.signalingTransportHandler.getIdentifyCredentialsForPeer(peerId),
|
||||
getLocalPeerId: (): string => this.state.getLocalPeerId(),
|
||||
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
|
||||
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive(),
|
||||
@@ -212,8 +224,8 @@ export class WebRTCService implements OnDestroy {
|
||||
getActivePeers: (): Map<string, PeerData> => this.peerMediaFacade.getActivePeers(),
|
||||
renegotiate: (peerId: string): Promise<void> => this.peerMediaFacade.renegotiate(peerId),
|
||||
broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event),
|
||||
getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(),
|
||||
getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName(),
|
||||
broadcastIdentityScopedMessage: (buildEvent): void =>
|
||||
this.peerMediaFacade.broadcastIdentityScopedMessage(buildEvent),
|
||||
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
|
||||
});
|
||||
|
||||
@@ -230,7 +242,8 @@ export class WebRTCService implements OnDestroy {
|
||||
});
|
||||
|
||||
this.signalingMessageHandler = new IncomingSignalingMessageHandler({
|
||||
getLocalOderId: () => this.signalingTransportHandler.getIdentifyCredentials()?.oderId ?? null,
|
||||
getLocalOderIdForSignalUrl: (signalUrl: string) =>
|
||||
this.signalingTransportHandler.getLocalOderIdForSignalUrl(signalUrl),
|
||||
getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId),
|
||||
isVoiceConnected: () => this.state.isVoiceConnectedActive(),
|
||||
peerManager: this.peerManager,
|
||||
@@ -262,9 +275,10 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
private wireManagerEvents(): void {
|
||||
// Internal control-plane messages for on-demand screen-share delivery.
|
||||
this.peerManager.messageReceived$.subscribe((event) =>
|
||||
this.remoteScreenShareRequestController.handlePeerControlMessage(event)
|
||||
);
|
||||
this.peerManager.messageReceived$.subscribe((event) => {
|
||||
this.remoteScreenShareRequestController.handlePeerControlMessage(event);
|
||||
this.notePeerVoiceReport(event);
|
||||
});
|
||||
|
||||
// Peer manager -> connected peers signal
|
||||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
|
||||
@@ -285,6 +299,7 @@ export class WebRTCService implements OnDestroy {
|
||||
|
||||
this.peerManager.peerDisconnected$.subscribe((peerId) => {
|
||||
this.remoteScreenShareRequestController.handlePeerDisconnected(peerId);
|
||||
this.mediaManager.forgetPeerVoiceReport(peerId);
|
||||
});
|
||||
|
||||
// Media manager -> voice connected signal
|
||||
@@ -298,6 +313,31 @@ export class WebRTCService implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer's own `voice-state` message is the only evidence that can prove it left
|
||||
* our voice channel. The roster can lose a peer for reasons that have nothing to
|
||||
* do with voice - a dead socket makes the signal server broadcast `user_left` -
|
||||
* and that must never cut a negotiated media path.
|
||||
*/
|
||||
private notePeerVoiceReport(event: ChatEvent): void {
|
||||
if (event.type !== P2P_TYPE_VOICE_STATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = event.fromPeerId;
|
||||
const voiceState = event.voiceState;
|
||||
|
||||
if (!peerId || !voiceState) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaManager.notePeerVoiceReport(peerId, {
|
||||
isConnected: voiceState.isConnected ?? false,
|
||||
roomId: voiceState.roomId,
|
||||
serverId: voiceState.serverId
|
||||
});
|
||||
}
|
||||
|
||||
private handleSignalingConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void {
|
||||
this.state.updateSignalingConnectionStatus(
|
||||
connected ? true : this.signalingCoordinator.isAnySignalingConnected(),
|
||||
@@ -306,6 +346,8 @@ export class WebRTCService implements OnDestroy {
|
||||
);
|
||||
|
||||
if (connected) {
|
||||
// Peers whose reconnect budget ran out during the outage stay dead until re-armed.
|
||||
this.peerManager.resumeStalledPeerRecovery();
|
||||
this.signalingReconnectedSubject$.next(signalUrl);
|
||||
}
|
||||
}
|
||||
@@ -503,9 +545,10 @@ export class WebRTCService implements OnDestroy {
|
||||
*
|
||||
* @param peerId - The target peer ID.
|
||||
* @param event - The chat event to send.
|
||||
* @returns whether the event reached the peer's open data channel.
|
||||
*/
|
||||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||
this.peerMediaFacade.sendToPeer(peerId, event);
|
||||
sendToPeer(peerId: string, event: ChatEvent): boolean {
|
||||
return this.peerMediaFacade.sendToPeer(peerId, event);
|
||||
}
|
||||
|
||||
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
|
||||
@@ -653,6 +696,16 @@ export class WebRTCService implements OnDestroy {
|
||||
await this.voiceSessionController.setLocalStream(stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the live microphone to another device without leaving voice.
|
||||
*
|
||||
* @param deviceId - Device id, or an empty string to follow the system default.
|
||||
* @returns The new local stream, or `null` when voice is not active.
|
||||
*/
|
||||
async switchInputDevice(deviceId: string): Promise<MediaStream | null> {
|
||||
return await this.voiceSessionController.switchInputDevice(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
@@ -746,6 +799,14 @@ export class WebRTCService implements OnDestroy {
|
||||
this.mediaManager.setAllowedVoicePeerIds(allowedPeerIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a peer's incoming voice may be heard, judged on the same evidence as the
|
||||
* outgoing microphone rather than on the roster alone.
|
||||
*/
|
||||
mayHearPeerVoice(peerId: string, hasEstablishedPlayback: boolean): boolean {
|
||||
return this.mediaManager.mayHearPeerVoice(peerId, hasEstablishedPlayback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sharing the screen (or a window) with all connected peers.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user