优化完成

This commit is contained in:
2026-04-25 21:09:45 +08:00
parent bcd55f9dac
commit d48ce78c03
10 changed files with 707 additions and 569 deletions

View File

@@ -11,7 +11,6 @@ onebyone/
├── renderer.js # UI渲染器负责将状态映射到DOM
├── store.js # 状态管理使用Observable模式
├── models.js # 数据模型定义
├── api.js # API客户端
├── websocket.js # WebSocket管理
├── utils.js # 工具函数
└── style.css # 样式文件

View File

@@ -1,155 +0,0 @@
/**
* API客户端
* 封装所有API调用处理HTTP请求
*/
class ApiClient {
constructor(baseUrl = '') {
this.baseUrl = baseUrl || location.origin;
}
/**
* 获取通话信息
* @param {string} callId - 通话ID
* @returns {Promise<Object>} 通话信息
*/
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<Object>} 加入结果
*/
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<Object>} 离开结果
*/
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<Object>} 更新结果
*/
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<Array>} 消息列表
*/
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<Object>} 发送结果
*/
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;

View File

@@ -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;

View File

@@ -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
```

View File

@@ -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;

View File

@@ -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;

View File

@@ -19,7 +19,6 @@ graph TB
subgraph Base["📦 数据与工具"]
MODELS[models.js<br/>数据模型]
API[api.js<br/>API客户端]
UTILS[utils.js<br/>工具函数]
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<br/>export default store]
RE[renderer.js<br/>export default UIRenderer]
AP[api.js<br/>export default apiClient]
UT[utils.js<br/>export { formatTime, showNotification, ... }]
MO[models.js<br/>export { mockCallSession, mockMessages }]
CH[chatmessage.js<br/>export { sendMessage, toggleSidebar, ... }]
@@ -335,7 +332,6 @@ graph LR
```mermaid
graph LR
subgraph Client["客户端"]
API_CLIENT[api.js<br/>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

View File

@@ -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 };

View File

@@ -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
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
: '';
// 根据是否为Host显示不同角色标签
const isHost = localUser.isHost;
const roleTag = isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>'
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>';
// 头像区域
let avatarHtml;
if (role === 'local') {
avatarHtml = `<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">`;
} else {
avatarHtml = `
<div class="relative">
<img src="${user.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
`;
}
// 角色标签
let roleTag;
if (role === 'local') {
const isHost = user.isHost;
roleTag = isHost
? '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>'
: '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>';
} else if (role === 'participant') {
roleTag = '<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>';
} else {
// remote, host
roleTag = '<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>';
}
// 媒体状态 data-field仅local
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
// 右侧内容
let rightHtml;
if (role === 'participant') {
const speakingHtml = (mediaState.isSpeaking && mediaState.audio)
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
: '';
rightHtml = `
<div class="flex items-center gap-2">
${muteIconHtml}
${speakingHtml}
</div>
`;
} else {
rightHtml = muteIconHtml;
}
div.innerHTML = `
<img src="${localUser.avatar}" class="w-10 h-10 rounded-full object-cover">
${avatarHtml}
<div class="flex-1">
<div class="text-sm font-medium">
${localUser.name}
${user.name}
${roleTag}
</div>
<div class="${mediaStatusClass}" data-field="localUser.mediaStatus">${mediaStatusText}</div>
<div class="${mediaStatusClass}"${dataFieldAttr}>${mediaStatusText}</div>
</div>
${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
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
: '';
div.innerHTML = `
<div class="relative">
<img src="${hostInfo.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
<div class="flex-1">
<div class="text-sm font-medium">
${hostInfo.name}
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
</div>
<div class="${mediaStatusClass}">${mediaStatusText}</div>
</div>
${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
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
: '';
div.innerHTML = `
<div class="relative">
<img src="${remoteUser.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
<div class="flex-1">
<div class="text-sm font-medium">
${remoteUser.name}
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
</div>
<div class="${mediaStatusClass}">${mediaStatusText}</div>
</div>
${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
? '<i class="fas fa-microphone-slash text-gray-500 text-xs"></i>'
: '';
const speakingHtml = (participant.mediaState.isSpeaking && participant.mediaState.audio)
? '<div class="audio-wave w-6"><span></span><span></span><span></span><span></span><span></span></div>'
: '';
div.innerHTML = `
<div class="relative">
<img src="${participant.avatar}" class="w-10 h-10 rounded-full object-cover">
<div class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900"></div>
</div>
<div class="flex-1">
<div class="text-sm font-medium">
${participant.name}
<span class="text-xs bg-purple-500 px-1.5 rounded ml-1">参与者</span>
</div>
<div class="${mediaStatusClass}">${mediaStatusText}</div>
</div>
<div class="flex items-center gap-2">
${muteIconHtml}
${speakingHtml}
</div>
`;
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);
}
}

View File

@@ -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<void>}
*/
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<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) {
@@ -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<void>}
*/
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);
}