import { mockCallSession } from './models.js'; import { RenderStreaming } from "../../module/renderstreaming.js"; import { getServerConfig, getRTCConfiguration } from "../js/config.js"; import { showNotification, generateId } from './utils.js'; import chatMessage from './chatmessage.js'; import { DEFAULT_PARTICIPANT_AVATAR, DEFAULT_PARTICIPANT_NAME, buildParticipantsSyncData, omitParticipant, removeParticipant, upsertParticipant } from './participants.js'; import { AUDIO_CONFIG, VAD_CONFIG, VIDEO_ONLY_CONSTRAINT, buildVideoConstraints, getAdaptiveVideoBitrate, getResolutionLabel, getTargetResolutionBitrate } from './media-config.js'; import { buildStatsLogPayload, createAudioAnalyser, getAudioLevel } from './media-monitoring.js'; import { bindInviteSocketEvents, buildSocketUserInfoPayload, createSignalingInstance, ensureSignalingStarted, getActiveSignalingInstance, sendInviteSignal, sendSocketUserInfo } from './signaling-session.js'; import { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js'; import { createLogger } from './logger.js'; const logger = createLogger('store'); class CallStateManager { constructor() { this.state = { id: generateId(), session: { ...mockCallSession, status: 'idle' }, localStream: null, remoteStream: null, remoteStreams: {}, participants: {} }; this.listeners = []; this.socketEventHandlers = {}; this._inviteEventSignaling = null; } subscribe(callback) { this.listeners.push(callback); return () => { this.listeners = this.listeners.filter(cb => cb !== callback); }; } notify(changes) { this.listeners.forEach(cb => cb(this.state, changes)); } async init() { await this.setupConfig(); this.loadUserSettings(); await this.getLocalStream(); } loadUserSettings() { const userSettings = localStorage.getItem('userSettings'); if (userSettings) { try { const settings = JSON.parse(userSettings); if (settings.name || settings.avatar) { this.state.session.localUser = { ...this.state.session.localUser, id: settings.userId || this.state.session.localUser.id, name: settings.name || this.state.session.localUser.name, avatar: settings.avatar || this.state.session.localUser.avatar }; this.notify({ type: 'USER_SETTINGS_UPDATED', user: this.state.session.localUser }); } if (settings.resolution) { this._savedResolution = settings.resolution; logger.debug(`已恢复分辨率设置: ${settings.resolution.width}x${settings.resolution.height}`); } } catch (error) { logger.error('Error loading user settings:', error); } } } async setupConfig() { const res = await getServerConfig(); this.useWebSocket = res.useWebSocket; } async getLocalStream() { try { logger.debug('Requesting camera permission...'); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { logger.error('getUserMedia is not supported'); throw new Error('getUserMedia is not supported'); } const videoConstraints = buildVideoConstraints(this._savedResolution); const stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: AUDIO_CONFIG }); logger.debug('Stream obtained successfully:', stream); logger.debug('Video tracks:', stream.getVideoTracks()); logger.debug('Audio tracks:', stream.getAudioTracks()); this.state.localStream = stream; this.state.session.localUser.mediaState.video = true; this.state.session.localUser.mediaState.audio = true; logger.debug('Local stream stored, notifying UI...'); this.notify({ type: 'LOCAL_STREAM_OBTAINED', stream }); this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: true }); this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'audio', value: true }); this.emitMediaStateChange(); this.startActivityDetection(this.state.localStream, { isLocal: true }); } catch (error) { logger.error('Error getting local stream:', error); this.state.session.localUser.mediaState.video = false; this.state.session.localUser.mediaState.audio = false; this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: false }); this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'audio', value: false }); } } async updateLocalMedia(mediaType, value) { await this._updateLocalMediaRefactored(mediaType, value); return; } async _updateLocalMediaRefactored(mediaType, value) { if (mediaType === 'video' && value) { await this._enableLocalVideo(); this._notifyUserListUpdate(); return; } this.state.session.localUser.mediaState[mediaType] = value; this._notifyLocalMediaChange(mediaType, value); this.emitMediaStateChange(); if (mediaType === 'video' && !value) { this._disableLocalVideoTracks(); } if (mediaType === 'audio') { this._setLocalAudioTrackEnabled(value); } this._notifyUserListUpdate(); } async _enableLocalVideo() { try { const newVideoTrack = await this._requestNewVideoTrack(); this._replaceLocalVideoTrack(newVideoTrack); await this._updateOutgoingVideoTrack(newVideoTrack); this.state.session.localUser.mediaState.video = true; this.notify({ type: 'LOCAL_STREAM_OBTAINED', stream: this.state.localStream }); this._notifyLocalMediaChange('video', true); this.emitMediaStateChange(); this.startActivityDetection(this.state.localStream, { isLocal: true }); } catch (error) { logger.error('Error reopening video:', error); this.state.session.localUser.mediaState.video = false; this._notifyLocalMediaChange('video', false); } } async _requestNewVideoTrack() { const newVideoStream = await navigator.mediaDevices.getUserMedia(VIDEO_ONLY_CONSTRAINT); const newVideoTrack = newVideoStream.getVideoTracks()[0]; if (!newVideoTrack) { throw new Error('Failed to get video track'); } return newVideoTrack; } _replaceLocalVideoTrack(newVideoTrack) { if (this.state.localStream) { const oldVideoTracks = this.state.localStream.getVideoTracks(); oldVideoTracks.forEach(track => { track.stop(); this.state.localStream.removeTrack(track); }); this.state.localStream.addTrack(newVideoTrack); return; } this.state.localStream = new MediaStream([newVideoTrack]); } async _updateOutgoingVideoTrack(newVideoTrack) { if (!this.renderstreaming) { return; } logger.debug('Updating video track in WebRTC connection'); if (this.role === 'host') { const participantIds = Object.keys(this.state.remoteStreams); for (const participantId of participantIds) { await this._updateVideoTrackForPeer(newVideoTrack, participantId); } return; } await this._updateVideoTrackForPeer(newVideoTrack); } async _updateVideoTrackForPeer(newVideoTrack, participantId = undefined) { const transceivers = this.renderstreaming.getTransceivers(participantId); if (!transceivers) { return; } const videoTransceivers = transceivers.filter(transceiver => transceiver.sender && transceiver.sender.track && transceiver.sender.track.kind === 'video'); if (videoTransceivers.length > 0) { await this._replaceVideoTrackOnTransceivers(videoTransceivers, newVideoTrack, participantId); } else { this._addVideoTransceiver(newVideoTrack, participantId); } this._scheduleVideoSenderUpdate(participantId); } async _replaceVideoTrackOnTransceivers(videoTransceivers, newVideoTrack, participantId) { for (const transceiver of videoTransceivers) { try { await transceiver.sender.replaceTrack(newVideoTrack); logger.debug(participantId ? `Replaced video track for participant ${participantId}` : 'Successfully replaced video track'); } catch (error) { logger.error(participantId ? `Error replacing video track for ${participantId}:` : 'Error replacing video track:', error); } } } _addVideoTransceiver(newVideoTrack, participantId) { try { if (participantId) { this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' }, participantId); logger.debug(`Added new video transceiver for participant ${participantId}`); return; } this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' }); logger.debug('Added new video transceiver'); } catch (error) { logger.error(participantId ? `Error adding video transceiver for ${participantId}:` : 'Error adding video transceiver:', error); } } _scheduleVideoSenderUpdate(participantId) { setTimeout(() => { this.setCodecPreferences(participantId); }, 100); setTimeout(() => { this.setVideoEncodingParameters(participantId); }, 200); } _disableLocalVideoTracks() { if (!this.state.localStream) { return; } this.state.session.localUser.mediaState.video = false; this.state.localStream.getTracks().forEach(track => { if (track.kind === 'video') { track.stop(); } }); } _setLocalAudioTrackEnabled(value) { if (!this.state.localStream) { return; } this.state.session.localUser.mediaState.audio = value; this.state.localStream.getTracks().forEach(track => { if (track.kind === 'audio') { track.enabled = value; } }); } _notifyLocalMediaChange(mediaType, value) { this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); } _notifyUserListUpdate() { this.notify({ type: 'USER_LIST_UPDATE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser }); } onSocketEvent(eventName, handler) { this.socketEventHandlers[eventName] = handler; } async connectSignaling() { await this.setupConfig(); const { signaling, reused } = await ensureSignalingStarted(this._signaling, this.useWebSocket); this._signaling = signaling; this._inviteEventSignaling = bindInviteSocketEvents(this._signaling, this.socketEventHandlers, this._inviteEventSignaling); if (reused) { logger.debug('Signaling already connected, reusing existing instance'); return this._signaling; } logger.debug('Signaling connected (WebSocket only, no room yet)'); return this._signaling; } getActiveSignaling() { return getActiveSignalingInstance(this._signaling, this.renderstreaming); } sendInviteCall(payload) { sendInviteSignal(this.getActiveSignaling(), 'sendInviteCall', payload); } sendInviteAccepted(payload) { sendInviteSignal(this.getActiveSignaling(), 'sendInviteAccepted', payload); } sendInviteRejected(payload) { sendInviteSignal(this.getActiveSignaling(), 'sendInviteRejected', payload); } syncSocketUserInfo(userInfo = null) { const payload = buildSocketUserInfoPayload(userInfo, this.state.session.localUser); this.state.session.localUser = { ...this.state.session.localUser, id: payload.id, name: payload.name, avatar: payload.avatar }; sendSocketUserInfo(this.getActiveSignaling(), payload); } async _createSignalingAndRTC(connectionId) { this.connectionId = connectionId; this.state.session.status = 'connecting'; this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' }); if (!this.state.localStream) { logger.debug('Local stream not available, waiting for initialization...'); await new Promise((resolve) => { const checkStream = () => { if (this.state.localStream) { resolve(); } else { setTimeout(checkStream, 100); } }; checkStream(); }); } const signaling = this._signaling || createSignalingInstance(this.useWebSocket); const config = getRTCConfiguration(); this.renderstreaming = new RenderStreaming(signaling, config); this._signaling = null; } async setUp(connectionId) { await this._createSignalingAndRTC(connectionId); this._registerCallbacks(); await this._startConnection(connectionId); } _registerCallbacks() { this.renderstreaming.onNewPeer = (participantId) => { logger.debug(`New peer created for ${participantId}, adding local tracks`); if (this.state.localStream) { const tracks = this.state.localStream.getTracks(); for (const track of tracks) { this.renderstreaming.addTransceiver(track, { direction: 'sendonly' }, participantId); } this.setCodecPreferences(participantId); this.setVideoEncodingParameters(participantId); } }; this.renderstreaming.onConnect = (connectionId, data) => { if (data && data.role) { this.role = data.role; this.state.session.localUser.isHost = (this.role === 'host'); if (data.participantId) { this.selfParticipantId = data.participantId; } logger.debug(`Connected as ${this.role}, participantId: ${this.selfParticipantId}`); } this.state.session.status = 'ongoing'; this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' }); if (this.role === 'participant') { if (this.state.localStream) { this.state.localStream.getAudioTracks().forEach(track => { track.enabled = false; }); } this.state.session.localUser.mediaState.audio = false; this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'audio', value: false }); logger.debug('Participant joined with audio muted by default'); } this.sendMessage('user-info', { id: this.state.session.localUser.id, name: this.state.session.localUser.name, avatar: this.state.session.localUser.avatar }); this.emitMediaStateChange(); if (this.state.localStream) { this.showStatsMessage(); } else { logger.error('Local stream is not available'); showNotification('本地视频流不可用', 'error'); } }; this.renderstreaming.onDisconnect = () => { logger.debug('Received disconnect from server, host left or room closed'); this.hangUp(); }; this.renderstreaming.onGotAnswer = (connectionId) => { logger.debug('SDP Answer received, resetting encoding parameters for connectionId:', connectionId); if (this.role === 'host') { const allParticipantIds = Object.keys(this.state.remoteStreams || {}); for (const pid of allParticipantIds) { setTimeout(() => { this.setVideoEncodingParameters(pid); }, 50); } } else { setTimeout(() => { this.setVideoEncodingParameters(); }, 50); } }; this.renderstreaming.onParticipantJoined = (participantId) => { logger.debug(`Participant joined: ${participantId}`); this._upsertParticipant(participantId); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); }; this.renderstreaming.onParticipantLeft = (participantId) => { logger.debug(`Participant left: ${participantId}, room still active`); this.updateRemoteUserStatus('offline'); this.updateRemoteUserNetworkQuality('no_signal'); showNotification('对方已离开通话', 'warning'); if (this.state.remoteStreams[participantId]) { this.state.remoteStreams[participantId].getTracks().forEach(track => track.stop()); delete this.state.remoteStreams[participantId]; } if (this.state.remoteStream) { this.state.remoteStream.getTracks().forEach(track => track.stop()); this.state.remoteStream = null; } this._removeParticipant(participantId); this.notify({ type: 'PARTICIPANT_LEFT', connectionId: participantId }); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); }; this.renderstreaming.onTrackEvent = (data) => { this._handleTrackEvent(data); }; this.renderstreaming.onMessage = (data) => { this._handleRenderStreamingMessage(data); }; } async _startConnection(connectionId) { await this.renderstreaming.start(); await this.renderstreaming.createConnection(connectionId); this.startNetworkQualityDetection(); this.startActivityDetection(this.state.localStream, { isLocal: true }); this.startActivityDetection(this.state.remoteStream, { isLocal: false }); } async hangUp() { this.clearStatsMessage(); this.stopNetworkQualityDetection(); if (this.durationInterval) { clearInterval(this.durationInterval); this.durationInterval = null; } this.durationSynced = false; const isHost = this.role === 'host'; logger.debug(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`); if (this.renderstreaming) { try { await this.renderstreaming.deleteConnection(); await this.renderstreaming.stop(); } catch (error) { logger.error('Error during hangUp:', error); } this.renderstreaming = null; } this.updateRemoteUserStatus('offline'); this.updateRemoteUserNetworkQuality('no_signal'); this.state.participants = {}; this.selfParticipantId = null; this.connectionId = null; this.role = null; this.state.session.status = 'ended'; this.notify({ type: 'CALL_ENDED', reason: isHost ? 'host_hangup' : 'participant_hangup' }); } _handleTrackEvent(data) { const direction = data.transceiver.direction; if (direction === 'sendrecv' || direction === 'recvonly') { this._handleIncomingTrack(data); return; } if (direction === 'sendonly' && data.track.kind === 'audio') { this.startActivityDetection(this.state.localStream, { isLocal: true }); } } _handleIncomingTrack(data) { const trackParticipantId = data.participantId || this.connectionId; const isHost = this.role === 'host'; const targetStream = this._getOrCreateRemoteStream(trackParticipantId, isHost); this._replaceTrackOfSameKind(targetStream, data.track); logger.debug('Added new track:', data.track.kind, 'for participant:', trackParticipantId); if (isHost && !this.state.participants[trackParticipantId]) { this._upsertParticipant(trackParticipantId); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); } this._notifyRemoteStreamUpdate(targetStream, trackParticipantId, isHost, data.track.kind); if (this.state.session.remoteUser.status !== 'online') { this.updateRemoteUserStatus('online'); this.updateRemoteUserNetworkQuality('good'); this.sendMessage('user-info', { id: this.state.session.localUser.id, name: this.state.session.localUser.name, avatar: this.state.session.localUser.avatar }); this._startDurationTimer(); } if (data.track.kind === 'audio') { this.startActivityDetection(this.state.remoteStream, { isLocal: false }); } } _getOrCreateRemoteStream(trackParticipantId, isHost) { if (isHost) { if (!this.state.remoteStreams[trackParticipantId]) { this.state.remoteStreams[trackParticipantId] = new MediaStream(); } return this.state.remoteStreams[trackParticipantId]; } if (this.state.remoteStream == null) { this.state.remoteStream = new MediaStream(); } return this.state.remoteStream; } _replaceTrackOfSameKind(targetStream, track) { const existingTracks = targetStream.getTracks().filter(existingTrack => existingTrack.kind === track.kind); existingTracks.forEach(existingTrack => { targetStream.removeTrack(existingTrack); logger.debug('Removed old track:', existingTrack.kind); }); targetStream.addTrack(track); } _notifyRemoteStreamUpdate(targetStream, trackParticipantId, isHost, trackKind) { const notifyStreamUpdate = () => { this.notify({ type: 'REMOTE_STREAM_OBTAINED', stream: targetStream, connectionId: trackParticipantId, isHost }); logger.debug('Notified UI about remote stream update'); }; if (trackKind === 'audio' && targetStream.getVideoTracks().length === 0) { logger.debug('Audio track arrived first, delaying stream notification for video track...'); setTimeout(() => { const nowHasVideo = targetStream.getVideoTracks().length > 0; logger.debug(`After delay, stream has video: ${nowHasVideo}`); notifyStreamUpdate(); }, 200); return; } notifyStreamUpdate(); } _handleRenderStreamingMessage(data) { logger.debug('收到信令消息:', data); switch (data.type) { case 'chat-message': this._handleChatMessage(data); break; case 'media-state-changed': this._handleMediaStateChangedMessage(data); break; case 'user-info': this._handleUserInfoMessage(data); break; case 'participants-sync': this._handleParticipantsSyncMessage(data); break; default: break; } } _handleChatMessage(data) { const chatPayload = data.data || data.message; if (!chatPayload) { return; } chatMessage.handleChatMessage(chatPayload); if (data.participantId && this.role === 'host' && this.state.participants[data.participantId]) { this._upsertParticipant(data.participantId, { id: chatPayload.senderId, ...(chatPayload.senderName ? { name: chatPayload.senderName } : {}), ...(chatPayload.senderAvatar ? { avatar: chatPayload.senderAvatar } : {}) }); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); return; } if (!this.role || this.role !== 'host') { if (data.participantId && this.state.participants[data.participantId]) { this._upsertParticipant(data.participantId, { ...(chatPayload.senderName ? { name: chatPayload.senderName } : {}), ...(chatPayload.senderAvatar ? { avatar: chatPayload.senderAvatar } : {}) }); this._notifyParticipantsUpdate(); } else if (chatPayload.senderId !== this.state.session.localUser.id) { this._updateRemoteUserProfile({ id: chatPayload.senderId, name: chatPayload.senderName, avatar: chatPayload.senderAvatar }); } } } _handleMediaStateChangedMessage(data) { logger.debug('收到媒体状态更新:', data.data, 'from participant:', data.participantId); if (this.role === 'host') { if (data.participantId && this.state.participants[data.participantId]) { this._upsertParticipant(data.participantId, { mediaState: data.data }); } this.updateRemoteMedia(data.data, data.participantId); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); return; } if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) { this._upsertParticipant(data.participantId, { mediaState: data.data }); this._notifyParticipantsUpdate(); return; } if (data.participantId === this.selfParticipantId) { return; } logger.debug('Received media-state-changed from Host, updating remoteUser:', data.data); this.updateRemoteMedia(data.data, data.participantId); this._notifyParticipantsUpdate(); } _handleUserInfoMessage(data) { logger.debug('收到用户信息:', data.data, 'from participant:', data.participantId); if (!data.data) { return; } if (data.participantId && this.role === 'host') { this._upsertParticipant(data.participantId, { id: data.data.id || '', name: data.data.name || DEFAULT_PARTICIPANT_NAME, avatar: data.data.avatar || DEFAULT_PARTICIPANT_AVATAR }); this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); return; } this._updateRemoteUserProfile({ id: data.data.id || this.state.session.remoteUser.id, name: data.data.name || this.state.session.remoteUser.name, avatar: data.data.avatar || this.state.session.remoteUser.avatar }); } _handleParticipantsSyncMessage(data) { if (this.role === 'host' || !data.data) { return; } logger.debug('收到成员同步列表:', data.data); this.state.participants = omitParticipant(data.data, this.selfParticipantId); this._notifyParticipantsUpdate(); this._syncCallDuration(data.callDuration); } _updateRemoteUserProfile(profile) { this._setRemoteUserState(profile); this._notifyRemoteUserChange({ mediaState: this.state.session.remoteUser.mediaState }); } _syncCallDuration(callDuration) { if (this.durationSynced || typeof callDuration !== 'number') { return; } this.state.session.duration = callDuration; this.durationSynced = true; this._startDurationTimer(); this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); logger.debug(`Call duration synced: ${callDuration} seconds`); } _startDurationTimer() { if (this.durationInterval) { return; } this.durationInterval = setInterval(() => { this.state.session.duration++; this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); }, 1000); } _setRemoteUserState(patch) { this.state.session.remoteUser = { ...this.state.session.remoteUser, ...patch }; } _setRemoteUserMediaState(mediaState) { this._setRemoteUserState({ mediaState: { ...this.state.session.remoteUser.mediaState, ...mediaState } }); } _notifyRemoteUserChange(changes = {}) { this.notify({ type: 'REMOTE_MEDIA_CHANGE', ...changes, localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser }); this._notifyUserListUpdate(); } sendMessage(type, data) { if (this.renderstreaming) { this.renderstreaming.sendMessage({ type: type, data: data }); } } _notifyParticipantsUpdate() { this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants }); } _upsertParticipant(participantId, patch = {}) { return upsertParticipant(this.state.participants, participantId, patch); } _removeParticipant(participantId) { return removeParticipant(this.state.participants, participantId); } broadcastParticipantsList() { if (this.role !== 'host' || !this.renderstreaming) return; const memberList = buildParticipantsSyncData(this.state.session.localUser, this.state.participants); this.renderstreaming.sendMessage({ type: 'participants-sync', data: memberList, callDuration: this.state.session.duration }); logger.debug('Broadcast participants list:', Object.keys(memberList)); } setCodecPreferences(participantId) { const capabilities = RTCRtpSender.getCapabilities('video'); if (!capabilities || !capabilities.codecs || capabilities.codecs.length === 0) return; const { codecs } = capabilities; let selectedCodecs = []; const av1Codec = codecs.find(c => c.mimeType === 'video/AV1'); const vp9Codec = codecs.find(c => c.mimeType === 'video/VP9'); const h264HighCodec = codecs.find(c => c.mimeType === 'video/H264' && c.sdpFmtpLine && c.sdpFmtpLine.includes('profile-level-id=6400')); const h264Codec = codecs.find(c => c.mimeType === 'video/H264'); if (av1Codec) selectedCodecs.push(av1Codec); if (vp9Codec) selectedCodecs.push(vp9Codec); if (h264HighCodec) selectedCodecs.push(h264HighCodec); if (h264Codec && (!h264HighCodec || h264Codec !== h264HighCodec)) selectedCodecs.push(h264Codec); if (selectedCodecs.length === 0) return; if (this.renderstreaming) { const transceivers = this.renderstreaming.getTransceivers(participantId); if (transceivers && transceivers.length > 0) { const videoTransceivers = transceivers.filter(t => { if (t.sender && t.sender.track) { return t.sender.track.kind === 'video'; } return t.mid !== null && t.receiver && t.receiver.track && t.receiver.track.kind === 'video'; }); if (videoTransceivers && videoTransceivers.length > 0) { videoTransceivers.forEach(t => { try { t.setCodecPreferences(selectedCodecs); } catch (e) { logger.error('Error setting codec preferences:', e); } }); logger.debug(`Codec preferences set: ${selectedCodecs.map(c => c.mimeType).join(' > ')}`); } } } } setVideoEncodingParameters(participantId) { if (!this.renderstreaming) return; const transceivers = this.renderstreaming.getTransceivers(participantId); if (!transceivers || transceivers.length === 0) return; const videoTransceivers = transceivers.filter(t => t.sender && t.sender.track && t.sender.track.kind === 'video'); for (const transceiver of videoTransceivers) { try { const sender = transceiver.sender; const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) { params.encodings = [{}]; } const videoTrack = sender.track; const settings = videoTrack ? videoTrack.getSettings() : {}; const height = settings.height || 1080; const maxBitrate = getAdaptiveVideoBitrate(height); params.encodings[0].maxBitrate = maxBitrate; params.encodings[0].scaleResolutionDownBy = 1.0; params.encodings[0].xGoogleMinBitrate = Math.floor(maxBitrate * 0.5); if (params.degradationPreference !== undefined) { params.degradationPreference = 'maintain-resolution'; } sender.setParameters(params); logger.debug(`Set video encoding: maxBitrate=${maxBitrate / 1000000}Mbps, scaleResolutionDownBy=1.0, xGoogleMinBitrate=${Math.floor(maxBitrate * 0.5)}${participantId ? ` for ${participantId}` : ''}`); } catch (error) { logger.error('Error setting video encoding parameters:', error); } } } async changeResolution(width, height) { if (!this.state.localStream) { showNotification('本地视频流不可用', 'error'); return; } const videoTracks = this.state.localStream.getVideoTracks(); if (videoTracks.length === 0) { showNotification('视频轨道不可用', 'error'); return; } const track = videoTracks[0]; const label = getResolutionLabel(height); try { await track.applyConstraints({ width: { ideal: width, max: width }, height: { ideal: height, max: height }, frameRate: { ideal: 30, max: 30 } }); logger.debug(`分辨率已切换为 ${width}x${height}`); const maxBitrate = getTargetResolutionBitrate(height); this._applyMaxBitrate(maxBitrate); const userSettings = JSON.parse(localStorage.getItem('userSettings') || '{}'); userSettings.resolution = { width, height }; localStorage.setItem('userSettings', JSON.stringify(userSettings)); this.notify({ type: 'RESOLUTION_CHANGED', resolution: { width, height, label } }); showNotification('已切换为 ' + label, 'success'); } catch (error) { logger.error('切换分辨率失败:', error); showNotification('切换分辨率失败,摄像头可能不支持该分辨率', 'error'); } } _applyMaxBitrate(maxBitrate) { if (!this.renderstreaming) return; const isHost = this.role === 'host'; const participantIds = isHost ? Object.keys(this.state.participants) : [null]; for (const pid of participantIds) { const transceivers = this.renderstreaming.getTransceivers(pid); if (!transceivers) continue; const videoTransceivers = transceivers.filter(t => t.sender && t.sender.track && t.sender.track.kind === 'video'); for (const transceiver of videoTransceivers) { try { const sender = transceiver.sender; const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) { params.encodings = [{}]; } params.encodings[0].maxBitrate = maxBitrate; sender.setParameters(params); logger.debug(`Updated maxBitrate to ${maxBitrate} for ${pid || 'self'}`); } catch (error) { logger.error('Error updating maxBitrate:', error); } } } } updateRemoteMedia(mediaState, participantId) { this._setRemoteUserMediaState(mediaState); this._notifyRemoteUserChange({ mediaState, participantId }); } updateRemoteUserStatus(status) { this._setRemoteUserState({ status }); this._notifyRemoteUserChange(); } updateRemoteUserNetworkQuality(networkQuality) { this._setRemoteUserState({ networkQuality }); this._notifyRemoteUserChange(); } _setSpeakingState(isLocal, isSpeaking) { if (isLocal) { this.state.session.localUser.mediaState.isSpeaking = isSpeaking; this._notifyLocalMediaChange('isSpeaking', isSpeaking); this.emitMediaStateChange(); return; } this.updateRemoteMedia({ isSpeaking }); } async endCall() { logger.debug(`endCall called. Role: ${this.role}`); await this.hangUp(); } async joinCall(connectionId) { this.state.session.status = 'connecting'; this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' }); showNotification('正在加入通话 (' + connectionId + ')'); await this.init(); this.connectionId = connectionId; } async createCall() { this.state.session.status = 'connecting'; this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' }); showNotification('正在创建通话...'); await this.init(); } async detectNetworkQuality() { if (!this.renderstreaming) { return; } try { const stats = await this.renderstreaming.getStats(); if (!stats) { return; } const summary = summarizeInboundStats(stats); const quality = getNetworkQualityFromSummary(summary); if (this.state.session.remoteUser.networkQuality !== quality) { this.updateRemoteUserNetworkQuality(quality); this.notify({ type: 'NETWORK_CHANGE', quality }); } } catch (error) { logger.error('Error detecting network quality:', error); } } startActivityDetection(stream, { isLocal = false } = {}) { if (!stream) { return; } const audioTracks = stream.getAudioTracks(); if (audioTracks.length === 0) { return; } try { const { threshold, debounceTime, fftSize } = VAD_CONFIG; const { analyser, dataArray } = createAudioAnalyser(stream, fftSize); let isSpeaking = false; let lastActivityTime = 0; const detectActivity = () => { if (!stream || !this.renderstreaming) { return; } const level = getAudioLevel(analyser, dataArray); const currentTime = Date.now(); if (level > threshold / 100) { lastActivityTime = currentTime; if (!isSpeaking) { isSpeaking = true; this._setSpeakingState(isLocal, true); } } else if (isSpeaking && currentTime - lastActivityTime > debounceTime) { isSpeaking = false; this._setSpeakingState(isLocal, false); } if (this.state.session.status === 'ongoing') { requestAnimationFrame(detectActivity); } }; detectActivity(); logger.debug(`${isLocal ? 'Local' : 'Remote'} activity detection started`); } catch (error) { logger.error(`Error starting ${isLocal ? 'local' : 'remote'} activity detection:`, error); } } startNetworkQualityDetection() { this.networkQualityInterval = setInterval(() => { this.detectNetworkQuality(); }, 3000); } stopNetworkQualityDetection() { if (this.networkQualityInterval) { clearInterval(this.networkQualityInterval); this.networkQualityInterval = null; } } emitMediaStateChange() { const payload = { userId: this.state.session.localUser.id, ...this.state.session.localUser.mediaState }; logger.debug('[WebSocket Emit] media-state-changed:', payload); if (this.renderstreaming) { this.renderstreaming.sendMessage({ type: 'media-state-changed', data: payload }); } } async showStatsMessage() { logger.debug('Showing stats message'); await this.detectNetworkQuality(); this.statsInterval = setInterval(async () => { if (!this.renderstreaming) { return; } try { const stats = await this.renderstreaming.getStats(); if (!stats) { return; } const statsSummary = summarizeInboundStats(stats); const statsLog = buildStatsLogPayload(this.state.session.remoteUser.networkQuality, statsSummary); logger.debug('=== WebRTC Statistics ==='); logger.debug(`Network Quality: ${statsLog.networkQuality}`); logger.debug('Video Stats:', statsLog.video); logger.debug('Audio Stats:', statsLog.audio); logger.debug('========================'); } catch (error) { logger.error('Error showing stats message:', error); } }, 5000); } clearStatsMessage() { logger.debug('Clearing stats message'); if (this.statsInterval) { clearInterval(this.statsInterval); this.statsInterval = null; } } getState() { return this.state; } getLocalUser() { return this.state.session.localUser; } getRemoteUser() { return this.state.session.remoteUser; } getConnectionId() { return this.connectionId; } getRenderStreaming() { return this.renderstreaming; } } const store = new CallStateManager(); window.addEventListener('beforeunload', async () => { if (!store.renderstreaming) return; await store.renderstreaming.stop(); }, true); export default store;