/** * ========================================== * 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);