1099 lines
46 KiB
JavaScript
1099 lines
46 KiB
JavaScript
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';
|
|
import { MeetingRecorder } from './meeting-recorder.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;
|
|
this.meetingRecorder = new MeetingRecorder();
|
|
}
|
|
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 toggleRecording() {
|
|
const isRecording = this.state.session.localUser.mediaState.recording || false;
|
|
|
|
if (isRecording) {
|
|
return this.stopRecording();
|
|
}
|
|
|
|
return this.startRecording();
|
|
}
|
|
async startRecording() {
|
|
if (this.state.session.status !== 'ongoing') {
|
|
throw new Error('会议连接成功后才能开始录制');
|
|
}
|
|
|
|
await this.meetingRecorder.start({
|
|
localStream: this.state.localStream,
|
|
remoteStream: this.state.remoteStream,
|
|
remoteStreams: this.state.remoteStreams,
|
|
connectionId: this.connectionId
|
|
});
|
|
await this._updateLocalMediaRefactored('recording', true);
|
|
|
|
return {
|
|
recording: true,
|
|
message: '开始录制会议'
|
|
};
|
|
}
|
|
async stopRecording() {
|
|
const result = await this.meetingRecorder.stop();
|
|
await this._updateLocalMediaRefactored('recording', false);
|
|
if (!result) {
|
|
return {
|
|
recording: false,
|
|
message: '停止录制会议'
|
|
};
|
|
}
|
|
|
|
try {
|
|
const uploadResult = await this.uploadRecording(result);
|
|
return {
|
|
recording: false,
|
|
message: uploadResult?.url ? `录制已上传到服务器:${uploadResult.url}` : `录制已上传:${result.filename}`
|
|
};
|
|
}
|
|
catch (error) {
|
|
logger.error('Recording upload failed:', error);
|
|
this.meetingRecorder.download(result.blob, result.filename);
|
|
return {
|
|
recording: false,
|
|
message: `上传失败,已下载到本地:${result.filename}`
|
|
};
|
|
}
|
|
}
|
|
async uploadRecording({ blob, filename }) {
|
|
const formData = new FormData();
|
|
formData.append('meetingId', this.connectionId || this.state.session.id || 'unknown');
|
|
formData.append('userId', this.state.session.localUser.id || '');
|
|
formData.append('filename', filename);
|
|
formData.append('recording', blob, filename);
|
|
|
|
const response = await fetch('/api/recordings', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const responseBody = await response.json().catch(() => ({}));
|
|
if (!response.ok || responseBody.success === false) {
|
|
throw new Error(responseBody.message || 'Recording upload failed');
|
|
}
|
|
|
|
return responseBody;
|
|
}
|
|
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() {
|
|
if (this.meetingRecorder?.isRecording()) {
|
|
try {
|
|
await this.stopRecording();
|
|
}
|
|
catch (error) {
|
|
logger.error('Error stopping recording before hangUp:', error);
|
|
}
|
|
}
|
|
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;
|