Files
webRtc/WebApp/client/public/onebyone/script.js
2026-03-02 22:32:57 +08:00

704 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ==========================================
* 1. 类型定义 (Type Definitions)
* 后端可参考此部分设计数据库模型和 API 响应格式
* ==========================================
*/
/**
* @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} 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 - 是否为自己发送
*/
/**
* ==========================================
* 2. 模拟数据层 (Mock Data Layer)
* 后端接口返回的数据格式应与此结构一致
* ==========================================
*/
// [API_RESPONSE: GET /api/call/:callId]
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,
// [API_RESPONSE: 嵌套用户信息]
localUser: {
id: "user-local-001",
name: "我",
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop",
isHost: true,
mediaState: {
audio: true,
video: true,
screenShare: false,
isSpeaking: false
}
},
remoteUser: {
id: "user-remote-002",
name: "Sarah Chen",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop",
status: "online", // online | offline | connecting
networkQuality: "excellent", // excellent | good | fair | poor
mediaState: {
audio: true,
video: true,
screenShare: false,
isSpeaking: false
}
}
};
// [API_RESPONSE: GET /api/call/:callId/messages]
const mockMessages = [
{
id: "msg-001",
senderId: "system",
senderName: "系统",
senderAvatar: "/assets/system.png",
content: "通话已建立连接",
type: "system",
timestamp: "2024-01-15T14:30:00.000Z",
isSelf: false
},
{
id: "msg-002",
senderId: "user-remote-002",
senderName: "Sarah Chen",
senderAvatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop",
content: "嗨,能听到我说话吗?",
type: "text",
timestamp: "2024-01-15T14:32:15.000Z",
isSelf: false
},
{
id: "msg-003",
senderId: "user-local-001",
senderName: "我",
senderAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop",
content: "很清楚!你的画面也很清晰 👍",
type: "text",
timestamp: "2024-01-15T14:32:45.000Z",
isSelf: true
}
];
/**
* ==========================================
* 3. 状态管理 (State Management)
* 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia
* ==========================================
*/
class CallStateManager {
constructor() {
// 核心状态
this.state = {
session: { ...mockCallSession },
messages: [...mockMessages],
isSidebarOpen: false,
unreadCount: 0,
localStream: null, // MediaStream 对象
remoteStream: null // MediaStream 对象
};
// 监听器数组
this.listeners = [];
// 初始化
this.init();
}
// 订阅状态变化
subscribe(callback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
// 通知所有监听器
notify(changes) {
this.listeners.forEach(cb => cb(this.state, changes));
}
// 初始化
init() {
// 启动通话时长计时器
this.durationInterval = setInterval(() => {
this.state.session.duration++;
this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration });
}, 1000);
// 模拟远端音频活动 (实际应由 WebRTC VAD 检测触发)
this.simulateRemoteActivity();
}
// 更新本地媒体状态
updateLocalMedia(mediaType, value) {
this.state.session.localUser.mediaState[mediaType] = value;
this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value });
// [API_CALL: POST /api/call/:callId/media]
// [WEBSOCKET_EMIT: media-state-changed]
this.emitMediaStateChange();
}
// 更新远端媒体状态 (由 WebSocket 消息触发)
updateRemoteMedia(mediaState) {
this.state.session.remoteUser.mediaState = {
...this.state.session.remoteUser.mediaState,
...mediaState
};
this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState });
}
// 添加消息
addMessage(message) {
this.state.messages.push(message);
// 如果侧边栏关闭且不是自己发的,增加未读
if (!this.state.isSidebarOpen && !message.isSelf) {
this.state.unreadCount++;
}
this.notify({ type: 'NEW_MESSAGE', message, unreadCount: this.state.unreadCount });
}
// 切换侧边栏
toggleSidebar() {
this.state.isSidebarOpen = !this.state.isSidebarOpen;
if (this.state.isSidebarOpen) {
this.state.unreadCount = 0;
}
this.notify({ type: 'SIDEBAR_TOGGLE', isOpen: this.state.isSidebarOpen });
}
// 结束通话
endCall() {
clearInterval(this.durationInterval);
this.state.session.status = 'ended';
this.notify({ type: 'CALL_ENDED' });
// [API_CALL: POST /api/call/:callId/leave]
// [WEBSOCKET_EMIT: leave-call]
}
// 模拟远端活动 (开发测试用)
simulateRemoteActivity() {
setInterval(() => {
if (Math.random() > 0.7) {
const isSpeaking = Math.random() > 0.5;
this.updateRemoteMedia({ isSpeaking });
}
}, 800);
}
// 模拟网络质量变化 (开发测试用)
simulateNetworkChange() {
const qualities = ['excellent', 'good', 'fair', 'poor'];
setInterval(() => {
if (Math.random() > 0.8) {
const quality = qualities[Math.floor(Math.random() * qualities.length)];
this.state.session.remoteUser.networkQuality = quality;
this.notify({ type: 'NETWORK_CHANGE', quality });
}
}, 5000);
}
// 发送媒体状态到服务器
emitMediaStateChange() {
const payload = {
userId: this.state.session.localUser.id,
...this.state.session.localUser.mediaState
};
console.log('[WebSocket Emit] media-state-changed:', payload);
// socket.emit('media-state-changed', payload);
}
// Getters
getState() { return this.state; }
getLocalUser() { return this.state.session.localUser; }
getRemoteUser() { return this.state.session.remoteUser; }
getMessages() { return this.state.messages; }
}
/**
* ==========================================
* 4. UI 渲染器 (UI Renderer)
* 负责将状态映射到 DOM与状态管理解耦
* ==========================================
*/
class UIRenderer {
constructor(stateManager) {
this.stateManager = stateManager;
this.unsubscribe = stateManager.subscribe(this.render.bind(this));
// 缓存 DOM 元素
this.elements = {
// 头部
headerTitle: document.getElementById('headerTitle'),
callDuration: document.getElementById('callDuration'),
encryptionBadge: document.getElementById('encryptionBadge'),
unreadBadge: document.getElementById('unreadBadge'),
// 远端视频
remoteVideo: document.getElementById('remoteVideo'),
remoteAvatar: document.getElementById('remoteAvatar'),
remoteName: document.getElementById('remoteName'),
remoteStatus: document.getElementById('remoteStatus'),
remoteSpeakingIndicator: document.getElementById('remoteSpeakingIndicator'),
remoteAudioWave: document.getElementById('remoteAudioWave'),
networkStatus: document.getElementById('networkStatus'),
networkStatusText: document.getElementById('networkStatusText'),
connectingOverlay: document.getElementById('connectingOverlay'),
// 本地视频
localVideo: document.getElementById('localVideo'),
localVideoPlaceholder: document.getElementById('localVideoPlaceholder'),
localAudioWave: document.getElementById('localAudioWave'),
localInitials: document.getElementById('localInitials'),
// 侧边栏
sidebar: document.getElementById('sidebar'),
chatContent: document.getElementById('chatContent'),
userList: document.getElementById('userList'),
// 控制按钮
micBtn: document.getElementById('micBtn'),
videoBtn: document.getElementById('videoBtn'),
screenBtn: document.getElementById('screenBtn'),
connectionQuality: document.getElementById('connectionQuality')
};
}
// 主渲染入口
render(state, changes) {
if (!changes) {
// 初始化全量渲染
this.renderHeader(state);
this.renderRemoteVideo(state);
this.renderLocalVideo(state);
this.renderUserList(state);
this.renderMessages(state);
this.renderControls(state);
return;
}
// 增量更新
switch (changes.type) {
case 'DURATION_UPDATE':
this.updateDuration(changes.duration);
break;
case 'LOCAL_MEDIA_CHANGE':
this.updateLocalControl(changes.mediaType, changes.value);
this.updateLocalVideoUI(state);
break;
case 'REMOTE_MEDIA_CHANGE':
this.updateRemoteVideoUI(state);
break;
case 'NEW_MESSAGE':
this.appendMessage(changes.message);
this.updateUnreadBadge(changes.unreadCount);
break;
case 'SIDEBAR_TOGGLE':
this.toggleSidebarUI(changes.isOpen);
break;
case 'NETWORK_CHANGE':
this.updateNetworkStatus(changes.quality);
break;
case 'CALL_ENDED':
this.showCallEnded(state.session.duration);
break;
}
}
// 渲染头部信息
renderHeader(state) {
const { session } = state;
this.elements.headerTitle.textContent = `${session.remoteUser.name} 的通话`;
this.elements.encryptionBadge.style.display = session.isEncrypted ? 'flex' : 'none';
this.updateDuration(session.duration);
}
updateDuration(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
this.elements.callDuration.textContent =
`${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// 渲染远端视频区域
renderRemoteVideo(state) {
const { remoteUser } = state.session;
this.elements.remoteName.textContent = remoteUser.name;
this.elements.remoteAvatar.src = remoteUser.avatar;
this.elements.remoteVideo.src = remoteUser.avatar; // 实际应为视频流
this.updateRemoteVideoUI(state);
this.updateNetworkStatus(remoteUser.networkQuality);
}
updateRemoteVideoUI(state) {
const { remoteUser } = state.session;
const media = remoteUser.mediaState;
// 音频状态
if (media.isSpeaking && media.audio) {
this.elements.remoteAudioWave.classList.remove('hidden');
this.elements.remoteSpeakingIndicator.classList.remove('hidden');
} else {
this.elements.remoteAudioWave.classList.add('hidden');
this.elements.remoteSpeakingIndicator.classList.add('hidden');
}
// 连接状态
const statusText = {
'online': '正在通话',
'connecting': '连接中...',
'offline': '已断开'
};
this.elements.remoteStatus.textContent = statusText[remoteUser.status];
// 视频占位 (实际应根据 video track 判断)
if (!media.video) {
this.elements.remoteVideo.style.opacity = '0.3';
} else {
this.elements.remoteVideo.style.opacity = '1';
}
}
// 渲染本地视频
renderLocalVideo(state) {
const { localUser } = state.session;
this.elements.localVideo.src = localUser.avatar; // 实际应为视频流
this.elements.localInitials.textContent = localUser.name.substring(0, 2);
this.updateLocalVideoUI(state);
}
updateLocalVideoUI(state) {
const { localUser } = state.session;
const media = localUser.mediaState;
// 视频开关
if (!media.video) {
this.elements.localVideoPlaceholder.classList.remove('hidden');
this.elements.localVideo.style.opacity = '0';
} else {
this.elements.localVideoPlaceholder.classList.add('hidden');
this.elements.localVideo.style.opacity = '1';
}
// 音频波形
if (media.isSpeaking && media.audio) {
this.elements.localAudioWave.classList.remove('hidden');
} else {
this.elements.localAudioWave.classList.add('hidden');
}
}
// 渲染用户列表
renderUserList(state) {
// 实际项目中应根据用户列表动态生成
// 这里使用静态HTML仅更新状态
}
// 渲染消息列表
renderMessages(state) {
// 实际项目中应根据消息列表动态生成
// 这里使用静态HTML仅更新新消息
}
// 追加新消息
appendMessage(message) {
// 实际项目中应动态生成消息元素
console.log('New message:', message);
}
// 渲染控制按钮
renderControls(state) {
const { localUser } = state.session;
const media = localUser.mediaState;
this.updateLocalControl('audio', media.audio);
this.updateLocalControl('video', media.video);
this.updateLocalControl('screenShare', media.screenShare);
}
// 更新本地控制按钮状态
updateLocalControl(mediaType, value) {
const btnMap = {
'audio': this.elements.micBtn,
'video': this.elements.videoBtn,
'screenShare': this.elements.screenBtn
};
const btn = btnMap[mediaType];
if (btn) {
btn.dataset.active = !value;
btn.querySelector('[data-icon="default"]').classList.toggle('hidden', !value);
btn.querySelector('[data-icon="active"]').classList.toggle('hidden', value);
}
}
// 更新未读消息徽章
updateUnreadBadge(count) {
if (count > 0) {
this.elements.unreadBadge.textContent = count;
this.elements.unreadBadge.classList.remove('hidden');
} else {
this.elements.unreadBadge.classList.add('hidden');
}
}
// 切换侧边栏UI
toggleSidebarUI(isOpen) {
this.elements.sidebar.classList.toggle('hidden', !isOpen);
}
// 更新网络状态
updateNetworkStatus(quality) {
const qualityText = {
'excellent': '优秀',
'good': '良好',
'fair': '一般',
'poor': '较差'
};
if (quality !== 'excellent') {
this.elements.networkStatus.classList.remove('hidden');
this.elements.networkStatusText.textContent = `网络${qualityText[quality]}`;
} else {
this.elements.networkStatus.classList.add('hidden');
}
this.elements.connectionQuality.textContent = `连接质量: ${qualityText[quality]}`;
}
// 显示通话结束
showCallEnded(duration) {
const mins = Math.floor(duration / 60);
const secs = duration % 60;
alert(`通话已结束,时长: ${mins}${secs}`);
}
}
/**
* ==========================================
* 5. 事件处理 (Event Handlers)
* 处理用户交互事件
* ==========================================
*/
// 全局状态管理器实例
let stateManager;
let uiRenderer;
// 初始化应用
function initApp() {
stateManager = new CallStateManager();
uiRenderer = new UIRenderer(stateManager);
// 绑定键盘事件
document.addEventListener('keydown', handleKeyPress);
}
// 处理键盘事件
function handleKeyPress(e) {
// 空格键静音
if (e.code === 'Space') {
e.preventDefault();
const micBtn = document.getElementById('micBtn');
toggleMute(micBtn);
}
// Ctrl+V 切换视频
if (e.ctrlKey && e.key === 'v') {
e.preventDefault();
const videoBtn = document.getElementById('videoBtn');
toggleVideo(videoBtn);
}
}
// 切换静音
function toggleMute(btn) {
const isActive = btn.dataset.active === 'true';
stateManager.updateLocalMedia('audio', !isActive);
}
// 切换视频
function toggleVideo(btn) {
const isActive = btn.dataset.active === 'true';
stateManager.updateLocalMedia('video', !isActive);
}
// 切换屏幕共享
function toggleScreenShare(btn) {
const isActive = btn.dataset.active === 'true';
stateManager.updateLocalMedia('screenShare', !isActive);
}
// 切换本地视频
function toggleLocalVideo() {
const videoBtn = document.getElementById('videoBtn');
toggleVideo(videoBtn);
}
// 切换侧边栏
function toggleSidebar() {
stateManager.toggleSidebar();
}
// 结束通话
function endCall() {
stateManager.endCall();
}
// 发送消息
function sendMessage() {
const input = document.getElementById('chatInput');
const content = input.value.trim();
if (content) {
const newMessage = {
id: `msg-${Date.now()}`,
senderId: stateManager.getLocalUser().id,
senderName: stateManager.getLocalUser().name,
senderAvatar: stateManager.getLocalUser().avatar,
content: content,
type: 'text',
timestamp: new Date().toISOString(),
isSelf: true
};
stateManager.addMessage(newMessage);
input.value = '';
// [API_CALL: POST /api/call/:callId/message]
// [WEBSOCKET_EMIT: send-message]
console.log('[WebSocket Emit] send-message:', newMessage);
}
}
// 处理聊天输入提交
function handleChatSubmit(e) {
if (e.key === 'Enter') {
sendMessage();
}
}
// 本地视频窗口拖拽功能
document.addEventListener('DOMContentLoaded', function() {
const videoContainer = document.getElementById('localVideoContainer');
let isDragging = false;
let offsetX, offsetY;
const edgeThreshold = 20; // 边缘吸附阈值
videoContainer.addEventListener('mousedown', function(e) {
// 只有在点击容器本身而不是内部按钮时才开始拖拽
if (e.target === videoContainer || e.target === videoContainer.querySelector('#localVideo') || e.target === videoContainer.querySelector('#localVideoPlaceholder')) {
isDragging = true;
offsetX = e.clientX - videoContainer.getBoundingClientRect().left;
offsetY = e.clientY - videoContainer.getBoundingClientRect().top;
videoContainer.style.cursor = 'grabbing';
videoContainer.style.zIndex = '100';
}
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
let newX = e.clientX - offsetX;
let newY = e.clientY - offsetY;
// 计算屏幕边界
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const containerWidth = videoContainer.offsetWidth;
const containerHeight = videoContainer.offsetHeight;
// 边缘吸附逻辑
if (newX < edgeThreshold) {
newX = 0;
} else if (newX > windowWidth - containerWidth - edgeThreshold) {
newX = windowWidth - containerWidth;
}
if (newY < edgeThreshold) {
newY = 0;
} else if (newY > windowHeight - containerHeight - edgeThreshold) {
newY = windowHeight - containerHeight;
}
// 更新位置
videoContainer.style.left = newX + 'px';
videoContainer.style.top = newY + 'px';
videoContainer.style.bottom = 'auto';
videoContainer.style.right = 'auto';
});
document.addEventListener('mouseup', function() {
if (isDragging) {
isDragging = false;
videoContainer.style.cursor = 'move';
videoContainer.style.zIndex = '10';
}
});
});
// 页面加载完成后初始化
window.addEventListener('DOMContentLoaded', initApp);