Files
Toju/src/app/core/services/webrtc.service.ts
Myx fbdb5c38cf
Some checks failed
Queue Release Build / prepare (push) Successful in 13s
Queue Release Build / build-windows (push) Has started running
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Fix electron windows screen and window picker for screensharing
2026-03-11 14:39:47 +01:00

985 lines
32 KiB
TypeScript

/**
* WebRTCService - thin Angular service that composes specialised managers.
*
* Each concern lives in its own file under `./webrtc/`:
* • SignalingManager - WebSocket lifecycle & reconnection
* • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels
* • MediaManager - mic voice, mute, deafen, bitrate
* • ScreenShareManager - screen capture & mixed audio
* • WebRTCLogger - debug / diagnostic logging
*
* 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, @typescript-eslint/no-unused-vars */
import {
Injectable,
signal,
computed,
inject,
OnDestroy
} from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service';
import { DebuggingService } from './debugging.service';
import { ScreenShareSourcePickerService } from './screen-share-source-picker.service';
import {
SignalingManager,
PeerConnectionManager,
MediaManager,
ScreenShareManager,
WebRTCLogger,
IdentifyCredentials,
JoinedServerInfo,
VoiceStateSnapshot,
LatencyProfile,
ScreenShareStartOptions,
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_VIEW_SERVER,
SIGNALING_TYPE_LEAVE_SERVER,
SIGNALING_TYPE_OFFER,
SIGNALING_TYPE_ANSWER,
SIGNALING_TYPE_ICE_CANDIDATE,
SIGNALING_TYPE_CONNECTED,
SIGNALING_TYPE_SERVER_USERS,
SIGNALING_TYPE_USER_JOINED,
SIGNALING_TYPE_USER_LEFT,
DEFAULT_DISPLAY_NAME,
P2P_TYPE_SCREEN_SHARE_REQUEST,
P2P_TYPE_SCREEN_SHARE_STOP,
P2P_TYPE_VOICE_STATE,
P2P_TYPE_SCREEN_STATE
} from './webrtc';
interface SignalingUserSummary {
oderId: string;
displayName: string;
}
interface IncomingSignalingPayload {
sdp?: RTCSessionDescriptionInit;
candidate?: RTCIceCandidateInit;
}
type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' | 'payload'> & {
type: string;
payload?: IncomingSignalingPayload;
oderId?: string;
serverTime?: number;
serverId?: string;
users?: SignalingUserSummary[];
displayName?: string;
fromUserId?: string;
};
@Injectable({
providedIn: 'root'
})
export class WebRTCService implements OnDestroy {
private readonly timeSync = inject(TimeSyncService);
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private lastJoinedServer: JoinedServerInfo | null = null;
private readonly memberServerIds = new Set<string>();
private activeServerId: string | null = null;
/** The server ID where voice is currently active, or `null` when not in voice. */
private voiceServerId: string | null = null;
/** Maps each remote peer ID to the server they were discovered from. */
private readonly peerServerMap = new Map<string, string>();
private readonly serviceDestroyed$ = new Subject<void>();
private remoteScreenShareRequestsEnabled = false;
private readonly desiredRemoteScreenSharePeers = new Set<string>();
private readonly activeRemoteScreenSharePeers = new Set<string>();
private readonly _localPeerId = signal<string>(uuidv4());
private readonly _isSignalingConnected = signal(false);
private readonly _isVoiceConnected = signal(false);
private readonly _connectedPeers = signal<string[]>([]);
private readonly _isMuted = signal(false);
private readonly _isDeafened = signal(false);
private readonly _isScreenSharing = signal(false);
private readonly _isNoiseReductionEnabled = signal(false);
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
private readonly _isScreenShareRemotePlaybackSuppressed = signal(false);
private readonly _hasConnectionError = signal(false);
private readonly _connectionErrorMessage = signal<string | null>(null);
private readonly _hasEverConnected = signal(false);
/**
* Reactive snapshot of per-peer latencies (ms).
* Updated whenever a ping/pong round-trip completes.
* Keyed by remote peer (oderId).
*/
private readonly _peerLatencies = signal<ReadonlyMap<string, number>>(new Map());
// Public computed signals (unchanged external API)
readonly peerId = computed(() => this._localPeerId());
readonly isConnected = computed(() => this._isSignalingConnected());
readonly hasEverConnected = computed(() => this._hasEverConnected());
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
readonly connectedPeers = computed(() => this._connectedPeers());
readonly isMuted = computed(() => this._isMuted());
readonly isDeafened = computed(() => this._isDeafened());
readonly isScreenSharing = computed(() => this._isScreenSharing());
readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
readonly screenStream = computed(() => this._screenStreamSignal());
readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed());
readonly hasConnectionError = computed(() => this._hasConnectionError());
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
readonly shouldShowConnectionError = computed(() => {
if (!this._hasConnectionError())
return false;
if (this._isVoiceConnected() && this._connectedPeers().length > 0)
return false;
return true;
});
/** Per-peer latency map (ms). Read via `peerLatencies()`. */
readonly peerLatencies = computed(() => this._peerLatencies());
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> {
return this.peerManager.messageReceived$.asObservable();
}
get onPeerConnected(): Observable<string> {
return this.peerManager.peerConnected$.asObservable();
}
get onPeerDisconnected(): Observable<string> {
return this.peerManager.peerDisconnected$.asObservable();
}
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> {
return this.peerManager.remoteStream$.asObservable();
}
get onVoiceConnected(): Observable<void> {
return this.mediaManager.voiceConnected$.asObservable();
}
private readonly signalingManager: SignalingManager;
private readonly peerManager: PeerConnectionManager;
private readonly mediaManager: MediaManager;
private readonly screenShareManager: ScreenShareManager;
constructor() {
// Create managers with null callbacks first to break circular initialization
this.signalingManager = new SignalingManager(
this.logger,
() => this.lastIdentifyCredentials,
() => this.lastJoinedServer,
() => this.memberServerIds
);
this.peerManager = new PeerConnectionManager(this.logger, null!);
this.mediaManager = new MediaManager(this.logger, null!);
this.screenShareManager = new ScreenShareManager(this.logger, null!);
// Now wire up cross-references (all managers are instantiated)
this.peerManager.setCallbacks({
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
isSignalingConnected: (): boolean => this._isSignalingConnected(),
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
getLocalPeerId: (): string => this._localPeerId(),
isScreenSharingActive: (): boolean => this._isScreenSharing()
});
this.mediaManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections,
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event),
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
getIdentifyDisplayName: (): string =>
this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME
});
this.screenShareManager.setCallbacks({
getActivePeers: (): Map<string, import('./webrtc').PeerData> =>
this.peerManager.activePeerConnections,
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open(
sources,
options.includeSystemAudio
),
updateLocalScreenShareState: (state): void => {
this._isScreenSharing.set(state.active);
this._screenStreamSignal.set(state.stream);
this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback);
}
});
this.wireManagerEvents();
}
private wireManagerEvents(): void {
// Signaling → connection status
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
this._isSignalingConnected.set(connected);
if (connected)
this._hasEverConnected.set(true);
this._hasConnectionError.set(!connected);
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
});
// Signaling → message routing
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
// Signaling → heartbeat → broadcast states
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
// Internal control-plane messages for on-demand screen-share delivery.
this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event));
// Peer manager → connected peers signal
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) =>
this._connectedPeers.set(peers)
);
// If we are already sharing when a new peer connection finishes, push the
// current screen-share tracks to that peer and renegotiate.
this.peerManager.peerConnected$.subscribe((peerId) => {
if (!this.screenShareManager.getIsScreenActive()) {
if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) {
this.requestRemoteScreenShares([peerId]);
}
return;
}
this.screenShareManager.syncScreenShareToPeer(peerId);
if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) {
this.requestRemoteScreenShares([peerId]);
}
});
this.peerManager.peerDisconnected$.subscribe((peerId) => {
this.activeRemoteScreenSharePeers.delete(peerId);
this.screenShareManager.clearScreenShareRequest(peerId);
});
// Media manager → voice connected signal
this.mediaManager.voiceConnected$.subscribe(() => {
this._isVoiceConnected.set(true);
});
// Peer manager → latency updates
this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => {
const next = new Map(this.peerManager.peerLatencies);
this._peerLatencies.set(next);
});
}
private handleSignalingMessage(message: IncomingSignalingMessage): void {
this.signalingMessage$.next(message);
this.logger.info('Signaling message', { type: message.type });
switch (message.type) {
case SIGNALING_TYPE_CONNECTED:
this.handleConnectedSignalingMessage(message);
return;
case SIGNALING_TYPE_SERVER_USERS:
this.handleServerUsersSignalingMessage(message);
return;
case SIGNALING_TYPE_USER_JOINED:
this.handleUserJoinedSignalingMessage(message);
return;
case SIGNALING_TYPE_USER_LEFT:
this.handleUserLeftSignalingMessage(message);
return;
case SIGNALING_TYPE_OFFER:
this.handleOfferSignalingMessage(message);
return;
case SIGNALING_TYPE_ANSWER:
this.handleAnswerSignalingMessage(message);
return;
case SIGNALING_TYPE_ICE_CANDIDATE:
this.handleIceCandidateSignalingMessage(message);
return;
default:
return;
}
}
private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('Server connected', { oderId: message.oderId });
if (typeof message.serverTime === 'number') {
this.timeSync.setFromServerTime(message.serverTime);
}
}
private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void {
const users = Array.isArray(message.users) ? message.users : [];
this.logger.info('Server users', {
count: users.length,
serverId: message.serverId
});
for (const user of users) {
if (!user.oderId)
continue;
const existing = this.peerManager.activePeerConnections.get(user.oderId);
const healthy = this.isPeerHealthy(existing);
if (existing && !healthy) {
this.logger.info('Removing stale peer before recreate', { oderId: user.oderId });
this.peerManager.removePeer(user.oderId);
}
if (healthy)
continue;
this.logger.info('Create peer connection to existing user', {
oderId: user.oderId,
serverId: message.serverId
});
this.peerManager.createPeerConnection(user.oderId, true);
this.peerManager.createAndSendOffer(user.oderId);
if (message.serverId) {
this.peerServerMap.set(user.oderId, message.serverId);
}
}
}
private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User joined', {
displayName: message.displayName,
oderId: message.oderId
});
}
private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void {
this.logger.info('User left', {
displayName: message.displayName,
oderId: message.oderId,
serverId: message.serverId
});
if (message.oderId) {
this.peerManager.removePeer(message.oderId);
this.peerServerMap.delete(message.oderId);
}
}
private handleOfferSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
const offerEffectiveServer = this.voiceServerId || this.activeServerId;
if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) {
this.peerServerMap.set(fromUserId, offerEffectiveServer);
}
this.peerManager.handleOffer(fromUserId, sdp);
}
private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
this.peerManager.handleAnswer(fromUserId, sdp);
}
private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
if (!fromUserId || !candidate)
return;
this.peerManager.handleIceCandidate(fromUserId, candidate);
}
/**
* Close all peer connections that were discovered from a server
* other than `serverId`. Also removes their entries from
* {@link peerServerMap} so the bookkeeping stays clean.
*
* This ensures audio (and data channels) are scoped to only
* the voice-active (or currently viewed) server.
*/
private closePeersNotInServer(serverId: string): void {
const peersToClose: string[] = [];
this.peerServerMap.forEach((peerServerId, peerId) => {
if (peerServerId !== serverId) {
peersToClose.push(peerId);
}
});
for (const peerId of peersToClose) {
this.logger.info('Closing peer from different server', { peerId,
currentServer: serverId });
this.peerManager.removePeer(peerId);
this.peerServerMap.delete(peerId);
}
}
private getCurrentVoiceState(): VoiceStateSnapshot {
return {
isConnected: this._isVoiceConnected(),
isMuted: this._isMuted(),
isDeafened: this._isDeafened(),
isScreenSharing: this._isScreenSharing(),
roomId: this.mediaManager.getCurrentVoiceRoomId(),
serverId: this.mediaManager.getCurrentVoiceServerId()
};
}
// PUBLIC API - matches the old monolithic service's interface
/**
* Connect to a signaling server via WebSocket.
*
* @param serverUrl - The WebSocket URL of the signaling server.
* @returns An observable that emits `true` once connected.
*/
connectToSignalingServer(serverUrl: string): Observable<boolean> {
return this.signalingManager.connect(serverUrl);
}
/**
* Ensure the signaling WebSocket is connected, reconnecting if needed.
*
* @param timeoutMs - Maximum time (ms) to wait for the connection.
* @returns `true` if connected within the timeout.
*/
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
return this.signalingManager.ensureConnected(timeoutMs);
}
/**
* Send a signaling-level message (with `from` and `timestamp` auto-populated).
*
* @param message - The signaling message payload (excluding `from` / `timestamp`).
*/
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
}
/**
* Send a raw JSON payload through the signaling WebSocket.
*
* @param message - Arbitrary JSON message.
*/
sendRawMessage(message: Record<string, unknown>): void {
this.signalingManager.sendRawMessage(message);
}
/**
* Track the currently-active server ID (for server-scoped operations).
*
* @param serverId - The server to mark as active.
*/
setCurrentServer(serverId: string): void {
this.activeServerId = serverId;
}
/**
* Send an identify message to the signaling server.
*
* The credentials are cached so they can be replayed after a reconnect.
*
* @param oderId - The user's unique order/peer ID.
* @param displayName - The user's display name.
*/
identify(oderId: string, displayName: string): void {
this.lastIdentifyCredentials = { oderId,
displayName };
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY,
oderId,
displayName });
}
/**
* Join a server (room) on the signaling server.
*
* @param roomId - The server / room ID to join.
* @param userId - The local user ID.
*/
joinRoom(roomId: string, userId: string): void {
this.lastJoinedServer = { serverId: roomId,
userId };
this.memberServerIds.add(roomId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId: roomId });
}
/**
* Switch to a different server. If already a member, sends a view event;
* otherwise joins the server.
*
* @param serverId - The target server ID.
* @param userId - The local user ID.
*/
switchServer(serverId: string, userId: string): void {
this.lastJoinedServer = { serverId,
userId };
if (this.memberServerIds.has(serverId)) {
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER,
serverId });
this.logger.info('Viewed server (already joined)', {
serverId,
userId,
voiceConnected: this._isVoiceConnected()
});
} else {
this.memberServerIds.add(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER,
serverId });
this.logger.info('Joined new server via switch', {
serverId,
userId,
voiceConnected: this._isVoiceConnected()
});
}
}
/**
* Leave one or all servers.
*
* If `serverId` is provided, leaves only that server.
* Otherwise leaves every joined server and performs a full cleanup.
*
* @param serverId - Optional server to leave; omit to leave all.
*/
leaveRoom(serverId?: string): void {
if (serverId) {
this.memberServerIds.delete(serverId);
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId });
this.logger.info('Left server', { serverId });
if (this.memberServerIds.size === 0) {
this.fullCleanup();
}
return;
}
this.memberServerIds.forEach((sid) => {
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER,
serverId: sid });
});
this.memberServerIds.clear();
this.fullCleanup();
}
/**
* Check whether the local client has joined a given server.
*
* @param serverId - The server to check.
*/
hasJoinedServer(serverId: string): boolean {
return this.memberServerIds.has(serverId);
}
/** Returns a read-only set of all currently-joined server IDs. */
getJoinedServerIds(): ReadonlySet<string> {
return this.memberServerIds;
}
/**
* Broadcast a {@link ChatEvent} to every connected peer.
*
* @param event - The chat event to send.
*/
broadcastMessage(event: ChatEvent): void {
this.peerManager.broadcastMessage(event);
}
/**
* Send a {@link ChatEvent} to a specific peer.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
sendToPeer(peerId: string, event: ChatEvent): void {
this.peerManager.sendToPeer(peerId, event);
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
const nextDesiredPeers = new Set(
peerIds.filter((peerId): peerId is string => !!peerId)
);
if (!enabled) {
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]);
return;
}
this.remoteScreenShareRequestsEnabled = true;
for (const activePeerId of [...this.activeRemoteScreenSharePeers]) {
if (!nextDesiredPeers.has(activePeerId)) {
this.stopRemoteScreenShares([activePeerId]);
}
}
this.desiredRemoteScreenSharePeers.clear();
nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId));
this.requestRemoteScreenShares([...nextDesiredPeers]);
}
/**
* Send a {@link ChatEvent} to a peer with back-pressure awareness.
*
* @param peerId - The target peer ID.
* @param event - The chat event to send.
*/
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
return this.peerManager.sendToPeerBuffered(peerId, event);
}
/** Returns an array of currently-connected peer IDs. */
getConnectedPeers(): string[] {
return this.peerManager.getConnectedPeerIds();
}
/**
* Get the composite remote {@link MediaStream} for a connected peer.
*
* @param peerId - The remote peer whose stream to retrieve.
* @returns The stream, or `null` if the peer has no active stream.
*/
getRemoteStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
}
/**
* Get the remote voice-only stream for a connected peer.
*
* @param peerId - The remote peer whose voice stream to retrieve.
* @returns The stream, or `null` if the peer has no active voice audio.
*/
getRemoteVoiceStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerVoiceStreams.get(peerId) ?? null;
}
/**
* Get the remote screen-share stream for a connected peer.
*
* This contains the screen video track and any audio track that belongs to
* the screen share itself, not the peer's normal voice-chat audio.
*
* @param peerId - The remote peer whose screen-share stream to retrieve.
* @returns The stream, or `null` if the peer has no active screen share.
*/
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null;
}
/**
* Get the current local media stream (microphone audio).
*
* @returns The local {@link MediaStream}, or `null` if voice is not active.
*/
getLocalStream(): MediaStream | null {
return this.mediaManager.getLocalStream();
}
/**
* Get the raw local microphone stream before gain / RNNoise processing.
*
* @returns The raw microphone {@link MediaStream}, or `null` if voice is not active.
*/
getRawMicStream(): MediaStream | null {
return this.mediaManager.getRawMicStream();
}
/**
* Request microphone access and start sending audio to all peers.
*
* @returns The captured local {@link MediaStream}.
*/
async enableVoice(): Promise<MediaStream> {
const stream = await this.mediaManager.enableVoice();
this.syncMediaSignals();
return stream;
}
/** Stop local voice capture and remove audio senders from peers. */
disableVoice(): void {
this.voiceServerId = null;
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
}
/**
* Inject an externally-obtained media stream as the local voice source.
*
* @param stream - The media stream to use.
*/
async setLocalStream(stream: MediaStream): Promise<void> {
await this.mediaManager.setLocalStream(stream);
this.syncMediaSignals();
}
/**
* Toggle the local microphone mute state.
*
* @param muted - Explicit state; if omitted, the current state is toggled.
*/
toggleMute(muted?: boolean): void {
this.mediaManager.toggleMute(muted);
this._isMuted.set(this.mediaManager.getIsMicMuted());
}
/**
* Toggle self-deafen (suppress incoming audio playback).
*
* @param deafened - Explicit state; if omitted, the current state is toggled.
*/
toggleDeafen(deafened?: boolean): void {
this.mediaManager.toggleDeafen(deafened);
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
/**
* Toggle RNNoise noise reduction on the local microphone.
*
* When enabled, the raw mic audio is routed through an AudioWorklet
* that applies neural-network noise suppression before being sent
* to peers.
*
* @param enabled - Explicit state; if omitted, the current state is toggled.
*/
async toggleNoiseReduction(enabled?: boolean): Promise<void> {
await this.mediaManager.toggleNoiseReduction(enabled);
this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled());
}
/**
* Set the output volume for remote audio playback.
*
* @param volume - Normalised volume (0-1).
*/
setOutputVolume(volume: number): void {
this.mediaManager.setOutputVolume(volume);
}
/**
* Set the input (microphone) volume.
*
* Adjusts a Web Audio GainNode on the local mic stream so the level
* sent to peers changes in real time without renegotiation.
*
* @param volume - Normalised volume (0-1).
*/
setInputVolume(volume: number): void {
this.mediaManager.setInputVolume(volume);
}
/**
* Set the maximum audio bitrate for all peer connections.
*
* @param kbps - Target bitrate in kilobits per second.
*/
async setAudioBitrate(kbps: number): Promise<void> {
return this.mediaManager.setAudioBitrate(kbps);
}
/**
* Apply a predefined latency profile that maps to a specific bitrate.
*
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
*/
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
return this.mediaManager.setLatencyProfile(profile);
}
/**
* Start broadcasting voice-presence heartbeats to all peers.
*
* Also marks the given server as the active voice server and closes
* any peer connections that belong to other servers so that audio
* is isolated to the correct voice channel.
*
* @param roomId - The voice channel room ID.
* @param serverId - The voice channel server ID.
*/
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
if (serverId) {
this.voiceServerId = serverId;
}
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
}
/** Stop the voice-presence heartbeat. */
stopVoiceHeartbeat(): void {
this.mediaManager.stopVoiceHeartbeat();
}
/**
* Start sharing the screen (or a window) with all connected peers.
*
* @param options - Screen-share capture options.
* @returns The screen-capture {@link MediaStream}.
*/
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.screenShareManager.startScreenShare(options);
}
/** Stop screen sharing and restore microphone audio on all peers. */
stopScreenShare(): void {
this.screenShareManager.stopScreenShare();
}
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.leaveRoom();
this.mediaManager.stopVoiceHeartbeat();
this.signalingManager.close();
this._isSignalingConnected.set(false);
this._hasEverConnected.set(false);
this._hasConnectionError.set(false);
this._connectionErrorMessage.set(null);
this.serviceDestroyed$.next();
}
/** Alias for {@link disconnect}. */
disconnectAll(): void {
this.disconnect();
}
private fullCleanup(): void {
this.voiceServerId = null;
this.peerServerMap.clear();
this.remoteScreenShareRequestsEnabled = false;
this.desiredRemoteScreenSharePeers.clear();
this.activeRemoteScreenSharePeers.clear();
this.peerManager.closeAllPeers();
this._connectedPeers.set([]);
this.mediaManager.disableVoice();
this._isVoiceConnected.set(false);
this.screenShareManager.stopScreenShare();
this._isScreenSharing.set(false);
this._screenStreamSignal.set(null);
this._isScreenShareRemotePlaybackSuppressed.set(false);
}
/** Synchronise Angular signals from the MediaManager's internal state. */
private syncMediaSignals(): void {
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
this._isMuted.set(this.mediaManager.getIsMicMuted());
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
}
/** Returns true if a peer connection exists and its data channel is open. */
private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean {
if (!peer)
return false;
const connState = peer.connection?.connectionState;
const dcState = peer.dataChannel?.readyState;
return connState === 'connected' && dcState === 'open';
}
private handlePeerControlMessage(event: ChatEvent): void {
if (!event.fromPeerId) {
return;
}
if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) {
this.peerManager.clearRemoteScreenShareStream(event.fromPeerId);
return;
}
if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) {
this.screenShareManager.requestScreenShareForPeer(event.fromPeerId);
return;
}
if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) {
this.screenShareManager.stopScreenShareForPeer(event.fromPeerId);
}
}
private requestRemoteScreenShares(peerIds: string[]): void {
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
for (const peerId of peerIds) {
if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) {
continue;
}
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST });
this.activeRemoteScreenSharePeers.add(peerId);
}
}
private stopRemoteScreenShares(peerIds: string[]): void {
const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds());
for (const peerId of peerIds) {
if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) {
this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP });
}
this.activeRemoteScreenSharePeers.delete(peerId);
this.peerManager.clearRemoteScreenShareStream(peerId);
}
}
ngOnDestroy(): void {
this.disconnect();
this.serviceDestroyed$.complete();
this.signalingManager.destroy();
this.peerManager.destroy();
this.mediaManager.destroy();
this.screenShareManager.destroy();
}
}