feat: Add webcam basic support

This commit is contained in:
2026-03-30 03:10:44 +02:00
parent 727059fb52
commit b7d4bf20e3
40 changed files with 1042 additions and 296 deletions

View File

@@ -129,6 +129,7 @@ export function createPeerConnection(
audioSender: undefined,
videoSender: undefined,
remoteVoiceStreamIds: new Set<string>(),
remoteCameraStreamIds: new Set<string>(),
remoteScreenShareStreamIds: new Set<string>()
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>>(),

View File

@@ -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);
});
}