diff --git a/WebApp/client/public/onebyone/README.md b/WebApp/client/public/onebyone/README.md index 58425bf..27e4bfd 100644 --- a/WebApp/client/public/onebyone/README.md +++ b/WebApp/client/public/onebyone/README.md @@ -11,7 +11,6 @@ onebyone/ ├── renderer.js # UI渲染器,负责将状态映射到DOM ├── store.js # 状态管理,使用Observable模式 ├── models.js # 数据模型定义 -├── api.js # API客户端 ├── websocket.js # WebSocket管理 ├── utils.js # 工具函数 └── style.css # 样式文件 diff --git a/WebApp/client/public/onebyone/api.js b/WebApp/client/public/onebyone/api.js deleted file mode 100644 index 29a6e93..0000000 --- a/WebApp/client/public/onebyone/api.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * API客户端 - * 封装所有API调用,处理HTTP请求 - */ - -class ApiClient { - constructor(baseUrl = '') { - this.baseUrl = baseUrl || location.origin; - } - - /** - * 获取通话信息 - * @param {string} callId - 通话ID - * @returns {Promise} 通话信息 - */ - async getCallInfo(callId) { - try { - const response = await fetch(`${this.baseUrl}/api/call/${callId}`); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error getting call info:', error); - throw error; - } - } - - /** - * 加入通话 - * @param {string} callId - 通话ID - * @returns {Promise} 加入结果 - */ - async joinCall(callId) { - try { - const response = await fetch(`${this.baseUrl}/api/call/${callId}/join`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error joining call:', error); - throw error; - } - } - - /** - * 离开通话 - * @param {string} callId - 通话ID - * @returns {Promise} 离开结果 - */ - async leaveCall(callId) { - try { - const response = await fetch(`${this.baseUrl}/api/call/${callId}/leave`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error leaving call:', error); - throw error; - } - } - - /** - * 更新媒体状态 - * @param {string} callId - 通话ID - * @param {Object} mediaState - 媒体状态 {audio?: boolean, video?: boolean} - * @returns {Promise} 更新结果 - */ - async updateMediaState(callId, mediaState) { - try { - const response = await fetch(`${this.baseUrl}/api/call/${callId}/media`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(mediaState) - }); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error updating media state:', error); - throw error; - } - } - - /** - * 获取历史消息 - * @param {string} callId - 通话ID - * @param {number} limit - 消息数量限制 - * @param {string} before - 时间戳 - * @returns {Promise} 消息列表 - */ - async getMessages(callId, limit = 50, before = null) { - try { - let url = `${this.baseUrl}/api/call/${callId}/messages?limit=${limit}`; - if (before) { - url += `&before=${before}`; - } - - const response = await fetch(url); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error getting messages:', error); - throw error; - } - } - - /** - * 发送消息 - * @param {string} callId - 通话ID - * @param {string} content - 消息内容 - * @param {string} type - 消息类型 - * @returns {Promise} 发送结果 - */ - async sendMessage(callId, content, type = 'text') { - try { - const response = await fetch(`${this.baseUrl}/api/call/${callId}/message`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ content, type }) - }); - if (!response.ok) { - throw new Error(`API error: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error('Error sending message:', error); - throw error; - } - } -} - -// 创建单例实例 -const apiClient = new ApiClient(); - -export default apiClient; diff --git a/WebApp/client/public/onebyone/chatmessage.js b/WebApp/client/public/onebyone/chatmessage.js index 71c58e2..043d16c 100644 --- a/WebApp/client/public/onebyone/chatmessage.js +++ b/WebApp/client/public/onebyone/chatmessage.js @@ -5,6 +5,9 @@ import { showNotification, generateId } from './utils.js'; import store from './store.js'; import { mockMessages } from './models.js'; + +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB + // 消息相关的状态管理方法 let messageState = { messages: [...mockMessages], @@ -19,7 +22,7 @@ let listeners = []; * @param {Function} callback - 回调函数 * @returns {Function} 取消订阅的函数 */ -export function subscribe(callback) { +function subscribe(callback) { listeners.push(callback); return () => { listeners = listeners.filter(cb => cb !== callback); @@ -38,7 +41,7 @@ function notify(changes) { * 添加消息 * @param {Object} message - 消息对象 */ -export function addMessage (message) { +function addMessage (message) { messageState.messages.push(message); // 如果侧边栏关闭且不是自己发的,增加未读 @@ -55,7 +58,7 @@ export function addMessage (message) { * @param {Object} message - 消息对象 * @param {Object} renderstreaming - WebRTC连接管理实例 */ -export function sendChatMessage(message) { +function sendChatMessage(message) { if (store.getRenderStreaming()) { store.getRenderStreaming().sendMessage({ type: 'chat-message', @@ -68,7 +71,7 @@ export function sendChatMessage(message) { * 处理接收到的聊天消息 * @param {Object} data - 消息数据 */ -export function handleChatMessage(data) { +function handleChatMessage(data) { console.log('处理聊天:', data); addMessage(data); @@ -85,7 +88,7 @@ export function handleChatMessage(data) { * 切换侧边栏 * @returns {boolean} 切换后的状态 */ -export function toggleSidebar() { +function toggleSidebar() { messageState.isSidebarOpen = !messageState.isSidebarOpen; if (messageState.isSidebarOpen) { messageState.unreadCount = 0; @@ -98,14 +101,14 @@ export function toggleSidebar() { * 获取消息状态 * @returns {Object} 消息状态 */ -export function getMessageState() { +function getMessageState() { return messageState; } /** * 发送消息 */ -export function sendMessage() { +function sendMessage() { const chatInput = document.getElementById('chatInput'); const content = chatInput.value.trim(); @@ -138,7 +141,7 @@ export function sendMessage() { * 处理聊天输入回车 * @param {KeyboardEvent} event - 键盘事件 */ -export function handleChatSubmit(event) { +function handleChatSubmit(event) { if (event.key === 'Enter') { sendMessage(); } @@ -147,7 +150,7 @@ export function handleChatSubmit(event) { /** * 打开图片选择器 */ -export function openImagePicker() { +function openImagePicker() { document.getElementById('imageInput').click(); } @@ -155,7 +158,7 @@ export function openImagePicker() { * 处理图片上传 * @param {Event} event - 事件对象 */ -export function handleImageUpload(event) { +function handleImageUpload(event) { const file = event.target.files[0]; if (file) { // 检查文件类型 @@ -165,7 +168,7 @@ export function handleImageUpload(event) { } // 检查文件大小(限制为5MB) - if (file.size > 5 * 1024 * 1024) { + if (file.size > MAX_IMAGE_SIZE) { showNotification('图片文件不能超过5MB', 3000); return; } @@ -188,7 +191,7 @@ export function handleImageUpload(event) { * @param {string} imageUrl - 图片URL * @param {string} fileName - 文件名 */ -export function sendImageMessage(imageUrl, fileName) { +function sendImageMessage(imageUrl, fileName) { const state = store.getState(); const newMessage = { id: generateId(), @@ -214,7 +217,7 @@ export function sendImageMessage(imageUrl, fileName) { /** * 绑定消息相关的DOM事件 */ -export function bindMessageEvents() { +function bindMessageEvents() { // 发送消息 window.sendMessage = sendMessage; diff --git a/WebApp/client/public/onebyone/code-structure.md b/WebApp/client/public/onebyone/code-structure.md new file mode 100644 index 0000000..f3731e0 --- /dev/null +++ b/WebApp/client/public/onebyone/code-structure.md @@ -0,0 +1,458 @@ +# onebyone 模块代码调用结构图 + +> 本文档基于优化后的代码自动生成,反映当前实际架构。 + +--- + +## 3.1 文件依赖关系图 + +```mermaid +graph TD + main[main.js] --> store[store.js] + main --> renderer[renderer.js] + main --> utils[utils.js] + main --> chatmsg[chatmessage.js] + + store --> models[models.js] + store --> utils + store --> chatmsg + store --> signaling[../../module/signaling.js] + store --> rs[../../module/renderstreaming.js] + store --> config[../js/config.js] + + renderer --> utils + renderer --> models + renderer --> chatmsg + renderer -.-> store + + chatmsg --> utils + chatmsg --> store + chatmsg --> models + + connect[connect/connect.js] --> store + connect --> utils + + endcall[endcall/endcall.js] --> utils + + index[index.html] --> main + connectHtml[connect/connect.html] --> connect + endcallHtml[endcall/endcall.html] --> endcall + + style utils fill:#e1f5fe + style models fill:#e1f5fe + style signaling fill:#fff3e0 + style rs fill:#fff3e0 + style config fill:#fff3e0 +``` + +**图例说明** +- 蓝色:内部工具/数据模块 +- 橙色:外部依赖模块(signaling.js、renderstreaming.js、config.js) + +--- + +## 3.2 核心调用链 + +### 通话初始化流程 + +```mermaid +sequenceDiagram + autonumber + participant Browser + participant main as main.js + participant store as store.js + participant render as renderer.js + participant RS as RenderStreaming + participant Sig as Signaling + + Browser->>main: DOMContentLoaded + main->>main: 检查 localStorage.connectionId + alt 无连接ID + main->>Browser: 跳转 connect/connect.html + else 有连接ID + main->>render: new UIRenderer(store) + render->>store: subscribe(render) + main->>store: joinCall(connectionId) + store->>store: init() + store->>store: setupConfig() / getServerConfig() + store->>store: loadUserSettings() + store->>store: getLocalStream() + store->>Browser: getUserMedia(MEDIA_CONSTRAINTS) + Browser-->>store: MediaStream + store->>store: notify(LOCAL_STREAM_OBTAINED) + store->>store: notify(LOCAL_MEDIA_CHANGE x2) + store->>store: emitMediaStateChange() + main->>store: setUp(connectionId) + store->>store: _createSignalingAndRTC() + store->>Sig: new WebSocketSignaling() / new Signaling() + store->>RS: new RenderStreaming(signaling, config) + store->>store: _registerCallbacks() + store->>store: _startConnection() + store->>RS: start() + store->>RS: createConnection(connectionId) + RS-->>store: onConnect(role, participantId) + store->>store: notify(CALL_STATUS_CHANGE, ongoing) + store->>store: sendMessage(user-info) + store->>store: emitMediaStateChange() + main->>main: bindDomEvents() + end +``` + +### 媒体控制流程 + +```mermaid +sequenceDiagram + autonumber + participant User + participant main as main.js + participant store as store.js + participant render as renderer.js + participant RS as RenderStreaming + participant Remote as 远端 + + User->>main: 点击 toggleMute / toggleVideo + main->>store: updateLocalMedia(type, value) + + alt 开启视频 + store->>Browser: getUserMedia(VIDEO_ONLY_CONSTRAINT) + Browser-->>store: newVideoTrack + store->>RS: replaceTrack / addTransceiver + store->>store: notify(LOCAL_STREAM_OBTAINED) + store->>store: notify(LOCAL_MEDIA_CHANGE, video=true) + store->>store: emitMediaStateChange() + else 关闭视频 + store->>store: track.stop() + store->>store: notify(LOCAL_MEDIA_CHANGE, video=false) + store->>store: emitMediaStateChange() + else 切换音频 + store->>store: track.enabled = value + store->>store: notify(LOCAL_MEDIA_CHANGE, audio=value) + store->>store: emitMediaStateChange() + end + + store->>render: notify(USER_LIST_UPDATE) + render->>render: renderUserList() + render->>render: renderControlButtons() + render->>render: renderLocalVideo() + + store->>RS: sendMessage(media-state-changed) + RS->>Remote: WebSocket 信令 + Remote-->>store: onMessage(media-state-changed) + store->>store: updateRemoteMedia() + store->>render: notify(REMOTE_MEDIA_CHANGE) + render->>render: renderRemoteVideo() + render->>render: renderUserList() + render->>render: renderParticipantVideoPlaceholder() +``` + +### 消息发送流程 + +```mermaid +sequenceDiagram + autonumber + participant User + participant chat as chatmessage.js + participant store as store.js + participant render as renderer.js + participant RS as RenderStreaming + participant Remote as 远端 + + User->>chat: 输入消息 / 回车 + chat->>chat: sendMessage() + chat->>chat: addMessage() 本地 + chat->>chat: notify(NEW_MESSAGE) + render->>render: renderMessageState(NEW_MESSAGE) + render->>render: renderChatMessages() + render->>render: renderUnreadCount() + + chat->>store: getRenderStreaming() + chat->>RS: sendMessage(chat-message) + RS->>Remote: WebSocket 信令 + + Remote-->>chat: handleChatMessage(data) + chat->>chat: addMessage() 远端 + chat->>chat: notify(NEW_MESSAGE) + render->>render: renderMessageState(NEW_MESSAGE) + render->>render: renderChatMessages() + render->>render: renderUnreadCount() + + User->>chat: 点击 toggleSidebar() + chat->>chat: notify(SIDEBAR_TOGGLE) + render->>render: renderMessageState(SIDEBAR_TOGGLE) + render->>render: renderSidebar() +``` + +### Participant 管理流程 + +```mermaid +sequenceDiagram + autonumber + participant store as store.js + participant render as renderer.js + participant RS as RenderStreaming + participant Remote as 远端Participant + + Note over RS,Remote: 新 Participant 加入 + RS-->>store: onParticipantJoined(participantId) + store->>store: 初始化 participant 默认信息 + store->>store: notify(PARTICIPANTS_UPDATE) + store->>RS: broadcastParticipantsList() + render->>render: renderUserList() + render->>render: syncParticipantTileNames() + + Note over RS,Remote: Participant 离开 + RS-->>store: onParticipantLeft(participantId) + store->>store: 清理 remoteStreams / participants + store->>store: notify(PARTICIPANT_LEFT) + store->>store: notify(PARTICIPANTS_UPDATE) + store->>RS: broadcastParticipantsList() + render->>render: renderParticipantLeft() + render->>render: renderUserList() + + Note over RS,Remote: 成员列表同步 + RS-->>store: onMessage(participants-sync) + store->>store: 过滤自身条目 -> state.participants + store->>store: notify(PARTICIPANTS_UPDATE) + render->>render: renderUserList() + render->>render: syncParticipantTileNames() +``` + +--- + +## 3.3 状态变化流转图 + +### Store -> Renderer 状态流转 + +```mermaid +graph LR + subgraph StoreNotify[store.js notify 类型] + A[INIT] + B[LOCAL_STREAM_OBTAINED] + C[LOCAL_MEDIA_CHANGE] + D[REMOTE_STREAM_OBTAINED] + E[REMOTE_MEDIA_CHANGE] + F[USER_LIST_UPDATE] + G[PARTICIPANTS_UPDATE] + H[NETWORK_CHANGE] + I[CALL_STATUS_CHANGE] + J[CALL_ENDED] + K[PARTICIPANT_LEFT] + L[DURATION_UPDATE] + end + + subgraph RenderMethod[renderer.js 渲染方法] + RM1[renderRemoteVideo] + RM2[renderLocalVideo] + RM3[renderLocalStream] + RM4[renderRemoteStream] + RM5[renderControlButtons] + RM6[renderUserList] + RM7[renderNetworkStatus] + RM8[renderCallStatus] + RM9[renderCallEnded] + RM10[renderParticipantLeft] + RM11[renderCallDuration] + RM12[renderHeader] + RM13[renderParticipantVideoPlaceholder] + RM14[syncParticipantTileNames] + end + + A --> RM1 & RM2 & RM5 & RM6 & RM12 + B --> RM3 & RM2 + C --> RM5 & RM2 & RM6 + D --> RM4 + E --> RM1 & RM6 & RM13 + F --> RM6 + G --> RM6 & RM14 + H --> RM7 + I --> RM8 + J --> RM9 + K --> RM10 + L --> RM11 +``` + +### chatMessage -> Renderer 状态流转 + +```mermaid +graph LR + subgraph ChatNotify[chatmessage.js notify 类型] + N[NEW_MESSAGE] + S[SIDEBAR_TOGGLE] + end + + subgraph MsgRender[renderer.js 消息渲染] + MR1[renderChatMessages] + MR2[renderUnreadCount] + MR3[renderSidebar] + end + + N --> MR1 & MR2 + S --> MR3 & MR2 +``` + +--- + +## 3.4 各模块导出函数清单 + +### main.js + +| 导出 | 类型 | 说明 | +|------|------|------| +| store | 变量 | 重新导出 store 单例,供外部调试使用 | + +**内部全局函数(绑定到 window):** + +| 函数 | 说明 | +|------|------| +| toggleSidebar | 切换侧边栏显示 | +| toggleMute | 切换麦克风状态 | +| toggleVideo | 切换摄像头状态 | +| toggleLocalVideo | 本地视频悬停控制 | +| toggleRecording | 切换录屏状态 | +| endCall | 显示结束通话确认对话框 | +| cancelEndCall | 取消结束通话 | +| confirmEndCall | 确认结束通话,调用 store.endCall() | +| showCallRequest | 显示通话请求弹窗 | +| rejectCall | 拒绝通话请求 | +| acceptCall | 接受通话请求,初始化通话 | + +### store.js (CallStateManager 类 / store 单例) + +| 方法 | 说明 | +|------|------| +| subscribe(callback) | 订阅状态变化 | +| notify(changes) | 通知所有监听器 | +| init() | 初始化配置、用户设置、本地流 | +| loadUserSettings() | 从 localStorage 加载用户设置 | +| setupConfig() | 获取服务器配置(WebSocket 模式) | +| getLocalStream() | 获取本地摄像头媒体流 | +| updateLocalMedia(mediaType, value) | 更新本地媒体状态(音频/视频/录屏) | +| _createSignalingAndRTC(connectionId) | 创建信令和 RTC 实例 | +| setUp(connectionId) | 设置 WebRTC 连接入口 | +| _registerCallbacks() | 注册所有 WebRTC 回调 | +| _startConnection(connectionId) | 启动连接和检测 | +| hangUp() | 挂断连接,清理资源 | +| sendMessage(type, data) | 通过 RenderStreaming 发送消息 | +| broadcastParticipantsList() | Host 端广播成员列表 | +| setCodecPreferences(participantId) | 设置 H264 编解码器偏好 | +| updateRemoteMedia(mediaState, participantId) | 更新远端媒体状态 | +| updateRemoteUserStatus(status) | 更新远端用户在线状态 | +| updateRemoteUserNetworkQuality(q) | 更新远端网络质量 | +| endCall() | 用户主动结束通话入口 | +| joinCall(connectionId) | 加入通话 | +| createCall() | 创建通话 | +| detectNetworkQuality() | 基于 WebRTC stats 检测网络质量 | +| startActivityDetection(stream, opts) | 启动音频活动检测(VAD) | +| startNetworkQualityDetection() | 启动定时网络质量检测 | +| stopNetworkQualityDetection() | 停止网络质量检测 | +| emitMediaStateChange() | 发送媒体状态变化信令 | +| showStatsMessage() | 显示并定时输出 WebRTC 统计信息 | +| clearStatsMessage() | 清除统计定时器 | +| getState / getLocalUser / getRemoteUser / getConnectionId / getRenderStreaming | Getter 方法 | + +### renderer.js (UIRenderer 类) + +| 方法 | 说明 | +|------|------| +| constructor(stateManager) | 构造函数,订阅 store 和 chatMessage | +| render(state, changes) | 核心渲染分发器,根据 changes.type 路由 | +| renderMessageState(msgState, changes) | 消息状态渲染分发器 | +| renderCallStatus(status) | 渲染通话状态(连接中覆盖层) | +| renderHeader(session) | 渲染头部信息 | +| renderHeaderTitle() | 渲染标题(含 connectionId) | +| renderCallDuration(duration) | 渲染通话时长 | +| renderRemoteVideo(remoteUser) | 渲染远端视频和占位符 | +| renderHeaderNetworkStatus(q) | 渲染头部网络质量文本 | +| renderLocalVideo(localUser, stream) | 渲染本地视频和占位符 | +| renderLocalStream(stream) | 将本地流绑定到 video 元素 | +| renderRemoteStream(stream, id, isHost) | 远端流渲染分发 | +| renderParticipantStream(stream, id) | Host 端多 participant 视频网格渲染 | +| renderParticipantVideoPlaceholder(id, show) | 精准更新 participant tile 占位背景 | +| syncParticipantTileNames(participants) | 同步所有 tile 名称标签 | +| updateParticipantTileName(id, name) | 更新指定 tile 名称 | +| renderSingleRemoteStream(stream) | Participant 端单路远端视频渲染 | +| renderLocalUserStatus(localUser) | 渲染本地用户媒体状态文本 | +| renderUserList(local, remote, participants) | 渲染侧边栏用户列表(支持多 participant) | +| createUserEntry(options) | 创建通用用户条目 DOM | +| getVideoResolution(track) | 获取视频轨道分辨率 | +| adjustVideoSize(video, resolution) | 调整视频元素尺寸 | +| renderControlButtons(mediaState) | 渲染底部控制按钮状态 | +| renderChatMessages(messages) | 渲染聊天消息列表 | +| createMessageElement(message) | 创建单条消息 DOM | +| renderUnreadCount(count) | 渲染未读消息角标 | +| renderSidebar(isOpen) | 渲染侧边栏显隐 | +| renderNetworkStatus(quality) | 渲染网络状态提示 | +| updateHeaderNetworkIndicator(q) | 更新头部网络指示器颜色 | +| renderCallEnded() | 渲染通话结束,跳转 endcall 页面 | +| renderParticipantLeft(id) | 清理 participant tile | +| getStatusText / getNetworkQualityText | 状态/质量文本映射 | +| destroy() | 销毁,取消订阅 | + +### chatmessage.js + +| 导出函数 | 说明 | +|----------|------| +| subscribe(callback) | 订阅消息状态变化 | +| addMessage(message) | 添加消息到列表 | +| sendChatMessage(message) | 通过 RenderStreaming 发送聊天消息 | +| handleChatMessage(data) | 处理接收到的聊天消息 | +| toggleSidebar() | 切换侧边栏,重置未读数 | +| getMessageState() | 获取当前消息状态 | +| sendMessage() | 用户发送消息入口(文本) | +| handleChatSubmit(event) | 回车发送处理 | +| openImagePicker() | 打开图片选择器 | +| handleImageUpload(event) | 处理图片上传(限制 5MB) | +| sendImageMessage(url, fileName) | 发送图片消息 | +| bindMessageEvents() | 绑定消息相关 DOM 事件到 window | + +### utils.js + +| 导出函数 | 说明 | +|----------|------| +| formatTime(seconds) | 格式化为 MM:SS | +| formatTimestamp(timestamp) | 格式化为 HH:MM | +| generateId() | 生成随机唯一 ID | +| showNotification(message, duration) | 显示顶部通知 | +| toggleElement(element, show) | 切换元素显隐(hidden 类) | +| toggleButtonState(button, active) | 切换按钮图标状态 | + +### models.js + +| 导出 | 类型 | 说明 | +|------|------|------| +| mockCallSession | 常量对象 | 默认通话会话数据结构(含 localUser / remoteUser) | +| mockMessages | 常量数组 | 默认聊天消息数组(含系统消息) | + +### connect/connect.js + +| 全局函数 | 说明 | +|----------|------| +| joinCall() | 读取输入框 connectionId,保存并跳转 | +| createCall() | 生成随机 connectionId,保存并跳转 | +| getAllConnectionIds() | 从服务器获取所有连接 ID 列表 | +| displayConnectionIds(ids) | 渲染连接 ID 列表到 DOM | +| selectConnectionId(id) | 选择指定 ID 填充输入框 | +| saveSettings() | 保存用户设置到 localStorage | +| handleAvatarUpload(event) | 上传头像(限制 2MB) | +| copyUserId() | 复制用户 ID 到剪贴板 | +| toggleSettingsMenu() | 切换设置菜单显隐 | + +### endcall/endcall.js + +| 全局函数 | 说明 | +|----------|------| +| reconnectCall() | 重新连接,跳转 index.html | +| leaveCall() | 清除 connectionId,跳转 connect.html | + +--- + +## 附录:页面跳转关系 + +```mermaid +graph LR + connect[connect/connect.html] -->|joinCall / createCall| index[index.html] + index -->|endCall| endcall[endcall/endcall.html] + endcall -->|reconnectCall| index + endcall -->|leaveCall| connect + index -->|无 connectionId| connect +``` diff --git a/WebApp/client/public/onebyone/connect/connect.js b/WebApp/client/public/onebyone/connect/connect.js index 7a1b3b7..ccd4e35 100644 --- a/WebApp/client/public/onebyone/connect/connect.js +++ b/WebApp/client/public/onebyone/connect/connect.js @@ -3,48 +3,9 @@ * 处理初始连接、创建通话和加入通话的功能 */ -import store from '../store.js'; +import { showNotification } from '../utils.js'; - -// 通知函数 -function showNotification(message, type = 'info') { - const notification = document.getElementById('notification'); - const notificationText = document.getElementById('notificationText'); - - if (notification && notificationText) { - notificationText.textContent = message; - - // 清除之前的类 - notification.className = 'fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]'; - - // 根据类型添加不同的图标 - const iconElement = notification.querySelector('i'); - if (iconElement) { - iconElement.className = 'fas fa-info-circle text-indigo-400'; - switch (type) { - case 'success': - iconElement.className = 'fas fa-check-circle text-green-400'; - break; - case 'error': - iconElement.className = 'fas fa-exclamation-circle text-red-400'; - break; - case 'warning': - iconElement.className = 'fas fa-exclamation-triangle text-yellow-400'; - break; - } - } - - // 显示通知 - notification.classList.remove('opacity-0', 'translate-y-[-20px]'); - notification.classList.add('opacity-100', 'translate-y-0'); - - // 3秒后隐藏 - setTimeout(() => { - notification.classList.remove('opacity-100', 'translate-y-0'); - notification.classList.add('opacity-0', 'translate-y-[-20px]'); - }, 3000); - } -} +const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB // 加入通话 function joinCall() { @@ -251,7 +212,7 @@ function handleAvatarUpload(event) { } // 检查文件大小 - if (file.size > 2 * 1024 * 1024) { // 2MB限制 + if (file.size > MAX_AVATAR_SIZE) { // 2MB限制 showNotification('图片大小不能超过2MB', 'error'); return; } @@ -353,7 +314,6 @@ window.addEventListener('DOMContentLoaded', () => { }); // 导出全局函数 -window.showNotification = showNotification; window.joinCall = joinCall; window.createCall = createCall; window.selectConnectionId = selectConnectionId; diff --git a/WebApp/client/public/onebyone/endcall/endcall.js b/WebApp/client/public/onebyone/endcall/endcall.js index 93f3b9b..3cf0a70 100644 --- a/WebApp/client/public/onebyone/endcall/endcall.js +++ b/WebApp/client/public/onebyone/endcall/endcall.js @@ -3,45 +3,7 @@ * 处理通话结束后的操作,如重新连接或返回连接界面 */ -// 通知函数 -function showNotification(message, type = 'info') { - const notification = document.getElementById('notification'); - const notificationText = document.getElementById('notificationText'); - - if (notification && notificationText) { - notificationText.textContent = message; - - // 清除之前的类 - notification.className = 'fixed top-20 left-1/2 transform -translate-x-1/2 glass px-6 py-3 rounded-full flex items-center gap-3 opacity-0 pointer-events-none transition-all duration-300 z-50 translate-y-[-20px]'; - - // 根据类型添加不同的图标 - const iconElement = notification.querySelector('i'); - if (iconElement) { - iconElement.className = 'fas fa-info-circle text-indigo-400'; - switch (type) { - case 'success': - iconElement.className = 'fas fa-check-circle text-green-400'; - break; - case 'error': - iconElement.className = 'fas fa-exclamation-circle text-red-400'; - break; - case 'warning': - iconElement.className = 'fas fa-exclamation-triangle text-yellow-400'; - break; - } - } - - // 显示通知 - notification.classList.remove('opacity-0', 'translate-y-[-20px]'); - notification.classList.add('opacity-100', 'translate-y-0'); - - // 3秒后隐藏 - setTimeout(() => { - notification.classList.remove('opacity-100', 'translate-y-0'); - notification.classList.add('opacity-0', 'translate-y-[-20px]'); - }, 3000); - } -} +import { showNotification } from '../utils.js'; // 重新连接 function reconnectCall() { @@ -93,6 +55,5 @@ window.addEventListener('DOMContentLoaded', () => { }); // 导出全局函数 -window.showNotification = showNotification; window.reconnectCall = reconnectCall; window.leaveCall = leaveCall; diff --git a/WebApp/client/public/onebyone/knowledge-graph.md b/WebApp/client/public/onebyone/knowledge-graph.md index 119abd5..972e808 100644 --- a/WebApp/client/public/onebyone/knowledge-graph.md +++ b/WebApp/client/public/onebyone/knowledge-graph.md @@ -19,7 +19,6 @@ graph TB subgraph Base["📦 数据与工具"] MODELS[models.js
数据模型] - API[api.js
API客户端] UTILS[utils.js
工具函数] end @@ -35,7 +34,6 @@ graph TB MAIN --> STORE MAIN --> RENDER - MAIN --> API MAIN --> UTILS MAIN --> CHAT @@ -323,7 +321,6 @@ graph LR direction TB ST[store.js
export default store] RE[renderer.js
export default UIRenderer] - AP[api.js
export default apiClient] UT[utils.js
export { formatTime, showNotification, ... }] MO[models.js
export { mockCallSession, mockMessages }] CH[chatmessage.js
export { sendMessage, toggleSidebar, ... }] @@ -335,7 +332,6 @@ graph LR ```mermaid graph LR subgraph Client["客户端"] - API_CLIENT[api.js
ApiClient类] CONN_JS[connect.js] end @@ -350,13 +346,6 @@ graph LR API_UPLOAD[/api/upload/avatar] end - API_CLIENT -.-> API_CALL - API_CLIENT -.-> API_JOIN - API_CLIENT -.-> API_LEAVE - API_CLIENT -.-> API_MEDIA - API_CLIENT -.-> API_MSG - API_CLIENT -.-> API_SEND - CONN_JS -.-> API_CONN CONN_JS -.-> API_UPLOAD diff --git a/WebApp/client/public/onebyone/main.js b/WebApp/client/public/onebyone/main.js index 04ff676..6c5a2f1 100644 --- a/WebApp/client/public/onebyone/main.js +++ b/WebApp/client/public/onebyone/main.js @@ -4,7 +4,6 @@ */ import store from './store.js'; import UIRenderer from './renderer.js'; -import apiClient from './api.js'; import { showNotification } from './utils.js'; import chatMessage from './chatmessage.js'; @@ -178,4 +177,4 @@ window.addEventListener('DOMContentLoaded', async () => { }); // 导出全局变量 -export { store, apiClient }; +export { store }; diff --git a/WebApp/client/public/onebyone/renderer.js b/WebApp/client/public/onebyone/renderer.js index 6c1409a..b1ea71f 100644 --- a/WebApp/client/public/onebyone/renderer.js +++ b/WebApp/client/public/onebyone/renderer.js @@ -6,6 +6,25 @@ import { formatTime, formatTimestamp, toggleElement, toggleButtonState } from '. import { mockCallSession } from './models.js'; import chatMessage from './chatmessage.js'; import store from './store.js'; + +const GRID_LAYOUT = { + maxColumns: 3, + breakpoints: [ + { maxParticipants: 1, template: '1fr' }, + { maxParticipants: 4, template: 'repeat(2, 1fr)' } + ], + defaultTemplate: 'repeat(3, 1fr)' +}; + +function getGridTemplateColumns(participantCount) { + for (const bp of GRID_LAYOUT.breakpoints) { + if (participantCount <= bp.maxParticipants) { + return bp.template; + } + } + return GRID_LAYOUT.defaultTemplate; +} + class UIRenderer { constructor(stateManager) { this.stateManager = stateManager; @@ -439,13 +458,7 @@ class UIRenderer { // 根据参与者数量调整网格列数 const tileCount = grid.querySelectorAll('[data-participant-id]').length; - if (tileCount <= 1) { - grid.style.gridTemplateColumns = '1fr'; - } else if (tileCount <= 4) { - grid.style.gridTemplateColumns = 'repeat(2, 1fr)'; - } else { - grid.style.gridTemplateColumns = 'repeat(3, 1fr)'; - } + grid.style.gridTemplateColumns = getGridTemplateColumns(tileCount); // 隐藏连接中提示 if (this.elements.connectingOverlay) { @@ -613,146 +626,129 @@ class UIRenderer { // 1. 渲染本地用户 // 判断当前用户角色:Host端localUser是主持人;Participant端localUser是参与者 - this.elements.userList.appendChild(this.createLocalUserEntry(localUser)); + this.elements.userList.appendChild(this.createUserEntry({ + user: localUser, + role: 'local' + })); // 2. 渲染远端成员 if (participantCount > 0) { // 有participants数据(Host端或Participant端收到participants-sync后) for (const [pid, p] of Object.entries(participantsMap)) { if (p.role === 'host') { - this.elements.userList.appendChild(this.createHostEntry(pid, p)); + this.elements.userList.appendChild(this.createUserEntry({ + user: p, + role: 'host', + id: pid + })); } else { - this.elements.userList.appendChild(this.createParticipantEntry(pid, p)); + this.elements.userList.appendChild(this.createUserEntry({ + user: p, + role: 'participant', + id: pid + })); } } } else if (remoteUser.status !== 'offline') { // 兼容:Participant端未收到participants-sync时,使用remoteUser显示Host - this.elements.userList.appendChild(this.createRemoteUserEntry(remoteUser)); + this.elements.userList.appendChild(this.createUserEntry({ + user: remoteUser, + role: 'remote' + })); } } - // 创建本地用户条目 - createLocalUserEntry(localUser) { - const div = document.createElement('div'); - div.className = 'flex items-center gap-3 p-2 rounded-lg hover:bg-white/5'; - div.dataset.userId = 'local'; + // 创建通用用户条目 + createUserEntry(options) { + const { user, role, id } = options; - const mediaStatusText = !localUser.mediaState.audio ? '静音中' : (!localUser.mediaState.video ? '视频关闭' : '在线'); - const mediaStatusClass = (!localUser.mediaState.audio || !localUser.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400'; - const muteIconHtml = !localUser.mediaState.audio + const div = document.createElement('div'); + const baseClass = 'flex items-center gap-3 p-2 rounded-lg'; + div.className = role === 'local' + ? `${baseClass} hover:bg-white/5` + : `${baseClass} bg-white/5`; + + // dataset.userId + switch (role) { + case 'local': + div.dataset.userId = 'local'; + break; + case 'remote': + div.dataset.userId = 'remote'; + break; + case 'host': + div.dataset.userId = `host_${id}`; + break; + case 'participant': + div.dataset.userId = `participant_${id}`; + break; + } + + const mediaState = user.mediaState; + const mediaStatusText = !mediaState.audio ? '静音中' : (!mediaState.video ? '视频关闭' : '在线'); + const mediaStatusClass = (!mediaState.audio || !mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400'; + const muteIconHtml = !mediaState.audio ? '' : ''; - // 根据是否为Host显示不同角色标签 - const isHost = localUser.isHost; - const roleTag = isHost - ? '主持人' - : '参与者'; + // 头像区域 + let avatarHtml; + if (role === 'local') { + avatarHtml = ``; + } else { + avatarHtml = ` +
+ +
+
+ `; + } + + // 角色标签 + let roleTag; + if (role === 'local') { + const isHost = user.isHost; + roleTag = isHost + ? '主持人' + : '参与者'; + } else if (role === 'participant') { + roleTag = '参与者'; + } else { + // remote, host + roleTag = '主持人'; + } + + // 媒体状态 data-field(仅local) + const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : ''; + + // 右侧内容 + let rightHtml; + if (role === 'participant') { + const speakingHtml = (mediaState.isSpeaking && mediaState.audio) + ? '
' + : ''; + rightHtml = ` +
+ ${muteIconHtml} + ${speakingHtml} +
+ `; + } else { + rightHtml = muteIconHtml; + } div.innerHTML = ` - + ${avatarHtml}
- ${localUser.name} + ${user.name} ${roleTag}
-
${mediaStatusText}
+
${mediaStatusText}
- ${muteIconHtml} + ${rightHtml} `; - return div; - } - // 创建Host条目(Participant端显示Host用) - createHostEntry(hostId, hostInfo) { - const div = document.createElement('div'); - div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5'; - div.dataset.userId = `host_${hostId}`; - - const mediaStatusText = !hostInfo.mediaState.audio ? '静音中' : (!hostInfo.mediaState.video ? '视频关闭' : '在线'); - const mediaStatusClass = (!hostInfo.mediaState.audio || !hostInfo.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400'; - const muteIconHtml = !hostInfo.mediaState.audio - ? '' - : ''; - - div.innerHTML = ` -
- -
-
-
-
- ${hostInfo.name} - 主持人 -
-
${mediaStatusText}
-
- ${muteIconHtml} - `; - return div; - } - - // 创建远程用户条目(兼容回退:Participant端未收到participants-sync时显示Host) - createRemoteUserEntry(remoteUser) { - const div = document.createElement('div'); - div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5'; - div.dataset.userId = 'remote'; - - const mediaStatusText = !remoteUser.mediaState.audio ? '静音中' : (!remoteUser.mediaState.video ? '视频关闭' : '在线'); - const mediaStatusClass = (!remoteUser.mediaState.audio || !remoteUser.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400'; - const muteIconHtml = !remoteUser.mediaState.audio - ? '' - : ''; - - div.innerHTML = ` -
- -
-
-
-
- ${remoteUser.name} - 主持人 -
-
${mediaStatusText}
-
- ${muteIconHtml} - `; - return div; - } - - // 创建Participant条目(Host端显示每个Participant) - createParticipantEntry(participantId, participant) { - const div = document.createElement('div'); - div.className = 'flex items-center gap-3 p-2 rounded-lg bg-white/5'; - div.dataset.userId = `participant_${participantId}`; - - const mediaStatusText = !participant.mediaState.audio ? '静音中' : (!participant.mediaState.video ? '视频关闭' : '在线'); - const mediaStatusClass = (!participant.mediaState.audio || !participant.mediaState.video) ? 'text-xs text-gray-500' : 'text-xs text-green-400'; - const muteIconHtml = !participant.mediaState.audio - ? '' - : ''; - const speakingHtml = (participant.mediaState.isSpeaking && participant.mediaState.audio) - ? '
' - : ''; - - div.innerHTML = ` -
- -
-
-
-
- ${participant.name} - 参与者 -
-
${mediaStatusText}
-
-
- ${muteIconHtml} - ${speakingHtml} -
- `; return div; } // 在renderer.js中添加方法 @@ -1058,13 +1054,7 @@ class UIRenderer { this.elements.remoteVideoPlaceholder.classList.remove('hidden'); } } else { - if (remainingTiles.length <= 1) { - grid.style.gridTemplateColumns = '1fr'; - } else if (remainingTiles.length <= 4) { - grid.style.gridTemplateColumns = 'repeat(2, 1fr)'; - } else { - grid.style.gridTemplateColumns = 'repeat(3, 1fr)'; - } + grid.style.gridTemplateColumns = getGridTemplateColumns(remainingTiles.length); } } diff --git a/WebApp/client/public/onebyone/store.js b/WebApp/client/public/onebyone/store.js index e1288b7..38e9af5 100644 --- a/WebApp/client/public/onebyone/store.js +++ b/WebApp/client/public/onebyone/store.js @@ -9,6 +9,28 @@ import { getServerConfig, getRTCConfiguration } from "../js/config.js";//服务 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: true, + audio: AUDIO_CONFIG +}; + +const VIDEO_ONLY_CONSTRAINT = { + video: true, + audio: false +}; + class CallStateManager { constructor() { // 核心状态 @@ -91,14 +113,7 @@ class CallStateManager { } // 请求摄像头权限并获取媒体流,启用回声消除 - const stream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - } - }); + const stream = await navigator.mediaDevices.getUserMedia(MEDIA_CONSTRAINTS); console.log('Stream obtained successfully:', stream); console.log('Video tracks:', stream.getVideoTracks()); @@ -120,7 +135,7 @@ class CallStateManager { this.emitMediaStateChange(); // 启动本地音频活动检测 - this.startLocalActivityDetection(); + this.startActivityDetection(this.state.localStream, { isLocal: true }); } catch (error) { console.error('Error getting local stream:', error); // 如果获取视频失败,保持视频关闭状态 @@ -140,7 +155,7 @@ class CallStateManager { if (mediaType === 'video' && value) { try { // 只获取新的视频轨道,不干扰正在工作的音频 - const newVideoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + const newVideoStream = await navigator.mediaDevices.getUserMedia(VIDEO_ONLY_CONSTRAINT); const newVideoTrack = newVideoStream.getVideoTracks()[0]; if (!newVideoTrack) { @@ -232,7 +247,7 @@ class CallStateManager { this.notify({ type: 'LOCAL_STREAM_OBTAINED', stream: this.state.localStream }); this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'video', value: true }); this.emitMediaStateChange(); - this.startLocalActivityDetection(); + this.startActivityDetection(this.state.localStream, { isLocal: true }); } catch (error) { console.error('Error reopening video:', error); @@ -280,12 +295,12 @@ class CallStateManager { } /** - * 设置WebRTC连接 + * 创建信令和RTC实例 * @async + * @param {string} connectionId - 连接ID * @returns {Promise} */ - async setUp(connectionId) { - //TODO + async _createSignalingAndRTC(connectionId) { this.connectionId = connectionId; // 获取连接ID // 设置状态为连接中 this.state.session.status = 'connecting'; @@ -326,6 +341,23 @@ class CallStateManager { ] }; this.renderstreaming = new RenderStreaming(signaling, config); + } + + /** + * 设置WebRTC连接 + * @async + * @returns {Promise} + */ + 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) { @@ -336,6 +368,7 @@ class CallStateManager { this.setCodecPreferences(participantId); } }; + // 连接建立回调 this.renderstreaming.onConnect = (connectionId, data) => { // 保存角色信息(host/participant) @@ -515,24 +548,26 @@ class CallStateManager { name: this.state.session.localUser.name, avatar: this.state.session.localUser.avatar }); - // 启动通话时长计时器 - this.durationInterval = setInterval(() => { - this.state.session.duration++; - this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); - }, 1000); + // 启动通话时长计时器(避免重复启动) + 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.startRemoteActivityDetection(); + this.startActivityDetection(this.state.remoteStream, { isLocal: false }); } } else if (direction == "sendonly") { // 本地发送轨道,启动本地音频活动检测 if (data.track.kind === 'audio') { - this.startLocalActivityDetection(); + this.startActivityDetection(this.state.localStream, { isLocal: true }); } } }; - // 初始化 RenderStreaming 实例后 + this.renderstreaming.onMessage = (data) => { console.log('收到消息:', data); if (data.type === 'chat-message') { @@ -657,26 +692,44 @@ class CallStateManager { } 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} + */ + async _startConnection(connectionId) { // 启动WebRTC连接 await this.renderstreaming.start(); await this.renderstreaming.createConnection(connectionId); - // 启动网络质量检测 this.startNetworkQualityDetection(); // 启动本地音频活动检测 - this.startLocalActivityDetection(); - // 启动远端音频活动检测 - this.startRemoteActivityDetection(); - //模拟远端活动 (开发测试用) - //this.simulateRemoteActivity(); + this.startActivityDetection(this.state.localStream, { isLocal: true }); + //启动远端音频活动检测 + this.startActivityDetection(this.state.remoteStream, { isLocal: false }); } /** @@ -694,6 +747,8 @@ class CallStateManager { clearInterval(this.durationInterval); this.durationInterval = null; } + // 重置通话时长同步标志 + this.durationSynced = false; const isHost = this.role === 'host'; console.log(`Disconnect peer on ${this.connectionId}. Role: ${this.role}`); @@ -769,7 +824,8 @@ class CallStateManager { this.renderstreaming.sendMessage({ type: 'participants-sync', - data: memberList + data: memberList, + callDuration: this.state.session.duration }); console.log('Broadcast participants list:', Object.keys(memberList)); } @@ -853,27 +909,6 @@ class CallStateManager { await this.init(); } - // 模拟远端活动 (开发测试用) - simulateRemoteActivity() { - setInterval(() => { - if (Math.random() > 0.7) { - const isSpeaking = Math.random() > 0.5; - this.updateRemoteMedia({ isSpeaking }); - } - }, 800); - } - simulateNetworkChange() { - // 模拟网络质量变化 - - const qualities = ['good', 'fair', 'excellent', 'poor', 'no_signal']; - setInterval(() => { - if (Math.random() > 0.8) { - const quality = qualities[Math.floor(Math.random() * qualities.length)]; - this.state.session.remoteUser.networkQuality = quality; - this.notify({ type: 'NETWORK_CHANGE', quality }); - } - }, 5000); - } // 真实网络质量检测 async detectNetworkQuality() { if (!this.renderstreaming) { @@ -953,181 +988,81 @@ class CallStateManager { console.error('Error detecting network quality:', error); } } - // 真实音频活动检测 - 远端 - startRemoteActivityDetection() { - // 检查是否有远端音频流 - if (!this.state.remoteStream) { + // 音频活动检测 + startActivityDetection(stream, { isLocal = false } = {}) { + if (!stream) { return; } - // 获取音频轨道 - const audioTracks = this.state.remoteStream.getAudioTracks(); + 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(this.state.remoteStream); - - // 创建音频分析器 + const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); - analyser.fftSize = 256; + analyser.fftSize = fftSize; - // 连接音频节点 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) { + if (!stream || !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 level = rms / 128; - // 检测说话状态 const currentTime = Date.now(); if (level > threshold / 100) { - // 检测到说话 lastActivityTime = currentTime; if (!isSpeaking) { isSpeaking = true; - this.updateRemoteMedia({ 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; - 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); - } - } - - // 真实音频活动检测 - 本地 - 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 }); - // 发送媒体状态到服务器 + 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 }); } - } 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'); + console.log(`${isLocal ? 'Local' : 'Remote'} activity detection started`); } catch (error) { - console.error('Error starting local activity detection:', error); + console.error(`Error starting ${isLocal ? 'local' : 'remote'} activity detection:`, error); } } // 启动网络质量检测 @@ -1135,7 +1070,6 @@ class CallStateManager { // 每3秒检测一次网络质量 this.networkQualityInterval = setInterval(() => { this.detectNetworkQuality(); - //this.simulateNetworkChange(); }, 3000); }