优化完成
This commit is contained in:
@@ -11,7 +11,6 @@ onebyone/
|
||||
├── renderer.js # UI渲染器,负责将状态映射到DOM
|
||||
├── store.js # 状态管理,使用Observable模式
|
||||
├── models.js # 数据模型定义
|
||||
├── api.js # API客户端
|
||||
├── websocket.js # WebSocket管理
|
||||
├── utils.js # 工具函数
|
||||
└── style.css # 样式文件
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
458
WebApp/client/public/onebyone/code-structure.md
Normal file
458
WebApp/client/public/onebyone/code-structure.md
Normal 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
|
||||
```
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
// 创建通用用户条目
|
||||
createUserEntry(options) {
|
||||
const { user, role, id } = options;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex items-center gap-3 p-2 rounded-lg hover:bg-white/5';
|
||||
div.dataset.userId = 'local';
|
||||
const baseClass = 'flex items-center gap-3 p-2 rounded-lg';
|
||||
div.className = role === 'local'
|
||||
? `${baseClass} hover:bg-white/5`
|
||||
: `${baseClass} bg-white/5`;
|
||||
|
||||
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
|
||||
// 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
|
||||
// 头像区域
|
||||
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>';
|
||||
|
||||
div.innerHTML = `
|
||||
<img src="${localUser.avatar}" class="w-10 h-10 rounded-full object-cover">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
${localUser.name}
|
||||
${roleTag}
|
||||
</div>
|
||||
<div class="${mediaStatusClass}" data-field="localUser.mediaStatus">${mediaStatusText}</div>
|
||||
</div>
|
||||
${muteIconHtml}
|
||||
`;
|
||||
return div;
|
||||
} 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>';
|
||||
}
|
||||
|
||||
// 创建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}`;
|
||||
// 媒体状态 data-field(仅local)
|
||||
const dataFieldAttr = role === 'local' ? ' data-field="localUser.mediaStatus"' : '';
|
||||
|
||||
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)
|
||||
// 右侧内容
|
||||
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>'
|
||||
: '';
|
||||
|
||||
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>
|
||||
rightHtml = `
|
||||
<div class="flex items-center gap-2">
|
||||
${muteIconHtml}
|
||||
${speakingHtml}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
rightHtml = muteIconHtml;
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
${avatarHtml}
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
${user.name}
|
||||
${roleTag}
|
||||
</div>
|
||||
<div class="${mediaStatusClass}"${dataFieldAttr}>${mediaStatusText}</div>
|
||||
</div>
|
||||
${rightHtml}
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
// 启动通话时长计时器
|
||||
// 启动通话时长计时器(避免重复启动)
|
||||
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.startActivityDetection(this.state.localStream, { isLocal: true });
|
||||
//启动远端音频活动检测
|
||||
this.startRemoteActivityDetection();
|
||||
//模拟远端活动 (开发测试用)
|
||||
//this.simulateRemoteActivity();
|
||||
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 });
|
||||
}
|
||||
} 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;
|
||||
if (isLocal) {
|
||||
this.state.session.localUser.mediaState.isSpeaking = true;
|
||||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: true });
|
||||
// 发送媒体状态到服务器
|
||||
this.emitMediaStateChange();
|
||||
} else {
|
||||
this.updateRemoteMedia({ isSpeaking: true });
|
||||
}
|
||||
}
|
||||
} else if (isSpeaking && currentTime - lastActivityTime > debounceTime) {
|
||||
// 停止说话
|
||||
isSpeaking = false;
|
||||
if (isLocal) {
|
||||
this.state.session.localUser.mediaState.isSpeaking = false;
|
||||
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType: 'isSpeaking', value: false });
|
||||
// 发送媒体状态到服务器
|
||||
this.emitMediaStateChange();
|
||||
} else {
|
||||
this.updateRemoteMedia({ isSpeaking: false });
|
||||
}
|
||||
}
|
||||
|
||||
// 继续检测
|
||||
if (this.state.session.status === 'ongoing') {
|
||||
requestAnimationFrame(detectActivity);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始检测
|
||||
detectActivity();
|
||||
|
||||
console.log('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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user