Files
video_socket-server/client/public/onebyone/store.js
2026-05-16 21:26:19 +08:00

1455 lines
63 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';
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 });
}
/**
* 早期连接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();
console.log('Signaling connected (WebSocket only, no room yet)');
return this._signaling;
}
/**
* 创建信令和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}`);
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可以忽略
// 来自其他ParticipantparticipantId存在且在participants中且不是自身
// 来自HostparticipantId存在但不是自身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
// 更新remoteUserHost的视频流是本端远端画面
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端更新单一remoteUserHost的信息
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] - 目标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 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;