Files
video_socket-server/client/public/store.js
2026-05-24 01:29:34 +08:00

1447 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 状态管理
* 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia
*/
import { mockCallSession } from './models.js';
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";// 信令管理
import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连接管理
import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置
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 { getNetworkQualityFromSummary, summarizeInboundStats } from './webrtc-stats.js';
class CallStateManager {
constructor() {
// 核心状态
this.state = {
id: generateId(),
session: {
...mockCallSession,
status: 'idle' // 初始状态为空闲
},
localStream: null, // MediaStream 对象
remoteStream: null, // 单路远端流兼容旧逻辑participant端使用
remoteStreams: {}, // 多路远端流 Map: { connectionId: MediaStream }host端使用
participants: {} // 多Participant用户信息 Map: { participantId: { id, name, avatar, mediaState, status } }host端使用
};
// 监听器数组
this.listeners = [];
this.socketEventHandlers = {};
this._socketInviteBound = false;
}
// 订阅状态变化
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
};
// 通知UI更新
this.notify({ type: 'USER_SETTINGS_UPDATED', user: this.state.session.localUser });
}
// 恢复保存的分辨率设置
if (settings.resolution) {
this._savedResolution = settings.resolution;
console.log(`已恢复分辨率设置: ${settings.resolution.width}x${settings.resolution.height}`);
}
} catch (error) {
console.error('Error loading user settings:', error);
}
}
}
async setupConfig() {
const res = await getServerConfig();
this.useWebSocket = res.useWebSocket;
}
// 获取本地摄像头视频流
async getLocalStream() {
try {
console.log('Requesting camera permission...');
// 检查浏览器是否支持getUserMedia
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.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
});
console.log('Stream obtained successfully:', stream);
console.log('Video tracks:', stream.getVideoTracks());
console.log('Audio tracks:', stream.getAudioTracks());
this.state.localStream = stream;
this.state.session.localUser.mediaState.video = true;
this.state.session.localUser.mediaState.audio = true;
console.log('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) {
console.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) {
console.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;
}
console.log('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);
console.log(participantId
? `Replaced video track for participant ${participantId}`
: 'Successfully replaced video track');
} catch (error) {
console.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);
console.log(`Added new video transceiver for participant ${participantId}`);
return;
}
this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' });
console.log('Added new video transceiver');
} catch (error) {
console.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
});
}
/**
* 早期连接WebSocket信令在connect视图调用
* 仅建立WebSocket连接不创建/加入房间
* @async
* @returns {Promise<WebSocketSignaling|Signaling>} 信令实例
*/
async connectSignaling() {
// 先获取配置
await this.setupConfig();
if (this._signaling) {
console.log('Signaling already connected, reusing existing instance');
return this._signaling;
}
// 创建信令实例
this._signaling = this.useWebSocket ? new WebSocketSignaling() : new Signaling();
await this._signaling.start();
this._bindSocketInviteEvents(this._signaling);
console.log('Signaling connected (WebSocket only, no room yet)');
return this._signaling;
}
_bindSocketInviteEvents(signaling) {
if (!signaling || this._socketInviteBound || typeof signaling.addEventListener !== 'function') {
return;
}
['invite-call', 'invite-accepted', 'invite-rejected', 'invite-failed'].forEach((eventName) => {
signaling.addEventListener(eventName, (event) => {
const handler = this.socketEventHandlers[eventName];
if (typeof handler === 'function') {
handler(event.detail);
}
});
});
this._socketInviteBound = true;
}
onSocketEvent(eventName, handler) {
this.socketEventHandlers[eventName] = handler;
}
getActiveSignaling() {
if (this._signaling) {
return this._signaling;
}
if (this.renderstreaming && this.renderstreaming._signaling) {
return this.renderstreaming._signaling;
}
return null;
}
sendInviteCall(payload) {
const signaling = this.getActiveSignaling();
if (!signaling || typeof signaling.sendInviteCall !== 'function') {
throw new Error('Invite signaling is not ready');
}
signaling.sendInviteCall(payload);
}
sendInviteAccepted(payload) {
const signaling = this.getActiveSignaling();
if (!signaling || typeof signaling.sendInviteAccepted !== 'function') {
throw new Error('Invite signaling is not ready');
}
signaling.sendInviteAccepted(payload);
}
sendInviteRejected(payload) {
const signaling = this.getActiveSignaling();
if (!signaling || typeof signaling.sendInviteRejected !== 'function') {
throw new Error('Invite signaling is not ready');
}
signaling.sendInviteRejected(payload);
}
/**
* 在仅建立WebSocket连接时同步当前用户信息
* @param {{ id?: string, name?: string, avatar?: string } | null} userInfo - 用户信息
*/
syncSocketUserInfo(userInfo = null) {
const settings = userInfo || (() => {
try {
return JSON.parse(localStorage.getItem('userSettings') || '{}');
} catch (error) {
console.error('Error parsing user settings:', error);
return {};
}
})();
const payload = {
id: settings.id || settings.userId || this.state.session.localUser.id || '',
name: settings.name || this.state.session.localUser.name || '我',
avatar: settings.avatar || this.state.session.localUser.avatar || '/images/p1.png'
};
this.state.session.localUser = {
...this.state.session.localUser,
id: payload.id,
name: payload.name,
avatar: payload.avatar
};
if (this._signaling && typeof this._signaling.sendMessage === 'function') {
this._signaling.sendMessage('', {
type: 'user-info',
data: payload
});
}
}
/**
* 创建信令和RTC实例
* @async
* @param {string} connectionId - 连接ID
* @returns {Promise<void>}
*/
async _createSignalingAndRTC(connectionId) {
this.connectionId = connectionId; // 获取连接ID
// 设置状态为连接中
this.state.session.status = 'connecting';
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
// 确保本地流已经初始化
if (!this.state.localStream) {
console.log('Local stream not available, waiting for initialization...');
// 等待localStream初始化
await new Promise((resolve) => {
const checkStream = () => {
if (this.state.localStream) {
resolve();
} else {
setTimeout(checkStream, 100);
}
};
checkStream();
});
}
// 复用已有信令实例connectSignaling()已建立WebSocket或创建新实例
const signaling = this._signaling || (this.useWebSocket ? new WebSocketSignaling() : new Signaling());
const config = getRTCConfiguration(); // 获取RTC配置
this.renderstreaming = new RenderStreaming(signaling, config);
this._signaling = null; // RenderStreaming 已接管信令,清除引用
}
/**
* 设置WebRTC连接
* @async
* @returns {Promise<void>}
*/
async setUp(connectionId) {
await this._createSignalingAndRTC(connectionId);
this._registerCallbacks();
await this._startConnection(connectionId);
}
/**
* 注册所有WebRTC回调
*/
_registerCallbacks() {
this.renderstreaming.onNewPeer = (participantId) => {
console.log(`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) => {
// 保存角色信息host/participant
if (data && data.role) {
this.role = data.role;
// 更新localUser的isHost标志
this.state.session.localUser.isHost = (this.role === 'host');
// 保存自身的participantId用于从participants-sync中过滤自身
if (data.participantId) {
this.selfParticipantId = data.participantId;
}
console.log(`Connected as ${this.role}, participantId: ${this.selfParticipantId}`);
}
// 连接建立后更新状态为ongoing
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 });
console.log('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) {
// const tracks = this.state.localStream.getTracks();
// for (const track of tracks) {
// this.renderstreaming.addTransceiver(track, { direction: 'sendonly' });
// }
// this.setCodecPreferences();
this.showStatsMessage();
} else {
console.error('Local stream is not available');
showNotification('本地视频流不可用', 'error');
}
};
// 连接断开回调(收到服务器的 disconnect 消息,通常是 host 离开导致房间关闭)
this.renderstreaming.onDisconnect = () => {
console.log('Received disconnect from server, host left or room closed');
this.hangUp(); // 房间已关闭,挂断连接
};
// SDP Answer 接收回调:重新设置编码参数以保障画质
this.renderstreaming.onGotAnswer = (connectionId) => {
console.log('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);
}
};
// participant加入回调host收到新participant加入房间
this.renderstreaming.onParticipantJoined = (participantId) => {
console.log(`Participant joined: ${participantId}`);
this._upsertParticipant(participantId);
this._notifyParticipantsUpdate();
this.broadcastParticipantsList();
};
// participant离开回调host收到房间仍然存在
this.renderstreaming.onParticipantLeft = (participantId) => {
console.log(`Participant left: ${participantId}, room still active`);
this.updateRemoteUserStatus('offline');
this.updateRemoteUserNetworkQuality('no_signal');
showNotification('对方已离开通话', 'warning');
// 清理该 participant 的远端流
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;
}
// 清理该 participant 的用户信息
this._removeParticipant(participantId);
// 通知UI更新用participantId作为connectionId传给renderer
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);
};
}
/**
* 启动WebRTC连接和检测
* @async
* @param {string} connectionId - 连接ID
* @returns {Promise<void>}
*/
async _startConnection(connectionId) {
// 启动WebRTC连接
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 });
}
/**
* 挂断WebRTC连接
* Host挂断房间删除通知所有participants
* Participant挂断仅自己离开房间保留
* @async
* @returns {Promise<void>}
*/
async hangUp() {
this.clearStatsMessage(); // 清除统计信息
this.stopNetworkQualityDetection(); // 停止网络质量检测
// 停止通话时长计时器
if (this.durationInterval) {
clearInterval(this.durationInterval);
this.durationInterval = null;
}
// 重置通话时长同步标志
this.durationSynced = false;
const isHost = this.role === 'host';
console.log(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`);
// 删除连接并停止WebRTC
if (this.renderstreaming) {
try {
// 发送断开连接信令给服务器
// 服务器会根据角色决定:
// - host断开通知所有participants删除房间
// - participant断开仅通知host保留房间
await this.renderstreaming.deleteConnection();
await this.renderstreaming.stop();
} catch (error) {
console.error('Error during hangUp:', error);
}
this.renderstreaming = null;
}
// 更新远程用户状态为离线
this.updateRemoteUserStatus('offline');
this.updateRemoteUserNetworkQuality('no_signal');
// 清理participants
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);
console.log('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);
console.log('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
});
console.log('Notified UI about remote stream update');
};
if (trackKind === 'audio' && targetStream.getVideoTracks().length === 0) {
console.log('Audio track arrived first, delaying stream notification for video track...');
setTimeout(() => {
const nowHasVideo = targetStream.getVideoTracks().length > 0;
console.log(`After delay, stream has video: ${nowHasVideo}`);
notifyStreamUpdate();
}, 200);
return;
}
notifyStreamUpdate();
}
_handleRenderStreamingMessage(data) {
console.log('收到消息:', 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) {
console.log('收到媒体状态变化:', 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;
}
console.log('Received media-state-changed from Host, updating remoteUser:', data.data);
this.updateRemoteMedia(data.data, data.participantId);
this._notifyParticipantsUpdate();
}
_handleUserInfoMessage(data) {
console.log('收到用户信息:', 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;
}
console.log('收到成员列表同步:', 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 });
console.log(`通话时长已同步,当前时长: ${callDuration}`);
}
_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();
}
/**
* 发送消息
* @param {string} type - 消息类型
* @param {Object} data - 消息数据
*/
sendMessage(type, data) {
if (this.renderstreaming) {
this.renderstreaming.sendMessage({
type: type,
data: data
});
}
}
/**
* Participant state helpers
*/
_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);
}
/**
* Host端广播完整成员列表给所有Participant
* 包含Host自身信息 + 所有Participant信息
* Participant收到后可展示完整通话成员列表
*/
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
});
console.log('Broadcast participants list:', Object.keys(memberList));
}
/**
* 设置编解码器偏好
* 优先选择 VP9/AV1更高效的压缩回退到 H264 High Profile
*/
setCodecPreferences(participantId) {
const capabilities = RTCRtpSender.getCapabilities('video');
if (!capabilities || !capabilities.codecs || capabilities.codecs.length === 0) return;
const { codecs } = capabilities;
// 构建多codec优先级列表而非只选一个
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) {
console.error('Error setting codec preferences:', e);
}
});
console.log(`Codec preferences set: ${selectedCodecs.map(c => c.mimeType).join(' > ')}`);
}
}
}
}
/**
* 设置视频发送编码参数
* 提升 maxBitrate 以改善远端视频画质
* @param {string} [participantId] - 目标participanthost端使用
*/
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 = [{}];
}
// 根据实际采集分辨率动态设置maxBitrate
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);
// 优先保持分辨率,降低帧率来适应带宽
// 'maintain-resolution' 在带宽不足时保持清晰度
if (params.degradationPreference !== undefined) {
params.degradationPreference = 'maintain-resolution';
}
sender.setParameters(params);
console.log(`Set video encoding: maxBitrate=${maxBitrate / 1000000}Mbps, scaleResolutionDownBy=1.0, xGoogleMinBitrate=${Math.floor(maxBitrate * 0.5)}${participantId ? ` for ${participantId}` : ''}`);
} catch (error) {
console.error('Error setting video encoding parameters:', error);
}
}
}
/**
* 动态切换视频分辨率
* 使用 MediaStreamTrack.applyConstraints() 在通话中实时调整
* 同时根据分辨率调整编码比特率
* @param {number} width - 目标宽度
* @param {number} height - 目标高度
*/
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 {
// 使用 applyConstraints 在不重新获取流的情况下调整分辨率
await track.applyConstraints({
width: { ideal: width, max: width },
height: { ideal: height, max: height },
frameRate: { ideal: 30, max: 30 }
});
console.log(`分辨率已切换为 ${width}x${height}`);
// 根据分辨率调整编码比特率
// 480p: ~1Mbps, 720p: ~2.5Mbps, 1080p: ~4Mbps, 2K: ~6Mbps
const maxBitrate = getTargetResolutionBitrate(height);
this._applyMaxBitrate(maxBitrate);
// 保存当前分辨率设置到本地存储
const userSettings = JSON.parse(localStorage.getItem('userSettings') || '{}');
userSettings.resolution = { width, height };
localStorage.setItem('userSettings', JSON.stringify(userSettings));
// 通知 UI 更新
this.notify({ type: 'RESOLUTION_CHANGED', resolution: { width, height, label } });
showNotification(`已切换为 ${label}`, 'success');
} catch (error) {
console.error('切换分辨率失败:', error);
showNotification('切换分辨率失败,摄像头不支持该分辨率', 'error');
}
}
/**
* 对所有视频 sender 应用 maxBitrate
* @param {number} maxBitrate - 最大比特率bps
*/
_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);
console.log(`Updated maxBitrate to ${maxBitrate} for ${pid || 'self'}`);
} catch (error) {
console.error('Error updating maxBitrate:', error);
}
}
}
}
// 更新远端媒体状态 (由 WebSocket 触发)
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() {
console.log(`endCall called. Role: ${this.role}`);
// 调用 hangUp() 正确关闭 WebRTC 连接并发送断开信令
// hangUp 内部会根据角色区分:
// - host: 通知所有participants删除房间
// - participant: 仅自己离开,房间保留
await this.hangUp();
}
// 加入通话
async joinCall(connectionId) {
this.state.session.status = 'connecting';
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
showNotification(`正在加入通话 (${connectionId})`);
// 初始化
await this.init();
// 保存连接ID
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) {
console.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();
console.log(`${isLocal ? 'Local' : 'Remote'} activity detection started`);
} catch (error) {
console.error(`Error starting ${isLocal ? 'local' : 'remote'} activity detection:`, error);
}
}
// 启动网络质量检测
startNetworkQualityDetection() {
// 每3秒检测一次网络质量
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
};
console.log('[WebSocket Emit] media-state-changed:', payload);
// 使用WebRTC发送媒体状态变化
if (this.renderstreaming) {
this.renderstreaming.sendMessage({
type: 'media-state-changed',
data: payload
});
}
}
// 显示统计信息
async showStatsMessage() {
console.log('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);
// 输出详细统计信息
console.log('=== WebRTC Statistics ===');
console.log(`Network Quality: ${statsLog.networkQuality}`);
console.log('Video Stats:', statsLog.video);
console.log('Audio Stats:', statsLog.audio);
console.log('========================');
} catch (error) {
console.error('Error showing stats message:', error);
}
}, 5000); // 每5秒更新一次统计信息
}
// 清除统计信息
clearStatsMessage() {
console.log('Clearing stats message');
// 清理统计信息定时器
if (this.statsInterval) {
clearInterval(this.statsInterval);
this.statsInterval = null;
}
}
// Getters
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(); // 停止WebRTC连接
}, true);
export default store;