-
@for (share of activeShares(); track trackShare($index, share)) {
-
-
No live screen shares yet
+
No live streams yet
- Click Screen Share below to start streaming, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
+ Turn on your camera, click Screen Share below, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
@if (connectedVoiceUsers().length > 0) {
diff --git a/toju-app/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts
similarity index 82%
rename from toju-app/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts
rename to toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts
index eb1ede3..8eb9717 100644
--- a/toju-app/src/app/domains/screen-share/feature/screen-share-workspace/screen-share-workspace.component.ts
+++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace.component.ts
@@ -29,34 +29,36 @@ import {
lucideX
} from '@ng-icons/lucide';
-import { User } from '../../../../shared-kernel';
+import { User } from '../../../shared-kernel';
import {
loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage,
VoiceSessionFacade,
VoiceWorkspacePosition,
VoiceWorkspaceService
-} from '../../../../domains/voice-session';
-import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
-import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
-import { ScreenShareFacade } from '../../application/screen-share.facade';
-import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
-import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
-import { UsersActions } from '../../../../store/users/users.actions';
-import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
-import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
-import { ScreenSharePlaybackService } from './screen-share-playback.service';
-import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
-import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
+} from '../../../domains/voice-session';
+import { VoiceConnectionFacade, VoicePlaybackService } from '../../../domains/voice-connection';
+import {
+ ScreenShareFacade,
+ ScreenShareQuality,
+ ScreenShareStartOptions
+} from '../../../domains/screen-share';
+import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
+import { UsersActions } from '../../../store/users/users.actions';
+import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
+import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
+import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
+import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
+import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
@Component({
- selector: 'app-screen-share-workspace',
+ selector: 'app-voice-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
ScreenShareQualityDialogComponent,
- ScreenShareStreamTileComponent,
+ VoiceWorkspaceStreamTileComponent,
UserAvatarComponent
],
viewProviders: [
@@ -75,19 +77,19 @@ import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models'
lucideX
})
],
- templateUrl: './screen-share-workspace.component.html',
+ templateUrl: './voice-workspace.component.html',
host: {
class: 'pointer-events-none absolute inset-0 z-20 block'
}
})
-export class ScreenShareWorkspaceComponent {
+export class VoiceWorkspaceComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject>(ElementRef);
private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
- private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
+ private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
@@ -160,7 +162,7 @@ export class ScreenShareWorkspaceComponent {
return voiceUsers;
});
- readonly activeShares = computed(() => {
+ readonly activeShares = computed(() => {
this.remoteStreamRevision();
const room = this.currentRoom();
@@ -172,17 +174,34 @@ export class ScreenShareWorkspaceComponent {
return [];
}
- const shares: ScreenShareWorkspaceStreamItem[] = [];
- const localStream = this.screenShare.screenStream();
+ const shares: VoiceWorkspaceStreamItem[] = [];
+ const localScreenStream = this.screenShare.screenStream();
+ const localCameraStream = this.webrtc.isCameraEnabled()
+ ? this.webrtc.getLocalCameraStream()
+ : null;
const localPeerKey = this.getUserPeerKey(me);
- if (localStream && localPeerKey) {
+ if (localScreenStream && localPeerKey) {
shares.push({
- id: localPeerKey,
+ id: this.buildStreamId(localPeerKey, 'screen'),
peerKey: localPeerKey,
user: me,
- stream: localStream,
- isLocal: true
+ stream: localScreenStream,
+ isLocal: true,
+ kind: 'screen',
+ hasAudio: this.hasActiveAudio(localScreenStream)
+ });
+ }
+
+ if (localCameraStream && localPeerKey) {
+ shares.push({
+ id: this.buildStreamId(localPeerKey, 'camera'),
+ peerKey: localPeerKey,
+ user: me,
+ stream: localCameraStream,
+ isLocal: true,
+ kind: 'camera',
+ hasAudio: false
});
}
@@ -201,23 +220,37 @@ export class ScreenShareWorkspaceComponent {
continue;
}
- if (user.screenShareState?.isSharing === false) {
- continue;
+ const remoteShare = user.screenShareState?.isSharing === false
+ ? null
+ : this.getRemoteScreenShareStream(user);
+
+ if (remoteShare) {
+ shares.push({
+ id: this.buildStreamId(remoteShare.peerKey, 'screen'),
+ peerKey: remoteShare.peerKey,
+ user,
+ stream: remoteShare.stream,
+ isLocal: false,
+ kind: 'screen',
+ hasAudio: this.hasActiveAudio(remoteShare.stream)
+ });
}
- const remoteShare = this.getRemoteShareStream(user);
+ const remoteCamera = user.cameraState?.isEnabled === false
+ ? null
+ : this.getRemoteCameraStream(user);
- if (!remoteShare) {
- continue;
+ if (remoteCamera) {
+ shares.push({
+ id: this.buildStreamId(remoteCamera.peerKey, 'camera'),
+ peerKey: remoteCamera.peerKey,
+ user,
+ stream: remoteCamera.stream,
+ isLocal: false,
+ kind: 'camera',
+ hasAudio: false
+ });
}
-
- shares.push({
- id: remoteShare.peerKey,
- peerKey: remoteShare.peerKey,
- user,
- stream: remoteShare.stream,
- isLocal: false
- });
}
return shares.sort((shareA, shareB) => {
@@ -225,6 +258,10 @@ export class ScreenShareWorkspaceComponent {
return shareA.isLocal ? 1 : -1;
}
+ if (shareA.kind !== shareB.kind) {
+ return shareA.kind === 'screen' ? -1 : 1;
+ }
+
return shareA.user.displayName.localeCompare(shareB.user.displayName);
});
});
@@ -233,12 +270,12 @@ export class ScreenShareWorkspaceComponent {
const requested = this.voiceWorkspace.focusedStreamId();
const activeShares = this.activeShares();
- if (requested && activeShares.some((share) => share.peerKey === requested)) {
+ if (requested && activeShares.some((share) => share.id === requested)) {
return requested;
}
if (activeShares.length === 1) {
- return activeShares[0].peerKey;
+ return activeShares[0].id;
}
return null;
@@ -250,12 +287,12 @@ export class ScreenShareWorkspaceComponent {
);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly widescreenShare = computed(
- () => this.activeShares().find((share) => share.peerKey === this.widescreenShareId()) ?? null
+ () => this.activeShares().find((share) => share.id === this.widescreenShareId()) ?? null
);
readonly focusedAudioShare = computed(() => {
const share = this.widescreenShare();
- return share && !share.isLocal ? share : null;
+ return share && !share.isLocal && share.hasAudio ? share : null;
});
readonly focusedShareTitle = computed(() => {
const share = this.widescreenShare();
@@ -264,16 +301,20 @@ export class ScreenShareWorkspaceComponent {
return 'Focused stream';
}
- return share.isLocal ? 'Your stream' : share.user.displayName;
+ if (!share.isLocal) {
+ return share.user.displayName;
+ }
+
+ return share.kind === 'camera' ? 'Your camera' : 'Your screen';
});
readonly thumbnailShares = computed(() => {
const widescreenShareId = this.widescreenShareId();
if (!widescreenShareId) {
- return [] as ScreenShareWorkspaceStreamItem[];
+ return [] as VoiceWorkspaceStreamItem[];
}
- return this.activeShares().filter((share) => share.peerKey !== widescreenShareId);
+ return this.activeShares().filter((share) => share.id !== widescreenShareId);
});
readonly miniPreviewShare = computed(
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
@@ -285,7 +326,11 @@ export class ScreenShareWorkspaceComponent {
return 'Voice workspace';
}
- return previewShare.isLocal ? 'Your stream' : previewShare.user.displayName;
+ if (!previewShare.isLocal) {
+ return previewShare.user.displayName;
+ }
+
+ return previewShare.kind === 'camera' ? 'Your camera' : 'Your screen';
});
readonly liveShareCount = computed(() => this.activeShares().length);
readonly connectedVoiceChannelName = computed(() => {
@@ -313,7 +358,7 @@ export class ScreenShareWorkspaceComponent {
this.clearHeaderHideTimeout();
this.cleanupObservedRemoteStreams();
this.screenShare.syncRemoteScreenShareRequests([], false);
- this.screenSharePlayback.teardownAll();
+ this.workspacePlayback.teardownAll();
});
this.screenShare.onRemoteStream
@@ -372,7 +417,7 @@ export class ScreenShareWorkspaceComponent {
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
if (!shouldConnectRemoteShares) {
- this.screenSharePlayback.teardownAll();
+ this.workspacePlayback.teardownAll();
}
});
@@ -486,7 +531,7 @@ export class ScreenShareWorkspaceComponent {
return this.getUserPeerKey(user) || `${index}`;
}
- trackShare(index: number, share: ScreenShareWorkspaceStreamItem): string {
+ trackShare(index: number, share: VoiceWorkspaceStreamItem): string {
return share.id || `${index}`;
}
@@ -523,7 +568,7 @@ export class ScreenShareWorkspaceComponent {
return 100;
}
- return this.screenSharePlayback.getUserVolume(share.peerKey);
+ return this.workspacePlayback.getUserVolume(share.peerKey);
}
focusedShareMuted(): boolean {
@@ -533,7 +578,7 @@ export class ScreenShareWorkspaceComponent {
return false;
}
- return this.screenSharePlayback.isUserMuted(share.peerKey);
+ return this.workspacePlayback.isUserMuted(share.peerKey);
}
toggleFocusedShareMuted(): void {
@@ -543,9 +588,9 @@ export class ScreenShareWorkspaceComponent {
return;
}
- this.screenSharePlayback.setUserMuted(
+ this.workspacePlayback.setUserMuted(
share.peerKey,
- !this.screenSharePlayback.isUserMuted(share.peerKey)
+ !this.workspacePlayback.isUserMuted(share.peerKey)
);
}
@@ -559,10 +604,10 @@ export class ScreenShareWorkspaceComponent {
const input = event.target as HTMLInputElement;
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
- this.screenSharePlayback.setUserVolume(share.peerKey, nextVolume);
+ this.workspacePlayback.setUserVolume(share.peerKey, nextVolume);
- if (nextVolume > 0 && this.screenSharePlayback.isUserMuted(share.peerKey)) {
- this.screenSharePlayback.setUserMuted(share.peerKey, false);
+ if (nextVolume > 0 && this.workspacePlayback.isUserMuted(share.peerKey)) {
+ this.workspacePlayback.setUserMuted(share.peerKey, false);
}
}
@@ -684,6 +729,13 @@ export class ScreenShareWorkspaceComponent {
}
})
);
+
+ this.store.dispatch(
+ UsersActions.updateCameraState({
+ userId: user.id,
+ cameraState: { isEnabled: false }
+ })
+ );
}
this.voiceSession.endSession();
@@ -791,7 +843,11 @@ export class ScreenShareWorkspaceComponent {
return user?.oderId || user?.id || null;
}
- private getRemoteShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
+ private buildStreamId(peerKey: string, kind: VoiceWorkspaceStreamItem['kind']): string {
+ return `${kind}:${peerKey}`;
+ }
+
+ private getRemoteScreenShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
const peerKeys = [user.oderId, user.id].filter(
(candidate): candidate is string => !!candidate
);
@@ -807,10 +863,30 @@ export class ScreenShareWorkspaceComponent {
return null;
}
+ private getRemoteCameraStream(user: User): { peerKey: string; stream: MediaStream } | null {
+ const peerKeys = [user.oderId, user.id].filter(
+ (candidate): candidate is string => !!candidate
+ );
+
+ for (const peerKey of peerKeys) {
+ const stream = this.webrtc.getRemoteCameraStream(peerKey);
+
+ if (stream && this.hasActiveVideo(stream)) {
+ return { peerKey, stream };
+ }
+ }
+
+ return null;
+ }
+
private hasActiveVideo(stream: MediaStream): boolean {
return stream.getVideoTracks().some((track) => track.readyState === 'live');
}
+ private hasActiveAudio(stream: MediaStream): boolean {
+ return stream.getAudioTracks().some((track) => track.readyState === 'live');
+ }
+
private ensureMiniWindowPosition(): void {
const bounds = this.getWorkspaceBounds();
diff --git a/toju-app/src/app/features/room/voice-workspace/voice-workspace.models.ts b/toju-app/src/app/features/room/voice-workspace/voice-workspace.models.ts
new file mode 100644
index 0000000..09b0018
--- /dev/null
+++ b/toju-app/src/app/features/room/voice-workspace/voice-workspace.models.ts
@@ -0,0 +1,13 @@
+import { User } from '../../../shared-kernel';
+
+export type VoiceWorkspaceStreamKind = 'camera' | 'screen';
+
+export interface VoiceWorkspaceStreamItem {
+ id: string;
+ peerKey: string;
+ user: User;
+ stream: MediaStream;
+ isLocal: boolean;
+ kind: VoiceWorkspaceStreamKind;
+ hasAudio: boolean;
+}
diff --git a/toju-app/src/app/infrastructure/realtime/README.md b/toju-app/src/app/infrastructure/realtime/README.md
index cea9113..bb6fd66 100644
--- a/toju-app/src/app/infrastructure/realtime/README.md
+++ b/toju-app/src/app/infrastructure/realtime/README.md
@@ -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:
diff --git a/toju-app/src/app/infrastructure/realtime/media/media.manager.ts b/toju-app/src/app/infrastructure/realtime/media/media.manager.ts
index 027c9de..a65bfb1 100644
--- a/toju-app/src/app/infrastructure/realtime/media/media.manager.ts
+++ b/toju-app/src/app/infrastructure/realtime/media/media.manager.ts
@@ -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 {
+ 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 {
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, right: Set): 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();
diff --git a/toju-app/src/app/infrastructure/realtime/media/screen-share.manager.ts b/toju-app/src/app/infrastructure/realtime/media/screen-share.manager.ts
index 2c4abbf..8e42042 100644
--- a/toju-app/src/app/infrastructure/realtime/media/screen-share.manager.ts
+++ b/toju-app/src/app/infrastructure/realtime/media/screen-share.manager.ts
@@ -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
diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts
index e6be8a9..85dba12 100644
--- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts
+++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts
@@ -129,6 +129,7 @@ export function createPeerConnection(
audioSender: undefined,
videoSender: undefined,
remoteVoiceStreamIds: new Set(),
+ remoteCameraStreamIds: new Set(),
remoteScreenShareStreamIds: new Set()
};
diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts
index a3d87a6..5978fbd 100644
--- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts
+++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts
@@ -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;
remotePeerStreams: Map;
remotePeerVoiceStreams: Map;
+ remotePeerCameraStreams: Map;
remotePeerScreenShareStreams: Map;
disconnectedPeerTracker: Map;
peerReconnectTimers: Map>;
@@ -88,6 +91,7 @@ export function createPeerConnectionManagerState(): PeerConnectionManagerState {
activePeerConnections: new Map(),
remotePeerStreams: new Map(),
remotePeerVoiceStreams: new Map(),
+ remotePeerCameraStreams: new Map(),
remotePeerScreenShareStreams: new Map(),
disconnectedPeerTracker: new Map(),
peerReconnectTimers: new Map>(),
diff --git a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts
index 2dcba12..cf596e7 100644
--- a/toju-app/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts
+++ b/toju-app/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts
@@ -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);
});
}
diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts
index d10a724..2a66bdd 100644
--- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts
+++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts
@@ -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 => 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 {
+ 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();
}
diff --git a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts
index c00f229..2efba53 100644
--- a/toju-app/src/app/infrastructure/realtime/realtime.constants.ts
+++ b/toju-app/src/app/infrastructure/realtime/realtime.constants.ts
@@ -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';
diff --git a/toju-app/src/app/infrastructure/realtime/realtime.types.ts b/toju-app/src/app/infrastructure/realtime/realtime.types.ts
index f68a12e..5b1e7c0 100644
--- a/toju-app/src/app/infrastructure/realtime/realtime.types.ts
+++ b/toju-app/src/app/infrastructure/realtime/realtime.types.ts
@@ -22,6 +22,8 @@ export interface PeerData {
screenAudioSender?: RTCRtpSender;
/** Known remote stream ids that carry the peer's voice audio. */
remoteVoiceStreamIds: Set;
+ /** Known remote stream ids that carry the peer's camera video. */
+ remoteCameraStreamIds: Set;
/** Known remote stream ids that carry the peer's screen-share audio/video. */
remoteScreenShareStreamIds: Set;
}
diff --git a/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts b/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts
index 14ef404..0a17aa9 100644
--- a/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts
+++ b/toju-app/src/app/infrastructure/realtime/state/webrtc-state-controller.ts
@@ -14,6 +14,7 @@ export class WebRtcStateController {
readonly connectedPeers: Signal;
readonly isMuted: Signal;
readonly isDeafened: Signal;
+ readonly isCameraEnabled: Signal;
readonly isScreenSharing: Signal;
readonly isNoiseReductionEnabled: Signal;
readonly screenStream: Signal;
@@ -31,6 +32,7 @@ export class WebRtcStateController {
private readonly _connectedPeers = signal([]);
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(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);
}
diff --git a/toju-app/src/app/infrastructure/realtime/streams/peer-media-facade.ts b/toju-app/src/app/infrastructure/realtime/streams/peer-media-facade.ts
index 23da3b2..814b08b 100644
--- a/toju-app/src/app/infrastructure/realtime/streams/peer-media-facade.ts
+++ b/toju-app/src/app/infrastructure/realtime/streams/peer-media-facade.ts
@@ -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();
}
diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts
index 376b963..558a3e7 100644
--- a/toju-app/src/app/shared-kernel/chat-events.ts
+++ b/toju-app/src/app/shared-kernel/chat-events.ts
@@ -59,6 +59,7 @@ export interface ChatEventBase {
permissions?: Partial;
voiceState?: Partial;
isScreenSharing?: boolean;
+ isCameraEnabled?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
@@ -216,6 +217,11 @@ export interface ScreenStateEvent extends ChatEventBase {
isScreenSharing: boolean;
}
+export interface CameraStateEvent extends ChatEventBase {
+ type: 'camera-state';
+ isCameraEnabled: boolean;
+}
+
export interface VoiceStateRequestEvent extends ChatEventBase {
type: 'voice-state-request';
}
@@ -332,6 +338,7 @@ export type ChatEvent =
| VoiceStateEvent
| VoiceChannelMoveEvent
| ScreenStateEvent
+ | CameraStateEvent
| VoiceStateRequestEvent
| StateRequestEvent
| ScreenShareRequestEvent
diff --git a/toju-app/src/app/shared-kernel/user.models.ts b/toju-app/src/app/shared-kernel/user.models.ts
index 47a21f2..f2c4e3f 100644
--- a/toju-app/src/app/shared-kernel/user.models.ts
+++ b/toju-app/src/app/shared-kernel/user.models.ts
@@ -1,4 +1,4 @@
-import type { VoiceState, ScreenShareState } from './voice-state.models';
+import type { CameraState, VoiceState, ScreenShareState } from './voice-state.models';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
@@ -19,6 +19,7 @@ export interface User {
isRoomOwner?: boolean;
voiceState?: VoiceState;
screenShareState?: ScreenShareState;
+ cameraState?: CameraState;
}
export interface RoomMember {
diff --git a/toju-app/src/app/shared-kernel/voice-state.models.ts b/toju-app/src/app/shared-kernel/voice-state.models.ts
index c8c57d5..9459473 100644
--- a/toju-app/src/app/shared-kernel/voice-state.models.ts
+++ b/toju-app/src/app/shared-kernel/voice-state.models.ts
@@ -15,3 +15,7 @@ export interface ScreenShareState {
sourceId?: string;
sourceName?: string;
}
+
+export interface CameraState {
+ isEnabled: boolean;
+}
diff --git a/toju-app/src/app/store/rooms/rooms.effects.ts b/toju-app/src/app/store/rooms/rooms.effects.ts
index ca9dc5d..30c3940 100644
--- a/toju-app/src/app/store/rooms/rooms.effects.ts
+++ b/toju-app/src/app/store/rooms/rooms.effects.ts
@@ -1067,6 +1067,8 @@ export class RoomsEffects {
return this.handleVoiceChannelMove(event, currentRoom, savedRooms, currentUser ?? null);
case 'screen-state':
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'screen') : EMPTY;
+ case 'camera-state':
+ return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'camera') : EMPTY;
case 'server-state-request':
return this.handleServerStateRequest(event, currentRoom, savedRooms);
case 'server-state-full':
@@ -1091,7 +1093,12 @@ export class RoomsEffects {
)
);
- private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen') {
+ private handleVoiceOrScreenState(
+ event: ChatEvent,
+ allUsers: User[],
+ currentUser: User | null,
+ kind: 'voice' | 'screen' | 'camera'
+ ) {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId)
@@ -1157,10 +1164,35 @@ export class RoomsEffects {
voiceState: vs }));
}
- // screen-state
- const isSharing = event.isScreenSharing as boolean | undefined;
+ if (kind === 'screen') {
+ const isSharing = event.isScreenSharing as boolean | undefined;
- if (isSharing === undefined)
+ if (isSharing === undefined)
+ return EMPTY;
+
+ if (!userExists) {
+ return of(
+ UsersActions.userJoined({
+ user: buildSignalingUser(
+ { oderId: userId,
+ displayName: event.displayName || 'User' },
+ { screenShareState: { isSharing } }
+ )
+ })
+ );
+ }
+
+ return of(
+ UsersActions.updateScreenShareState({
+ userId,
+ screenShareState: { isSharing }
+ })
+ );
+ }
+
+ const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
+
+ if (isCameraEnabled === undefined)
return EMPTY;
if (!userExists) {
@@ -1169,16 +1201,16 @@ export class RoomsEffects {
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || 'User' },
- { screenShareState: { isSharing } }
+ { cameraState: { isEnabled: isCameraEnabled } }
)
})
);
}
return of(
- UsersActions.updateScreenShareState({
+ UsersActions.updateCameraState({
userId,
- screenShareState: { isSharing }
+ cameraState: { isEnabled: isCameraEnabled }
})
);
}
diff --git a/toju-app/src/app/store/users/users.actions.ts b/toju-app/src/app/store/users/users.actions.ts
index ad2d88b..140e15b 100644
--- a/toju-app/src/app/store/users/users.actions.ts
+++ b/toju-app/src/app/store/users/users.actions.ts
@@ -10,7 +10,8 @@ import {
User,
BanEntry,
VoiceState,
- ScreenShareState
+ ScreenShareState,
+ CameraState
} from '../../shared-kernel';
export const UsersActions = createActionGroup({
@@ -52,6 +53,7 @@ export const UsersActions = createActionGroup({
'Update Host': props<{ userId: string }>(),
'Update Voice State': props<{ userId: string; voiceState: Partial }>(),
- 'Update Screen Share State': props<{ userId: string; screenShareState: Partial }>()
+ 'Update Screen Share State': props<{ userId: string; screenShareState: Partial }>(),
+ 'Update Camera State': props<{ userId: string; cameraState: Partial }>()
}
});
diff --git a/toju-app/src/app/store/users/users.reducer.ts b/toju-app/src/app/store/users/users.reducer.ts
index ca79fb3..5defc27 100644
--- a/toju-app/src/app/store/users/users.reducer.ts
+++ b/toju-app/src/app/store/users/users.reducer.ts
@@ -212,6 +212,23 @@ export const usersReducer = createReducer(
state
);
}),
+ on(UsersActions.updateCameraState, (state, { userId, cameraState }) => {
+ const prev = state.entities[userId]?.cameraState || {
+ isEnabled: false
+ };
+
+ return usersAdapter.updateOne(
+ {
+ id: userId,
+ changes: {
+ cameraState: {
+ isEnabled: cameraState.isEnabled ?? prev.isEnabled
+ }
+ }
+ },
+ state
+ );
+ }),
on(UsersActions.syncUsers, (state, { users }) =>
usersAdapter.upsertMany(users, state)
),