初始化
This commit is contained in:
157
client/public/onebyone/README.md
Normal file
157
client/public/onebyone/README.md
Normal file
@@ -0,0 +1,157 @@
|
||||
OneByOne 视频通话应用代码结构分析
|
||||
1. 项目概述
|
||||
OneByOne 是一个基于 WebRTC 和 WebSocket 的一对一视频通话应用,具有现代化的用户界面和丰富的功能。
|
||||
|
||||
2. 目录结构
|
||||
|
||||
plainText
|
||||
onebyone/
|
||||
├── index.html # 主HTML文件,包含页面结构
|
||||
├── main.js # 主入口文件,初始化应用
|
||||
├── renderer.js # UI渲染器,负责将状态映射到DOM
|
||||
├── store.js # 状态管理,使用Observable模式
|
||||
├── models.js # 数据模型定义
|
||||
├── websocket.js # WebSocket管理
|
||||
├── utils.js # 工具函数
|
||||
└── style.css # 样式文件
|
||||
3. 核心模块分析
|
||||
3.1 数据模型 (models.js)
|
||||
定义了应用的核心数据结构:
|
||||
|
||||
CallSession: 通话会话,包含通话ID、类型、状态、时长等信息
|
||||
LocalUser: 本地用户,包含用户ID、名称、头像、媒体状态等
|
||||
RemoteUser: 远端用户,包含用户ID、名称、头像、状态、网络质量等
|
||||
MediaState: 媒体状态,包含音频、视频、屏幕共享、录屏、说话状态等
|
||||
ChatMessage: 聊天消息,包含消息ID、发送者信息、内容、类型、时间戳等
|
||||
3.2 状态管理 (store.js)
|
||||
使用简单的 Observable 模式实现状态管理:
|
||||
|
||||
核心状态:通话会话、消息列表、侧边栏状态、未读消息数、媒体流等
|
||||
状态更新方法:
|
||||
updateLocalMedia(): 更新本地媒体状态
|
||||
updateRemoteMedia(): 更新远端媒体状态
|
||||
addMessage(): 添加消息
|
||||
toggleSidebar(): 切换侧边栏
|
||||
endCall(): 结束通话
|
||||
事件通知:通过 notify() 方法通知所有监听器状态变化
|
||||
3.3 UI渲染器 (renderer.js)
|
||||
负责将状态映射到DOM:
|
||||
|
||||
DOM元素缓存:缓存常用DOM元素,提高性能
|
||||
渲染方法:
|
||||
renderHeader(): 渲染头部
|
||||
renderCallDuration(): 渲染通话时长
|
||||
renderRemoteVideo(): 渲染远端视频
|
||||
renderLocalVideo(): 渲染本地视频
|
||||
renderControlButtons(): 渲染控制按钮
|
||||
renderChatMessages(): 渲染聊天消息
|
||||
renderLocalUserStatus(): 渲染本地用户状态
|
||||
消息渲染:支持文本消息和图片消息的渲染
|
||||
3.4 WebSocket管理 (websocket.js)
|
||||
管理WebSocket连接和事件:
|
||||
|
||||
连接管理:连接、断开、重连
|
||||
消息处理:处理服务器发送的消息
|
||||
事件订阅:支持事件订阅和触发
|
||||
心跳检测:支持ping/pong心跳机制
|
||||
3.5 主入口 (main.js)
|
||||
初始化应用,连接各个模块:
|
||||
|
||||
应用初始化:初始化渲染器、WebSocket连接、事件绑定
|
||||
WebSocket事件绑定:处理服务器事件
|
||||
DOM事件绑定:处理用户交互
|
||||
功能实现:
|
||||
麦克风、视频、录屏控制
|
||||
聊天消息发送
|
||||
图片上传和发送
|
||||
通话结束确认
|
||||
3.6 样式文件 (style.css)
|
||||
提供应用的样式:
|
||||
|
||||
基础样式:布局、颜色、字体等
|
||||
组件样式:玻璃效果、控制按钮、聊天消息等
|
||||
动画效果:消息滑入、音频波形、视频淡入等
|
||||
4. 核心功能
|
||||
4.1 视频通话
|
||||
支持一对一视频通话
|
||||
本地视频预览(画中画模式)
|
||||
远端视频显示
|
||||
视频开关控制
|
||||
4.2 音频控制
|
||||
麦克风开关控制
|
||||
说话状态检测
|
||||
音频波形动画
|
||||
4.3 聊天功能
|
||||
文本消息发送
|
||||
图片消息发送(支持文件选择和预览)
|
||||
消息时间戳
|
||||
消息类型区分(系统消息、自己的消息、对方的消息)
|
||||
4.4 录屏功能
|
||||
录屏开关控制
|
||||
录制状态通知
|
||||
4.5 网络状态
|
||||
网络质量检测和显示
|
||||
连接状态提示
|
||||
4.6 其他功能
|
||||
端到端加密提示
|
||||
通话时长显示
|
||||
键盘快捷键(空格键静音,Ctrl+V切换视频)
|
||||
通知系统
|
||||
5. 技术特点
|
||||
5.1 模块化设计
|
||||
代码按功能模块分离,职责清晰
|
||||
使用ES6模块系统,便于维护和扩展
|
||||
5.2 状态管理
|
||||
使用Observable模式实现简单的状态管理
|
||||
状态变化自动触发UI更新
|
||||
5.3 响应式设计
|
||||
使用Tailwind CSS实现响应式布局
|
||||
适配不同屏幕尺寸
|
||||
5.4 现代化UI
|
||||
玻璃拟态效果
|
||||
平滑动画
|
||||
清晰的视觉层次
|
||||
5.5 事件驱动
|
||||
使用事件机制处理用户交互和系统通知
|
||||
松耦合的组件通信
|
||||
6. API接口
|
||||
应用定义了以下API接口:
|
||||
|
||||
GET /api/call/:callId - 获取通话信息
|
||||
POST /api/call/:callId/join - 加入通话
|
||||
POST /api/call/:callId/leave - 离开通话
|
||||
POST /api/call/:callId/media - 更新媒体状态
|
||||
GET /api/call/:callId/messages - 获取历史消息
|
||||
POST /api/call/:callId/message - 发送消息
|
||||
7. WebSocket事件
|
||||
应用使用WebSocket处理实时通信,支持以下事件:
|
||||
|
||||
connect - 连接建立
|
||||
disconnect - 连接断开
|
||||
user-joined - 用户加入
|
||||
user-left - 用户离开
|
||||
media-state-changed - 媒体状态变化
|
||||
message-received - 收到消息
|
||||
network-quality - 网络质量变化
|
||||
call-ended - 通话结束
|
||||
8. 代码优化建议
|
||||
错误处理:增加更完善的错误处理机制,特别是WebSocket连接和API调用
|
||||
性能优化:
|
||||
减少DOM操作,使用虚拟DOM或批量更新
|
||||
优化图片加载和处理
|
||||
安全性:
|
||||
增加输入验证,防止XSS攻击
|
||||
实现真正的端到端加密
|
||||
可扩展性:
|
||||
考虑使用更成熟的状态管理库(如Redux)
|
||||
增加单元测试和集成测试
|
||||
用户体验:
|
||||
增加更多的动画和过渡效果
|
||||
优化移动端体验
|
||||
增加更多的用户反馈
|
||||
9. 总结
|
||||
OneByOne 是一个功能完整、界面现代化的一对一视频通话应用,采用了模块化设计和现代化的前端技术。它具有视频通话、音频控制、聊天功能、录屏功能等核心功能,并且支持实时通信和状态管理。
|
||||
|
||||
应用的代码结构清晰,职责分离明确,使用了Observable模式进行状态管理,WebSocket进行实时通信,Tailwind CSS进行样式设计。这些技术选择使得应用具有良好的可维护性和可扩展性。
|
||||
|
||||
通过进一步的优化和扩展,OneByOne可以成为一个功能更加强大、用户体验更加出色的视频通话应用。
|
||||
248
client/public/onebyone/chatmessage.js
Normal file
248
client/public/onebyone/chatmessage.js
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* 消息模块
|
||||
* 处理聊天消息的发送、接收和显示
|
||||
*/
|
||||
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],
|
||||
unreadCount: 0,
|
||||
isSidebarOpen: false
|
||||
};
|
||||
|
||||
let listeners = [];
|
||||
|
||||
/**
|
||||
* 订阅状态变化
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {Function} 取消订阅的函数
|
||||
*/
|
||||
function subscribe(callback) {
|
||||
listeners.push(callback);
|
||||
return () => {
|
||||
listeners = listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器
|
||||
* @param {Object} changes - 变化对象
|
||||
*/
|
||||
function notify(changes) {
|
||||
listeners.forEach(cb => cb(messageState, changes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息
|
||||
* @param {Object} message - 消息对象
|
||||
*/
|
||||
function addMessage (message) {
|
||||
messageState.messages.push(message);
|
||||
|
||||
// 如果侧边栏关闭且不是自己发的,增加未读
|
||||
if (!messageState.isSidebarOpen && !message.isSelf) {
|
||||
messageState.unreadCount++;
|
||||
notify({ type: 'SIDEBAR_TOGGLE', unreadCount: messageState.unreadCount });
|
||||
}
|
||||
|
||||
notify({ type: 'NEW_MESSAGE', message, unreadCount: messageState.unreadCount });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
* @param {Object} message - 消息对象
|
||||
* @param {Object} renderstreaming - WebRTC连接管理实例
|
||||
*/
|
||||
function sendChatMessage(message) {
|
||||
if (store.getRenderStreaming()) {
|
||||
store.getRenderStreaming().sendMessage({
|
||||
type: 'chat-message',
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的聊天消息
|
||||
* @param {Object} data - 消息数据
|
||||
*/
|
||||
function handleChatMessage(data) {
|
||||
console.log('处理聊天:', data);
|
||||
addMessage(data);
|
||||
|
||||
const isImage = data.content && data.content.startsWith('data:image/');
|
||||
|
||||
// 显示通知
|
||||
if (!data.isSelf) {
|
||||
const content = isImage ? '[图片]' : data.content;
|
||||
showNotification(`${data.senderName}: ${content.substring(0, 20)}${content.length > 20 ? '...' : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换侧边栏
|
||||
* @returns {boolean} 切换后的状态
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
messageState.isSidebarOpen = !messageState.isSidebarOpen;
|
||||
if (messageState.isSidebarOpen) {
|
||||
messageState.unreadCount = 0;
|
||||
}
|
||||
notify({ type: 'SIDEBAR_TOGGLE', isOpen: messageState.isSidebarOpen, unreadCount: messageState.unreadCount });
|
||||
return messageState.isSidebarOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息状态
|
||||
* @returns {Object} 消息状态
|
||||
*/
|
||||
function getMessageState() {
|
||||
return messageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
function sendMessage() {
|
||||
const chatInput = document.getElementById('chatInput');
|
||||
const content = chatInput.value.trim();
|
||||
|
||||
if (content) {
|
||||
const state = store.getState();
|
||||
const message = {
|
||||
id: generateId(),
|
||||
senderId: state.session.localUser.id,
|
||||
senderName: state.session.localUser.name,
|
||||
senderAvatar: state.session.localUser.avatar,
|
||||
content: content,
|
||||
type: 'text',
|
||||
timestamp: new Date().toISOString(),
|
||||
isSelf: true
|
||||
};
|
||||
|
||||
addMessage(message);
|
||||
|
||||
const newMessage = { ...message };
|
||||
newMessage.isSelf = false;
|
||||
chatInput.value = '';
|
||||
// 发送消息到服务器
|
||||
sendChatMessage(newMessage);
|
||||
|
||||
//wsManager.send('chat-message', message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理聊天输入回车
|
||||
* @param {KeyboardEvent} event - 键盘事件
|
||||
*/
|
||||
function handleChatSubmit(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开图片选择器
|
||||
*/
|
||||
function openImagePicker() {
|
||||
document.getElementById('imageInput').click();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片上传
|
||||
* @param {Event} event - 事件对象
|
||||
*/
|
||||
function handleImageUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showNotification('请选择图片文件', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件大小(限制为5MB)
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
showNotification('图片文件不能超过5MB', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取图片文件
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const imageUrl = e.target.result;
|
||||
sendImageMessage(imageUrl, file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// 重置文件输入
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
* @param {string} imageUrl - 图片URL
|
||||
* @param {string} fileName - 文件名
|
||||
*/
|
||||
function sendImageMessage(imageUrl, fileName) {
|
||||
const state = store.getState();
|
||||
const newMessage = {
|
||||
id: generateId(),
|
||||
senderId: state.session.localUser.id,
|
||||
senderName: state.session.localUser.name,
|
||||
senderAvatar: state.session.localUser.avatar,
|
||||
content: imageUrl,
|
||||
fileName: fileName,
|
||||
type: 'file',
|
||||
timestamp: new Date().toISOString(),
|
||||
isSelf: true
|
||||
};
|
||||
|
||||
// 添加消息到本地列表
|
||||
addMessage(newMessage);
|
||||
|
||||
// 发送消息到服务器
|
||||
const messageToSend = { ...newMessage };
|
||||
messageToSend.isSelf = false;
|
||||
sendChatMessage(messageToSend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定消息相关的DOM事件
|
||||
*/
|
||||
function bindMessageEvents() {
|
||||
// 发送消息
|
||||
window.sendMessage = sendMessage;
|
||||
|
||||
// 处理聊天输入回车
|
||||
window.handleChatSubmit = handleChatSubmit;
|
||||
|
||||
// 打开图片选择器
|
||||
window.openImagePicker = openImagePicker;
|
||||
|
||||
// 处理图片上传
|
||||
window.handleImageUpload = handleImageUpload;
|
||||
}
|
||||
|
||||
// 导出所有函数
|
||||
export default {
|
||||
sendMessage,
|
||||
handleChatSubmit,
|
||||
openImagePicker,
|
||||
handleImageUpload,
|
||||
sendImageMessage,
|
||||
bindMessageEvents,
|
||||
addMessage,
|
||||
sendChatMessage,
|
||||
handleChatMessage,
|
||||
toggleSidebar,
|
||||
getMessageState,
|
||||
subscribe
|
||||
};
|
||||
458
client/public/onebyone/code-structure.md
Normal file
458
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
|
||||
```
|
||||
114
client/public/onebyone/connect/connect.html
Normal file
114
client/public/onebyone/connect/connect.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 连接界面</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../css/style.css">
|
||||
</head>
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
<!-- 用户设置区域 -->
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<button id="userSettingsBtn" class="flex items-center gap-2 glass px-3 py-2 rounded-full hover:bg-white/10 transition-colors">
|
||||
<img id="userAvatar" src="/images/p1.png" class="w-8 h-8 rounded-full object-cover">
|
||||
<span id="userName" class="text-sm font-medium">我</span>
|
||||
<i class="fas fa-chevron-down text-xs text-gray-400"></i>
|
||||
</button>
|
||||
|
||||
<!-- 设置菜单 -->
|
||||
<div id="settingsMenu" class="hidden absolute top-full right-0 mt-2 glass rounded-xl shadow-lg w-48 z-20">
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium mb-2">个人设置</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">昵称</label>
|
||||
<input type="text" id="nicknameInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="输入昵称">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">头像</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<img id="avatarPreview" src="/images/p1.png" class="w-10 h-10 rounded-full object-cover">
|
||||
<input type="file" id="avatarInput" accept="image/*" class="hidden" onchange="handleAvatarUpload(event)">
|
||||
<button onclick="document.getElementById('avatarInput').click()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">更换头像</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">用户ID</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" id="userIdInput" class="w-full bg-transparent border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" readonly>
|
||||
<button onclick="copyUserId()" class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<button onclick="saveSettings()" class="w-full px-4 py-2 text-sm text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors">保存设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
============================================================
|
||||
初始连接界面
|
||||
============================================================
|
||||
-->
|
||||
<div class="h-full w-full flex items-center justify-center bg-black/90">
|
||||
<div class="text-center max-w-md px-8">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-8 shadow-lg">
|
||||
<i class="fas fa-video text-white text-4xl"></i>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">VideoCall</h1>
|
||||
<p class="text-gray-400 mb-8">一对一视频通话</p>
|
||||
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="glass rounded-xl p-4">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">连接ID</label>
|
||||
<input type="text"
|
||||
id="connectionIdInput"
|
||||
placeholder="输入连接ID"
|
||||
class="w-full bg-transparent border border-white/20 rounded-lg px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
连接ID是用于建立点对点通话的唯一标识,由发起方生成并分享给接收方。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-6">
|
||||
<button id="connectBtn" class="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>加入通话</span>
|
||||
</button>
|
||||
<button id="createCallBtn" class="flex-1 px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>创建通话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 浏览全部ID按钮 -->
|
||||
<button id="browseIdsBtn" class="w-full px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2 mb-4">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>浏览全部ID</span>
|
||||
</button>
|
||||
|
||||
<!-- 连接ID列表 -->
|
||||
<div id="connectionIdsList" class="glass rounded-xl p-4 mb-6 hidden">
|
||||
<h3 class="text-sm font-medium text-gray-300 mb-2">可用的连接ID</h3>
|
||||
<div id="idsContainer" class="max-h-40 overflow-y-auto space-y-2">
|
||||
<!-- 连接ID将在这里动态生成 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification" class="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]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="connect.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
323
client/public/onebyone/connect/connect.js
Normal file
323
client/public/onebyone/connect/connect.js
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 连接界面逻辑
|
||||
* 处理初始连接、创建通话和加入通话的功能
|
||||
*/
|
||||
|
||||
import { showNotification } from '../utils.js';
|
||||
|
||||
const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// 加入通话
|
||||
function joinCall() {
|
||||
const connectionId = document.getElementById('connectionIdInput').value.trim();
|
||||
if (connectionId) {
|
||||
showNotification(`正在加入通话 (${connectionId})`);
|
||||
|
||||
// 保存连接ID到本地存储
|
||||
localStorage.setItem('connectionId', connectionId);
|
||||
|
||||
// 跳转到通话界面
|
||||
window.location.href = '../index.html';
|
||||
} else {
|
||||
showNotification('请输入连接ID', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通话
|
||||
function createCall() {
|
||||
showNotification('正在创建通话...');
|
||||
|
||||
// 生成随机连接ID
|
||||
const connectionId = 'conn_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// 保存连接ID到本地存储
|
||||
localStorage.setItem('connectionId', connectionId);
|
||||
|
||||
// 跳转到通话界面
|
||||
window.location.href = '../index.html';
|
||||
}
|
||||
|
||||
|
||||
// 获取所有连接ID
|
||||
async function getAllConnectionIds() {
|
||||
showNotification('正在获取连接ID列表...');
|
||||
try {
|
||||
const response = await fetch('/signaling/connection-ids');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch connection IDs');
|
||||
}
|
||||
const data = await response.json();
|
||||
displayConnectionIds(data.connectionIds);
|
||||
} catch (error) {
|
||||
console.error('Error fetching connection IDs:', error);
|
||||
showNotification('获取连接ID失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示连接ID列表
|
||||
function displayConnectionIds(connectionIds) {
|
||||
const idsContainer = document.getElementById('idsContainer');
|
||||
const connectionIdsList = document.getElementById('connectionIdsList');
|
||||
|
||||
if (idsContainer) {
|
||||
// 清空容器
|
||||
idsContainer.innerHTML = '';
|
||||
|
||||
if (connectionIds.length === 0) {
|
||||
idsContainer.innerHTML = '<p class="text-gray-500 text-sm">暂无可用的连接ID</p>';
|
||||
} else {
|
||||
// 为每个连接ID创建一个元素
|
||||
connectionIds.forEach(id => {
|
||||
const idElement = document.createElement('div');
|
||||
idElement.className = 'flex items-center justify-between p-2 bg-white/5 rounded-lg hover:bg-white/10 cursor-pointer transition-colors';
|
||||
idElement.innerHTML = `
|
||||
<span class="text-sm">${id}</span>
|
||||
<button class="text-xs bg-indigo-600 hover:bg-indigo-700 px-2 py-1 rounded" onclick="selectConnectionId('${id}')">选择</button>
|
||||
`;
|
||||
idsContainer.appendChild(idElement);
|
||||
});
|
||||
}
|
||||
|
||||
// 显示连接ID列表
|
||||
if (connectionIdsList) {
|
||||
connectionIdsList.classList.remove('hidden');
|
||||
}
|
||||
|
||||
showNotification(`找到 ${connectionIds.length} 个连接ID`);
|
||||
}
|
||||
}
|
||||
|
||||
// 选择连接ID
|
||||
function selectConnectionId(id) {
|
||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||
if (connectionIdInput) {
|
||||
connectionIdInput.value = id;
|
||||
showNotification(`已选择连接ID: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定事件监听器
|
||||
function bindEvents() {
|
||||
// 连接按钮
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
if (connectBtn) {
|
||||
connectBtn.addEventListener('click', joinCall);
|
||||
}
|
||||
|
||||
// 创建通话按钮
|
||||
const createCallBtn = document.getElementById('createCallBtn');
|
||||
if (createCallBtn) {
|
||||
createCallBtn.addEventListener('click', createCall);
|
||||
}
|
||||
|
||||
// 浏览全部ID按钮
|
||||
const browseIdsBtn = document.getElementById('browseIdsBtn');
|
||||
if (browseIdsBtn) {
|
||||
browseIdsBtn.addEventListener('click', getAllConnectionIds);
|
||||
}
|
||||
|
||||
// 输入框回车事件
|
||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||
if (connectionIdInput) {
|
||||
connectionIdInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
joinCall();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 生成8位的用户ID
|
||||
function generateUserId() {
|
||||
// 生成8位随机字符串,包含字母和数字
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = 'user_';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 加载用户设置
|
||||
function loadUserSettings() {
|
||||
const defaultAvatar = '/images/p1.png';
|
||||
const userSettings = localStorage.getItem('userSettings');
|
||||
if (userSettings) {
|
||||
try {
|
||||
const settings = JSON.parse(userSettings);
|
||||
|
||||
// 设置用户ID
|
||||
if (settings.userId) {
|
||||
document.getElementById('userIdInput').value = settings.userId;
|
||||
}
|
||||
|
||||
// 设置昵称
|
||||
if (settings.name) {
|
||||
document.getElementById('nicknameInput').value = settings.name;
|
||||
document.getElementById('userName').textContent = settings.name;
|
||||
}
|
||||
|
||||
// 设置头像
|
||||
const avatar = settings.avatar || defaultAvatar;
|
||||
document.getElementById('userAvatar').src = avatar;
|
||||
document.getElementById('avatarPreview').src = avatar;
|
||||
} catch (error) {
|
||||
console.error('Error loading user settings:', error);
|
||||
// 加载失败时使用默认头像
|
||||
const defaultAvatar = '/images/p1.png';
|
||||
document.getElementById('userAvatar').src = defaultAvatar;
|
||||
document.getElementById('avatarPreview').src = defaultAvatar;
|
||||
}
|
||||
} else {
|
||||
// 生成新的用户ID
|
||||
const newUserId = generateUserId();
|
||||
document.getElementById('userIdInput').value = newUserId;
|
||||
|
||||
// 使用默认头像
|
||||
const defaultAvatar = '/images/p1.png';
|
||||
document.getElementById('userAvatar').src = defaultAvatar;
|
||||
document.getElementById('avatarPreview').src = defaultAvatar;
|
||||
|
||||
// 保存默认设置
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户设置
|
||||
function saveSettings() {
|
||||
const defaultAvatar = '/images/p1.png';
|
||||
const settings = {
|
||||
userId: document.getElementById('userIdInput').value,
|
||||
name: document.getElementById('nicknameInput').value || '我',
|
||||
avatar: document.getElementById('avatarPreview').src || defaultAvatar
|
||||
};
|
||||
|
||||
localStorage.setItem('userSettings', JSON.stringify(settings));
|
||||
|
||||
// 更新界面显示
|
||||
document.getElementById('userName').textContent = settings.name;
|
||||
document.getElementById('userAvatar').src = settings.avatar;
|
||||
|
||||
showNotification('设置已保存', 'success');
|
||||
}
|
||||
|
||||
// 处理头像上传
|
||||
function handleAvatarUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// 检查文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showNotification('请选择图片文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查文件大小
|
||||
if (file.size > MAX_AVATAR_SIZE) { // 2MB限制
|
||||
showNotification('图片大小不能超过2MB', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('userId', document.getElementById('userIdInput').value);
|
||||
|
||||
// 显示上传中通知
|
||||
showNotification('正在上传头像...');
|
||||
|
||||
// 上传头像到服务器
|
||||
fetch('/api/upload/avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('上传失败');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success && data.avatarUrl) {
|
||||
// 更新预览和本地存储
|
||||
const avatarUrl = data.avatarUrl;
|
||||
document.getElementById('avatarPreview').src = avatarUrl;
|
||||
document.getElementById('userAvatar').src = avatarUrl;
|
||||
|
||||
// 保存设置
|
||||
saveSettings();
|
||||
|
||||
showNotification('头像上传成功', 'success');
|
||||
} else {
|
||||
throw new Error('上传失败:' + (data.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error uploading avatar:', error);
|
||||
showNotification('头像上传失败,请重试', 'error');
|
||||
|
||||
// 上传失败时,使用默认头像
|
||||
const defaultAvatar = '/images/p1.png';
|
||||
document.getElementById('avatarPreview').src = defaultAvatar;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 复制用户ID到剪贴板
|
||||
function copyUserId() {
|
||||
const userIdInput = document.getElementById('userIdInput');
|
||||
userIdInput.select();
|
||||
document.execCommand('copy');
|
||||
showNotification('用户ID已复制到剪贴板', 'success');
|
||||
}
|
||||
|
||||
// 切换设置菜单
|
||||
function toggleSettingsMenu() {
|
||||
const settingsMenu = document.getElementById('settingsMenu');
|
||||
settingsMenu.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// 点击外部关闭设置菜单
|
||||
document.addEventListener('click', function(event) {
|
||||
const settingsMenu = document.getElementById('settingsMenu');
|
||||
const userSettingsBtn = document.getElementById('userSettingsBtn');
|
||||
|
||||
if (!settingsMenu.contains(event.target) && !userSettingsBtn.contains(event.target)) {
|
||||
settingsMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 绑定用户设置相关事件
|
||||
function bindUserSettingsEvents() {
|
||||
// 设置按钮点击事件
|
||||
const userSettingsBtn = document.getElementById('userSettingsBtn');
|
||||
if (userSettingsBtn) {
|
||||
userSettingsBtn.addEventListener('click', toggleSettingsMenu);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
bindEvents();
|
||||
bindUserSettingsEvents();
|
||||
|
||||
// 检查本地存储中是否有连接ID
|
||||
const savedConnectionId = localStorage.getItem('connectionId');
|
||||
if (savedConnectionId) {
|
||||
const connectionIdInput = document.getElementById('connectionIdInput');
|
||||
if (connectionIdInput) {
|
||||
connectionIdInput.value = savedConnectionId;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户设置
|
||||
loadUserSettings();
|
||||
});
|
||||
|
||||
// 导出全局函数
|
||||
window.joinCall = joinCall;
|
||||
window.createCall = createCall;
|
||||
window.selectConnectionId = selectConnectionId;
|
||||
window.saveSettings = saveSettings;
|
||||
window.handleAvatarUpload = handleAvatarUpload;
|
||||
window.copyUserId = copyUserId;
|
||||
window.toggleSettingsMenu = toggleSettingsMenu;
|
||||
255
client/public/onebyone/css/style.css
Normal file
255
client/public/onebyone/css/style.css
Normal file
@@ -0,0 +1,255 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(20px, 20px); }
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.end-call-pulse {
|
||||
animation: pulse-red 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); }
|
||||
50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
animation: messageSlide 0.3s ease-out;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes messageSlide {
|
||||
from { opacity: 0; transform: translateX(-10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 消息样式 */
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-header img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
/* 系统消息 */
|
||||
.message-system .message-sender {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.message-system .message-content {
|
||||
background-color: rgba(30, 64, 175, 0.3);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 对方消息 */
|
||||
.message-other .message-sender {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.message-other .message-content {
|
||||
background-color: #1e293b;
|
||||
color: #ffffff;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
/* 自己的消息 */
|
||||
.message-self {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-self .message-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-self .message-sender {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.message-self .message-content {
|
||||
background-color: #4f46e5;
|
||||
color: #ffffff;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* 图片消息样式 */
|
||||
.message-image-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-image-name {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.audio-wave {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.audio-wave span {
|
||||
width: 3px;
|
||||
background: #10b981;
|
||||
border-radius: 2px;
|
||||
animation: wave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; }
|
||||
.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; }
|
||||
.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; }
|
||||
.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; }
|
||||
.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; }
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
.video-fade-in {
|
||||
animation: videoFadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes videoFadeIn {
|
||||
from { opacity: 0; transform: scale(1.05); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* 数据绑定标记 - 开发调试时显示
|
||||
[data-field]::after {
|
||||
content: attr(data-field);
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
right: 0;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-field]:hover::after { opacity: 1; }
|
||||
[data-field] { position: relative; }*/
|
||||
|
||||
/* 分辨率选项样式 */
|
||||
.resolution-option {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.resolution-option:hover {
|
||||
color: white;
|
||||
}
|
||||
.resolution-option.active {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
color: white;
|
||||
}
|
||||
.resolution-option.active::before {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
margin-right: 6px;
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
/* 更多选项菜单动画 */
|
||||
#moreOptionsMenu {
|
||||
animation: menuFadeIn 0.15s ease-out;
|
||||
}
|
||||
@keyframes menuFadeIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
50
client/public/onebyone/endcall/endcall.html
Normal file
50
client/public/onebyone/endcall/endcall.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 通话结束</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../css/style.css">
|
||||
</head>
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
<!--
|
||||
============================================================
|
||||
结束通话界面
|
||||
============================================================
|
||||
-->
|
||||
<div class="h-full w-full flex items-center justify-center bg-black/80">
|
||||
<div class="text-center max-w-md px-8">
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<i class="fas fa-video-slash text-white text-3xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-white mb-4">通话已结束</h2>
|
||||
<p class="text-gray-400 mb-8" id="disconnectReason">连接已断开,请检查网络连接后重试</p>
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<button id="reconnectBtn" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-redo"></i>
|
||||
<span>重新连接</span>
|
||||
</button>
|
||||
<button id="leaveBtn" class="px-6 py-3 glass hover:bg-white/10 rounded-xl transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-8 text-xs text-gray-500">
|
||||
<p>连接ID: <span id="disconnectConnectionId">--</span></p>
|
||||
<p>断开时间: <span id="disconnectTime">--</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification" class="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]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="endcall.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
client/public/onebyone/endcall/endcall.js
Normal file
59
client/public/onebyone/endcall/endcall.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 结束通话界面逻辑
|
||||
* 处理通话结束后的操作,如重新连接或返回连接界面
|
||||
*/
|
||||
|
||||
import { showNotification } from '../utils.js';
|
||||
|
||||
// 重新连接
|
||||
function reconnectCall() {
|
||||
showNotification('正在重新连接...');
|
||||
|
||||
// 跳转到通话界面
|
||||
window.location.href = '../index.html';
|
||||
}
|
||||
|
||||
// 离开
|
||||
function leaveCall() {
|
||||
// 清除本地存储中的连接ID
|
||||
localStorage.removeItem('connectionId');
|
||||
|
||||
// 跳转到连接界面
|
||||
window.location.href = '../connect/connect.html';
|
||||
}
|
||||
|
||||
// 绑定事件监听器
|
||||
function bindEvents() {
|
||||
// 重新连接按钮
|
||||
const reconnectBtn = document.getElementById('reconnectBtn');
|
||||
if (reconnectBtn) {
|
||||
reconnectBtn.addEventListener('click', reconnectCall);
|
||||
}
|
||||
|
||||
// 离开按钮
|
||||
const leaveBtn = document.getElementById('leaveBtn');
|
||||
if (leaveBtn) {
|
||||
leaveBtn.addEventListener('click', leaveCall);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
bindEvents();
|
||||
|
||||
// 更新断开连接信息
|
||||
const disconnectConnectionId = document.getElementById('disconnectConnectionId');
|
||||
const disconnectTime = document.getElementById('disconnectTime');
|
||||
|
||||
if (disconnectConnectionId) {
|
||||
disconnectConnectionId.textContent = localStorage.getItem('connectionId') || '--';
|
||||
}
|
||||
|
||||
if (disconnectTime) {
|
||||
disconnectTime.textContent = new Date().toLocaleString();
|
||||
}
|
||||
});
|
||||
|
||||
// 导出全局函数
|
||||
window.reconnectCall = reconnectCall;
|
||||
window.leaveCall = leaveCall;
|
||||
614
client/public/onebyone/index.html
Normal file
614
client/public/onebyone/index.html
Normal file
@@ -0,0 +1,614 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VideoCall - 一对一视频通话</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
|
||||
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
|
||||
<!--
|
||||
============================================================
|
||||
注意:此文件为视频通话界面
|
||||
初始连接界面请访问 connect.html
|
||||
============================================================
|
||||
-->
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
数据模型定义 (Data Models)
|
||||
============================================================
|
||||
|
||||
1. CallSession 通话会话
|
||||
interface CallSession {
|
||||
id: string; // 通话唯一ID [PRIMARY_KEY]
|
||||
type: 'video' | 'audio'; // 通话类型
|
||||
status: 'connecting' | 'ongoing' | 'ended' | 'failed';
|
||||
startTime: string; // ISO 8601 格式
|
||||
duration: number; // 已进行秒数
|
||||
isEncrypted: boolean; // 是否端到端加密
|
||||
localUser: LocalUser; // 本地用户信息
|
||||
remoteUser: RemoteUser; // 远端用户信息
|
||||
}
|
||||
|
||||
2. LocalUser 本地用户
|
||||
interface LocalUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
isHost: boolean; // 是否主持人
|
||||
mediaState: MediaState; // 媒体状态
|
||||
}
|
||||
|
||||
3. RemoteUser 远端用户
|
||||
interface RemoteUser {
|
||||
id: string; // 用户ID
|
||||
name: string; // 显示名称
|
||||
avatar: string; // 头像URL
|
||||
status: 'online' | 'offline' | 'connecting';
|
||||
mediaState: MediaState; // 媒体状态
|
||||
networkQuality: 'excellent' | 'good' | 'fair' | 'poor'; // 网络质量
|
||||
}
|
||||
|
||||
4. MediaState 媒体状态
|
||||
interface MediaState {
|
||||
audio: boolean; // 音频是否开启
|
||||
video: boolean; // 视频是否开启
|
||||
screenShare: boolean; // 是否屏幕共享
|
||||
isSpeaking: boolean; // 是否正在说话(VAD检测)
|
||||
}
|
||||
|
||||
5. ChatMessage 聊天消息
|
||||
interface ChatMessage {
|
||||
id: string; // 消息ID
|
||||
senderId: string; // 发送者ID
|
||||
senderName: string; // 发送者名称
|
||||
senderAvatar: string; // 发送者头像
|
||||
content: string; // 消息内容
|
||||
type: 'text' | 'file' | 'system';
|
||||
timestamp: string; // ISO 8601 格式
|
||||
isSelf: boolean; // 是否自己发送
|
||||
}
|
||||
|
||||
============================================================
|
||||
API 接口定义 (API Endpoints)
|
||||
============================================================
|
||||
|
||||
[GET] /api/call/:callId // 获取通话信息
|
||||
[POST] /api/call/:callId/join // 加入通话
|
||||
[POST] /api/call/:callId/leave // 离开通话
|
||||
[POST] /api/call/:callId/media // 更新媒体状态 {audio?: boolean, video?: boolean}
|
||||
[GET] /api/call/:callId/messages?limit=50&before=timestamp // 获取历史消息
|
||||
[POST] /api/call/:callId/message // 发送消息 {content: string, type: 'text'}
|
||||
|
||||
WebSocket Events:
|
||||
- connect: 连接建立
|
||||
- disconnect: 连接断开
|
||||
- user-joined: {userId, timestamp}
|
||||
- user-left: {userId, timestamp}
|
||||
- media-state-changed: {userId, audio, video, screenShare, isSpeaking}
|
||||
- message-received: {message: ChatMessage}
|
||||
- network-quality: {userId, quality: 'excellent' | 'good' | 'fair' | 'poor'}
|
||||
- call-ended: {reason: 'user_hangup' | 'network_error' | 'timeout'}
|
||||
-->
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 顶部栏 (Header)
|
||||
数据源: CallSession
|
||||
更新频率: 实时 (WebSocket + 本地计时器)
|
||||
============================================================
|
||||
-->
|
||||
<header class="glass-strong h-16 flex items-center justify-between px-6 z-50 border-b border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-video text-white text-lg"></i>
|
||||
</div>
|
||||
<div>
|
||||
<!-- [DATA_FIELD: callSession.remoteUser.name] [TYPE: string] [REQUIRED] -->
|
||||
<h1 class="font-bold text-lg tracking-tight" data-field="remoteUser.name" id="headerTitle">
|
||||
与 Sarah 的通话
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span id="remoteNetworkIndicator" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
<span id="remoteNetworkQuality" class="flex items-center gap-1">
|
||||
<i class="fas fa-signal"></i>
|
||||
<span>优秀</span>
|
||||
</span>
|
||||
<!-- [DATA_FIELD: callSession.duration] [TYPE: string] [FORMAT: MM:SS] [UPDATE: 每秒] -->
|
||||
<span data-field="callSession.duration" id="callDuration">00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- [CONDITIONAL_RENDER: callSession.isEncrypted === true] -->
|
||||
<div class="hidden md:flex items-center gap-2 px-4 py-2 glass rounded-full text-sm" id="encryptionBadge">
|
||||
<i class="fas fa-shield-alt text-green-400"></i>
|
||||
<span class="text-gray-300">端到端加密</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 flex overflow-hidden relative">
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 视频区域 (Video Area)
|
||||
数据源: CallSession.remoteUser (对方) + CallSession.localUser (自己)
|
||||
更新频率: 实时 (WebRTC MediaStream + WebSocket 状态)
|
||||
============================================================
|
||||
-->
|
||||
<div class="flex-1 relative bg-black/40 overflow-hidden" id="videoArea">
|
||||
|
||||
<!--
|
||||
子区域: 多Participant视频网格(Host端显示)
|
||||
动态生成,每个participant一个视频格子
|
||||
-->
|
||||
<div id="participantGrid" class="hidden absolute inset-0 grid gap-3 p-3 auto-rows-fr" style="grid-template-columns: 1fr;">
|
||||
<!-- 动态生成的 participant 视频格子将插入这里 -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 远端视频 (Remote Video) - 单路(Participant端显示Host画面)
|
||||
数据源: RemoteUser
|
||||
-->
|
||||
<div class="absolute inset-0 video-fade-in">
|
||||
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="remoteVideo" alt="对方视频" class="w-full h-full object-contain" autoplay
|
||||
data-field="remoteUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- 远端未连接时的占位背景 -->
|
||||
<div id="remoteVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900/80 to-purple-900/80">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full bg-indigo-700/50 flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-user text-4xl text-white/70"></i>
|
||||
</div>
|
||||
<p class="text-white text-lg font-medium">等待对方连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-2">请确保对方已加入通话</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网络状态提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.networkQuality !== 'excellent'] -->
|
||||
<div id="networkStatus"
|
||||
class="absolute top-6 right-6 glass px-3 py-1.5 rounded-full flex items-center gap-2 text-xs hidden"
|
||||
data-field="remoteUser.networkQuality">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500"></i>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TYPE: string] [TRANSFORM: quality => text] -->
|
||||
<span class="text-gray-300" id="networkStatusText">网络不稳定</span>
|
||||
</div>
|
||||
|
||||
<!-- 连接中/重连提示 -->
|
||||
<!-- [CONDITIONAL_RENDER: callSession.status === 'connecting'] -->
|
||||
<div id="connectingOverlay" class="absolute inset-0 bg-black/60 flex items-center justify-center hidden"
|
||||
data-field="callSession.status">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3">
|
||||
</div>
|
||||
<p class="text-white font-medium">正在连接...</p>
|
||||
<p class="text-sm text-gray-400 mt-1" id="connectingText">等待对方接受邀请</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 本地视频 (Local Video - Picture in Picture)
|
||||
数据源: LocalUser
|
||||
-->
|
||||
<div
|
||||
class="absolute bottom-6 right-6 w-64 h-48 rounded-2xl overflow-hidden shadow-2xl border-2 border-white/20 video-fade-in z-10">
|
||||
<!-- [DATA_FIELD: localUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
|
||||
<!-- [FALLBACK: localUser.avatar] [TYPE: string] [URL] -->
|
||||
<video id="localVideo"
|
||||
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop" alt="本地视频"
|
||||
class="w-full h-full object-cover" autoplay muted data-field="localUser.videoStream">
|
||||
</video>
|
||||
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.video === false] -->
|
||||
<div id="localVideoPlaceholder"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-indigo-900 to-purple-900 hidden"
|
||||
data-field="localUser.videoOff">
|
||||
<span class="text-4xl font-bold" id="localInitials">我</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-3 left-3 glass px-2 py-1 rounded text-xs flex items-center gap-2">
|
||||
<span>我</span>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.isSpeaking === true] -->
|
||||
<div id="localAudioWave" class="audio-wave w-4 hidden" data-field="localUser.isSpeaking">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地视频悬停控制 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button onclick="toggleLocalVideo()"
|
||||
class="w-8 h-8 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-video text-xs" id="localVideoIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 侧边栏 (Sidebar)
|
||||
数据源: ChatMessage[] + User[]
|
||||
更新频率: 实时 (WebSocket)
|
||||
============================================================
|
||||
-->
|
||||
<aside class="w-80 glass-strong border-l border-white/10 flex flex-col hidden" id="sidebar">
|
||||
|
||||
<!--
|
||||
子区域: 用户列表 (User List)
|
||||
数据源: [localUser, remoteUser]
|
||||
-->
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3" id="userCountDisplay">
|
||||
通话成员 (1)
|
||||
</h3>
|
||||
<div class="space-y-2" id="userList">
|
||||
<!-- [LOOP_START: users as user] -->
|
||||
|
||||
<!-- 远端用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-white/5" data-user-id="remote">
|
||||
<div class="relative">
|
||||
<!-- [DATA_FIELD: remoteUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="remoteUser.avatar">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.status === 'online'] -->
|
||||
<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">
|
||||
<!-- [DATA_FIELD: remoteUser.name] -->
|
||||
<div class="text-sm font-medium" data-field="remoteUser.name">Sarah Chen</div>
|
||||
<!-- [DATA_FIELD: remoteUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" data-field="remoteUser.mediaStatus">在线</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs hidden"
|
||||
data-field="remoteUser.muteIcon"></i>
|
||||
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
|
||||
<div class="audio-wave w-6 hidden" data-field="remoteUser.speakingIndicator">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本地用户项 -->
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5" data-user-id="local">
|
||||
<!-- [DATA_FIELD: localUser.avatar] -->
|
||||
<img src="" class="w-10 h-10 rounded-full object-cover" data-field="localUser.avatar">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium">
|
||||
<!-- [DATA_FIELD: localUser.name] -->我
|
||||
<!-- [CONDITIONAL_RENDER: localUser.isHost === true] -->
|
||||
<span class="text-xs bg-indigo-500 px-1.5 rounded ml-1">主持人</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: localUser.mediaState] [TRANSFORM: state => statusText] -->
|
||||
<div class="text-xs text-gray-500" id="localMediaStatus" data-field="localUser.mediaStatus">
|
||||
静音中</div>
|
||||
</div>
|
||||
<!-- [CONDITIONAL_RENDER: localUser.mediaState.audio === false] -->
|
||||
<i class="fas fa-microphone-slash text-gray-500 text-xs" data-field="localUser.muteIcon"></i>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: users] -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 聊天消息列表 (Chat Messages)
|
||||
数据源: ChatMessage[]
|
||||
排序: 按 timestamp 升序
|
||||
-->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar" id="chatContent">
|
||||
<!-- [STATIC] 通话开始时间 -->
|
||||
<div class="text-center text-xs text-gray-500 my-4">
|
||||
通话开始 <!-- [DATA_FIELD: callSession.startTime] [FORMAT: HH:MM] -->14:30
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_START: messages as message] -->
|
||||
|
||||
<!-- 消息模板 (对方) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === false] -->
|
||||
<div class="chat-bubble" data-message-id="${message.id}">
|
||||
<div class="flex gap-3">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<!-- [DATA_FIELD: message.senderName] -->
|
||||
<span class="text-sm font-medium text-indigo-400" data-field="message.senderName">Sarah
|
||||
Chen</span>
|
||||
<!-- [DATA_FIELD: message.timestamp] [FORMAT: HH:MM] -->
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<!-- [DATA_FIELD: message.content] -->
|
||||
<div class="glass px-3 py-2 rounded-2xl rounded-tl-none text-sm text-gray-200"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息模板 (自己) -->
|
||||
<!-- [CONDITIONAL_RENDER: message.isSelf === true] -->
|
||||
<div class="chat-bubble">
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<!-- [DATA_FIELD: message.senderAvatar] -->
|
||||
<img src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
|
||||
class="w-8 h-8 rounded-full object-cover" data-field="message.senderAvatar">
|
||||
<div class="flex-1 flex flex-col items-end">
|
||||
<div class="flex items-baseline gap-2 mb-1 flex-row-reverse">
|
||||
<span class="text-sm font-medium text-green-400">我</span>
|
||||
<span class="text-xs text-gray-500" data-field="message.time">14:32</span>
|
||||
</div>
|
||||
<div class="bg-indigo-600 px-3 py-2 rounded-2xl rounded-tr-none text-sm text-white"
|
||||
data-field="message.content">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- [LOOP_END: messages] -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
子区域: 消息输入 (Message Input)
|
||||
API: [POST] /api/call/:callId/message
|
||||
-->
|
||||
<div class="p-4 border-t border-white/10">
|
||||
<div class="glass rounded-2xl flex items-center gap-2 p-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full hover:bg-white/10 flex items-center justify-center text-gray-400 transition-colors"
|
||||
onclick="openImagePicker()">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<!-- 隐藏的文件输入元素 -->
|
||||
<input type="file" id="imageInput" accept="image/*" class="hidden"
|
||||
onchange="handleImageUpload(event)">
|
||||
<!-- [INPUT_FIELD] [BIND: inputValue] [EVENT: onEnter => sendMessage()] -->
|
||||
<input type="text" id="chatInput" placeholder="输入消息..."
|
||||
class="flex-1 bg-transparent border-none outline-none text-sm text-white placeholder-gray-500 px-2"
|
||||
data-field="chatInput" onkeypress="handleChatSubmit(event)">
|
||||
<!-- [BUTTON] [EVENT: onclick => sendMessage()] -->
|
||||
<button onclick="sendMessage()"
|
||||
class="w-8 h-8 rounded-full bg-indigo-600 hover:bg-indigo-700 flex items-center justify-center transition-colors">
|
||||
<i class="fas fa-paper-plane text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!--
|
||||
============================================================
|
||||
区域: 底部控制栏 (Control Bar)
|
||||
数据源: LocalUser.mediaState
|
||||
API: [POST] /api/call/:callId/media
|
||||
WebSocket: emit 'media-state-changed'
|
||||
============================================================
|
||||
-->
|
||||
<footer class="glass-strong h-20 border-t border-white/10 flex items-center justify-center px-6 gap-4 z-50">
|
||||
|
||||
<!-- 左侧连接信息 -->
|
||||
<div class="absolute left-6 hidden md:flex items-center gap-3">
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-medium">一对一通话</div>
|
||||
<!-- [DATA_FIELD: remoteUser.networkQuality] [TRANSFORM: quality => displayText] -->
|
||||
<div class="text-xs text-gray-400" id="connectionQuality" data-field="connectionQualityText">
|
||||
连接质量: 优秀
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间控制按钮组 -->
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- 麦克风控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.audio] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleMute(this)" id="micBtn" data-field="localUser.audio" data-active="false">
|
||||
<i class="fas fa-microphone text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-microphone-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
静音 (Space)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 摄像头控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.video] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleVideo(this)" id="videoBtn" data-field="localUser.video" data-active="false">
|
||||
<i class="fas fa-video text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-video-slash text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
关闭视频 (Ctrl+V)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 录屏控制 -->
|
||||
<!-- [DATA_FIELD: localUser.mediaState.recording] [TYPE: boolean] -->
|
||||
<button
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group"
|
||||
onclick="toggleRecording(this)" id="recordBtn" data-field="localUser.recording" data-active="false">
|
||||
<i class="fas fa-circle text-lg" data-icon="default"></i>
|
||||
<i class="fas fa-stop text-lg hidden text-red-400" data-icon="active"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
录制
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 更多选项 -->
|
||||
<div class="relative">
|
||||
<button id="moreOptionsBtn"
|
||||
class="control-btn w-12 h-12 rounded-full glass flex items-center justify-center text-white hover:bg-white/10 relative group">
|
||||
<i class="fas fa-ellipsis-h text-lg"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
更多选项
|
||||
</span>
|
||||
</button>
|
||||
<!-- 更多选项下拉菜单 -->
|
||||
<div id="moreOptionsMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 glass rounded-xl shadow-lg w-52 z-50">
|
||||
<!-- 分辨率选项 -->
|
||||
<div class="p-3 border-b border-white/10">
|
||||
<h4 class="text-xs font-medium text-gray-400 mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-desktop text-xs"></i>
|
||||
视频分辨率
|
||||
</h4>
|
||||
<div class="space-y-1" id="resolutionOptions">
|
||||
<button onclick="changeResolution(480, 270)" data-resolution="480"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>流畅 480p</span>
|
||||
<span class="text-xs text-gray-500">省流量</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1280, 720)" data-resolution="720"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>高清 720p</span>
|
||||
<span class="text-xs text-gray-500">推荐</span>
|
||||
</button>
|
||||
<button onclick="changeResolution(1920, 1080)" data-resolution="1080"
|
||||
class="resolution-option active w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>超清 1080p</span>
|
||||
<span class="text-xs text-gray-500"></span>
|
||||
</button>
|
||||
<button onclick="changeResolution(2560, 1440)" data-resolution="1440"
|
||||
class="resolution-option w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-white/10 transition-colors">
|
||||
<span>2K 1440p</span>
|
||||
<span class="text-xs text-gray-500">最高画质</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 当前分辨率指示 -->
|
||||
<div class="px-3 py-2 flex items-center gap-2">
|
||||
<i class="fas fa-info-circle text-xs text-gray-500"></i>
|
||||
<span id="currentResolutionText" class="text-xs text-gray-500">当前: 1080p</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结束通话 -->
|
||||
<!-- [EVENT: onclick => endCall()] [API: POST /api/call/:callId/leave] -->
|
||||
<button
|
||||
class="control-btn w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white end-call-pulse ml-4 relative group"
|
||||
onclick="endCall()">
|
||||
<i class="fas fa-phone-slash text-xl"></i>
|
||||
<span
|
||||
class="absolute -top-10 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-black/80 rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
结束通话
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧聊天按钮 -->
|
||||
<div class="absolute right-6 flex items-center gap-3">
|
||||
<button
|
||||
class="control-btn w-10 h-10 rounded-full glass flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/10 transition-colors relative"
|
||||
onclick="toggleSidebar()">
|
||||
<i class="fas fa-comment-alt"></i>
|
||||
<!-- 未读消息计数角标 -->
|
||||
<span id="unreadBadge"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs font-bold text-white hidden">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- 通知组件 -->
|
||||
<div id="notification"
|
||||
class="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]">
|
||||
<i class="fas fa-info-circle text-indigo-400"></i>
|
||||
<span class="text-sm" id="notificationText">通知内容</span>
|
||||
</div>
|
||||
|
||||
<!-- 通话结束确认对话框 -->
|
||||
<div id="endCallDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-phone-slash text-red-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">结束通话</h3>
|
||||
<p class="text-gray-400 text-sm">确定要结束当前通话吗?</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="cancelEndCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button id="confirmEndCall"
|
||||
class="flex-1 py-2 rounded-lg bg-red-500 hover:bg-red-600 transition-colors">
|
||||
结束通话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通话请求弹窗 -->
|
||||
<div id="callRequestDialog" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 hidden">
|
||||
<div class="glass rounded-2xl p-6 w-80 max-w-md">
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-16 h-16 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<i class="fas fa-video text-indigo-500 text-2xl"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2" id="callRequestName">Sarah Chen</h3>
|
||||
<p class="text-gray-400 text-sm" id="callRequestText">正在请求与您进行视频通话</p>
|
||||
<div class="mt-4 flex items-center justify-center gap-4">
|
||||
<img id="callRequestAvatar"
|
||||
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
|
||||
class="w-16 h-16 rounded-full object-cover border-4 border-indigo-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="rejectCall" class="flex-1 py-2 rounded-lg glass hover:bg-white/10 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
<span>拒绝</span>
|
||||
</div>
|
||||
</button>
|
||||
<button id="acceptCall"
|
||||
class="flex-1 py-2 rounded-lg bg-green-500 hover:bg-green-600 transition-colors">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<i class="fas fa-phone"></i>
|
||||
<span>接受</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入模块化JavaScript文件 -->
|
||||
<script type="module" src="main.js"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
365
client/public/onebyone/knowledge-graph.md
Normal file
365
client/public/onebyone/knowledge-graph.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# OneByOne 知识图谱
|
||||
|
||||
## 1. 模块依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Page["📄 页面层"]
|
||||
INDEX[index.html<br/>主通话页面]
|
||||
CONN[connect/connect.html<br/>连接页面]
|
||||
END[endcall/endcall.html<br/>结束页面]
|
||||
end
|
||||
|
||||
subgraph Core["⚙️ 核心模块"]
|
||||
MAIN[main.js<br/>应用入口]
|
||||
STORE[store.js<br/>状态管理]
|
||||
RENDER[renderer.js<br/>UI渲染器]
|
||||
CHAT[chatmessage.js<br/>消息模块]
|
||||
end
|
||||
|
||||
subgraph Base["📦 数据与工具"]
|
||||
MODELS[models.js<br/>数据模型]
|
||||
UTILS[utils.js<br/>工具函数]
|
||||
end
|
||||
|
||||
subgraph External["🔗 外部依赖"]
|
||||
SIGNALING[signaling.js<br/>信令管理]
|
||||
RENDERSTR[renderstreaming.js<br/>WebRTC管理]
|
||||
CONFIG[config.js<br/>配置管理]
|
||||
end
|
||||
|
||||
INDEX --> MAIN
|
||||
CONN --> CONN_JS[connect.js]
|
||||
END --> END_JS[endcall.js]
|
||||
|
||||
MAIN --> STORE
|
||||
MAIN --> RENDER
|
||||
MAIN --> UTILS
|
||||
MAIN --> CHAT
|
||||
|
||||
STORE --> MODELS
|
||||
STORE --> SIGNALING
|
||||
STORE --> RENDERSTR
|
||||
STORE --> CONFIG
|
||||
STORE --> UTILS
|
||||
STORE --> CHAT
|
||||
|
||||
RENDER --> UTILS
|
||||
RENDER --> MODELS
|
||||
RENDER --> CHAT
|
||||
|
||||
CHAT --> UTILS
|
||||
CHAT --> STORE
|
||||
CHAT --> MODELS
|
||||
|
||||
CONN_JS --> STORE
|
||||
|
||||
style STORE fill:#f9f,stroke:#333,stroke-width:2px
|
||||
style RENDER fill:#bbf,stroke:#333,stroke-width:2px
|
||||
style CHAT fill:#bfb,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## 2. 数据模型关系图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Models["数据模型结构"]
|
||||
SESSION[CallSession<br/>通话会话]
|
||||
LOCAL[LocalUser<br/>本地用户]
|
||||
REMOTE[RemoteUser<br/>远端用户]
|
||||
MEDIA[MediaState<br/>媒体状态]
|
||||
MSG[ChatMessage<br/>聊天消息]
|
||||
end
|
||||
|
||||
SESSION --> LOCAL
|
||||
SESSION --> REMOTE
|
||||
LOCAL --> MEDIA
|
||||
REMOTE --> MEDIA
|
||||
|
||||
style SESSION fill:#f96,stroke:#333,stroke-width:2px
|
||||
style MEDIA fill:#9f6,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
### 模型属性说明
|
||||
|
||||
| 模型 | 属性 |
|
||||
|-----|------|
|
||||
| **CallSession** | id, type, status, startTime, duration, isEncrypted |
|
||||
| **LocalUser** | id, name, avatar, isHost, mediaState |
|
||||
| **RemoteUser** | id, name, avatar, status, networkQuality, mediaState |
|
||||
| **MediaState** | audio, video, screenShare, recording, isSpeaking |
|
||||
| **ChatMessage** | id, senderId, senderName, content, type, timestamp, isSelf |
|
||||
|
||||
## 3. 状态更新时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant USER as 👤 用户
|
||||
participant DOM as 🖱️ DOM事件
|
||||
participant MAIN as ⚙️ main.js
|
||||
participant STORE as 📦 store.js
|
||||
participant RENDER as 🎨 renderer.js
|
||||
participant UI as 🖥️ UI界面
|
||||
|
||||
USER->>DOM: 点击麦克风按钮
|
||||
DOM->>MAIN: toggleMute()
|
||||
MAIN->>STORE: updateLocalMedia('audio', false)
|
||||
STORE->>STORE: 更新 mediaState.audio
|
||||
STORE->>STORE: notify({type: 'LOCAL_MEDIA_CHANGE'})
|
||||
STORE->>RENDER: 回调 render(state, changes)
|
||||
RENDER->>RENDER: renderControlButtons(mediaState)
|
||||
RENDER->>RENDER: renderLocalUserStatus(localUser)
|
||||
RENDER->>UI: 更新DOM
|
||||
UI->>USER: 显示更新后的界面
|
||||
```
|
||||
|
||||
## 4. WebRTC 连接建立时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant USER as 👤 用户
|
||||
participant MAIN as ⚙️ main.js
|
||||
participant STORE as 📦 store.js
|
||||
participant SIGNAL as 📡 signaling.js
|
||||
participant RTC as 🔗 renderstreaming.js
|
||||
participant PEER as 👤 远端对等端
|
||||
|
||||
USER->>MAIN: 访问页面
|
||||
MAIN->>STORE: joinCall(connectionId)
|
||||
STORE->>STORE: init()
|
||||
STORE->>STORE: getLocalStream()<br/>获取本地摄像头
|
||||
MAIN->>STORE: setUp(connectionId)
|
||||
STORE->>SIGNAL: 创建信令实例
|
||||
STORE->>RTC: new RenderStreaming<br/>(signaling, config)
|
||||
RTC->>SIGNAL: start()
|
||||
RTC->>SIGNAL: createConnection(connectionId)
|
||||
|
||||
loop WebSocket信令交换
|
||||
PEER->>SIGNAL: 交换SDP/ICE
|
||||
SIGNAL->>RTC: 信令数据
|
||||
end
|
||||
|
||||
RTC->>STORE: onConnect 回调
|
||||
STORE->>STORE: 状态更新为 ongoing
|
||||
PEER->>RTC: 接收媒体流
|
||||
RTC->>STORE: onTrackEvent 回调
|
||||
STORE->>STORE: notify(REMOTE_STREAM_OBTAINED)
|
||||
STORE->>RENDER: 触发渲染更新
|
||||
RENDER->>UI: 显示远端视频
|
||||
```
|
||||
|
||||
## 5. 消息发送流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant USER as 👤 用户
|
||||
participant DOM as 🖱️ DOM
|
||||
participant CHAT as 💬 chatmessage.js
|
||||
participant STORE as 📦 store.js
|
||||
participant RTC as 🔗 renderstreaming.js
|
||||
participant PEER as 👤 远端用户
|
||||
|
||||
USER->>DOM: 输入消息并回车
|
||||
DOM->>CHAT: handleChatSubmit(event)
|
||||
CHAT->>CHAT: sendMessage()
|
||||
CHAT->>CHAT: addMessage(message)<br/>添加到本地列表
|
||||
CHAT->>STORE: getRenderStreaming()
|
||||
STORE-->>CHAT: 返回实例
|
||||
CHAT->>RTC: sendMessage()<br/>发送chat-message
|
||||
RTC->>PEER: 通过WebRTC数据通道发送
|
||||
|
||||
PEER->>RTC: 收到消息
|
||||
RTC->>STORE: onMessage 回调
|
||||
STORE->>CHAT: handleChatMessage(data)
|
||||
CHAT->>CHAT: addMessage()<br/>添加到消息列表
|
||||
CHAT->>RENDER: 更新聊天UI
|
||||
```
|
||||
|
||||
## 6. 页面流转状态图
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> 连接页面: 首次访问/无connectionId
|
||||
|
||||
连接页面: connect.html
|
||||
连接页面: - 输入连接ID
|
||||
连接页面: - 创建新通话
|
||||
连接页面: - 用户设置
|
||||
|
||||
通话页面: index.html
|
||||
通话页面: - 视频通话
|
||||
通话页面: - 聊天功能
|
||||
通话页面: - 媒体控制
|
||||
|
||||
结束页面: endcall.html
|
||||
结束页面: - 显示断开原因
|
||||
结束页面: - 重新连接选项
|
||||
|
||||
连接页面 --> 通话页面: 加入/创建通话
|
||||
通话页面 --> 结束页面: 通话结束/断开
|
||||
通话页面 --> 连接页面: 无有效connectionId
|
||||
结束页面 --> 通话页面: 重新连接
|
||||
结束页面 --> 连接页面: 返回
|
||||
```
|
||||
|
||||
## 7. 通话状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle: 初始化
|
||||
|
||||
idle: 空闲状态
|
||||
idle --> connecting: joinCall()<br/>加入通话
|
||||
idle --> connecting: createCall()<br/>创建通话
|
||||
|
||||
connecting: 连接中
|
||||
connecting --> ongoing: onConnect<br/>连接成功
|
||||
connecting --> failed: 连接失败
|
||||
|
||||
ongoing: 通话中
|
||||
ongoing --> ended: hangUp()<br/>挂断
|
||||
ongoing --> ended: endCall()<br/>结束通话
|
||||
|
||||
failed: 连接失败
|
||||
failed --> connecting: 重试连接
|
||||
failed --> ended: 放弃连接
|
||||
|
||||
ended: 已结束
|
||||
ended --> [*]: 清理资源
|
||||
```
|
||||
|
||||
## 8. 组件层次结构
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph index["index.html 组件结构"]
|
||||
HEADER["🧭 Header 顶部栏"]
|
||||
MAIN["📺 Main 主内容区"]
|
||||
FOOTER["🎛️ Footer 底部控制栏"]
|
||||
end
|
||||
|
||||
subgraph header["Header 内容"]
|
||||
TITLE["标题: 与xxx的通话"]
|
||||
NETWORK["网络质量指示"]
|
||||
DURATION["通话时长"]
|
||||
ENCRYPT["端到端加密标识"]
|
||||
end
|
||||
|
||||
subgraph main["Main 内容"]
|
||||
VIDEO_AREA["VideoArea 视频区"]
|
||||
SIDEBAR["Sidebar 侧边栏"]
|
||||
end
|
||||
|
||||
subgraph video["VideoArea"]
|
||||
REMOTE["RemoteVideo 远端视频"]
|
||||
LOCAL["LocalVideo 本地视频画中画"]
|
||||
end
|
||||
|
||||
subgraph sidebar["Sidebar"]
|
||||
USER_LIST["UserList 用户列表"]
|
||||
CHAT_MSG["ChatMessages 聊天消息"]
|
||||
CHAT_INPUT["ChatInput 消息输入"]
|
||||
end
|
||||
|
||||
subgraph footer["Footer 控制栏"]
|
||||
CTRL_MIC["🎤 麦克风"]
|
||||
CTRL_VIDEO["📹 视频"]
|
||||
CTRL_RECORD["🔴 录制"]
|
||||
CTRL_END["📞 结束通话"]
|
||||
CTRL_CHAT["💬 聊天"]
|
||||
end
|
||||
|
||||
HEADER --> TITLE
|
||||
HEADER --> NETWORK
|
||||
HEADER --> DURATION
|
||||
HEADER --> ENCRYPT
|
||||
|
||||
MAIN --> VIDEO_AREA
|
||||
MAIN --> SIDEBAR
|
||||
|
||||
VIDEO_AREA --> REMOTE
|
||||
VIDEO_AREA --> LOCAL
|
||||
|
||||
SIDEBAR --> USER_LIST
|
||||
SIDEBAR --> CHAT_MSG
|
||||
SIDEBAR --> CHAT_INPUT
|
||||
|
||||
FOOTER --> CTRL_MIC
|
||||
FOOTER --> CTRL_VIDEO
|
||||
FOOTER --> CTRL_RECORD
|
||||
FOOTER --> CTRL_END
|
||||
FOOTER --> CTRL_CHAT
|
||||
|
||||
style CTRL_END fill:#f66,stroke:#333,stroke-width:2px
|
||||
style ENCRYPT fill:#9f6,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
## 9. 文件引用汇总
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Import["导入关系"]
|
||||
direction TB
|
||||
M[main.js]
|
||||
S[store.js]
|
||||
R[renderer.js]
|
||||
C[chatmessage.js]
|
||||
CONN[connect.js]
|
||||
|
||||
M --> S
|
||||
M --> R
|
||||
M --> C
|
||||
S --> C
|
||||
R --> C
|
||||
CONN --> S
|
||||
end
|
||||
|
||||
subgraph Export["导出内容"]
|
||||
direction TB
|
||||
ST[store.js<br/>export default store]
|
||||
RE[renderer.js<br/>export default UIRenderer]
|
||||
UT[utils.js<br/>export { formatTime, showNotification, ... }]
|
||||
MO[models.js<br/>export { mockCallSession, mockMessages }]
|
||||
CH[chatmessage.js<br/>export { sendMessage, toggleSidebar, ... }]
|
||||
end
|
||||
```
|
||||
|
||||
## 10. API 接口调用图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Client["客户端"]
|
||||
CONN_JS[connect.js]
|
||||
end
|
||||
|
||||
subgraph Server["服务器"]
|
||||
API_CALL[/api/call/:callId]
|
||||
API_JOIN[/api/call/:callId/join]
|
||||
API_LEAVE[/api/call/:callId/leave]
|
||||
API_MEDIA[/api/call/:callId/media]
|
||||
API_MSG[/api/call/:callId/messages]
|
||||
API_SEND[/api/call/:callId/message]
|
||||
API_CONN[/signaling/connection-ids]
|
||||
API_UPLOAD[/api/upload/avatar]
|
||||
end
|
||||
|
||||
CONN_JS -.-> API_CONN
|
||||
CONN_JS -.-> API_UPLOAD
|
||||
|
||||
style API_CALL fill:#bbf,stroke:#333
|
||||
style API_JOIN fill:#bbf,stroke:#333
|
||||
style API_LEAVE fill:#bbf,stroke:#333
|
||||
style API_MEDIA fill:#bbf,stroke:#333
|
||||
style API_MSG fill:#bbf,stroke:#333
|
||||
style API_SEND fill:#bbf,stroke:#333
|
||||
style API_CONN fill:#fbf,stroke:#333
|
||||
style API_UPLOAD fill:#fbf,stroke:#333
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间: 2026-04-11*
|
||||
*适用于 OneByOne 视频通话应用*
|
||||
215
client/public/onebyone/main.js
Normal file
215
client/public/onebyone/main.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 主入口文件
|
||||
* 初始化应用,连接各个模块
|
||||
*/
|
||||
import store from './store.js';
|
||||
import UIRenderer from './renderer.js';
|
||||
import { showNotification } from './utils.js';
|
||||
import chatMessage from './chatmessage.js';
|
||||
|
||||
// 全局变量
|
||||
let connectionId = "";
|
||||
/**
|
||||
* 绑定DOM事件
|
||||
*/
|
||||
function bindDomEvents() {
|
||||
// 切换侧边栏
|
||||
window.toggleSidebar = function () {
|
||||
chatMessage.toggleSidebar();
|
||||
};
|
||||
|
||||
// 切换麦克风
|
||||
window.toggleMute = function (button) {
|
||||
const state = store.getState();
|
||||
const currentState = state.session.localUser.mediaState.audio;
|
||||
store.updateLocalMedia('audio', !currentState);
|
||||
};
|
||||
|
||||
// 切换视频
|
||||
window.toggleVideo = function (button) {
|
||||
const state = store.getState();
|
||||
const currentState = state.session.localUser.mediaState.video;
|
||||
store.updateLocalMedia('video', !currentState);
|
||||
};
|
||||
|
||||
// 切换本地视频(用于悬停控制)
|
||||
window.toggleLocalVideo = function () {
|
||||
window.toggleVideo();
|
||||
};
|
||||
|
||||
// 切换录屏
|
||||
window.toggleRecording = function (button) {
|
||||
const state = store.getState();
|
||||
const currentState = state.session.localUser.mediaState.recording || false;
|
||||
store.updateLocalMedia('recording', !currentState);
|
||||
|
||||
// 显示录制状态通知
|
||||
if (!currentState) {
|
||||
showNotification('开始录制');
|
||||
} else {
|
||||
showNotification('停止录制');
|
||||
}
|
||||
};
|
||||
|
||||
// 更多选项菜单切换
|
||||
window.toggleMoreOptions = function () {
|
||||
const menu = document.getElementById('moreOptionsMenu');
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换视频分辨率
|
||||
window.changeResolution = function (width, height) {
|
||||
store.changeResolution(width, height);
|
||||
// 关闭菜单
|
||||
const menu = document.getElementById('moreOptionsMenu');
|
||||
if (menu) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 结束通话
|
||||
window.endCall = function () {
|
||||
// 显示确认对话框
|
||||
document.getElementById('endCallDialog').classList.remove('hidden');
|
||||
};
|
||||
|
||||
// 取消结束通话
|
||||
window.cancelEndCall = function () {
|
||||
document.getElementById('endCallDialog').classList.add('hidden');
|
||||
};
|
||||
|
||||
// 确认结束通话
|
||||
window.confirmEndCall = function () {
|
||||
document.getElementById('endCallDialog').classList.add('hidden');
|
||||
store.endCall();
|
||||
showNotification('通话已结束');
|
||||
};
|
||||
|
||||
// 显示通话请求弹窗
|
||||
window.showCallRequest = function (caller) {
|
||||
const dialog = document.getElementById('callRequestDialog');
|
||||
if (dialog) {
|
||||
// 设置通话请求信息
|
||||
if (document.getElementById('callRequestName')) {
|
||||
document.getElementById('callRequestName').textContent = caller.name;
|
||||
}
|
||||
if (document.getElementById('callRequestAvatar')) {
|
||||
document.getElementById('callRequestAvatar').src = caller.avatar;
|
||||
}
|
||||
// 显示弹窗
|
||||
dialog.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// 拒绝通话
|
||||
window.rejectCall = function () {
|
||||
const dialog = document.getElementById('callRequestDialog');
|
||||
if (dialog) {
|
||||
dialog.classList.add('hidden');
|
||||
}
|
||||
showNotification('已拒绝通话请求');
|
||||
// 可以在这里添加发送拒绝通话请求到服务器的逻辑
|
||||
};
|
||||
|
||||
// 接受通话
|
||||
window.acceptCall = function () {
|
||||
const dialog = document.getElementById('callRequestDialog');
|
||||
if (dialog) {
|
||||
dialog.classList.add('hidden');
|
||||
}
|
||||
showNotification('已接受通话请求');
|
||||
// 可以在这里添加发送接受通话请求到服务器的逻辑
|
||||
// 然后初始化通话
|
||||
store.initCall();
|
||||
store.setUp(connectionId);
|
||||
};
|
||||
|
||||
// 绑定消息相关事件
|
||||
chatMessage.bindMessageEvents();
|
||||
|
||||
// 键盘快捷键
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// 空格键静音
|
||||
if (event.code === 'Space' && !event.target.matches('input, textarea')) {
|
||||
event.preventDefault();
|
||||
window.toggleMute();
|
||||
}
|
||||
|
||||
// Ctrl+V 切换视频
|
||||
if (event.ctrlKey && event.key === 'v') {
|
||||
event.preventDefault();
|
||||
window.toggleVideo();
|
||||
}
|
||||
});
|
||||
|
||||
// 绑定对话框事件
|
||||
document.getElementById('cancelEndCall').addEventListener('click', window.cancelEndCall);
|
||||
document.getElementById('confirmEndCall').addEventListener('click', window.confirmEndCall);
|
||||
|
||||
// 更多选项按钮事件
|
||||
const moreOptionsBtn = document.getElementById('moreOptionsBtn');
|
||||
if (moreOptionsBtn) {
|
||||
moreOptionsBtn.addEventListener('click', window.toggleMoreOptions);
|
||||
}
|
||||
|
||||
// 点击外部关闭更多选项菜单
|
||||
document.addEventListener('click', function(event) {
|
||||
const moreOptionsMenu = document.getElementById('moreOptionsMenu');
|
||||
const moreOptionsBtnEl = document.getElementById('moreOptionsBtn');
|
||||
if (moreOptionsMenu && moreOptionsBtnEl &&
|
||||
!moreOptionsMenu.contains(event.target) &&
|
||||
!moreOptionsBtnEl.contains(event.target)) {
|
||||
moreOptionsMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 绑定通话请求对话框事件
|
||||
if (document.getElementById('rejectCall')) {
|
||||
document.getElementById('rejectCall').addEventListener('click', window.rejectCall);
|
||||
}
|
||||
if (document.getElementById('acceptCall')) {
|
||||
document.getElementById('acceptCall').addEventListener('click', window.acceptCall);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 页面加载完成后初始化
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
// 检查本地存储中是否有连接ID
|
||||
const connectionId = localStorage.getItem('connectionId');
|
||||
|
||||
if (!connectionId) {
|
||||
// 如果没有连接ID,跳转到连接界面
|
||||
window.location.href = './connect/connect.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化 store
|
||||
//await store.init();
|
||||
|
||||
// 初始化渲染器
|
||||
const renderer = new UIRenderer(store);
|
||||
|
||||
// 加入通话
|
||||
await store.joinCall(connectionId);
|
||||
|
||||
// 设置WebRTC连接
|
||||
await store.setUp(connectionId);
|
||||
|
||||
renderer.renderHeaderTitle();
|
||||
|
||||
// 绑定DOM事件
|
||||
bindDomEvents();
|
||||
|
||||
console.log('Video call app initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
showNotification('初始化失败,请刷新页面重试', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出全局变量
|
||||
export { store };
|
||||
112
client/public/onebyone/models.js
Normal file
112
client/public/onebyone/models.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 类型定义和数据模型
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CallSession
|
||||
* @property {string} id - 通话唯一标识 (UUID)
|
||||
* @property {'video'|'audio'} type - 通话类型
|
||||
* @property {'connecting'|'ongoing'|'ended'|'failed'} status - 通话状态
|
||||
* @property {string} startTime - ISO 8601 时间戳
|
||||
* @property {number} duration - 已进行秒数
|
||||
* @property {boolean} isEncrypted - 是否启用端到端加密
|
||||
* @property {LocalUser} localUser - 本地用户信息
|
||||
* @property {RemoteUser} remoteUser - 远端用户信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LocalUser
|
||||
* @property {string} id - 用户ID
|
||||
* @property {string} name - 显示名称
|
||||
* @property {string} avatar - 头像URL
|
||||
* @property {boolean} isHost - 是否主持人
|
||||
* @property {MediaState} mediaState - 媒体状态
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RemoteUser
|
||||
* @property {string} id - 用户ID
|
||||
* @property {string} name - 显示名称
|
||||
* @property {string} avatar - 头像URL
|
||||
* @property {'online'|'offline'|'connecting'} status - 在线状态
|
||||
* @property {MediaState} mediaState - 媒体状态
|
||||
* @property {'excellent'|'good'|'fair'|'poor'} networkQuality - 网络质量
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MediaState
|
||||
* @property {boolean} audio - 音频是否开启
|
||||
* @property {boolean} video - 视频是否开启
|
||||
* @property {boolean} screenShare - 是否屏幕共享
|
||||
* @property {boolean} recording - 是否正在录屏
|
||||
* @property {boolean} isSpeaking - 是否正在说话(VAD)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ChatMessage
|
||||
* @property {string} id - 消息唯一ID
|
||||
* @property {string} senderId - 发送者ID
|
||||
* @property {string} senderName - 发送者名称
|
||||
* @property {string} senderAvatar - 发送者头像URL
|
||||
* @property {string} content - 消息内容
|
||||
* @property {'text'|'file'|'system'} type - 消息类型
|
||||
* @property {string} timestamp - ISO 8601 时间戳
|
||||
* @property {boolean} isSelf - 是否为自己发送
|
||||
*/
|
||||
|
||||
// 模拟通话会话数据
|
||||
const mockCallSession = {
|
||||
id: "call-8842-2024-001",
|
||||
type: "video",
|
||||
status: "ongoing", // connecting | ongoing | ended | failed
|
||||
startTime: "2024-01-15T14:30:00.000Z",
|
||||
duration: 0, // 秒数,后端可不返回,前端本地计算
|
||||
isEncrypted: true,
|
||||
|
||||
// 本地用户信息
|
||||
localUser: {
|
||||
id: "user-local-001",
|
||||
name: "我",
|
||||
avatar: "/images/p1.png",
|
||||
isHost: true,
|
||||
mediaState: {
|
||||
audio: true,
|
||||
video: true,
|
||||
screenShare: false,
|
||||
recording: false,
|
||||
isSpeaking: false
|
||||
}
|
||||
},
|
||||
|
||||
// 远端用户信息
|
||||
remoteUser: {
|
||||
id: "user-remote-002",
|
||||
name: "Unity",
|
||||
avatar: "/images/p2.png",
|
||||
status: "offline", // online | offline | connecting
|
||||
networkQuality: "no_signal", // excellent | good | fair | poor | no_signal
|
||||
mediaState: {
|
||||
audio: true,
|
||||
video: true,
|
||||
screenShare: false,
|
||||
recording: false,
|
||||
isSpeaking: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟聊天消息数据
|
||||
const mockMessages = [
|
||||
{
|
||||
id: "msg-001",
|
||||
senderId: "system",
|
||||
senderName: "系统",
|
||||
senderAvatar: "/images/screenshot.png",
|
||||
content: "通话已建立连接",
|
||||
type: "system",
|
||||
timestamp: "2024-01-15T14:30:00.000Z",
|
||||
isSelf: false
|
||||
}
|
||||
];
|
||||
|
||||
export { mockCallSession, mockMessages };
|
||||
1125
client/public/onebyone/renderer.js
Normal file
1125
client/public/onebyone/renderer.js
Normal file
File diff suppressed because it is too large
Load Diff
1430
client/public/onebyone/store.js
Normal file
1430
client/public/onebyone/store.js
Normal file
File diff suppressed because it is too large
Load Diff
94
client/public/onebyone/utils.js
Normal file
94
client/public/onebyone/utils.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化时间为 MM:SS 格式
|
||||
* @param {number} seconds - 秒数
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
export function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为 HH:MM 格式
|
||||
* @param {string} timestamp - ISO 8601 时间戳
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
export function formatTimestamp(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @returns {string} 唯一ID
|
||||
*/
|
||||
export function generateId() {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示通知
|
||||
* @param {string} message - 通知内容
|
||||
* @param {number} duration - 显示时长(毫秒)
|
||||
*/
|
||||
export function showNotification(message, duration = 3000) {
|
||||
const notification = document.getElementById('notification');
|
||||
const notificationText = document.getElementById('notificationText');
|
||||
|
||||
if (notification && notificationText) {
|
||||
notificationText.textContent = message;
|
||||
notification.classList.remove('opacity-0', 'translate-y-[-20px]');
|
||||
notification.classList.add('opacity-100', 'translate-y-0');
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('opacity-100', 'translate-y-0');
|
||||
notification.classList.add('opacity-0', 'translate-y-[-20px]');
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换元素的显示/隐藏
|
||||
* @param {HTMLElement} element - DOM元素
|
||||
* @param {boolean} show - 是否显示
|
||||
*/
|
||||
export function toggleElement(element, show) {
|
||||
if (element) {
|
||||
if (show) {
|
||||
element.classList.remove('hidden');
|
||||
} else {
|
||||
element.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换按钮状态
|
||||
* @param {HTMLElement} button - 按钮元素
|
||||
* @param {boolean} active - 是否激活
|
||||
*/
|
||||
export function toggleButtonState(button, active) {
|
||||
if (button) {
|
||||
button.dataset.active = active;
|
||||
|
||||
const defaultIcon = button.querySelector('[data-icon="default"]');
|
||||
const activeIcon = button.querySelector('[data-icon="active"]');
|
||||
|
||||
if (defaultIcon && activeIcon) {
|
||||
if (active) {
|
||||
defaultIcon.classList.add('hidden');
|
||||
activeIcon.classList.remove('hidden');
|
||||
} else {
|
||||
defaultIcon.classList.remove('hidden');
|
||||
activeIcon.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user