diff --git a/client/public/participants.js b/client/public/participants.js new file mode 100644 index 0000000..328f971 --- /dev/null +++ b/client/public/participants.js @@ -0,0 +1,77 @@ +export const DEFAULT_PARTICIPANT_NAME = '参与者'; +export const DEFAULT_PARTICIPANT_AVATAR = '/images/p2.png'; + +const DEFAULT_MEDIA_STATE = Object.freeze({ + audio: false, + video: true, + isSpeaking: false +}); + +function createParticipantRecord(current = {}, patch = {}) { + return { + id: '', + name: DEFAULT_PARTICIPANT_NAME, + avatar: DEFAULT_PARTICIPANT_AVATAR, + mediaState: { ...DEFAULT_MEDIA_STATE }, + status: 'online', + ...current, + ...patch, + mediaState: { + ...DEFAULT_MEDIA_STATE, + ...(current.mediaState || {}), + ...(patch.mediaState || {}) + } + }; +} + +export function upsertParticipant(participants, participantId, patch = {}) { + if (!participantId) { + return null; + } + + participants[participantId] = createParticipantRecord(participants[participantId], patch); + return participants[participantId]; +} + +export function removeParticipant(participants, participantId) { + if (!participantId || !participants[participantId]) { + return false; + } + + delete participants[participantId]; + return true; +} + +export function omitParticipant(participants, excludedParticipantId) { + const filtered = {}; + + for (const [participantId, participant] of Object.entries(participants || {})) { + if (participantId !== excludedParticipantId) { + filtered[participantId] = participant; + } + } + + return filtered; +} + +export function buildParticipantsSyncData(localUser, participants) { + const memberList = { + host: { + id: localUser.id, + name: localUser.name, + avatar: localUser.avatar, + mediaState: { ...localUser.mediaState }, + status: 'online', + role: 'host' + } + }; + + for (const [participantId, participant] of Object.entries(participants || {})) { + memberList[participantId] = { + ...createParticipantRecord(participant), + role: 'participant' + }; + } + + return memberList; +} diff --git a/client/public/store.js b/client/public/store.js index bd0de6b..120387a 100644 --- a/client/public/store.js +++ b/client/public/store.js @@ -8,6 +8,14 @@ import { RenderStreaming } from "../../module/renderstreaming.js"; // WebRTC连 import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务器配置和RTC配置 import { showNotification, generateId } from './utils.js'; // 导入通知函数 import chatMessage from './chatmessage.js'; +import { + DEFAULT_PARTICIPANT_AVATAR, + DEFAULT_PARTICIPANT_NAME, + buildParticipantsSyncData, + omitParticipant, + removeParticipant, + upsertParticipant +} from './participants.js'; const AUDIO_CONFIG = { echoCancellation: true, @@ -574,16 +582,8 @@ class CallStateManager { // 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._upsertParticipant(participantId); + this._notifyParticipantsUpdate(); this.broadcastParticipantsList(); }; @@ -603,256 +603,20 @@ class CallStateManager { this.state.remoteStream = null; } // 清理该 participant 的用户信息 - delete this.state.participants[participantId]; + this._removeParticipant(participantId); // 通知UI更新,用participantId作为connectionId传给renderer this.notify({ type: 'PARTICIPANT_LEFT', connectionId: participantId }); - this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants }); + this._notifyParticipantsUpdate(); 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._handleTrackEvent(data); }; this.renderstreaming.onMessage = (data) => { - console.log('收到消息:', data); - if (data.type === 'chat-message') { - const chatPayload = data.data || data.message; - if (!chatPayload) { - return; - } - - chatMessage.handleChatMessage(chatPayload); - if (data.participantId && this.role === 'host' && this.state.participants[data.participantId]) { - this.state.participants[data.participantId].id = chatPayload.senderId; - if (chatPayload.senderName) { - this.state.participants[data.participantId].name = chatPayload.senderName; - } - if (chatPayload.senderAvatar) { - this.state.participants[data.participantId].avatar = chatPayload.senderAvatar; - } - this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants }); - this.broadcastParticipantsList(); - } - if (!this.role || this.role !== 'host') { - if (data.participantId && this.state.participants[data.participantId]) { - if (chatPayload.senderName) { - this.state.participants[data.participantId].name = chatPayload.senderName; - } - if (chatPayload.senderAvatar) { - this.state.participants[data.participantId].avatar = chatPayload.senderAvatar; - } - this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants }); - } else if (chatPayload.senderId !== this.state.session.localUser.id) { - this.state.session.remoteUser = { - ...this.state.session.remoteUser, - id: chatPayload.senderId, - name: chatPayload.senderName, - avatar: chatPayload.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}秒`); - } - } - } + this._handleRenderStreamingMessage(data); }; } @@ -924,6 +688,256 @@ class CallStateManager { this.notify({ type: 'CALL_ENDED', reason: isHost ? 'host_hangup' : 'participant_hangup' }); } + _handleTrackEvent(data) { + const direction = data.transceiver.direction; + + if (direction === 'sendrecv' || direction === 'recvonly') { + this._handleIncomingTrack(data); + return; + } + + if (direction === 'sendonly' && data.track.kind === 'audio') { + this.startActivityDetection(this.state.localStream, { isLocal: true }); + } + } + + _handleIncomingTrack(data) { + const trackParticipantId = data.participantId || this.connectionId; + const isHost = this.role === 'host'; + const targetStream = this._getOrCreateRemoteStream(trackParticipantId, isHost); + + this._replaceTrackOfSameKind(targetStream, data.track); + console.log('Added new track:', data.track.kind, 'for participant:', trackParticipantId); + + if (isHost && !this.state.participants[trackParticipantId]) { + this._upsertParticipant(trackParticipantId); + this._notifyParticipantsUpdate(); + this.broadcastParticipantsList(); + } + + this._notifyRemoteStreamUpdate(targetStream, trackParticipantId, isHost, data.track.kind); + + if (this.state.session.remoteUser.status !== 'online') { + this.updateRemoteUserStatus('online'); + this.updateRemoteUserNetworkQuality('good'); + this.sendMessage('user-info', { + id: this.state.session.localUser.id, + name: this.state.session.localUser.name, + avatar: this.state.session.localUser.avatar + }); + this._startDurationTimer(); + } + + if (data.track.kind === 'audio') { + this.startActivityDetection(this.state.remoteStream, { isLocal: false }); + } + } + + _getOrCreateRemoteStream(trackParticipantId, isHost) { + if (isHost) { + if (!this.state.remoteStreams[trackParticipantId]) { + this.state.remoteStreams[trackParticipantId] = new MediaStream(); + } + return this.state.remoteStreams[trackParticipantId]; + } + + if (this.state.remoteStream == null) { + this.state.remoteStream = new MediaStream(); + } + + return this.state.remoteStream; + } + + _replaceTrackOfSameKind(targetStream, track) { + const existingTracks = targetStream.getTracks().filter(existingTrack => existingTrack.kind === track.kind); + existingTracks.forEach(existingTrack => { + targetStream.removeTrack(existingTrack); + console.log('Removed old track:', existingTrack.kind); + }); + + targetStream.addTrack(track); + } + + _notifyRemoteStreamUpdate(targetStream, trackParticipantId, isHost, trackKind) { + const notifyStreamUpdate = () => { + this.notify({ + type: 'REMOTE_STREAM_OBTAINED', + stream: targetStream, + connectionId: trackParticipantId, + isHost + }); + console.log('Notified UI about remote stream update'); + }; + + if (trackKind === 'audio' && targetStream.getVideoTracks().length === 0) { + console.log('Audio track arrived first, delaying stream notification for video track...'); + setTimeout(() => { + const nowHasVideo = targetStream.getVideoTracks().length > 0; + console.log(`After delay, stream has video: ${nowHasVideo}`); + notifyStreamUpdate(); + }, 200); + return; + } + + notifyStreamUpdate(); + } + + _handleRenderStreamingMessage(data) { + console.log('收到消息:', data); + + switch (data.type) { + case 'chat-message': + this._handleChatMessage(data); + break; + case 'media-state-changed': + this._handleMediaStateChangedMessage(data); + break; + case 'user-info': + this._handleUserInfoMessage(data); + break; + case 'participants-sync': + this._handleParticipantsSyncMessage(data); + break; + default: + break; + } + } + + _handleChatMessage(data) { + const chatPayload = data.data || data.message; + if (!chatPayload) { + return; + } + + chatMessage.handleChatMessage(chatPayload); + + if (data.participantId && this.role === 'host' && this.state.participants[data.participantId]) { + this._upsertParticipant(data.participantId, { + id: chatPayload.senderId, + ...(chatPayload.senderName ? { name: chatPayload.senderName } : {}), + ...(chatPayload.senderAvatar ? { avatar: chatPayload.senderAvatar } : {}) + }); + this._notifyParticipantsUpdate(); + this.broadcastParticipantsList(); + return; + } + + if (!this.role || this.role !== 'host') { + if (data.participantId && this.state.participants[data.participantId]) { + this._upsertParticipant(data.participantId, { + ...(chatPayload.senderName ? { name: chatPayload.senderName } : {}), + ...(chatPayload.senderAvatar ? { avatar: chatPayload.senderAvatar } : {}) + }); + this._notifyParticipantsUpdate(); + } else if (chatPayload.senderId !== this.state.session.localUser.id) { + this._updateRemoteUserProfile({ + id: chatPayload.senderId, + name: chatPayload.senderName, + avatar: chatPayload.senderAvatar + }); + } + } + } + + _handleMediaStateChangedMessage(data) { + console.log('收到媒体状态变化:', data.data, 'from participant:', data.participantId); + + if (this.role === 'host') { + if (data.participantId && this.state.participants[data.participantId]) { + this._upsertParticipant(data.participantId, { + mediaState: data.data + }); + } + + this.updateRemoteMedia(data.data, data.participantId); + this._notifyParticipantsUpdate(); + this.broadcastParticipantsList(); + return; + } + + if (data.participantId && data.participantId !== this.selfParticipantId && this.state.participants[data.participantId]) { + this._upsertParticipant(data.participantId, { + mediaState: data.data + }); + this._notifyParticipantsUpdate(); + return; + } + + if (data.participantId === this.selfParticipantId) { + return; + } + + console.log('Received media-state-changed from Host, updating remoteUser:', data.data); + this.updateRemoteMedia(data.data, data.participantId); + this._notifyParticipantsUpdate(); + } + + _handleUserInfoMessage(data) { + console.log('收到用户信息:', data.data, 'from participant:', data.participantId); + if (!data.data) { + return; + } + + if (data.participantId && this.role === 'host') { + this._upsertParticipant(data.participantId, { + id: data.data.id || '', + name: data.data.name || DEFAULT_PARTICIPANT_NAME, + avatar: data.data.avatar || DEFAULT_PARTICIPANT_AVATAR + }); + this._notifyParticipantsUpdate(); + this.broadcastParticipantsList(); + return; + } + + this._updateRemoteUserProfile({ + id: data.data.id || this.state.session.remoteUser.id, + name: data.data.name || this.state.session.remoteUser.name, + avatar: data.data.avatar || this.state.session.remoteUser.avatar + }); + } + + _handleParticipantsSyncMessage(data) { + if (this.role === 'host' || !data.data) { + return; + } + + console.log('收到成员列表同步:', data.data); + this.state.participants = omitParticipant(data.data, this.selfParticipantId); + this._notifyParticipantsUpdate(); + this._syncCallDuration(data.callDuration); + } + + _updateRemoteUserProfile(profile) { + this.state.session.remoteUser = { + ...this.state.session.remoteUser, + ...profile + }; + this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState: this.state.session.remoteUser.mediaState }); + } + + _syncCallDuration(callDuration) { + if (this.durationSynced || typeof callDuration !== 'number') { + return; + } + + this.state.session.duration = callDuration; + this.durationSynced = true; + this._startDurationTimer(); + this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); + console.log(`通话时长已同步,当前时长: ${callDuration}秒`); + } + + _startDurationTimer() { + if (this.durationInterval) { + return; + } + + this.durationInterval = setInterval(() => { + this.state.session.duration++; + this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); + }, 1000); + } + /** * 发送消息 * @param {string} type - 消息类型 @@ -938,6 +952,21 @@ class CallStateManager { } } + /** + * Participant state helpers + */ + _notifyParticipantsUpdate() { + this.notify({ type: 'PARTICIPANTS_UPDATE', participants: this.state.participants }); + } + + _upsertParticipant(participantId, patch = {}) { + return upsertParticipant(this.state.participants, participantId, patch); + } + + _removeParticipant(participantId) { + return removeParticipant(this.state.participants, participantId); + } + /** * Host端广播完整成员列表给所有Participant * 包含Host自身信息 + 所有Participant信息 @@ -946,25 +975,7 @@ class CallStateManager { 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' - }; - } + const memberList = buildParticipantsSyncData(this.state.session.localUser, this.state.participants); this.renderstreaming.sendMessage({ type: 'participants-sync',