import { createLogger } from '../../shared/logger.js'; const logger = createLogger('server-recording-peer'); export class ServerRecordingPeer { constructor({ rtcConfiguration, getLocalStream, getSignaling, getConnectionId, getParticipantId }) { this.rtcConfiguration = rtcConfiguration; this.getLocalStream = getLocalStream; this.getSignaling = getSignaling; this.getConnectionId = getConnectionId; this.getParticipantId = getParticipantId; this.peers = new Map(); } async start(request) { if (!request || !request.recordingId) { return; } this.stop(request.recordingId); const localStream = this.getLocalStream(); const tracks = localStream ? localStream.getTracks().filter(track => track.readyState !== 'ended') : []; if (tracks.length === 0) { this._sendStatus(request, 'no-local-media'); return; } const pc = new RTCPeerConnection(this.rtcConfiguration); const state = { pc, recordingId: request.recordingId, connectionId: request.connectionId || this.getConnectionId(), pendingCandidates: [] }; this.peers.set(request.recordingId, state); pc.onicecandidate = (event) => { if (!event.candidate) { return; } this._sendCandidate(state, event.candidate); }; pc.onconnectionstatechange = () => { logger.debug(`recording peer ${request.recordingId} state: ${pc.connectionState}`); }; tracks.forEach(track => { pc.addTransceiver(track, { direction: 'sendonly', streams: localStream ? [localStream] : [] }); }); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); this._sendOffer(state); } async applyAnswer(answer) { const state = this.peers.get(answer?.recordingId); if (!state || !answer?.sdp) { return; } await state.pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })); await this._flushPendingCandidates(state); } async addIceCandidate(candidate) { const state = this.peers.get(candidate?.recordingId); if (!state || !candidate?.candidate) { return; } const iceCandidate = new RTCIceCandidate({ candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex }); if (!state.pc.remoteDescription) { state.pendingCandidates.push(iceCandidate); return; } await state.pc.addIceCandidate(iceCandidate); } stop(recordingId) { if (!recordingId) { this.peers.forEach(peerState => this._closePeer(peerState)); this.peers.clear(); return; } const state = this.peers.get(recordingId); if (!state) { return; } this._closePeer(state); this.peers.delete(recordingId); } _closePeer(state) { state.pendingCandidates = []; state.pc.close(); } async _flushPendingCandidates(state) { if (!state?.pc?.remoteDescription || !state.pendingCandidates.length) { return; } const pendingCandidates = state.pendingCandidates.splice(0, state.pendingCandidates.length); for (const candidate of pendingCandidates) { await state.pc.addIceCandidate(candidate); } } _sendOffer(state) { const signaling = this.getSignaling(); if (!signaling || typeof signaling.sendRecordingOffer !== 'function') { return; } signaling.sendRecordingOffer({ recordingId: state.recordingId, connectionId: state.connectionId, participantId: this.getParticipantId() || '', sdp: state.pc.localDescription?.sdp || '' }); } _sendCandidate(state, candidate) { const signaling = this.getSignaling(); if (!signaling || typeof signaling.sendRecordingCandidate !== 'function') { return; } signaling.sendRecordingCandidate({ recordingId: state.recordingId, connectionId: state.connectionId, participantId: this.getParticipantId() || '', candidate: candidate.candidate, sdpMid: candidate.sdpMid, sdpMLineIndex: candidate.sdpMLineIndex }); } _sendStatus(request, status) { const signaling = this.getSignaling(); if (!signaling || typeof signaling.sendRecordingStatus !== 'function') { return; } signaling.sendRecordingStatus({ recordingId: request.recordingId, connectionId: request.connectionId || this.getConnectionId(), participantId: this.getParticipantId() || '', status }); } }