【m】修改为服务器录屏
This commit is contained in:
@@ -124,6 +124,11 @@ export class MeetingRecorder {
|
||||
this.audioSources = [];
|
||||
this.recordingStream = null;
|
||||
this.connectionId = '';
|
||||
this.layout = 'grid';
|
||||
this.onChunk = null;
|
||||
this.storeChunks = true;
|
||||
this.mixedAudioDestination = null;
|
||||
this.mixedAudioTrackIds = new Set();
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
@@ -137,7 +142,7 @@ export class MeetingRecorder {
|
||||
return Boolean(this.mediaRecorder && this.mediaRecorder.state !== 'inactive');
|
||||
}
|
||||
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId } = {}) {
|
||||
async start({ localStream, remoteStream, remoteStreams, connectionId, layout, onChunk, storeChunks } = {}) {
|
||||
if (this.isRecording()) {
|
||||
throw new Error('会议正在录制中');
|
||||
}
|
||||
@@ -156,7 +161,11 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
this.connectionId = connectionId || '';
|
||||
this.layout = layout || 'grid';
|
||||
this.onChunk = typeof onChunk === 'function' ? onChunk : null;
|
||||
this.storeChunks = storeChunks !== false;
|
||||
this.chunks = [];
|
||||
this.mixedAudioTrackIds = new Set();
|
||||
this.canvas = canvas;
|
||||
this.context = context;
|
||||
|
||||
@@ -179,6 +188,16 @@ export class MeetingRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
syncAudio({ localStream, remoteStream, remoteStreams } = {}) {
|
||||
if (!this.isRecording() || !this.audioContext || !this.mixedAudioDestination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streams = collectStreams({ localStream, remoteStream, remoteStreams });
|
||||
const audioTracks = collectLiveAudioTracks(streams);
|
||||
audioTracks.forEach(track => this._connectAudioTrack(track));
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording()) {
|
||||
return Promise.resolve(null);
|
||||
@@ -203,16 +222,26 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
this.audioContext = new AudioContextCtor();
|
||||
const destination = this.audioContext.createMediaStreamDestination();
|
||||
this.mixedAudioDestination = this.audioContext.createMediaStreamDestination();
|
||||
audioTracks.forEach(track => this._connectAudioTrack(track));
|
||||
|
||||
audioTracks.forEach(track => {
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(destination);
|
||||
this.audioSources.push(source);
|
||||
});
|
||||
return this.mixedAudioDestination.stream.getAudioTracks()[0] || null;
|
||||
}
|
||||
|
||||
return destination.stream.getAudioTracks()[0] || null;
|
||||
_connectAudioTrack(track) {
|
||||
if (!track || track.readyState === 'ended') {
|
||||
return;
|
||||
}
|
||||
const trackId = track.id || `${track.kind}-${Date.now()}`;
|
||||
if (this.mixedAudioTrackIds.has(trackId)) {
|
||||
return;
|
||||
}
|
||||
this.mixedAudioTrackIds.add(trackId);
|
||||
|
||||
const sourceStream = new this.window.MediaStream([track]);
|
||||
const source = this.audioContext.createMediaStreamSource(sourceStream);
|
||||
source.connect(this.mixedAudioDestination);
|
||||
this.audioSources.push(source);
|
||||
}
|
||||
|
||||
startMediaRecorder(stream) {
|
||||
@@ -224,7 +253,17 @@ export class MeetingRecorder {
|
||||
this.mediaRecorder = new MediaRecorderCtor(stream, options);
|
||||
this.mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
if (this.storeChunks) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
if (this.onChunk) {
|
||||
try {
|
||||
this.onChunk(event.data);
|
||||
}
|
||||
catch (_error) {
|
||||
// Ignore chunk callback failures so recording can continue.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
@@ -235,9 +274,9 @@ export class MeetingRecorder {
|
||||
this.cleanup();
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.mediaRecorder.mimeType || 'video/webm' });
|
||||
const filename = this.buildFilename();
|
||||
const mimeType = blob.type || this.mediaRecorder.mimeType || 'video/webm';
|
||||
const mimeType = this.mediaRecorder.mimeType || 'video/webm';
|
||||
const blob = this.storeChunks ? new Blob(this.chunks, { type: mimeType }) : null;
|
||||
this.cleanup();
|
||||
if (this.pendingStop) {
|
||||
this.pendingStop.resolve({ blob, filename, mimeType });
|
||||
@@ -267,6 +306,15 @@ export class MeetingRecorder {
|
||||
context.fillStyle = '#020617';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (this.layout === 'host-only') {
|
||||
if (localVideo) {
|
||||
drawVideoCover(context, localVideo, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
drawEmptyFrame(context, canvas);
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteVideos.length > 0) {
|
||||
drawGrid(context, remoteVideos, canvas);
|
||||
if (localVideo) {
|
||||
@@ -328,9 +376,13 @@ export class MeetingRecorder {
|
||||
}
|
||||
|
||||
this.audioSources = [];
|
||||
this.mixedAudioDestination = null;
|
||||
this.mixedAudioTrackIds = new Set();
|
||||
this.mediaRecorder = null;
|
||||
this.canvas = null;
|
||||
this.context = null;
|
||||
this.chunks = [];
|
||||
this.onChunk = null;
|
||||
this.storeChunks = true;
|
||||
}
|
||||
}
|
||||
|
||||
175
client/public/call/media/server-recording-peer.js
Normal file
175
client/public/call/media/server-recording-peer.js
Normal file
@@ -0,0 +1,175 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInst
|
||||
import { getNetworkQualityFromSummary, summarizeInboundStats } from './media/webrtc-stats.js';
|
||||
import { createLogger } from '../shared/logger.js';
|
||||
import { MeetingRecorder } from './media/meeting-recorder.js';
|
||||
import { ServerRecordingPeer } from './media/server-recording-peer.js';
|
||||
|
||||
const logger = createLogger('store');
|
||||
class CallStateManager {
|
||||
@@ -28,6 +29,9 @@ class CallStateManager {
|
||||
this.listeners = [];
|
||||
this.socketEventHandlers = {};
|
||||
this._inviteEventSignaling = null;
|
||||
this._recordingEventSignaling = null;
|
||||
this.serverRecordingSession = null;
|
||||
this.serverRecordingPeer = null;
|
||||
this.meetingRecorder = new MeetingRecorder();
|
||||
}
|
||||
subscribe(callback) {
|
||||
@@ -112,12 +116,73 @@ class CallStateManager {
|
||||
async toggleRecording() {
|
||||
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
||||
|
||||
if (this.useWebSocket && this.connectionId) {
|
||||
return isRecording ? this.stopServerRecording() : this.startServerRecording();
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
return this.stopRecording();
|
||||
}
|
||||
|
||||
return this.startRecording();
|
||||
}
|
||||
async startServerRecording() {
|
||||
if (this.state.session.status !== 'ongoing') {
|
||||
throw new Error('会议连接成功后才能开始录制');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/recording-sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
connectionId: this.connectionId,
|
||||
layout: 'grid',
|
||||
format: 'webm'
|
||||
})
|
||||
});
|
||||
const responseBody = await response.json().catch(() => ({}));
|
||||
if (!response.ok || responseBody.success === false) {
|
||||
throw new Error(responseBody.message || '服务端录制启动失败');
|
||||
}
|
||||
|
||||
this.serverRecordingSession = responseBody.session;
|
||||
this._setRecordingMediaState(true);
|
||||
return {
|
||||
recording: true,
|
||||
message: '服务端录制已开始'
|
||||
};
|
||||
}
|
||||
async stopServerRecording() {
|
||||
const recordingId = this.serverRecordingSession?.id;
|
||||
if (!recordingId) {
|
||||
this._setRecordingMediaState(false);
|
||||
return {
|
||||
recording: false,
|
||||
message: '服务端录制已停止'
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/recording-sessions/${encodeURIComponent(recordingId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const responseBody = await response.json().catch(() => ({}));
|
||||
if (!response.ok || responseBody.success === false) {
|
||||
throw new Error(responseBody.message || '服务端录制停止失败');
|
||||
}
|
||||
|
||||
this.serverRecordingSession = responseBody.session;
|
||||
this._setRecordingMediaState(false);
|
||||
return {
|
||||
recording: false,
|
||||
message: '服务端录制已停止'
|
||||
};
|
||||
}
|
||||
_setRecordingMediaState(value) {
|
||||
this.state.session.localUser.mediaState.recording = value;
|
||||
this._notifyLocalMediaChange('recording', value);
|
||||
this.emitMediaStateChange();
|
||||
this._notifyUserListUpdate();
|
||||
}
|
||||
async startRecording() {
|
||||
if (this.state.session.status !== 'ongoing') {
|
||||
throw new Error('会议连接成功后才能开始录制');
|
||||
@@ -229,6 +294,7 @@ class CallStateManager {
|
||||
async _updateLocalMediaRefactored(mediaType, value) {
|
||||
if (mediaType === 'video' && value) {
|
||||
await this._enableLocalVideo();
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -241,6 +307,7 @@ class CallStateManager {
|
||||
if (mediaType === 'audio') {
|
||||
this._setLocalAudioTrackEnabled(value);
|
||||
}
|
||||
this._refreshServerRecordingPeer();
|
||||
this._notifyUserListUpdate();
|
||||
}
|
||||
async _enableLocalVideo() {
|
||||
@@ -441,6 +508,8 @@ class CallStateManager {
|
||||
await this._startConnection(connectionId);
|
||||
}
|
||||
_registerCallbacks() {
|
||||
this._ensureServerRecordingPeer();
|
||||
this._bindRecordingSignalHandlers();
|
||||
this.renderstreaming.onNewPeer = (participantId) => {
|
||||
logger.debug(`New peer created for ${participantId}, adding local tracks`);
|
||||
if (this.state.localStream) {
|
||||
@@ -534,6 +603,46 @@ class CallStateManager {
|
||||
this._handleRenderStreamingMessage(data);
|
||||
};
|
||||
}
|
||||
_ensureServerRecordingPeer() {
|
||||
if (this.serverRecordingPeer) {
|
||||
return this.serverRecordingPeer;
|
||||
}
|
||||
|
||||
this.serverRecordingPeer = new ServerRecordingPeer({
|
||||
rtcConfiguration: getRTCConfiguration(),
|
||||
getLocalStream: () => this.state.localStream,
|
||||
getSignaling: () => this.getActiveSignaling(),
|
||||
getConnectionId: () => this.connectionId,
|
||||
getParticipantId: () => this.selfParticipantId || (this.role === 'host' ? 'host' : '')
|
||||
});
|
||||
return this.serverRecordingPeer;
|
||||
}
|
||||
_bindRecordingSignalHandlers() {
|
||||
const signaling = this.renderstreaming?._signaling;
|
||||
if (!signaling || signaling === this._recordingEventSignaling || typeof signaling.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
signaling.addEventListener('recording-started', (event) => {
|
||||
this._handleRecordingStarted(event.detail);
|
||||
});
|
||||
signaling.addEventListener('recording-peer-request', (event) => {
|
||||
this._handleRecordingPeerRequest(event.detail);
|
||||
});
|
||||
signaling.addEventListener('recording-stopped', (event) => {
|
||||
this._handleRecordingStopped(event.detail);
|
||||
});
|
||||
signaling.addEventListener('recording-status', (event) => {
|
||||
this._handleRecordingStatus(event.detail);
|
||||
});
|
||||
signaling.addEventListener('recording-answer', (event) => {
|
||||
this._handleRecordingAnswer(event.detail);
|
||||
});
|
||||
signaling.addEventListener('recording-candidate', (event) => {
|
||||
this._handleRecordingCandidate(event.detail);
|
||||
});
|
||||
this._recordingEventSignaling = signaling;
|
||||
}
|
||||
async _startConnection(connectionId) {
|
||||
await this.renderstreaming.start();
|
||||
await this.renderstreaming.createConnection(connectionId);
|
||||
@@ -552,6 +661,9 @@ class CallStateManager {
|
||||
}
|
||||
this.clearStatsMessage();
|
||||
this.stopNetworkQualityDetection();
|
||||
if (this.serverRecordingPeer) {
|
||||
this.serverRecordingPeer.stop();
|
||||
}
|
||||
if (this.durationInterval) {
|
||||
clearInterval(this.durationInterval);
|
||||
this.durationInterval = null;
|
||||
@@ -674,6 +786,116 @@ class CallStateManager {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_isCurrentRecordingEvent(data) {
|
||||
return data && (!data.connectionId || data.connectionId === this.connectionId);
|
||||
}
|
||||
_handleRecordingStarted(data) {
|
||||
if (!this._isCurrentRecordingEvent(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverRecordingSession = {
|
||||
id: data.recordingId,
|
||||
connectionId: data.connectionId,
|
||||
status: data.status,
|
||||
layout: data.layout,
|
||||
format: data.format,
|
||||
startedAt: data.startedAt
|
||||
};
|
||||
this._setRecordingMediaState(true);
|
||||
showNotification('服务端录制已开始', 'success');
|
||||
}
|
||||
_handleRecordingStopped(data) {
|
||||
if (!this._isCurrentRecordingEvent(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.serverRecordingSession && this.serverRecordingSession.id === data.recordingId) {
|
||||
this.serverRecordingSession = {
|
||||
...this.serverRecordingSession,
|
||||
status: data.status,
|
||||
stoppedAt: data.stoppedAt
|
||||
};
|
||||
}
|
||||
if (this.serverRecordingPeer) {
|
||||
this.serverRecordingPeer.stop(data.recordingId);
|
||||
}
|
||||
this._setRecordingMediaState(false);
|
||||
showNotification('服务端录制已停止', 'success');
|
||||
}
|
||||
async _handleRecordingPeerRequest(data) {
|
||||
if (!this._isCurrentRecordingEvent(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('收到服务端录制媒体请求:', data);
|
||||
this.notify({
|
||||
type: 'RECORDING_PEER_REQUEST',
|
||||
recordingId: data.recordingId,
|
||||
mediaMode: data.mediaMode
|
||||
});
|
||||
try {
|
||||
await this._ensureServerRecordingPeer().start(data);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('服务端录制 PeerConnection 创建失败:', error);
|
||||
showNotification('服务端录制媒体连接失败', 'error');
|
||||
}
|
||||
}
|
||||
_isServerRecordingActive() {
|
||||
return this.useWebSocket
|
||||
&& this.serverRecordingSession
|
||||
&& this.serverRecordingSession.status === 'recording';
|
||||
}
|
||||
_refreshServerRecordingPeer() {
|
||||
if (!this._isServerRecordingActive() || !this.serverRecordingPeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverRecordingPeer.start({
|
||||
recordingId: this.serverRecordingSession.id,
|
||||
connectionId: this.connectionId,
|
||||
mediaMode: 'webrtc-sendonly'
|
||||
}).catch((error) => {
|
||||
logger.error('服务端录制媒体重协商失败:', error);
|
||||
});
|
||||
}
|
||||
_handleRecordingStatus(data) {
|
||||
if (!this._isCurrentRecordingEvent(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('收到服务端录制状态:', data);
|
||||
this.notify({
|
||||
type: 'RECORDING_STATUS',
|
||||
status: data.status,
|
||||
recordingId: data.recordingId
|
||||
});
|
||||
}
|
||||
async _handleRecordingAnswer(data) {
|
||||
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.serverRecordingPeer.applyAnswer(data);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('服务端录制 answer 处理失败:', error);
|
||||
}
|
||||
}
|
||||
async _handleRecordingCandidate(data) {
|
||||
if (!this._isCurrentRecordingEvent(data) || !this.serverRecordingPeer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.serverRecordingPeer.addIceCandidate(data);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('服务端录制 candidate 处理失败:', error);
|
||||
}
|
||||
}
|
||||
_handleChatMessage(data) {
|
||||
const chatPayload = data.data || data.message;
|
||||
if (!chatPayload) {
|
||||
|
||||
Reference in New Issue
Block a user