feat: Add webcam basic support
This commit is contained in:
@@ -29,10 +29,10 @@ realtime/
|
||||
│ ├── recovery/
|
||||
│ │ └── peer-recovery.ts Disconnect grace period + reconnect loop
|
||||
│ └── streams/
|
||||
│ └── remote-streams.ts Classifies incoming tracks (voice vs screen)
|
||||
│ └── remote-streams.ts Classifies incoming tracks (voice vs camera vs screen)
|
||||
│
|
||||
├── media/ Local capture and processing
|
||||
│ ├── media.manager.ts getUserMedia, mute, deafen, gain pipeline
|
||||
│ ├── media.manager.ts getUserMedia, mute, deafen, camera capture, same-room routing, gain pipeline
|
||||
│ ├── noise-reduction.manager.ts RNNoise AudioWorklet graph
|
||||
│ ├── voice-session-controller.ts Higher-level wrapper over MediaManager
|
||||
│ ├── screen-share.manager.ts Screen capture + per-peer track distribution
|
||||
@@ -229,12 +229,44 @@ graph LR
|
||||
click Peers "media/media.manager.ts" "MediaManager.bindLocalTracksToAllPeers()" _blank
|
||||
```
|
||||
|
||||
`MediaManager` grabs the mic with `getUserMedia`, optionally pipes it through the RNNoise AudioWorklet for noise reduction (48 kHz, loaded from `rnnoise-worklet.js`), optionally runs it through a `GainNode` for input volume control, and then routes the resulting audio track only to peers that currently belong to the same active voice channel.
|
||||
`MediaManager` grabs the mic with `getUserMedia`, optionally pipes it through the RNNoise AudioWorklet for noise reduction (48 kHz, loaded from `rnnoise-worklet.js`), optionally runs it through a `GainNode` for input volume control, and then routes the resulting audio track only to peers that currently belong to the same active voice channel. The same manager also owns camera capture as a separate video-only stream, attaches it to its own video transceiver, and applies the same voice-channel routing rules so webcam video only reaches peers in the active voice room.
|
||||
|
||||
Mute just disables the audio track (`track.enabled = false`), the connection stays up. Deafen suppresses incoming audio playback on the local side.
|
||||
|
||||
Because peers stay connected across the server for shared state and chat, voice-channel isolation is enforced in both transport and playback: outgoing mic audio is only attached to peers whose voice membership matches the local user's current channel, and remote voice audio plus join/leave cues are only active when the remote peer's announced `voiceState.roomId` and `voiceState.serverId` match the local user's current voice channel.
|
||||
|
||||
### Camera
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as VoiceControls/UI
|
||||
participant MM as MediaManager
|
||||
participant Peer as PeerConnectionManager
|
||||
participant Remote as Remote peer
|
||||
participant RS as remote-streams.ts
|
||||
participant Shell as VoiceWorkspaceComponent
|
||||
|
||||
UI->>MM: enableCamera()
|
||||
Note over MM: getUserMedia({ video: true, audio: false })
|
||||
Note over MM: Store localCameraStream
|
||||
MM->>MM: syncCameraRouting()
|
||||
Note over MM: Attach video track only to same-room peers
|
||||
MM->>Peer: renegotiate(peerId)
|
||||
MM->>Remote: broadcast camera-state
|
||||
Peer->>Remote: offer/answer with camera video transceiver
|
||||
Remote->>RS: ontrack(video)
|
||||
Note over RS: Classify as camera, not screen share
|
||||
RS->>Shell: getRemoteCameraStream(peerId)
|
||||
Shell->>Shell: Render camera tile in voice workspace
|
||||
|
||||
UI->>MM: disableCamera()
|
||||
MM->>MM: stopLocalCameraStream()
|
||||
MM->>MM: detach camera sender from peers
|
||||
MM->>Remote: broadcast camera-state(false)
|
||||
```
|
||||
|
||||
Camera capture is video-only, uses a dedicated camera sender, and follows the same same-room peer filter as outgoing voice audio. Incoming camera video is classified separately from screen-share tracks so the workspace can show both at the same time.
|
||||
|
||||
### Screen share
|
||||
|
||||
Screen capture uses a platform-specific strategy:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars,, id-length */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars, */
|
||||
/**
|
||||
* Manages local voice media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching audio tracks to peer connections, bitrate tuning,
|
||||
* Manages local voice and camera media: getUserMedia, mute, deafen,
|
||||
* attaching/detaching tracks to peer connections, bitrate tuning,
|
||||
* and optional RNNoise-based noise reduction.
|
||||
*/
|
||||
import { Subject } from 'rxjs';
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
VOLUME_MAX,
|
||||
VOICE_HEARTBEAT_INTERVAL_MS,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_CAMERA_STATE,
|
||||
P2P_TYPE_VOICE_STATE
|
||||
} from '../realtime.constants';
|
||||
|
||||
@@ -40,6 +41,8 @@ export interface MediaManagerCallbacks {
|
||||
/** Get identify credentials (for broadcasting). */
|
||||
getIdentifyOderId(): string;
|
||||
getIdentifyDisplayName(): string;
|
||||
/** Push the current local camera state back into service-level signals. */
|
||||
setCameraEnabled?(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export class MediaManager {
|
||||
@@ -53,6 +56,9 @@ export class MediaManager {
|
||||
*/
|
||||
private rawMicStream: MediaStream | null = null;
|
||||
|
||||
/** The dedicated local camera stream, always captured without audio. */
|
||||
private localCameraStream: MediaStream | null = null;
|
||||
|
||||
/** Remote audio output volume (0-1). */
|
||||
private remoteAudioVolume = VOLUME_MAX;
|
||||
|
||||
@@ -86,6 +92,7 @@ export class MediaManager {
|
||||
private isVoiceActive = false;
|
||||
private isMicMuted = false;
|
||||
private isSelfDeafened = false;
|
||||
private isCameraActive = false;
|
||||
|
||||
/** Current voice channel room ID (set when joining voice). */
|
||||
private currentVoiceRoomId: string | undefined;
|
||||
@@ -118,6 +125,10 @@ export class MediaManager {
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.rawMicStream;
|
||||
}
|
||||
/** Returns the current local camera stream, or `null` if the camera is disabled. */
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
return this.localCameraStream;
|
||||
}
|
||||
/** Whether voice is currently active (mic captured). */
|
||||
getIsVoiceActive(): boolean {
|
||||
return this.isVoiceActive;
|
||||
@@ -130,6 +141,10 @@ export class MediaManager {
|
||||
getIsSelfDeafened(): boolean {
|
||||
return this.isSelfDeafened;
|
||||
}
|
||||
/** Whether the local camera is currently active. */
|
||||
getIsCameraActive(): boolean {
|
||||
return this.isCameraActive;
|
||||
}
|
||||
/** Current remote audio output volume (normalised 0-1). */
|
||||
getRemoteAudioVolume(): number {
|
||||
return this.remoteAudioVolume;
|
||||
@@ -156,10 +171,12 @@ export class MediaManager {
|
||||
|
||||
this.allowedVoicePeerIds = nextAllowed;
|
||||
this.syncVoiceRouting();
|
||||
this.syncCameraRouting();
|
||||
}
|
||||
|
||||
refreshVoiceRouting(): void {
|
||||
this.syncVoiceRouting();
|
||||
this.syncCameraRouting();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +246,7 @@ export class MediaManager {
|
||||
* The peer connections themselves are kept alive.
|
||||
*/
|
||||
disableVoice(): void {
|
||||
this.disableCamera();
|
||||
this.noiseReduction.disable();
|
||||
this.teardownInputGain();
|
||||
|
||||
@@ -285,6 +303,78 @@ export class MediaManager {
|
||||
this.voiceConnected$.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request camera access and bind the resulting video track to peers in the
|
||||
* active voice channel. Audio is explicitly disabled for this capture.
|
||||
*/
|
||||
async enableCamera(): Promise<MediaStream> {
|
||||
if (!this.isVoiceActive) {
|
||||
throw new Error('Voice must be active before enabling the camera.');
|
||||
}
|
||||
|
||||
try {
|
||||
this.stopLocalCameraStream();
|
||||
|
||||
const mediaConstraints: MediaStreamConstraints = {
|
||||
audio: false,
|
||||
video: true
|
||||
};
|
||||
|
||||
this.logger.info('getUserMedia camera constraints', mediaConstraints);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error(
|
||||
'navigator.mediaDevices is not available. '
|
||||
+ 'This requires a secure context (HTTPS or localhost). '
|
||||
+ 'If accessing from an external device, use HTTPS.'
|
||||
);
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
|
||||
const cameraTrack = stream.getVideoTracks()[0];
|
||||
|
||||
if (!cameraTrack) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
throw new Error('Camera capture did not return a video track.');
|
||||
}
|
||||
|
||||
cameraTrack.onended = () => {
|
||||
if (this.isCameraActive) {
|
||||
this.disableCamera();
|
||||
}
|
||||
};
|
||||
|
||||
this.localCameraStream = stream;
|
||||
this.isCameraActive = true;
|
||||
this.callbacks.setCameraEnabled?.(true);
|
||||
|
||||
this.logger.attachTrackDiagnostics(cameraTrack, 'localCamera');
|
||||
this.logger.logStream('localCamera', stream);
|
||||
|
||||
this.syncCameraRouting();
|
||||
this.broadcastCameraState();
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get camera media', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop camera capture and remove camera senders from every peer. */
|
||||
disableCamera(): void {
|
||||
if (!this.localCameraStream && !this.isCameraActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopLocalCameraStream();
|
||||
this.isCameraActive = false;
|
||||
this.callbacks.setCameraEnabled?.(false);
|
||||
|
||||
this.syncCameraRouting();
|
||||
this.broadcastCameraState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the local microphone mute state.
|
||||
*
|
||||
@@ -366,43 +456,41 @@ export class MediaManager {
|
||||
/**
|
||||
* Set the output volume for remote audio.
|
||||
*
|
||||
* @param volume - Normalised value: 0 = silent, 1 = 100%, up to 2 = 200%.
|
||||
* @param volume - Normalized value: 0 = silent, 1 = 100%, up to 2 = 200%.
|
||||
*/
|
||||
setOutputVolume(volume: number): void {
|
||||
this.remoteAudioVolume = Math.max(VOLUME_MIN, Math.min(2, volume));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the input (microphone) volume.
|
||||
* Set the input microphone gain.
|
||||
*
|
||||
* If a local stream is active the gain node is updated in real time.
|
||||
* If no stream exists yet the value is stored and applied on connect.
|
||||
* If a local stream is already active the gain node is updated immediately.
|
||||
* Otherwise the value is stored and applied the next time voice starts.
|
||||
*
|
||||
* @param volume - Normalised 0-1 (0 = silent, 1 = 100%).
|
||||
* @param volume - Normalized 0-1 value.
|
||||
*/
|
||||
setInputVolume(volume: number): void {
|
||||
this.inputGainVolume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
if (this.inputGainNode) {
|
||||
// Pipeline already exists - just update the gain value
|
||||
this.inputGainNode.gain.value = this.inputGainVolume;
|
||||
} else if (this.localMediaStream) {
|
||||
// Stream is active but gain pipeline hasn't been created yet
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.localMediaStream) {
|
||||
this.applyInputGainToCurrentStream();
|
||||
this.bindLocalTracksToAllPeers();
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current input gain value (0-1). */
|
||||
/** Return the current input gain value. */
|
||||
getInputVolume(): number {
|
||||
return this.inputGainVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum audio bitrate on every active peer's audio sender.
|
||||
*
|
||||
* The value is clamped between {@link AUDIO_BITRATE_MIN_BPS} and
|
||||
* {@link AUDIO_BITRATE_MAX_BPS}.
|
||||
* Set the maximum audio bitrate on every active peer audio sender.
|
||||
*
|
||||
* @param kbps - Target bitrate in kilobits per second.
|
||||
*/
|
||||
@@ -413,15 +501,16 @@ export class MediaManager {
|
||||
);
|
||||
|
||||
this.callbacks.getActivePeers().forEach(async (peerData) => {
|
||||
const sender =
|
||||
peerData.audioSender ||
|
||||
peerData.connection.getSenders().find((s) => s.track?.kind === TRACK_KIND_AUDIO);
|
||||
const sender = peerData.audioSender
|
||||
|| peerData.connection.getSenders().find((candidate) => candidate.track?.kind === TRACK_KIND_AUDIO);
|
||||
|
||||
if (!sender?.track)
|
||||
if (!sender?.track) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (peerData.connection.signalingState !== 'stable')
|
||||
if (peerData.connection.signalingState !== 'stable') {
|
||||
return;
|
||||
}
|
||||
|
||||
let params: RTCRtpSendParameters;
|
||||
|
||||
@@ -447,7 +536,7 @@ export class MediaManager {
|
||||
/**
|
||||
* Apply a named latency profile that maps to a predefined bitrate.
|
||||
*
|
||||
* @param profile - One of `'low'`, `'balanced'`, or `'high'`.
|
||||
* @param profile - One of `low`, `balanced`, or `high`.
|
||||
*/
|
||||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||||
await this.setAudioBitrate(LATENCY_PROFILE_BITRATES[profile]);
|
||||
@@ -491,59 +580,10 @@ export class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind local audio/video tracks to all existing peer transceivers.
|
||||
* Restores transceiver direction to sendrecv if previously set to recvonly
|
||||
* (which happens when disableVoice calls removeTrack).
|
||||
*/
|
||||
/** Bind any active local mic/camera tracks to the current peer set. */
|
||||
private bindLocalTracksToAllPeers(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
|
||||
if (!this.localMediaStream)
|
||||
return;
|
||||
|
||||
const localStream = this.localMediaStream;
|
||||
const localAudioTrack = localStream.getAudioTracks()[0] || null;
|
||||
const localVideoTrack = localStream.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
if (localAudioTrack) {
|
||||
if (this.allowedVoicePeerIds.has(peerId)) {
|
||||
this.attachVoiceTrackToPeer(peerId, peerData, localStream, localAudioTrack);
|
||||
} else {
|
||||
this.detachVoiceTrackFromPeer(peerData);
|
||||
}
|
||||
}
|
||||
|
||||
if (localVideoTrack) {
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const videoSender = videoTransceiver.sender;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
if (
|
||||
videoTransceiver &&
|
||||
(videoTransceiver.direction === TRANSCEIVER_RECV_ONLY ||
|
||||
videoTransceiver.direction === TRANSCEIVER_INACTIVE)
|
||||
) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
videoSender
|
||||
.replaceTrack(localVideoTrack)
|
||||
.then(() => this.logger.info('video replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('video replaceTrack failed', error));
|
||||
}
|
||||
|
||||
this.callbacks.renegotiate(peerId);
|
||||
});
|
||||
this.syncVoiceRouting();
|
||||
this.syncCameraRouting();
|
||||
}
|
||||
|
||||
private syncVoiceRouting(): void {
|
||||
@@ -562,6 +602,22 @@ export class MediaManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncCameraRouting(): void {
|
||||
const peers = this.callbacks.getActivePeers();
|
||||
const localCameraStream = this.localCameraStream;
|
||||
const localCameraTrack = localCameraStream?.getVideoTracks()[0] || null;
|
||||
|
||||
peers.forEach((peerData, peerId) => {
|
||||
const didChange = localCameraStream && localCameraTrack && this.allowedVoicePeerIds.has(peerId)
|
||||
? this.attachCameraTrackToPeer(peerId, peerData, localCameraStream, localCameraTrack)
|
||||
: this.detachCameraTrackFromPeer(peerData, peerId);
|
||||
|
||||
if (didChange) {
|
||||
void this.callbacks.renegotiate(peerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private attachVoiceTrackToPeer(
|
||||
peerId: string,
|
||||
peerData: PeerData,
|
||||
@@ -613,6 +669,78 @@ export class MediaManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private attachCameraTrackToPeer(
|
||||
peerId: string,
|
||||
peerData: PeerData,
|
||||
localStream: MediaStream,
|
||||
localCameraTrack: MediaStreamTrack
|
||||
): boolean {
|
||||
const videoTransceiver = this.getOrCreateReusableTransceiver(peerData, TRACK_KIND_VIDEO, {
|
||||
preferredSender: peerData.videoSender,
|
||||
excludedSenders: [peerData.screenVideoSender]
|
||||
});
|
||||
const videoSender = videoTransceiver.sender;
|
||||
const needsDirectionRestore = videoTransceiver.direction === TRANSCEIVER_RECV_ONLY
|
||||
|| videoTransceiver.direction === TRANSCEIVER_INACTIVE;
|
||||
const needsTrackReplace = videoSender.track !== localCameraTrack;
|
||||
|
||||
peerData.videoSender = videoSender;
|
||||
|
||||
if (!needsDirectionRestore && !needsTrackReplace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (needsDirectionRestore) {
|
||||
videoTransceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||
}
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(localStream);
|
||||
}
|
||||
|
||||
if (needsTrackReplace) {
|
||||
videoSender
|
||||
.replaceTrack(localCameraTrack)
|
||||
.then(() => this.logger.info('camera replaceTrack ok', { peerId }))
|
||||
.catch((error) => this.logger.error('camera replaceTrack failed', error));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private detachCameraTrackFromPeer(peerData: PeerData, peerId: string): boolean {
|
||||
const videoSender = peerData.videoSender
|
||||
?? peerData.connection.getSenders().find((sender) => sender !== peerData.screenVideoSender && sender.track?.kind === TRACK_KIND_VIDEO);
|
||||
const videoTransceiver = videoSender
|
||||
? peerData.connection.getTransceivers().find((transceiver) => transceiver.sender === videoSender)
|
||||
: undefined;
|
||||
|
||||
if (!videoTransceiver) {
|
||||
return false;
|
||||
}
|
||||
|
||||
peerData.videoSender = videoTransceiver.sender;
|
||||
|
||||
const hasTrack = !!videoTransceiver.sender.track;
|
||||
const needsDirectionReset = videoTransceiver.direction === TRANSCEIVER_SEND_RECV;
|
||||
|
||||
if (!hasTrack && !needsDirectionReset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasTrack) {
|
||||
videoTransceiver.sender.replaceTrack(null)
|
||||
.then(() => this.logger.info('camera replaceTrack cleared', { peerId }))
|
||||
.catch((error) => this.logger.error('Failed to clear camera sender track', error, { peerId }));
|
||||
}
|
||||
|
||||
if (needsDirectionReset) {
|
||||
videoTransceiver.direction = TRANSCEIVER_RECV_ONLY;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private areSetsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
@@ -690,6 +818,19 @@ export class MediaManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Broadcast the local camera state to all connected peers. */
|
||||
private broadcastCameraState(): void {
|
||||
const oderId = this.callbacks.getIdentifyOderId();
|
||||
const displayName = this.callbacks.getIdentifyDisplayName();
|
||||
|
||||
this.callbacks.broadcastMessage({
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: this.isCameraActive
|
||||
});
|
||||
}
|
||||
|
||||
// -- Input gain helpers --
|
||||
|
||||
/**
|
||||
@@ -764,6 +905,22 @@ export class MediaManager {
|
||||
this.preGainStream = null;
|
||||
}
|
||||
|
||||
private stopLocalCameraStream(): void {
|
||||
if (!this.localCameraStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.localCameraStream.getTracks().forEach((track) => {
|
||||
if (track.kind === TRACK_KIND_VIDEO) {
|
||||
track.onended = null;
|
||||
}
|
||||
|
||||
track.stop();
|
||||
});
|
||||
|
||||
this.localCameraStream = null;
|
||||
}
|
||||
|
||||
/** Clean up all resources. */
|
||||
destroy(): void {
|
||||
this.teardownInputGain();
|
||||
|
||||
@@ -403,18 +403,17 @@ export class ScreenShareManager {
|
||||
|
||||
this.logger.attachTrackDiagnostics(screenVideoTrack, `screenVideo:${peerId}`);
|
||||
|
||||
let videoSender = peerData.videoSender || peerData.connection.getSenders().find((sender) => sender.track?.kind === TRACK_KIND_VIDEO);
|
||||
let screenVideoSender = peerData.screenVideoSender;
|
||||
|
||||
if (!videoSender) {
|
||||
if (!screenVideoSender) {
|
||||
const videoTransceiver = peerData.connection.addTransceiver(TRACK_KIND_VIDEO, {
|
||||
direction: TRANSCEIVER_SEND_RECV
|
||||
});
|
||||
|
||||
videoSender = videoTransceiver.sender;
|
||||
peerData.videoSender = videoSender;
|
||||
screenVideoSender = videoTransceiver.sender;
|
||||
} else {
|
||||
const videoTransceiver = peerData.connection.getTransceivers().find(
|
||||
(transceiver) => transceiver.sender === videoSender
|
||||
(transceiver) => transceiver.sender === screenVideoSender
|
||||
);
|
||||
|
||||
if (videoTransceiver?.direction === TRANSCEIVER_RECV_ONLY) {
|
||||
@@ -422,16 +421,16 @@ export class ScreenShareManager {
|
||||
}
|
||||
}
|
||||
|
||||
peerData.screenVideoSender = videoSender;
|
||||
peerData.screenVideoSender = screenVideoSender;
|
||||
|
||||
if (typeof videoSender.setStreams === 'function') {
|
||||
videoSender.setStreams(this.activeScreenStream);
|
||||
if (typeof screenVideoSender.setStreams === 'function') {
|
||||
screenVideoSender.setStreams(this.activeScreenStream);
|
||||
}
|
||||
|
||||
videoSender.replaceTrack(screenVideoTrack)
|
||||
screenVideoSender.replaceTrack(screenVideoTrack)
|
||||
.then(() => {
|
||||
this.logger.info('screen video replaceTrack ok', { peerId });
|
||||
void this.applyScreenShareVideoParameters(videoSender, preset, peerId);
|
||||
void this.applyScreenShareVideoParameters(screenVideoSender, preset, peerId);
|
||||
})
|
||||
.catch((error) => this.logger.error('screen video replaceTrack failed', error));
|
||||
|
||||
@@ -474,7 +473,7 @@ export class ScreenShareManager {
|
||||
private detachScreenTracksFromPeer(peerData: PeerData, peerId: string): void {
|
||||
const transceivers = peerData.connection.getTransceivers();
|
||||
const videoTransceiver = transceivers.find(
|
||||
(transceiver) => transceiver.sender === peerData.videoSender || transceiver.sender === peerData.screenVideoSender
|
||||
(transceiver) => transceiver.sender === peerData.screenVideoSender
|
||||
);
|
||||
const screenAudioTransceiver = transceivers.find(
|
||||
(transceiver) => transceiver.sender === peerData.screenAudioSender
|
||||
|
||||
@@ -129,6 +129,7 @@ export function createPeerConnection(
|
||||
audioSender: undefined,
|
||||
videoSender: undefined,
|
||||
remoteVoiceStreamIds: new Set<string>(),
|
||||
remoteCameraStreamIds: new Set<string>(),
|
||||
remoteScreenShareStreamIds: new Set<string>()
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DATA_CHANNEL_LOW_WATER_BYTES,
|
||||
DATA_CHANNEL_STATE_OPEN,
|
||||
DEFAULT_DISPLAY_NAME,
|
||||
P2P_TYPE_CAMERA_STATE,
|
||||
P2P_TYPE_PING,
|
||||
P2P_TYPE_PONG,
|
||||
P2P_TYPE_SCREEN_STATE,
|
||||
@@ -285,7 +286,7 @@ export async function sendToPeerBuffered(
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the current voice and screen-share states to a single peer.
|
||||
* Send the current voice, camera, and screen-share states to a single peer.
|
||||
*/
|
||||
export function sendCurrentStatesToPeer(
|
||||
context: PeerConnectionManagerContext,
|
||||
@@ -310,6 +311,13 @@ export function sendCurrentStatesToPeer(
|
||||
displayName,
|
||||
isScreenSharing: callbacks.isScreenSharingActive()
|
||||
});
|
||||
|
||||
sendToPeer(context, peerId, {
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: callbacks.isCameraEnabled()
|
||||
});
|
||||
}
|
||||
|
||||
export function sendCurrentStatesToChannel(
|
||||
@@ -346,13 +354,22 @@ export function sendCurrentStatesToChannel(
|
||||
displayName,
|
||||
isScreenSharing: callbacks.isScreenSharingActive()
|
||||
};
|
||||
const cameraStatePayload = {
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: callbacks.isCameraEnabled()
|
||||
};
|
||||
const voiceStateRaw = JSON.stringify(voiceStatePayload);
|
||||
const screenStateRaw = JSON.stringify(screenStatePayload);
|
||||
const cameraStateRaw = JSON.stringify(cameraStatePayload);
|
||||
|
||||
channel.send(voiceStateRaw);
|
||||
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', voiceStateRaw, voiceStatePayload);
|
||||
channel.send(screenStateRaw);
|
||||
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', screenStateRaw, screenStatePayload);
|
||||
channel.send(cameraStateRaw);
|
||||
logDataChannelTraffic(context, channel, remotePeerId, 'outbound', cameraStateRaw, cameraStatePayload);
|
||||
|
||||
logger.info('[data-channel] Sent initial states to channel', { remotePeerId, voiceState });
|
||||
} catch (error) {
|
||||
@@ -366,7 +383,7 @@ export function sendCurrentStatesToChannel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast the current voice and screen-share states to all connected peers. */
|
||||
/** Broadcast the current voice, camera, and screen-share states to all connected peers. */
|
||||
export function broadcastCurrentStates(context: PeerConnectionManagerContext): void {
|
||||
const { callbacks } = context;
|
||||
const credentials = callbacks.getIdentifyCredentials();
|
||||
@@ -387,6 +404,13 @@ export function broadcastCurrentStates(context: PeerConnectionManagerContext): v
|
||||
displayName,
|
||||
isScreenSharing: callbacks.isScreenSharingActive()
|
||||
});
|
||||
|
||||
broadcastMessage(context, {
|
||||
type: P2P_TYPE_CAMERA_STATE,
|
||||
oderId,
|
||||
displayName,
|
||||
isCameraEnabled: callbacks.isCameraEnabled()
|
||||
});
|
||||
}
|
||||
|
||||
function logDataChannelTraffic(
|
||||
@@ -433,6 +457,9 @@ function summarizePeerMessage(payload: PeerMessage, base?: Record<string, unknow
|
||||
if (typeof payload['isScreenSharing'] === 'boolean')
|
||||
summary['isScreenSharing'] = payload['isScreenSharing'];
|
||||
|
||||
if (typeof payload['isCameraEnabled'] === 'boolean')
|
||||
summary['isCameraEnabled'] = payload['isCameraEnabled'];
|
||||
|
||||
if (typeof payload['content'] === 'string')
|
||||
summary['contentLength'] = payload['content'].length;
|
||||
|
||||
|
||||
@@ -73,6 +73,9 @@ export class PeerConnectionManager {
|
||||
/** Remote voice-only streams keyed by remote peer ID. */
|
||||
readonly remotePeerVoiceStreams = this.state.remotePeerVoiceStreams;
|
||||
|
||||
/** Remote camera streams keyed by remote peer ID. */
|
||||
readonly remotePeerCameraStreams = this.state.remotePeerCameraStreams;
|
||||
|
||||
/** Remote screen-share streams keyed by remote peer ID. */
|
||||
readonly remotePeerScreenShareStreams = this.state.remotePeerScreenShareStreams;
|
||||
|
||||
|
||||
@@ -27,12 +27,15 @@ export interface PeerConnectionCallbacks {
|
||||
getLocalPeerId(): string;
|
||||
/** Whether screen sharing is active. */
|
||||
isScreenSharingActive(): boolean;
|
||||
/** Whether the local camera is active. */
|
||||
isCameraEnabled(): boolean;
|
||||
}
|
||||
|
||||
export interface PeerConnectionManagerState {
|
||||
activePeerConnections: Map<string, PeerData>;
|
||||
remotePeerStreams: Map<string, MediaStream>;
|
||||
remotePeerVoiceStreams: Map<string, MediaStream>;
|
||||
remotePeerCameraStreams: Map<string, MediaStream>;
|
||||
remotePeerScreenShareStreams: Map<string, MediaStream>;
|
||||
disconnectedPeerTracker: Map<string, DisconnectedPeerEntry>;
|
||||
peerReconnectTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||
@@ -88,6 +91,7 @@ export function createPeerConnectionManagerState(): PeerConnectionManagerState {
|
||||
activePeerConnections: new Map<string, PeerData>(),
|
||||
remotePeerStreams: new Map<string, MediaStream>(),
|
||||
remotePeerVoiceStreams: new Map<string, MediaStream>(),
|
||||
remotePeerCameraStreams: new Map<string, MediaStream>(),
|
||||
remotePeerScreenShareStreams: new Map<string, MediaStream>(),
|
||||
disconnectedPeerTracker: new Map<string, DisconnectedPeerEntry>(),
|
||||
peerReconnectTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
||||
|
||||
@@ -10,6 +10,7 @@ export function handleRemoteTrack(
|
||||
const { logger, state } = context;
|
||||
const track = event.track;
|
||||
const isScreenAudio = isScreenShareAudioTrack(context, event, remotePeerId);
|
||||
const isScreenVideo = isScreenShareVideoTrack(context, event, remotePeerId);
|
||||
const settings =
|
||||
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||
|
||||
@@ -38,7 +39,11 @@ export function handleRemoteTrack(
|
||||
const voiceStream = isVoiceAudioTrack(track, isScreenAudio)
|
||||
? buildAudioOnlyStream(state.remotePeerVoiceStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
const cameraStream = isCameraTrack(track, isScreenAudio, isScreenVideo)
|
||||
? buildCameraStream(state.remotePeerCameraStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
const screenShareStream = isScreenShareTrack(track, isScreenAudio)
|
||||
|| isScreenVideo
|
||||
? buildScreenShareStream(state.remotePeerScreenShareStreams.get(remotePeerId), track)
|
||||
: null;
|
||||
|
||||
@@ -50,6 +55,10 @@ export function handleRemoteTrack(
|
||||
state.remotePeerVoiceStreams.set(remotePeerId, voiceStream);
|
||||
}
|
||||
|
||||
if (cameraStream) {
|
||||
state.remotePeerCameraStreams.set(remotePeerId, cameraStream);
|
||||
}
|
||||
|
||||
if (screenShareStream) {
|
||||
state.remotePeerScreenShareStreams.set(remotePeerId, screenShareStream);
|
||||
}
|
||||
@@ -57,6 +66,7 @@ export function handleRemoteTrack(
|
||||
rememberIncomingStreamIds(state, event, remotePeerId, {
|
||||
isScreenAudio,
|
||||
isVoiceAudio: !!voiceStream,
|
||||
isCameraTrack: !!cameraStream,
|
||||
isScreenTrack: !!screenShareStream
|
||||
});
|
||||
|
||||
@@ -98,7 +108,7 @@ function buildCompositeRemoteStream(
|
||||
incomingTrack: MediaStreamTrack
|
||||
): MediaStream {
|
||||
return buildMergedStream(state.remotePeerStreams.get(remotePeerId), incomingTrack, {
|
||||
replaceVideoTrack: true
|
||||
replaceVideoTrack: false
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +131,16 @@ function buildScreenShareStream(
|
||||
});
|
||||
}
|
||||
|
||||
function buildCameraStream(
|
||||
existingStream: MediaStream | undefined,
|
||||
incomingTrack: MediaStreamTrack
|
||||
): MediaStream {
|
||||
return buildMergedStream(existingStream, incomingTrack, {
|
||||
allowedKinds: [TRACK_KIND_VIDEO],
|
||||
replaceVideoTrack: true
|
||||
});
|
||||
}
|
||||
|
||||
function buildMergedStream(
|
||||
existingStream: MediaStream | undefined,
|
||||
incomingTrack: MediaStreamTrack,
|
||||
@@ -166,12 +186,17 @@ function removeRemoteTrack(
|
||||
const compositeStream = removeTrackFromStreamMap(state.remotePeerStreams, remotePeerId, trackId);
|
||||
|
||||
removeTrackFromStreamMap(state.remotePeerVoiceStreams, remotePeerId, trackId);
|
||||
removeTrackFromStreamMap(state.remotePeerCameraStreams, remotePeerId, trackId);
|
||||
removeTrackFromStreamMap(state.remotePeerScreenShareStreams, remotePeerId, trackId);
|
||||
|
||||
if (!state.remotePeerVoiceStreams.has(remotePeerId)) {
|
||||
peerData?.remoteVoiceStreamIds.clear();
|
||||
}
|
||||
|
||||
if (!state.remotePeerCameraStreams.has(remotePeerId)) {
|
||||
peerData?.remoteCameraStreamIds.clear();
|
||||
}
|
||||
|
||||
if (!state.remotePeerScreenShareStreams.has(remotePeerId)) {
|
||||
peerData?.remoteScreenShareStreamIds.clear();
|
||||
}
|
||||
@@ -247,8 +272,16 @@ function isVoiceAudioTrack(track: MediaStreamTrack, isScreenAudio: boolean): boo
|
||||
return track.kind === TRACK_KIND_AUDIO && !isScreenAudio;
|
||||
}
|
||||
|
||||
function isCameraTrack(
|
||||
track: MediaStreamTrack,
|
||||
isScreenAudio: boolean,
|
||||
isScreenVideo: boolean
|
||||
): boolean {
|
||||
return track.kind === TRACK_KIND_VIDEO && !isScreenAudio && !isScreenVideo;
|
||||
}
|
||||
|
||||
function isScreenShareTrack(track: MediaStreamTrack, isScreenAudio: boolean): boolean {
|
||||
return track.kind === TRACK_KIND_VIDEO || isScreenAudio;
|
||||
return track.kind === TRACK_KIND_AUDIO && isScreenAudio;
|
||||
}
|
||||
|
||||
function isScreenShareAudioTrack(
|
||||
@@ -306,6 +339,57 @@ function isScreenShareAudioTrack(
|
||||
return transceiverIndex > 0;
|
||||
}
|
||||
|
||||
function isScreenShareVideoTrack(
|
||||
context: PeerConnectionManagerContext,
|
||||
event: RTCTrackEvent,
|
||||
remotePeerId: string
|
||||
): boolean {
|
||||
if (event.track.kind !== TRACK_KIND_VIDEO) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const peerData = context.state.activePeerConnections.get(remotePeerId);
|
||||
|
||||
if (!peerData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const incomingStreamIds = getIncomingStreamIds(event);
|
||||
|
||||
if (incomingStreamIds.some((streamId) => peerData.remoteScreenShareStreamIds.has(streamId))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (incomingStreamIds.some((streamId) => peerData.remoteCameraStreamIds.has(streamId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const screenVideoTransceiver = peerData.connection.getTransceivers().find(
|
||||
(transceiver) => transceiver.sender === peerData.screenVideoSender
|
||||
);
|
||||
|
||||
if (screenVideoTransceiver && matchesTransceiver(event.transceiver, screenVideoTransceiver)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cameraVideoTransceiver = peerData.connection.getTransceivers().find(
|
||||
(transceiver) => transceiver.sender === peerData.videoSender
|
||||
);
|
||||
|
||||
if (cameraVideoTransceiver) {
|
||||
return !matchesTransceiver(event.transceiver, cameraVideoTransceiver);
|
||||
}
|
||||
|
||||
const videoTransceivers = peerData.connection.getTransceivers().filter((transceiver) =>
|
||||
transceiver.receiver.track?.kind === TRACK_KIND_VIDEO || transceiver === event.transceiver
|
||||
);
|
||||
const transceiverIndex = videoTransceivers.findIndex((transceiver) =>
|
||||
transceiver === event.transceiver || (!!transceiver.mid && transceiver.mid === event.transceiver.mid)
|
||||
);
|
||||
|
||||
return transceiverIndex > 0;
|
||||
}
|
||||
|
||||
function rememberIncomingStreamIds(
|
||||
state: PeerConnectionManagerContext['state'],
|
||||
event: RTCTrackEvent,
|
||||
@@ -313,6 +397,7 @@ function rememberIncomingStreamIds(
|
||||
options: {
|
||||
isScreenAudio: boolean;
|
||||
isVoiceAudio: boolean;
|
||||
isCameraTrack: boolean;
|
||||
isScreenTrack: boolean;
|
||||
}
|
||||
): void {
|
||||
@@ -328,10 +413,21 @@ function rememberIncomingStreamIds(
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.track.kind === TRACK_KIND_VIDEO || options.isScreenAudio || options.isScreenTrack) {
|
||||
if (options.isScreenAudio || options.isScreenTrack) {
|
||||
incomingStreamIds.forEach((streamId) => {
|
||||
peerData.remoteScreenShareStreamIds.add(streamId);
|
||||
peerData.remoteVoiceStreamIds.delete(streamId);
|
||||
peerData.remoteCameraStreamIds.delete(streamId);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.isCameraTrack) {
|
||||
incomingStreamIds.forEach((streamId) => {
|
||||
peerData.remoteCameraStreamIds.add(streamId);
|
||||
peerData.remoteVoiceStreamIds.delete(streamId);
|
||||
peerData.remoteScreenShareStreamIds.delete(streamId);
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -340,6 +436,7 @@ function rememberIncomingStreamIds(
|
||||
if (options.isVoiceAudio) {
|
||||
incomingStreamIds.forEach((streamId) => {
|
||||
peerData.remoteVoiceStreamIds.add(streamId);
|
||||
peerData.remoteCameraStreamIds.delete(streamId);
|
||||
peerData.remoteScreenShareStreamIds.delete(streamId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export class WebRTCService implements OnDestroy {
|
||||
readonly connectedPeers = this.state.connectedPeers;
|
||||
readonly isMuted = this.state.isMuted;
|
||||
readonly isDeafened = this.state.isDeafened;
|
||||
readonly isCameraEnabled = this.state.isCameraEnabled;
|
||||
readonly isScreenSharing = this.state.isScreenSharing;
|
||||
readonly isNoiseReductionEnabled = this.state.isNoiseReductionEnabled;
|
||||
readonly screenStream = this.state.screenStream;
|
||||
@@ -149,7 +150,8 @@ export class WebRTCService implements OnDestroy {
|
||||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(),
|
||||
getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(),
|
||||
getLocalPeerId: (): string => this.state.getLocalPeerId(),
|
||||
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive()
|
||||
isScreenSharingActive: (): boolean => this.state.isScreenSharingActive(),
|
||||
isCameraEnabled: (): boolean => this.state.isCameraEnabledActive()
|
||||
});
|
||||
|
||||
this.mediaManager.setCallbacks({
|
||||
@@ -157,7 +159,8 @@ export class WebRTCService implements OnDestroy {
|
||||
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()
|
||||
getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName(),
|
||||
setCameraEnabled: (enabled: boolean): void => this.state.setCameraEnabled(enabled)
|
||||
});
|
||||
|
||||
this.screenShareManager.setCallbacks({
|
||||
@@ -434,6 +437,16 @@ export class WebRTCService implements OnDestroy {
|
||||
return this.peerMediaFacade.getRemoteVoiceStream(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote camera stream for a connected peer.
|
||||
*
|
||||
* @param peerId - The remote peer whose camera stream to retrieve.
|
||||
* @returns The stream, or `null` if the peer has no active camera video.
|
||||
*/
|
||||
getRemoteCameraStream(peerId: string): MediaStream | null {
|
||||
return this.peerMediaFacade.getRemoteCameraStream(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the remote screen-share stream for a connected peer.
|
||||
*
|
||||
@@ -456,6 +469,15 @@ export class WebRTCService implements OnDestroy {
|
||||
return this.peerMediaFacade.getLocalStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current local camera stream.
|
||||
*
|
||||
* @returns The local camera {@link MediaStream}, or `null` if the camera is disabled.
|
||||
*/
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
return this.peerMediaFacade.getLocalCameraStream();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw local microphone stream before gain / RNNoise processing.
|
||||
*
|
||||
@@ -477,6 +499,25 @@ export class WebRTCService implements OnDestroy {
|
||||
/** Stop local voice capture and remove audio senders from peers. */
|
||||
disableVoice(): void {
|
||||
this.voiceSessionController.disableVoice();
|
||||
this.state.setCameraEnabled(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sharing the local camera video with peers in the active voice channel.
|
||||
*
|
||||
* @returns The camera {@link MediaStream}.
|
||||
*/
|
||||
async enableCamera(): Promise<MediaStream> {
|
||||
const stream = await this.mediaManager.enableCamera();
|
||||
|
||||
this.state.setCameraEnabled(this.mediaManager.getIsCameraActive());
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** Stop local camera capture and remove camera tracks from peers. */
|
||||
disableCamera(): void {
|
||||
this.mediaManager.disableCamera();
|
||||
this.state.setCameraEnabled(this.mediaManager.getIsCameraActive());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,6 +655,7 @@ export class WebRTCService implements OnDestroy {
|
||||
this.peerMediaFacade.closeAllPeers();
|
||||
this.state.clearPeerViewState();
|
||||
this.voiceSessionController.resetVoiceSession();
|
||||
this.state.setCameraEnabled(false);
|
||||
this.peerMediaFacade.stopScreenShare();
|
||||
this.state.clearScreenShareState();
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export const P2P_TYPE_STATE_REQUEST = 'state-request';
|
||||
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';
|
||||
export const P2P_TYPE_VOICE_STATE = 'voice-state';
|
||||
export const P2P_TYPE_SCREEN_STATE = 'screen-state';
|
||||
export const P2P_TYPE_CAMERA_STATE = 'camera-state';
|
||||
export const P2P_TYPE_SCREEN_SHARE_REQUEST = 'screen-share-request';
|
||||
export const P2P_TYPE_SCREEN_SHARE_STOP = 'screen-share-stop';
|
||||
export const P2P_TYPE_PING = 'ping';
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface PeerData {
|
||||
screenAudioSender?: RTCRtpSender;
|
||||
/** Known remote stream ids that carry the peer's voice audio. */
|
||||
remoteVoiceStreamIds: Set<string>;
|
||||
/** Known remote stream ids that carry the peer's camera video. */
|
||||
remoteCameraStreamIds: Set<string>;
|
||||
/** Known remote stream ids that carry the peer's screen-share audio/video. */
|
||||
remoteScreenShareStreamIds: Set<string>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export class WebRtcStateController {
|
||||
readonly connectedPeers: Signal<string[]>;
|
||||
readonly isMuted: Signal<boolean>;
|
||||
readonly isDeafened: Signal<boolean>;
|
||||
readonly isCameraEnabled: Signal<boolean>;
|
||||
readonly isScreenSharing: Signal<boolean>;
|
||||
readonly isNoiseReductionEnabled: Signal<boolean>;
|
||||
readonly screenStream: Signal<MediaStream | null>;
|
||||
@@ -31,6 +32,7 @@ export class WebRtcStateController {
|
||||
private readonly _connectedPeers = signal<string[]>([]);
|
||||
private readonly _isMuted = signal(false);
|
||||
private readonly _isDeafened = signal(false);
|
||||
private readonly _isCameraEnabled = signal(false);
|
||||
private readonly _isScreenSharing = signal(false);
|
||||
private readonly _isNoiseReductionEnabled = signal(false);
|
||||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||||
@@ -49,6 +51,7 @@ export class WebRtcStateController {
|
||||
this.connectedPeers = computed(() => this._connectedPeers());
|
||||
this.isMuted = computed(() => this._isMuted());
|
||||
this.isDeafened = computed(() => this._isDeafened());
|
||||
this.isCameraEnabled = computed(() => this._isCameraEnabled());
|
||||
this.isScreenSharing = computed(() => this._isScreenSharing());
|
||||
this.isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled());
|
||||
this.screenStream = computed(() => this._screenStreamSignal());
|
||||
@@ -89,6 +92,10 @@ export class WebRtcStateController {
|
||||
return this._isScreenSharing();
|
||||
}
|
||||
|
||||
isCameraEnabledActive(): boolean {
|
||||
return this._isCameraEnabled();
|
||||
}
|
||||
|
||||
setCurrentServer(serverId: string): void {
|
||||
this.activeServerId = serverId;
|
||||
}
|
||||
@@ -105,6 +112,10 @@ export class WebRtcStateController {
|
||||
this._isDeafened.set(deafened);
|
||||
}
|
||||
|
||||
setCameraEnabled(enabled: boolean): void {
|
||||
this._isCameraEnabled.set(enabled);
|
||||
}
|
||||
|
||||
setNoiseReductionEnabled(enabled: boolean): void {
|
||||
this._isNoiseReductionEnabled.set(enabled);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,10 @@ export class PeerMediaFacade {
|
||||
return this.dependencies.peerManager.remotePeerVoiceStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
getRemoteCameraStream(peerId: string): MediaStream | null {
|
||||
return this.dependencies.peerManager.remotePeerCameraStreams.get(peerId) ?? null;
|
||||
}
|
||||
|
||||
getRemoteScreenShareStream(peerId: string): MediaStream | null {
|
||||
return this.dependencies.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null;
|
||||
}
|
||||
@@ -89,6 +93,10 @@ export class PeerMediaFacade {
|
||||
return this.dependencies.mediaManager.getLocalStream();
|
||||
}
|
||||
|
||||
getLocalCameraStream(): MediaStream | null {
|
||||
return this.dependencies.mediaManager.getLocalCameraStream();
|
||||
}
|
||||
|
||||
getRawMicStream(): MediaStream | null {
|
||||
return this.dependencies.mediaManager.getRawMicStream();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user