2026-03-03 17:51:30 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 状态管理
|
|
|
|
|
|
* 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia
|
|
|
|
|
|
*/
|
2026-03-12 14:41:00 +08:00
|
|
|
|
import { mockCallSession } from './models.js';
|
2026-03-04 17:55:55 +08:00
|
|
|
|
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";// 信令管理
|
|
|
|
|
|
import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连接管理
|
|
|
|
|
|
import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置
|
2026-03-12 12:10:40 +08:00
|
|
|
|
import { showNotification, generateId } from './utils.js'; // 导入通知函数
|
2026-03-12 14:41:00 +08:00
|
|
|
|
import chatMessage from './chatmessage.js';
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 默认视频流尺寸
|
|
|
|
|
|
const defaultStreamWidth = 1280;
|
|
|
|
|
|
const defaultStreamHeight = 720;
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
class CallStateManager {
|
|
|
|
|
|
constructor() {
|
2026-03-05 11:30:27 +08:00
|
|
|
|
const renderstreaming = null; // WebRTC连接管理实例
|
|
|
|
|
|
const useWebSocket = null; // 是否使用WebSocket信令
|
|
|
|
|
|
const connectionId = null; // 连接ID
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 核心状态
|
|
|
|
|
|
this.state = {
|
2026-03-12 12:10:40 +08:00
|
|
|
|
id: generateId(),
|
2026-03-04 18:40:19 +08:00
|
|
|
|
session: {
|
|
|
|
|
|
...mockCallSession,
|
|
|
|
|
|
status: 'idle' // 初始状态为空闲
|
|
|
|
|
|
},
|
2026-03-03 17:51:30 +08:00
|
|
|
|
localStream: null, // MediaStream 对象
|
|
|
|
|
|
remoteStream: null // MediaStream 对象
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 监听器数组
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化
|
2026-03-04 22:29:10 +08:00
|
|
|
|
async init() {
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 启动通话时长计时器
|
|
|
|
|
|
this.durationInterval = setInterval(() => {
|
|
|
|
|
|
this.state.session.duration++;
|
|
|
|
|
|
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
|
|
|
|
|
|
}, 1000);
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 初始化配置
|
2026-03-04 22:29:10 +08:00
|
|
|
|
await this.setupConfig();
|
2026-03-04 11:19:50 +08:00
|
|
|
|
// 获取本地摄像头视频流
|
2026-03-04 22:29:10 +08:00
|
|
|
|
await this.getLocalStream();
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
async setupConfig() {
|
|
|
|
|
|
const res = await getServerConfig();
|
|
|
|
|
|
this.useWebSocket = res.useWebSocket;
|
|
|
|
|
|
}
|
2026-03-04 11:19:50 +08:00
|
|
|
|
// 获取本地摄像头视频流
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
// 请求摄像头权限并获取媒体流,启用回声消除
|
2026-03-04 11:19:50 +08:00
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
video: true,
|
2026-03-09 20:44:37 +08:00
|
|
|
|
audio: {
|
|
|
|
|
|
echoCancellation: true,
|
|
|
|
|
|
noiseSuppression: true,
|
|
|
|
|
|
autoGainControl: true
|
|
|
|
|
|
}
|
2026-03-04 11:19:50 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-06 13:59:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 启动本地音频活动检测
|
|
|
|
|
|
this.startLocalActivityDetection();
|
2026-03-04 11:19:50 +08:00
|
|
|
|
} 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 更新本地媒体状态
|
2026-03-04 11:19:50 +08:00
|
|
|
|
async updateLocalMedia(mediaType, value) {
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果是开启视频,重新获取摄像头资源
|
|
|
|
|
|
if (mediaType === 'video' && value) {
|
|
|
|
|
|
if (this.state.localStream) {
|
2026-03-06 15:15:01 +08:00
|
|
|
|
// 停止当前的媒体流
|
|
|
|
|
|
if (this.state.localStream) {
|
|
|
|
|
|
this.state.localStream.getTracks().forEach(track => track.stop());
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
this.state.localStream = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 请求摄像头权限并获取媒体流
|
|
|
|
|
|
this.state.localStream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
|
video: true,
|
|
|
|
|
|
audio: true
|
|
|
|
|
|
});
|
|
|
|
|
|
await this.getLocalStream();
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
// 更新WebRTC连接中的媒体轨道
|
2026-03-06 15:15:01 +08:00
|
|
|
|
if (this.renderstreaming) {
|
2026-03-09 20:44:37 +08:00
|
|
|
|
console.log('Updating media tracks in WebRTC connection');
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取所有收发器
|
|
|
|
|
|
const transceivers = this.renderstreaming.getTransceivers();
|
|
|
|
|
|
console.log('All transceivers:', transceivers);
|
|
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
// 查找现有的视频和音频收发器
|
2026-03-06 15:15:01 +08:00
|
|
|
|
const videoTransceivers = transceivers.filter(t => {
|
|
|
|
|
|
return t.sender && t.sender.track && t.sender.track.kind === 'video';
|
|
|
|
|
|
});
|
|
|
|
|
|
console.log('Found video transceivers:', videoTransceivers);
|
|
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
const audioTransceivers = transceivers.filter(t => {
|
|
|
|
|
|
return t.sender && t.sender.track && t.sender.track.kind === 'audio';
|
|
|
|
|
|
});
|
|
|
|
|
|
console.log('Found audio transceivers:', audioTransceivers);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取新的视频和音频轨道
|
2026-03-06 15:15:01 +08:00
|
|
|
|
const videoTracks = this.state.localStream.getVideoTracks();
|
|
|
|
|
|
console.log('New video tracks:', videoTracks);
|
|
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
const audioTracks = this.state.localStream.getAudioTracks();
|
|
|
|
|
|
console.log('New audio tracks:', audioTracks);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新视频轨道
|
2026-03-06 15:15:01 +08:00
|
|
|
|
if (videoTracks.length > 0) {
|
|
|
|
|
|
const newVideoTrack = videoTracks[0];
|
|
|
|
|
|
console.log('Using new video track:', newVideoTrack);
|
|
|
|
|
|
|
|
|
|
|
|
if (videoTransceivers.length > 0) {
|
|
|
|
|
|
// 替换现有的视频轨道
|
|
|
|
|
|
for (const transceiver of videoTransceivers) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('Replacing video track in transceiver:', transceiver);
|
|
|
|
|
|
await transceiver.sender.replaceTrack(newVideoTrack);
|
|
|
|
|
|
console.log('Successfully replaced video track');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error replacing video track:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 添加新的视频收发器
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('Adding new video transceiver');
|
|
|
|
|
|
const transceiver = this.renderstreaming.addTransceiver(newVideoTrack, { direction: 'sendonly' });
|
|
|
|
|
|
console.log('Added new video transceiver:', transceiver);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding new video transceiver:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-09 20:44:37 +08:00
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
2026-03-09 20:44:37 +08:00
|
|
|
|
// 更新音频轨道
|
|
|
|
|
|
if (audioTracks.length > 0) {
|
|
|
|
|
|
const newAudioTrack = audioTracks[0];
|
|
|
|
|
|
console.log('Using new audio track:', newAudioTrack);
|
|
|
|
|
|
|
|
|
|
|
|
if (audioTransceivers.length > 0) {
|
|
|
|
|
|
// 替换现有的音频轨道
|
|
|
|
|
|
for (const transceiver of audioTransceivers) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('Replacing audio track in transceiver:', transceiver);
|
|
|
|
|
|
await transceiver.sender.replaceTrack(newAudioTrack);
|
|
|
|
|
|
console.log('Successfully replaced audio track');
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error replacing audio track:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 添加新的音频收发器
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('Adding new audio transceiver');
|
|
|
|
|
|
const transceiver = this.renderstreaming.addTransceiver(newAudioTrack, { direction: 'sendonly' });
|
|
|
|
|
|
console.log('Added new audio transceiver:', transceiver);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error adding new audio transceiver:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
}
|
2026-03-09 20:44:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 延迟设置编解码器偏好,确保收发器已完全创建
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.setCodecPreferences();
|
|
|
|
|
|
}, 100);
|
2026-03-06 15:15:01 +08:00
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 直接更新媒体状态
|
|
|
|
|
|
this.state.session.localUser.mediaState[mediaType] = value;
|
|
|
|
|
|
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value });
|
|
|
|
|
|
|
|
|
|
|
|
// 发送媒体状态到服务器
|
|
|
|
|
|
this.emitMediaStateChange();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-04 11:19:50 +08:00
|
|
|
|
// 如果是关闭视频,释放摄像头资源
|
|
|
|
|
|
if (mediaType === 'video' && !value && this.state.localStream) {
|
|
|
|
|
|
this.state.localStream.getTracks().forEach(track => {
|
|
|
|
|
|
if (track.kind === 'video') {
|
|
|
|
|
|
track.stop();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
2026-03-04 11:19:50 +08:00
|
|
|
|
// 如果是音频状态变化,控制本地音频轨道
|
|
|
|
|
|
if (mediaType === 'audio' && this.state.localStream) {
|
|
|
|
|
|
this.state.localStream.getTracks().forEach(track => {
|
|
|
|
|
|
if (track.kind === 'audio') {
|
|
|
|
|
|
track.enabled = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
|
|
|
|
|
}
|
2026-03-12 12:10:40 +08:00
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置WebRTC连接
|
|
|
|
|
|
* @async
|
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async setUp(connectionId) {
|
|
|
|
|
|
//TODO
|
|
|
|
|
|
this.connectionId = connectionId; // 获取连接ID
|
2026-03-05 11:06:08 +08:00
|
|
|
|
// 设置状态为连接中
|
|
|
|
|
|
this.state.session.status = 'connecting';
|
|
|
|
|
|
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
|
2026-03-04 22:29:10 +08:00
|
|
|
|
// 确保本地流已经初始化
|
|
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 创建信令实例
|
2026-03-04 22:29:10 +08:00
|
|
|
|
const signaling = this.useWebSocket ? new WebSocketSignaling() : new Signaling();
|
2026-03-04 17:55:55 +08:00
|
|
|
|
const config = getRTCConfiguration(); // 获取RTC配置
|
2026-03-09 20:44:37 +08:00
|
|
|
|
// 优化RTC配置,确保支持高分辨率和良好的音频处理
|
2026-03-05 11:30:27 +08:00
|
|
|
|
config.peerConnectionOptions = {
|
|
|
|
|
|
optional: [
|
|
|
|
|
|
{ googCpuOveruseDetection: false }, // 禁用CPU过度使用检测
|
2026-03-09 20:44:37 +08:00
|
|
|
|
{ googScreencastMinBitrate: 3000 }, // 设置最小比特率
|
|
|
|
|
|
{ googEchoCancellation: true }, // 启用回声消除
|
|
|
|
|
|
{ googEchoCancellation2: true }, // 启用高级回声消除
|
|
|
|
|
|
{ googNoiseSuppression: true }, // 启用噪声抑制
|
|
|
|
|
|
{ googNoiseSuppression2: true }, // 启用高级噪声抑制
|
|
|
|
|
|
{ googAutoGainControl: true }, // 启用自动增益控制
|
|
|
|
|
|
{ googAutoGainControl2: true }, // 启用高级自动增益控制
|
|
|
|
|
|
{ googHighpassFilter: true }, // 启用高通滤波器
|
|
|
|
|
|
{ googTypingNoiseDetection: true } // 启用打字噪声检测
|
2026-03-05 11:30:27 +08:00
|
|
|
|
]
|
|
|
|
|
|
};
|
2026-03-04 17:55:55 +08:00
|
|
|
|
this.renderstreaming = new RenderStreaming(signaling, config); // 创建WebRTC连接管理实例
|
|
|
|
|
|
|
|
|
|
|
|
// 连接建立回调
|
|
|
|
|
|
this.renderstreaming.onConnect = () => {
|
2026-03-05 11:06:08 +08:00
|
|
|
|
// 连接建立后,更新状态为ongoing
|
2026-03-05 11:30:27 +08:00
|
|
|
|
this.state.session.status = 'ongoing';
|
|
|
|
|
|
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'ongoing' });
|
|
|
|
|
|
|
2026-03-04 22:29:10 +08:00
|
|
|
|
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');
|
2026-03-04 11:19:50 +08:00
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
};
|
2026-03-04 11:19:50 +08:00
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 连接断开回调
|
|
|
|
|
|
this.renderstreaming.onDisconnect = () => {
|
2026-03-04 22:29:10 +08:00
|
|
|
|
this.hangUp(); // 挂断连接
|
2026-03-04 17:55:55 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 轨道事件回调
|
|
|
|
|
|
this.renderstreaming.onTrackEvent = (data) => {
|
|
|
|
|
|
const direction = data.transceiver.direction;
|
|
|
|
|
|
if (direction == "sendrecv" || direction == "recvonly") {
|
|
|
|
|
|
if (this.state.remoteStream == null) {
|
|
|
|
|
|
this.state.remoteStream = new MediaStream();
|
|
|
|
|
|
}
|
2026-03-06 15:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 检查是否已经有相同类型的轨道
|
|
|
|
|
|
const existingTracks = this.state.remoteStream.getTracks().filter(track => track.kind === data.track.kind);
|
|
|
|
|
|
|
|
|
|
|
|
// 移除旧的轨道
|
|
|
|
|
|
existingTracks.forEach(track => {
|
|
|
|
|
|
this.state.remoteStream.removeTrack(track);
|
|
|
|
|
|
console.log('Removed old track:', track.kind);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 添加新的轨道
|
2026-03-04 17:55:55 +08:00
|
|
|
|
this.state.remoteStream.addTrack(data.track);
|
2026-03-06 15:15:01 +08:00
|
|
|
|
console.log('Added new track:', data.track.kind);
|
|
|
|
|
|
|
2026-03-04 22:29:10 +08:00
|
|
|
|
// 通知UI远程流已更新
|
|
|
|
|
|
this.notify({ type: 'REMOTE_STREAM_OBTAINED', stream: this.state.remoteStream });
|
2026-03-06 15:15:01 +08:00
|
|
|
|
console.log('Notified UI about remote stream update');
|
2026-03-05 16:35:32 +08:00
|
|
|
|
|
2026-03-06 13:59:38 +08:00
|
|
|
|
// 如果是音频轨道,启动远程音频活动检测
|
2026-03-05 16:35:32 +08:00
|
|
|
|
if (data.track.kind === 'audio') {
|
|
|
|
|
|
this.startRemoteActivityDetection();
|
|
|
|
|
|
}
|
2026-03-06 13:59:38 +08:00
|
|
|
|
} else if (direction == "sendonly") {
|
|
|
|
|
|
// 本地发送轨道,启动本地音频活动检测
|
|
|
|
|
|
if (data.track.kind === 'audio') {
|
|
|
|
|
|
this.startLocalActivityDetection();
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-03-12 12:10:40 +08:00
|
|
|
|
// 初始化 RenderStreaming 实例后
|
|
|
|
|
|
this.renderstreaming.onMessage = (data) => {
|
2026-03-12 12:46:22 +08:00
|
|
|
|
console.log('收到聊天:', data);
|
2026-03-12 12:10:40 +08:00
|
|
|
|
if (data.type === 'chat-message') {
|
2026-03-12 12:46:22 +08:00
|
|
|
|
// 处理聊天
|
|
|
|
|
|
// 添加到列表并更新UI
|
|
|
|
|
|
|
2026-03-12 14:41:00 +08:00
|
|
|
|
chatMessage.handleChatMessage(data.message);
|
2026-03-12 12:10:40 +08:00
|
|
|
|
} else if (data.type === 'on-message') {
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
2026-03-12 12:10:40 +08:00
|
|
|
|
};
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 启动WebRTC连接
|
|
|
|
|
|
await this.renderstreaming.start();
|
|
|
|
|
|
await this.renderstreaming.createConnection(connectionId);
|
|
|
|
|
|
|
2026-03-05 16:35:32 +08:00
|
|
|
|
// 启动网络质量检测
|
|
|
|
|
|
this.startNetworkQualityDetection();
|
2026-03-06 13:59:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 启动本地音频活动检测
|
|
|
|
|
|
this.startLocalActivityDetection();
|
2026-03-05 16:35:32 +08:00
|
|
|
|
// 启动远端音频活动检测
|
|
|
|
|
|
this.startRemoteActivityDetection();
|
|
|
|
|
|
//模拟远端活动 (开发测试用)
|
|
|
|
|
|
//this.simulateRemoteActivity();
|
2026-03-04 17:55:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 挂断WebRTC连接
|
|
|
|
|
|
* @async
|
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async hangUp() {
|
2026-03-04 22:29:10 +08:00
|
|
|
|
this.clearStatsMessage(); // 清除统计信息
|
2026-03-05 16:35:32 +08:00
|
|
|
|
this.stopNetworkQualityDetection(); // 停止网络质量检测
|
2026-03-05 11:32:51 +08:00
|
|
|
|
console.log(`Disconnect peer on ${this.connectionId}.`);
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
|
|
|
|
|
// 删除连接并停止WebRTC
|
2026-03-05 11:32:51 +08:00
|
|
|
|
if (this.renderstreaming) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.renderstreaming.deleteConnection();
|
|
|
|
|
|
await this.renderstreaming.stop();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error during hangUp:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.renderstreaming = null;
|
|
|
|
|
|
}
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
2026-03-04 22:29:10 +08:00
|
|
|
|
this.connectionId = null;
|
2026-03-05 11:32:51 +08:00
|
|
|
|
this.state.session.status = 'ended';
|
|
|
|
|
|
this.notify({ type: 'CALL_ENDED' });
|
2026-03-04 17:55:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 设置编解码器偏好
|
|
|
|
|
|
*/
|
|
|
|
|
|
setCodecPreferences() {
|
|
|
|
|
|
/** @type {RTCRtpCodecCapability[] | null} */
|
|
|
|
|
|
let selectedCodecs = null;
|
|
|
|
|
|
|
2026-03-05 11:32:51 +08:00
|
|
|
|
// 获取视频编解码器能力
|
|
|
|
|
|
const { codecs } = RTCRtpSender.getCapabilities('video');
|
|
|
|
|
|
if (codecs && codecs.length > 0) {
|
|
|
|
|
|
// 优先选择H.264编解码器
|
|
|
|
|
|
const h264Codec = codecs.find(c => c.mimeType === 'video/H264');
|
|
|
|
|
|
if (h264Codec) {
|
|
|
|
|
|
selectedCodecs = [h264Codec];
|
2026-03-04 17:55:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedCodecs == null) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取视频收发器并设置编解码器偏好
|
2026-03-05 11:32:51 +08:00
|
|
|
|
if (this.renderstreaming) {
|
|
|
|
|
|
const transceivers = this.renderstreaming.getTransceivers().filter(t => t.receiver.track.kind == "video");
|
|
|
|
|
|
if (transceivers && transceivers.length > 0) {
|
|
|
|
|
|
transceivers.forEach(t => t.setCodecPreferences(selectedCodecs));
|
|
|
|
|
|
}
|
2026-03-04 11:19:50 +08:00
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
|
2026-03-12 12:46:22 +08:00
|
|
|
|
// 更新远端媒体状态 (由 WebSocket 触发)
|
2026-03-03 17:51:30 +08:00
|
|
|
|
updateRemoteMedia(mediaState) {
|
|
|
|
|
|
this.state.session.remoteUser.mediaState = {
|
|
|
|
|
|
...this.state.session.remoteUser.mediaState,
|
|
|
|
|
|
...mediaState
|
|
|
|
|
|
};
|
|
|
|
|
|
this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 14:41:00 +08:00
|
|
|
|
// 添加消息
|
2026-03-03 17:51:30 +08:00
|
|
|
|
addMessage(message) {
|
2026-03-12 14:41:00 +08:00
|
|
|
|
chatMessage.addMessage(message);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 14:41:00 +08:00
|
|
|
|
// 发送聊天消息
|
|
|
|
|
|
sendChatMessage(message) {
|
|
|
|
|
|
chatMessage.sendChatMessage(message, this.renderstreaming);
|
2026-03-12 12:10:40 +08:00
|
|
|
|
}
|
2026-03-12 14:41:00 +08:00
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 切换侧边栏
|
|
|
|
|
|
toggleSidebar() {
|
2026-03-12 14:41:00 +08:00
|
|
|
|
chatMessage.toggleSidebar();
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 结束通话
|
|
|
|
|
|
endCall() {
|
|
|
|
|
|
clearInterval(this.durationInterval);
|
|
|
|
|
|
this.state.session.status = 'ended';
|
|
|
|
|
|
this.notify({ type: 'CALL_ENDED' });
|
|
|
|
|
|
|
|
|
|
|
|
// 发送结束通话请求到服务器
|
|
|
|
|
|
// [API_CALL: POST /api/call/:callId/leave]
|
|
|
|
|
|
// [WEBSOCKET_EMIT: leave-call]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 18:40:19 +08:00
|
|
|
|
// 加入通话
|
2026-03-04 22:29:10 +08:00
|
|
|
|
async joinCall(connectionId) {
|
2026-03-04 18:40:19 +08:00
|
|
|
|
this.state.session.status = 'connecting';
|
|
|
|
|
|
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
|
|
|
|
|
|
showNotification(`正在加入通话 (${connectionId})`);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化
|
2026-03-04 22:29:10 +08:00
|
|
|
|
await this.init();
|
2026-03-04 18:40:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 保存连接ID
|
|
|
|
|
|
this.connectionId = connectionId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建通话
|
2026-03-04 22:29:10 +08:00
|
|
|
|
async createCall() {
|
2026-03-04 18:40:19 +08:00
|
|
|
|
this.state.session.status = 'connecting';
|
|
|
|
|
|
this.notify({ type: 'CALL_STATUS_CHANGE', status: 'connecting' });
|
|
|
|
|
|
showNotification('正在创建通话...');
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化
|
2026-03-04 22:29:10 +08:00
|
|
|
|
await this.init();
|
2026-03-04 18:40:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 模拟远端活动 (开发测试用)
|
|
|
|
|
|
simulateRemoteActivity() {
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
if (Math.random() > 0.7) {
|
|
|
|
|
|
const isSpeaking = Math.random() > 0.5;
|
|
|
|
|
|
this.updateRemoteMedia({ isSpeaking });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 800);
|
|
|
|
|
|
}
|
|
|
|
|
|
simulateNetworkChange() {
|
2026-03-05 16:35:32 +08:00
|
|
|
|
// 模拟网络质量变化
|
|
|
|
|
|
|
|
|
|
|
|
const qualities = ['good', 'fair', 'excellent', 'poor'];
|
2026-03-03 17:51:30 +08:00
|
|
|
|
setInterval(() => {
|
|
|
|
|
|
if (Math.random() > 0.8) {
|
2026-03-05 16:39:19 +08:00
|
|
|
|
const quality = qualities[Math.floor(Math.random() * qualities.length)];
|
|
|
|
|
|
this.state.session.remoteUser.networkQuality = quality;
|
2026-03-03 17:51:30 +08:00
|
|
|
|
this.notify({ type: 'NETWORK_CHANGE', quality });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
2026-03-05 16:35:32 +08:00
|
|
|
|
// 真实网络质量检测
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新网络质量状态
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-12 12:10:40 +08:00
|
|
|
|
// 真实音频活动检测 - 远端
|
2026-03-05 16:35:32 +08:00
|
|
|
|
startRemoteActivityDetection() {
|
|
|
|
|
|
// 检查是否有远端音频流
|
|
|
|
|
|
if (!this.state.remoteStream) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取音频轨道
|
|
|
|
|
|
const audioTracks = this.state.remoteStream.getAudioTracks();
|
|
|
|
|
|
if (audioTracks.length === 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 创建音频上下文
|
|
|
|
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
|
|
|
|
|
|
|
// 创建媒体流源
|
|
|
|
|
|
const source = audioContext.createMediaStreamSource(this.state.remoteStream);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建音频分析器
|
|
|
|
|
|
const analyser = audioContext.createAnalyser();
|
|
|
|
|
|
analyser.fftSize = 256;
|
|
|
|
|
|
|
|
|
|
|
|
// 连接音频节点
|
|
|
|
|
|
source.connect(analyser);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建数据缓冲区
|
|
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
|
|
|
|
|
|
|
|
// 检测参数
|
|
|
|
|
|
const threshold = 15; // 音频电平阈值
|
|
|
|
|
|
const debounceTime = 500; // 防抖时间
|
|
|
|
|
|
let isSpeaking = false;
|
|
|
|
|
|
let lastActivityTime = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 音频活动检测循环
|
|
|
|
|
|
const detectActivity = () => {
|
|
|
|
|
|
if (!this.state.remoteStream || !this.renderstreaming) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取时域数据
|
|
|
|
|
|
analyser.getByteTimeDomainData(dataArray);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算音频电平
|
|
|
|
|
|
let sum = 0;
|
|
|
|
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
|
|
|
|
// 转换为振幅 (0-255 → -128-127)
|
|
|
|
|
|
const amplitude = dataArray[i] - 128;
|
|
|
|
|
|
sum += amplitude * amplitude;
|
|
|
|
|
|
}
|
|
|
|
|
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
|
|
|
|
const level = rms / 128; // 归一化到 0-1
|
|
|
|
|
|
|
|
|
|
|
|
// 检测说话状态
|
|
|
|
|
|
const currentTime = Date.now();
|
|
|
|
|
|
if (level > threshold / 100) {
|
|
|
|
|
|
// 检测到说话
|
|
|
|
|
|
lastActivityTime = currentTime;
|
|
|
|
|
|
if (!isSpeaking) {
|
|
|
|
|
|
isSpeaking = true;
|
|
|
|
|
|
this.updateRemoteMedia({ isSpeaking: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isSpeaking && currentTime - lastActivityTime > debounceTime) {
|
|
|
|
|
|
// 停止说话
|
|
|
|
|
|
isSpeaking = false;
|
|
|
|
|
|
this.updateRemoteMedia({ isSpeaking: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 继续检测
|
|
|
|
|
|
if (this.state.session.status === 'ongoing') {
|
|
|
|
|
|
requestAnimationFrame(detectActivity);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 开始检测
|
|
|
|
|
|
detectActivity();
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Remote activity detection started');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error starting remote activity detection:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-06 13:59:38 +08:00
|
|
|
|
|
|
|
|
|
|
// 真实音频活动检测 - 本地
|
|
|
|
|
|
startLocalActivityDetection() {
|
|
|
|
|
|
// 检查是否有本地音频流
|
|
|
|
|
|
if (!this.state.localStream) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取音频轨道
|
|
|
|
|
|
const audioTracks = this.state.localStream.getAudioTracks();
|
|
|
|
|
|
if (audioTracks.length === 0) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 创建音频上下文
|
|
|
|
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
|
|
|
|
|
|
|
// 创建媒体流源
|
|
|
|
|
|
const source = audioContext.createMediaStreamSource(this.state.localStream);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建音频分析器
|
|
|
|
|
|
const analyser = audioContext.createAnalyser();
|
|
|
|
|
|
analyser.fftSize = 256;
|
|
|
|
|
|
|
|
|
|
|
|
// 连接音频节点
|
|
|
|
|
|
source.connect(analyser);
|
|
|
|
|
|
|
|
|
|
|
|
// 创建数据缓冲区
|
|
|
|
|
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
|
|
|
|
|
|
|
|
|
|
|
// 检测参数
|
|
|
|
|
|
const threshold = 15; // 音频电平阈值
|
|
|
|
|
|
const debounceTime = 500; // 防抖时间
|
|
|
|
|
|
let isSpeaking = false;
|
|
|
|
|
|
let lastActivityTime = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 音频活动检测循环
|
|
|
|
|
|
const detectActivity = () => {
|
|
|
|
|
|
if (!this.state.localStream || !this.renderstreaming) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取时域数据
|
|
|
|
|
|
analyser.getByteTimeDomainData(dataArray);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算音频电平
|
|
|
|
|
|
let sum = 0;
|
|
|
|
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
|
|
|
|
// 转换为振幅 (0-255 → -128-127)
|
|
|
|
|
|
const amplitude = dataArray[i] - 128;
|
|
|
|
|
|
sum += amplitude * amplitude;
|
|
|
|
|
|
}
|
|
|
|
|
|
const rms = Math.sqrt(sum / dataArray.length);
|
|
|
|
|
|
const level = rms / 128; // 归一化到 0-1
|
|
|
|
|
|
|
|
|
|
|
|
// 检测说话状态
|
|
|
|
|
|
const currentTime = Date.now();
|
|
|
|
|
|
if (level > threshold / 100) {
|
|
|
|
|
|
// 检测到说话
|
|
|
|
|
|
lastActivityTime = currentTime;
|
|
|
|
|
|
if (!isSpeaking) {
|
|
|
|
|
|
isSpeaking = true;
|
|
|
|
|
|
this.state.session.localUser.mediaState.isSpeaking = true;
|
|
|
|
|
|
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: true });
|
|
|
|
|
|
// 发送媒体状态到服务器
|
|
|
|
|
|
this.emitMediaStateChange();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (isSpeaking && currentTime - lastActivityTime > debounceTime) {
|
|
|
|
|
|
// 停止说话
|
|
|
|
|
|
isSpeaking = false;
|
|
|
|
|
|
this.state.session.localUser.mediaState.isSpeaking = false;
|
|
|
|
|
|
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: false });
|
|
|
|
|
|
// 发送媒体状态到服务器
|
|
|
|
|
|
this.emitMediaStateChange();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 继续检测
|
|
|
|
|
|
if (this.state.session.status === 'ongoing') {
|
|
|
|
|
|
requestAnimationFrame(detectActivity);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 开始检测
|
|
|
|
|
|
detectActivity();
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Local activity detection started');
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Error starting local activity detection:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-05 16:35:32 +08:00
|
|
|
|
// 启动网络质量检测
|
|
|
|
|
|
startNetworkQualityDetection() {
|
|
|
|
|
|
// 每3秒检测一次网络质量
|
|
|
|
|
|
this.networkQualityInterval = setInterval(() => {
|
|
|
|
|
|
this.detectNetworkQuality();
|
|
|
|
|
|
//this.simulateNetworkChange();
|
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 停止网络质量检测
|
|
|
|
|
|
stopNetworkQualityDetection() {
|
|
|
|
|
|
if (this.networkQualityInterval) {
|
|
|
|
|
|
clearInterval(this.networkQualityInterval);
|
|
|
|
|
|
this.networkQualityInterval = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-03 17:51:30 +08:00
|
|
|
|
|
2026-03-12 12:46:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// 发送媒体状态到服务器
|
|
|
|
|
|
emitMediaStateChange() {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
userId: this.state.session.localUser.id,
|
|
|
|
|
|
...this.state.session.localUser.mediaState
|
|
|
|
|
|
};
|
|
|
|
|
|
console.log('[WebSocket Emit] media-state-changed:', payload);
|
|
|
|
|
|
// socket.emit('media-state-changed', payload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 22:29:10 +08:00
|
|
|
|
// 显示统计信息
|
2026-03-05 16:35:32 +08:00
|
|
|
|
async showStatsMessage() {
|
2026-03-04 22:29:10 +08:00
|
|
|
|
console.log('Showing stats message');
|
2026-03-05 16:35:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 立即执行一次网络质量检测
|
|
|
|
|
|
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秒更新一次统计信息
|
2026-03-04 22:29:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 清除统计信息
|
|
|
|
|
|
clearStatsMessage() {
|
|
|
|
|
|
console.log('Clearing stats message');
|
2026-03-05 16:35:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 清理统计信息定时器
|
|
|
|
|
|
if (this.statsInterval) {
|
|
|
|
|
|
clearInterval(this.statsInterval);
|
|
|
|
|
|
this.statsInterval = null;
|
|
|
|
|
|
}
|
2026-03-04 22:29:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 17:51:30 +08:00
|
|
|
|
// Getters
|
|
|
|
|
|
getState() { return this.state; }
|
|
|
|
|
|
getLocalUser() { return this.state.session.localUser; }
|
|
|
|
|
|
getRemoteUser() { return this.state.session.remoteUser; }
|
2026-03-12 14:41:00 +08:00
|
|
|
|
|
2026-03-04 18:40:19 +08:00
|
|
|
|
getConnectionId() { return this.connectionId; }
|
2026-03-12 14:41:00 +08:00
|
|
|
|
|
|
|
|
|
|
getRenderStreaming() { return this.renderstreaming; }
|
2026-03-03 17:51:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建单例实例
|
|
|
|
|
|
const store = new CallStateManager();
|
|
|
|
|
|
|
2026-03-04 17:55:55 +08:00
|
|
|
|
// 页面卸载前清理
|
|
|
|
|
|
window.addEventListener('beforeunload', async () => {
|
|
|
|
|
|
if (!store.renderstreaming)
|
|
|
|
|
|
return;
|
|
|
|
|
|
await store.renderstreaming.stop(); // 停止WebRTC连接
|
|
|
|
|
|
}, true);
|
2026-03-03 17:51:30 +08:00
|
|
|
|
export default store;
|