1432 lines
62 KiB
JavaScript
1432 lines
62 KiB
JavaScript
/**
|
||
* 状态管理
|
||
* 使用简单的 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';
|
||
|
||
const AUDIO_CONFIG = {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
autoGainControl: true
|
||
};
|
||
|
||
const VAD_CONFIG = {
|
||
threshold: 15,
|
||
debounceTime: 500,
|
||
fftSize: 256
|
||
};
|
||
|
||
const MEDIA_CONSTRAINTS = {
|
||
video: {
|
||
width: { ideal: 1920, max: 1920 },
|
||
height: { ideal: 1080, max: 1080 },
|
||
frameRate: { ideal: 30, max: 30 }
|
||
},
|
||
audio: AUDIO_CONFIG
|
||
};
|
||
|
||
const VIDEO_ONLY_CONSTRAINT = {
|
||
video: {
|
||
width: { ideal: 1920, max: 1920 },
|
||
height: { ideal: 1080, max: 1080 },
|
||
frameRate: { ideal: 30, max: 30 }
|
||
},
|
||
audio: false
|
||
};
|
||
|
||
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 = [];
|
||
}
|
||
|
||
// 订阅状态变化
|
||
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 = this._savedResolution
|
||
? {
|
||
width: { ideal: this._savedResolution.width, max: this._savedResolution.width },
|
||
height: { ideal: this._savedResolution.height, max: this._savedResolution.height },
|
||
frameRate: { ideal: 30, max: 30 }
|
||
}
|
||
: MEDIA_CONSTRAINTS.video;
|
||
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) {
|
||
|
||
// 如果是开启视频,重新获取摄像头资源
|
||
if (mediaType === 'video' && value) {
|
||
try {
|
||
// 只获取新的视频轨道,不干扰正在工作的音频
|
||
const newVideoStream = await navigator.mediaDevices.getUserMedia(VIDEO_ONLY_CONSTRAINT);
|
||
const newVideoTrack = newVideoStream.getVideoTracks()[0];
|
||
|
||
if (!newVideoTrack) {
|
||
throw new Error('Failed to get video track');
|
||
}
|
||
|
||
// 更新本地流中的视频轨道(替换旧的已停止的轨道)
|
||
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);
|
||
} else {
|
||
// 本地流不存在时(不应该发生),使用新流
|
||
this.state.localStream = newVideoStream;
|
||
}
|
||
|
||
// 更新WebRTC连接中的视频轨道
|
||
if (this.renderstreaming) {
|
||
console.log('Updating video track in WebRTC connection');
|
||
|
||
if (this.role === 'host') {
|
||
// Host端:需要遍历所有participant的peer来替换视频轨道
|
||
const participantIds = Object.keys(this.state.remoteStreams);
|
||
for (const participantId of participantIds) {
|
||
const transceivers = this.renderstreaming.getTransceivers(participantId);
|
||
if (!transceivers) continue;
|
||
|
||
const videoTransceivers = transceivers.filter(t =>
|
||
t.sender && t.sender.track && t.sender.track.kind === 'video'
|
||
);
|
||
|
||
if (videoTransceivers.length > 0) {
|
||
for (const transceiver of videoTransceivers) {
|
||
try {
|
||
await transceiver.sender.replaceTrack(newVideoTrack);
|
||
console.log(`Replaced video track for participant ${participantId}`);
|
||
} catch (error) {
|
||
console.error(`Error replacing video track for ${participantId}:`, error);
|
||
}
|
||
}
|
||
} else {
|
||
// 没有视频收发器,添加新的
|
||
try {
|
||
this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' }, participantId);
|
||
console.log(`Added new video transceiver for participant ${participantId}`);
|
||
} catch (error) {
|
||
console.error(`Error adding video transceiver for ${participantId}:`, error);
|
||
}
|
||
}
|
||
|
||
// 设置编解码器偏好
|
||
setTimeout(() => { this.setCodecPreferences(participantId); }, 100);
|
||
setTimeout(() => { this.setVideoEncodingParameters(participantId); }, 200);
|
||
}
|
||
} else {
|
||
// Participant端:使用单一peer
|
||
const transceivers = this.renderstreaming.getTransceivers();
|
||
if (transceivers) {
|
||
const videoTransceivers = transceivers.filter(t =>
|
||
t.sender && t.sender.track && t.sender.track.kind === 'video'
|
||
);
|
||
|
||
if (videoTransceivers.length > 0) {
|
||
for (const transceiver of videoTransceivers) {
|
||
try {
|
||
await transceiver.sender.replaceTrack(newVideoTrack);
|
||
console.log('Successfully replaced video track');
|
||
} catch (error) {
|
||
console.error('Error replacing video track:', error);
|
||
}
|
||
}
|
||
} else {
|
||
try {
|
||
this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' });
|
||
console.log('Added new video transceiver');
|
||
} catch (error) {
|
||
console.error('Error adding video transceiver:', error);
|
||
}
|
||
}
|
||
}
|
||
setTimeout(() => { this.setCodecPreferences(); }, 100);
|
||
setTimeout(() => { this.setVideoEncodingParameters(); }, 200);
|
||
}
|
||
}
|
||
|
||
// 更新状态和通知UI
|
||
this.state.session.localUser.mediaState.video = true;
|
||
this.notify({ type: 'LOCAL_STREAM_OBTAINED', stream: this.state.localStream });
|
||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: 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.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: false });
|
||
}
|
||
} else {
|
||
// 直接更新媒体状态
|
||
this.state.session.localUser.mediaState[mediaType] = value;
|
||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value });
|
||
|
||
// 发送媒体状态到服务器
|
||
this.emitMediaStateChange();
|
||
}
|
||
|
||
|
||
// 如果是关闭视频,释放摄像头资源
|
||
if (mediaType === 'video' && !value && this.state.localStream) {
|
||
this.state.session.localUser.mediaState.video = false;
|
||
this.state.localStream.getTracks().forEach(track => {
|
||
if (track.kind === 'video') {
|
||
track.stop();
|
||
}
|
||
});
|
||
// 发送媒体状态到服务器
|
||
this.emitMediaStateChange();
|
||
|
||
}
|
||
|
||
// 如果是音频状态变化,控制本地音频轨道
|
||
if (mediaType === 'audio' && this.state.localStream) {
|
||
this.state.session.localUser.mediaState.audio = value;
|
||
this.state.localStream.getTracks().forEach(track => {
|
||
if (track.kind === 'audio') {
|
||
track.enabled = value;
|
||
}
|
||
});
|
||
// 发送媒体状态到服务器
|
||
this.emitMediaStateChange();
|
||
}
|
||
|
||
// 通知UI更新用户列表
|
||
this.notify({ type: 'USER_LIST_UPDATE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
||
|
||
}
|
||
|
||
/**
|
||
* 创建信令和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();
|
||
});
|
||
}
|
||
|
||
// 创建信令实例
|
||
const signaling = this.useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||
const config = getRTCConfiguration(); // 获取RTC配置
|
||
this.renderstreaming = new RenderStreaming(signaling, config);
|
||
}
|
||
|
||
/**
|
||
* 设置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}`);
|
||
if (!this.state.participants[participantId]) {
|
||
this.state.participants[participantId] = {
|
||
id: '',
|
||
name: '参与者',
|
||
avatar: '/images/p2.png',
|
||
mediaState: { audio: false, video: true, isSpeaking: false },
|
||
status: 'online'
|
||
};
|
||
}
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
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 的用户信息
|
||
delete this.state.participants[participantId];
|
||
// 通知UI更新,用participantId作为connectionId传给renderer
|
||
this.notify({ type: 'PARTICIPANT_LEFT', connectionId: participantId });
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
this.broadcastParticipantsList();
|
||
};
|
||
|
||
// 轨道事件回调
|
||
this.renderstreaming.onTrackEvent = (data) => {
|
||
const direction = data.transceiver.direction;
|
||
if (direction == "sendrecv" || direction == "recvonly") {
|
||
// 使用participantId区分不同participant的流
|
||
const trackParticipantId = data.participantId || this.connectionId;
|
||
const isHost = this.role === 'host';
|
||
|
||
let targetStream = null;
|
||
if (isHost) {
|
||
// Host端: 按 participantId 管理多路远端流
|
||
if (!this.state.remoteStreams[trackParticipantId]) {
|
||
this.state.remoteStreams[trackParticipantId] = new MediaStream();
|
||
}
|
||
targetStream = this.state.remoteStreams[trackParticipantId];
|
||
} else {
|
||
// Participant端: 使用单一远端流
|
||
if (this.state.remoteStream == null) {
|
||
this.state.remoteStream = new MediaStream();
|
||
}
|
||
targetStream = this.state.remoteStream;
|
||
}
|
||
|
||
// 检查是否已经有相同类型的轨道
|
||
const existingTracks = targetStream.getTracks().filter(track => track.kind === data.track.kind);
|
||
existingTracks.forEach(track => {
|
||
targetStream.removeTrack(track);
|
||
console.log('Removed old track:', track.kind);
|
||
});
|
||
|
||
targetStream.addTrack(data.track);
|
||
console.log('Added new track:', data.track.kind, 'for participant:', trackParticipantId);
|
||
|
||
// Host端兜底:确保participants中有该participant条目
|
||
if (isHost && !this.state.participants[trackParticipantId]) {
|
||
this.state.participants[trackParticipantId] = {
|
||
id: '',
|
||
name: '参与者',
|
||
avatar: '/images/p2.png',
|
||
mediaState: { audio: false, video: true, isSpeaking: false },
|
||
status: 'online'
|
||
};
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
this.broadcastParticipantsList();
|
||
}
|
||
|
||
// 通知UI远程流已更新
|
||
// 关键优化:如果是音频轨道先到达且流中尚无视频轨道,
|
||
// 延迟通知UI等待视频轨道到达,避免音频先触发的UI更新导致黑屏
|
||
const notifyStreamUpdate = () => {
|
||
this.notify({
|
||
type: 'REMOTE_STREAM_OBTAINED',
|
||
stream: targetStream,
|
||
connectionId: trackParticipantId,
|
||
isHost: isHost
|
||
});
|
||
console.log('Notified UI about remote stream update');
|
||
};
|
||
|
||
if (data.track.kind === 'audio' && targetStream.getVideoTracks().length === 0) {
|
||
// 音频先到,视频尚未到达:延迟200ms通知,给视频轨道到达的机会
|
||
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);
|
||
} else {
|
||
// 视频轨道到达,或音频视频同时存在:立即通知
|
||
notifyStreamUpdate();
|
||
}
|
||
// 只有当收到远程流时才更新远程用户状态为在线
|
||
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
|
||
});
|
||
// 启动通话时长计时器(避免重复启动)
|
||
if (!this.durationInterval) {
|
||
this.durationInterval = setInterval(() => {
|
||
this.state.session.duration++;
|
||
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
|
||
}, 1000);
|
||
}
|
||
}
|
||
// 如果是音频轨道,启动远程音频活动检测
|
||
if (data.track.kind === 'audio') {
|
||
this.startActivityDetection(this.state.remoteStream, { isLocal: false });
|
||
}
|
||
} else if (direction == "sendonly") {
|
||
// 本地发送轨道,启动本地音频活动检测
|
||
if (data.track.kind === 'audio') {
|
||
this.startActivityDetection(this.state.localStream, { isLocal: true });
|
||
}
|
||
}
|
||
};
|
||
|
||
this.renderstreaming.onMessage = (data) => {
|
||
console.log('收到消息:', data);
|
||
if (data.type === 'chat-message') {
|
||
// 处理聊天
|
||
// 添加到列表并更新UI
|
||
chatMessage.handleChatMessage(data.message);
|
||
// Host端:按participantId更新对应用户信息
|
||
if (data.participantId && this.role === 'host' && this.state.participants[data.participantId]) {
|
||
this.state.participants[data.participantId].id = data.message.senderId;
|
||
if (data.message.senderName) {
|
||
this.state.participants[data.participantId].name = data.message.senderName;
|
||
}
|
||
if (data.message.senderAvatar) {
|
||
this.state.participants[data.participantId].avatar = data.message.senderAvatar;
|
||
}
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
this.broadcastParticipantsList();
|
||
}
|
||
// Participant端:根据消息来源更新对应用户信息
|
||
if (!this.role || this.role !== 'host') {
|
||
if (data.participantId && this.state.participants[data.participantId]) {
|
||
// 来自其他Participant的消息:更新participants中对应条目
|
||
if (data.message.senderName) {
|
||
this.state.participants[data.participantId].name = data.message.senderName;
|
||
}
|
||
if (data.message.senderAvatar) {
|
||
this.state.participants[data.participantId].avatar = data.message.senderAvatar;
|
||
}
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
} else if (data.message && data.message.senderId !== this.state.session.localUser.id) {
|
||
// 来自Host的消息:更新remoteUser
|
||
this.state.session.remoteUser = {
|
||
...this.state.session.remoteUser,
|
||
id: data.message.senderId,
|
||
name: data.message.senderName,
|
||
avatar: data.message.senderAvatar
|
||
};
|
||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState: this.state.session.remoteUser.mediaState });
|
||
}
|
||
}
|
||
} else if (data.type === 'media-state-changed') {
|
||
// 处理媒体状态变化
|
||
console.log('收到媒体状态变化:', data.data, 'from participant:', data.participantId);
|
||
if (this.role === 'host') {
|
||
// Host端:按participantId同步更新participants中对应participant的mediaState
|
||
if (data.participantId && this.state.participants[data.participantId]) {
|
||
this.state.participants[data.participantId].mediaState = {
|
||
...this.state.participants[data.participantId].mediaState,
|
||
...data.data
|
||
};
|
||
}
|
||
// 更新远端媒体状态
|
||
this.updateRemoteMedia(data.data, data.participantId);
|
||
// 通知UI更新participants
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
// Host端广播最新成员列表(含媒体状态)给所有Participant
|
||
this.broadcastParticipantsList();
|
||
} else {
|
||
// Participant端:根据消息来源更新对应条目
|
||
// Host的participantId在participants-sync中也会同步,所以不能仅靠participants中有无该key判断
|
||
// 自身发出的消息回声(participantId === selfParticipantId)可以忽略
|
||
// 来自其他Participant:participantId存在且在participants中,且不是自身
|
||
// 来自Host:participantId存在但不是自身(Host不在selfParticipantId中)
|
||
if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) {
|
||
// 来自其他Participant的媒体状态变化:仅更新participants中对应条目
|
||
// 不调用updateRemoteMedia,因为Participant端没有其他Participant的视频流
|
||
this.state.participants[data.participantId].mediaState = {
|
||
...this.state.participants[data.participantId].mediaState,
|
||
...data.data
|
||
};
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
} else if (data.participantId === this.selfParticipantId) {
|
||
// 自身消息回声,忽略
|
||
} else {
|
||
// 来自Host的媒体状态变化(Host的participantId不匹配participants中任何条目,或无participantId):
|
||
// 更新remoteUser(Host的视频流是本端远端画面)
|
||
console.log('Received media-state-changed from Host, updating remoteUser:', data.data);
|
||
this.updateRemoteMedia(data.data, data.participantId);
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
}
|
||
}
|
||
} else if (data.type === 'user-info') {
|
||
// 处理用户信息更新
|
||
console.log('收到用户信息:', data.data, 'from participant:', data.participantId);
|
||
if (data.data) {
|
||
if (data.participantId && this.role === 'host') {
|
||
// Host端:按participantId存储到participants Map
|
||
if (!this.state.participants[data.participantId]) {
|
||
this.state.participants[data.participantId] = {
|
||
id: '',
|
||
name: '参与者',
|
||
avatar: '/images/p2.png',
|
||
mediaState: { audio: false, video: true, isSpeaking: false },
|
||
status: 'online'
|
||
};
|
||
}
|
||
this.state.participants[data.participantId].id = data.data.id || '';
|
||
this.state.participants[data.participantId].name = data.data.name || '参与者';
|
||
this.state.participants[data.participantId].avatar = data.data.avatar || '/images/p2.png';
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
this.broadcastParticipantsList();
|
||
} else {
|
||
// Participant端:更新单一remoteUser(Host的信息)
|
||
this.state.session.remoteUser = {
|
||
...this.state.session.remoteUser,
|
||
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
|
||
};
|
||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState: this.state.session.remoteUser.mediaState });
|
||
}
|
||
}
|
||
} else if (data.type === 'participants-sync') {
|
||
// Participant端:接收Host广播的完整成员列表
|
||
if (this.role !== 'host' && data.data) {
|
||
console.log('收到成员列表同步:', data.data);
|
||
// 过滤掉自身条目,避免在列表中重复显示(自身已作为localUser显示)
|
||
const filtered = {};
|
||
for (const [pid, pInfo] of Object.entries(data.data)) {
|
||
if (pid !== this.selfParticipantId) {
|
||
filtered[pid] = pInfo;
|
||
}
|
||
}
|
||
this.state.participants = filtered;
|
||
this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants });
|
||
|
||
// 同步通话时长:仅首次同步,将Host的时长作为基准
|
||
if (!this.durationSynced && typeof data.callDuration === 'number') {
|
||
this.state.session.duration = data.callDuration;
|
||
this.durationSynced = true;
|
||
// 如果计时器尚未启动(远程流还未到达),先启动计时器
|
||
if (!this.durationInterval) {
|
||
this.durationInterval = setInterval(() => {
|
||
this.state.session.duration++;
|
||
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
|
||
}, 1000);
|
||
}
|
||
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
|
||
console.log(`通话时长已同步,当前时长: ${data.callDuration}秒`);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 启动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' });
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
* @param {string} type - 消息类型
|
||
* @param {Object} data - 消息数据
|
||
*/
|
||
sendMessage(type, data) {
|
||
if (this.renderstreaming) {
|
||
this.renderstreaming.sendMessage({
|
||
type: type,
|
||
data: data
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Host端广播完整成员列表给所有Participant
|
||
* 包含Host自身信息 + 所有Participant信息
|
||
* Participant收到后可展示完整通话成员列表
|
||
*/
|
||
broadcastParticipantsList() {
|
||
if (this.role !== 'host' || !this.renderstreaming) return;
|
||
|
||
const memberList = {};
|
||
|
||
// 添加Host自身信息
|
||
memberList['host'] = {
|
||
id: this.state.session.localUser.id,
|
||
name: this.state.session.localUser.name,
|
||
avatar: this.state.session.localUser.avatar,
|
||
mediaState: { ...this.state.session.localUser.mediaState },
|
||
status: 'online',
|
||
role: 'host'
|
||
};
|
||
|
||
// 添加所有Participant信息
|
||
for (const [pid, pInfo] of Object.entries(this.state.participants)) {
|
||
memberList[pid] = {
|
||
...pInfo,
|
||
role: 'participant'
|
||
};
|
||
}
|
||
|
||
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] - 目标participant(host端使用)
|
||
*/
|
||
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 bitrateMap = {
|
||
270: 1000000,
|
||
480: 1500000,
|
||
720: 2500000,
|
||
1080: 4000000,
|
||
1440: 6000000
|
||
};
|
||
// 找到最接近的分辨率对应的比特率
|
||
let maxBitrate = 4000000;
|
||
const heights = Object.keys(bitrateMap).map(Number).sort((a, b) => a - b);
|
||
for (const h of heights) {
|
||
if (height <= h) {
|
||
maxBitrate = bitrateMap[h];
|
||
break;
|
||
}
|
||
maxBitrate = bitrateMap[h];
|
||
}
|
||
|
||
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 = height >= 1440 ? '2K 1440p' : height >= 1080 ? '1080p 超清' : height >= 720 ? '720p 高清' : '480p 流畅';
|
||
|
||
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 bitrateMap = {
|
||
270: 1000000, // 480p
|
||
720: 2500000, // 720p
|
||
1080: 4000000, // 1080p
|
||
1440: 6000000 // 2K
|
||
};
|
||
const maxBitrate = bitrateMap[height] || 2500000;
|
||
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.state.session.remoteUser.mediaState = {
|
||
...this.state.session.remoteUser.mediaState,
|
||
...mediaState
|
||
};
|
||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState, participantId });
|
||
// 通知UI更新用户列表
|
||
this.notify({ type: 'USER_LIST_UPDATE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
||
}
|
||
|
||
// 更新远端用户状态
|
||
updateRemoteUserStatus(status) {
|
||
this.state.session.remoteUser.status = status;
|
||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
||
}
|
||
updateRemoteUserNetworkQuality(networkQuality) {
|
||
this.state.session.remoteUser.networkQuality = networkQuality;
|
||
this.notify({ type: 'REMOTE_MEDIA_CHANGE', localUser: this.state.session.localUser, remoteUser: this.state.session.remoteUser });
|
||
}
|
||
// 结束通话(用户主动点击挂断按钮)
|
||
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;
|
||
}
|
||
|
||
let totalPacketsLost = 0;
|
||
let totalPacketsReceived = 0;
|
||
let inboundRTPCount = 0;
|
||
let jitter = 0;
|
||
let roundTripTime = 0;
|
||
|
||
// 分析统计信息
|
||
stats.forEach(report => {
|
||
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||
inboundRTPCount++;
|
||
|
||
// 计算丢包率
|
||
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
|
||
totalPacketsLost += report.packetsLost;
|
||
totalPacketsReceived += report.packetsReceived;
|
||
}
|
||
|
||
// 获取抖动
|
||
if (report.jitter !== undefined) {
|
||
jitter = Math.max(jitter, report.jitter);
|
||
}
|
||
|
||
// 获取往返时间
|
||
if (report.roundTripTime !== undefined) {
|
||
roundTripTime = Math.max(roundTripTime, report.roundTripTime);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 计算网络质量指标
|
||
let quality = 'excellent';
|
||
|
||
if (inboundRTPCount > 0) {
|
||
// 基于丢包率判断
|
||
const packetLossRate = totalPacketsReceived > 0 ? (totalPacketsLost / (totalPacketsLost + totalPacketsReceived)) : 0;
|
||
|
||
// 基于抖动判断
|
||
const jitterMs = jitter * 1000;
|
||
|
||
// 基于往返时间判断
|
||
const rttMs = roundTripTime * 1000;
|
||
|
||
// 综合评估网络质量
|
||
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
|
||
quality = 'poor';
|
||
} else if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
|
||
quality = 'fair';
|
||
} else if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
|
||
quality = 'good';
|
||
} else {
|
||
quality = 'excellent';
|
||
}
|
||
} else {
|
||
// 没有收到任何RTP包,设置为无信号状态
|
||
quality = 'no_signal';
|
||
}
|
||
|
||
// 更新网络质量状态
|
||
if (this.state.session.remoteUser.networkQuality !== quality) {
|
||
this.state.session.remoteUser.networkQuality = 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 audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
const source = audioContext.createMediaStreamSource(stream);
|
||
const analyser = audioContext.createAnalyser();
|
||
analyser.fftSize = fftSize;
|
||
|
||
source.connect(analyser);
|
||
|
||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||
let isSpeaking = false;
|
||
let lastActivityTime = 0;
|
||
|
||
const detectActivity = () => {
|
||
if (!stream || !this.renderstreaming) {
|
||
return;
|
||
}
|
||
|
||
analyser.getByteTimeDomainData(dataArray);
|
||
|
||
let sum = 0;
|
||
for (let i = 0; i < dataArray.length; i++) {
|
||
const amplitude = dataArray[i] - 128;
|
||
sum += amplitude * amplitude;
|
||
}
|
||
const rms = Math.sqrt(sum / dataArray.length);
|
||
const level = rms / 128;
|
||
|
||
const currentTime = Date.now();
|
||
if (level > threshold / 100) {
|
||
lastActivityTime = currentTime;
|
||
if (!isSpeaking) {
|
||
isSpeaking = true;
|
||
if (isLocal) {
|
||
this.state.session.localUser.mediaState.isSpeaking = true;
|
||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: true });
|
||
this.emitMediaStateChange();
|
||
} else {
|
||
this.updateRemoteMedia({ isSpeaking: true });
|
||
}
|
||
}
|
||
} else if (isSpeaking && currentTime - lastActivityTime > debounceTime) {
|
||
isSpeaking = false;
|
||
if (isLocal) {
|
||
this.state.session.localUser.mediaState.isSpeaking = false;
|
||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: false });
|
||
this.emitMediaStateChange();
|
||
} else {
|
||
this.updateRemoteMedia({ isSpeaking: 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;
|
||
}
|
||
|
||
let statsSummary = {
|
||
video: {
|
||
packetsLost: 0,
|
||
packetsReceived: 0,
|
||
bytesReceived: 0,
|
||
jitter: 0,
|
||
roundTripTime: 0,
|
||
fps: 0,
|
||
bitrate: 0
|
||
},
|
||
audio: {
|
||
packetsLost: 0,
|
||
packetsReceived: 0,
|
||
bytesReceived: 0,
|
||
jitter: 0
|
||
}
|
||
};
|
||
|
||
// 分析统计信息
|
||
stats.forEach(report => {
|
||
if (report.type === 'inbound-rtp') {
|
||
if (report.mediaType === 'video') {
|
||
statsSummary.video.packetsLost = report.packetsLost || 0;
|
||
statsSummary.video.packetsReceived = report.packetsReceived || 0;
|
||
statsSummary.video.bytesReceived = report.bytesReceived || 0;
|
||
statsSummary.video.jitter = report.jitter || 0;
|
||
statsSummary.video.roundTripTime = report.roundTripTime || 0;
|
||
statsSummary.video.fps = report.framesPerSecond || 0;
|
||
|
||
// 计算视频比特率 (kbps)
|
||
if (report.bytesReceived && report.timestamp) {
|
||
const duration = report.timestamp / 1000; // 转换为秒
|
||
statsSummary.video.bitrate = duration > 0 ? Math.round((report.bytesReceived * 8) / (duration * 1000)) : 0;
|
||
}
|
||
} else if (report.mediaType === 'audio') {
|
||
statsSummary.audio.packetsLost = report.packetsLost || 0;
|
||
statsSummary.audio.packetsReceived = report.packetsReceived || 0;
|
||
statsSummary.audio.bytesReceived = report.bytesReceived || 0;
|
||
statsSummary.audio.jitter = report.jitter || 0;
|
||
}
|
||
}
|
||
});
|
||
|
||
// 输出详细统计信息
|
||
console.log('=== WebRTC Statistics ===');
|
||
console.log(`Network Quality: ${this.state.session.remoteUser.networkQuality}`);
|
||
console.log('Video Stats:', {
|
||
'Packets Lost': statsSummary.video.packetsLost,
|
||
'Packets Received': statsSummary.video.packetsReceived,
|
||
'Packet Loss Rate': statsSummary.video.packetsReceived > 0 ?
|
||
`${((statsSummary.video.packetsLost / (statsSummary.video.packetsLost + statsSummary.video.packetsReceived)) * 100).toFixed(2)}%` : '0%',
|
||
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
|
||
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
|
||
'FPS': statsSummary.video.fps.toFixed(1),
|
||
'Bitrate': `${statsSummary.video.bitrate}kbps`
|
||
});
|
||
console.log('Audio Stats:', {
|
||
'Packets Lost': statsSummary.audio.packetsLost,
|
||
'Packets Received': statsSummary.audio.packetsReceived,
|
||
'Packet Loss Rate': statsSummary.audio.packetsReceived > 0 ?
|
||
`${((statsSummary.audio.packetsLost / (statsSummary.audio.packetsLost + statsSummary.audio.packetsReceived)) * 100).toFixed(2)}%` : '0%',
|
||
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
|
||
});
|
||
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;
|