Files
webRtc/WebApp/client/public/bidirectional/index1.html

1405 lines
63 KiB
HTML
Raw Normal View History

2026-03-02 18:23:28 +08:00
<!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">
<style>
@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;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(40px, 40px); }
}
.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;
}
@keyframes messageSlide {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.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; }
</style>
</head>
<body class="h-screen w-screen flex flex-col text-white bg-grid relative">
<!--
============================================================
数据模型定义 (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-2 text-xs text-gray-400">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></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>
<button class="w-10 h-10 rounded-full glass flex items-center justify-center hover:bg-white/10 transition-colors relative" onclick="toggleSidebar()">
<i class="fas fa-comment-alt text-gray-300"></i>
<!-- [DATA_FIELD: unreadCount] [TYPE: number] [DEFAULT: 0] [CONDITION: >0] -->
<span id="unreadBadge" class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs flex items-center justify-center hidden" data-field="unreadCount">0</span>
</button>
</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">
<!--
子区域: 远端视频 (Remote Video)
数据源: RemoteUser
-->
<div class="absolute inset-0 video-fade-in">
<!-- [DATA_FIELD: remoteUser.videoStream] [TYPE: MediaStream | null] [BIND: srcObject] -->
<!-- [FALLBACK: remoteUser.avatar] [TYPE: string] [URL] -->
<img id="remoteVideo"
src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1280&h=720&fit=crop"
alt="对方视频"
class="w-full h-full object-cover"
data-field="remoteUser.videoStream">
<!-- 远端信息覆盖层 -->
<div class="absolute top-6 left-6 glass px-4 py-2 rounded-full flex items-center gap-3">
<div class="relative">
<!-- [DATA_FIELD: remoteUser.avatar] [TYPE: string] [URL] [REQUIRED] -->
<img id="remoteAvatar"
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="remoteUser.avatar">
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
<div id="remoteSpeakingIndicator" class="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-slate-900 hidden" data-field="remoteUser.isSpeaking"></div>
</div>
<div>
<!-- [DATA_FIELD: remoteUser.name] [TYPE: string] -->
<div class="text-sm font-medium" data-field="remoteUser.name" id="remoteName">Sarah Chen</div>
<div class="text-xs text-gray-400 flex items-center gap-2">
<!-- [DATA_FIELD: remoteUser.status] [TYPE: string] [ENUM: online/offline/connecting] -->
<span id="remoteStatus" data-field="remoteUser.status">正在通话</span>
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true && remoteUser.mediaState.audio === true] -->
<div id="remoteAudioWave" class="audio-wave w-6 hidden" data-field="remoteUser.audioActivity">
<span></span><span></span><span></span><span></span><span></span>
</div>
</div>
</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] -->
<img id="localVideo"
src="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop"
alt="本地视频"
class="w-full h-full object-cover"
data-field="localUser.videoStream">
<!-- [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">
通话成员 (<!-- [DATA_FIELD: userCount] [TYPE: number] [VALUE: 2] -->2)
</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="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop"
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.status] [TRANSFORM: status => displayText] -->
<div class="text-xs text-green-400" data-field="remoteUser.statusText">在线</div>
</div>
<!-- [CONDITIONAL_RENDER: remoteUser.mediaState.isSpeaking === true] -->
<div class="audio-wave w-6" data-field="remoteUser.speakingIndicator">
<span></span><span></span><span></span><span></span><span></span>
</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="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop"
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">
<i class="fas fa-plus"></i>
</button>
<!-- [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.screenShare] [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="toggleScreenShare(this)"
id="screenBtn"
data-field="localUser.screenShare"
data-active="false">
<i class="fas fa-desktop text-lg" data-icon="default"></i>
<i class="fas fa-stop-circle text-lg hidden text-green-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>
<!-- 更多选项 -->
<button 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>
<!-- 结束通话 -->
<!-- [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" onclick="toggleSidebar()">
<i class="fas fa-comment-alt"></i>
</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>
<!--
============================================================
数据层与逻辑层 (Data & Logic Layer)
实际项目中建议拆分为独立文件:
- models.ts (类型定义)
- api.ts (API 客户端)
- websocket.ts (WebSocket 管理)
- store.ts (状态管理)
- renderer.ts (UI 渲染器)
============================================================
-->
<script>
/**
* ==========================================
* 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) {
this.elements.localAudioWave.classList.remove('hidden');
} else {
this.elements.localAudioWave.classList.add('hidden');
}
}
// 渲染用户列表
renderUserList(state) {
const { localUser, remoteUser } = state.session;
// 远端用户状态
const remoteStatusText = remoteUser.status === 'online' ? '在线' : '连接中...';
const remoteStatusColor = remoteUser.status === 'online' ? 'text-green-400' : 'text-yellow-400';
// 本地用户状态
const localStatusText = !localUser.mediaState.audio ? '静音中' :
localUser.mediaState.isSpeaking ? '正在说话...' : '在线';
// HTML 已静态定义,这里更新动态内容
// 实际项目中可使用模板引擎渲染
}
// 渲染消息列表
renderMessages(state) {
this.elements.chatContent.innerHTML = '';
// 添加时间分隔线
const timeDiv = document.createElement('div');
timeDiv.className = 'text-center text-xs text-gray-500 my-4';
const startTime = new Date(state.session.startTime);
timeDiv.textContent = `通话开始 ${startTime.getHours()}:${String(startTime.getMinutes()).padStart(2, '0')}`;
this.elements.chatContent.appendChild(timeDiv);
// 渲染每条消息
state.messages.forEach(msg => this.appendMessage(msg, false));
// 滚动到底部
this.scrollToBottom();
}
appendMessage(message, shouldScroll = true) {
if (message.type === 'system') return; // 系统消息可选显示
const div = document.createElement('div');
div.className = 'chat-bubble';
const time = new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
const isSelf = message.isSelf;
const bubbleClass = isSelf ?
'bg-indigo-600 rounded-tr-none flex-row-reverse' :
'glass rounded-tl-none';
const nameColor = isSelf ? 'text-green-400' : 'text-indigo-400';
div.innerHTML = `
<div class="flex gap-3 ${isSelf ? 'flex-row-reverse' : ''}">
<img src="${message.senderAvatar}" class="w-8 h-8 rounded-full object-cover" onerror="this.src='https://via.placeholder.com/100'">
<div class="flex-1 ${isSelf ? 'flex flex-col items-end' : ''}">
<div class="flex items-baseline gap-2 mb-1 ${isSelf ? 'flex-row-reverse' : ''}">
<span class="text-sm font-medium ${nameColor}">${message.senderName}</span>
<span class="text-xs text-gray-500">${time}</span>
</div>
<div class="${bubbleClass} px-3 py-2 rounded-2xl text-sm text-white max-w-[80%] break-words">
${this.escapeHtml(message.content)}
</div>
</div>
</div>
`;
this.elements.chatContent.appendChild(div);
if (shouldScroll) this.scrollToBottom();
}
// 渲染控制按钮
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(type, value) {
const btnMap = {
'audio': { btn: this.elements.micBtn, iconDefault: 'fa-microphone', iconActive: 'fa-microphone-slash' },
'video': { btn: this.elements.videoBtn, iconDefault: 'fa-video', iconActive: 'fa-video-slash' },
'screenShare': { btn: this.elements.screenBtn, iconDefault: 'fa-desktop', iconActive: 'fa-stop-circle' }
};
const config = btnMap[type];
if (!config) return;
const btn = config.btn;
const iconDefault = btn.querySelector('[data-icon="default"]');
const iconActive = btn.querySelector('[data-icon="active"]');
// 清除所有状态类
btn.classList.remove('bg-red-500/20', 'bg-green-500/20', 'border', 'border-red-500/50', 'border-green-500/50');
btn.classList.add('glass');
iconDefault.classList.remove('hidden');
iconActive.classList.add('hidden');
if (!value) { // 关闭状态 (音频静音/视频关闭)
if (type === 'screenShare') return; // 屏幕共享特殊处理
btn.classList.remove('glass');
btn.classList.add('bg-red-500/20', 'border', 'border-red-500/50');
iconDefault.classList.add('hidden');
iconActive.classList.remove('hidden');
} else if (type === 'screenShare' && value) { // 屏幕共享开启
btn.classList.remove('glass');
btn.classList.add('bg-green-500/20', 'border', 'border-green-500/50');
iconDefault.classList.add('hidden');
iconActive.classList.remove('hidden');
iconActive.classList.add('text-green-400');
}
}
// 更新网络状态显示
updateNetworkStatus(quality) {
const qualityMap = {
'excellent': { text: '优秀', color: 'text-green-400', show: false },
'good': { text: '良好', color: 'text-blue-400', show: false },
'fair': { text: '一般', color: 'text-yellow-400', show: true },
'poor': { text: '较差', color: 'text-red-400', show: true }
};
const q = qualityMap[quality];
this.elements.connectionQuality.textContent = `连接质量: ${q.text}`;
this.elements.connectionQuality.className = `text-xs ${q.color}`;
if (q.show) {
this.elements.networkStatus.classList.remove('hidden');
this.elements.networkStatusText.textContent = quality === 'fair' ? '网络不稳定' : '网络连接差';
} else {
this.elements.networkStatus.classList.add('hidden');
}
}
// 切换侧边栏显示
toggleSidebarUI(isOpen) {
if (isOpen) {
this.elements.sidebar.classList.remove('hidden');
this.scrollToBottom();
} else {
this.elements.sidebar.classList.add('hidden');
}
}
// 更新未读徽章
updateUnreadBadge(count) {
const badge = this.elements.unreadBadge;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
// 显示通话结束
showCallEnded(duration) {
const mins = Math.floor(duration / 60);
const secs = duration % 60;
const timeStr = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
document.body.innerHTML = `
<div class="h-screen w-screen flex items-center justify-center bg-slate-900 text-white flex-col gap-4">
<div class="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mb-4 animate-bounce">
<i class="fas fa-check text-2xl"></i>
</div>
<h2 class="text-2xl font-bold">通话已结束</h2>
<p class="text-gray-400">通话时长: ${timeStr}</p>
<div class="flex gap-3 mt-4">
<button onclick="location.reload()" class="px-6 py-2 bg-indigo-600 rounded-full hover:bg-indigo-700 transition-colors">
重新拨打
</button>
<button onclick="window.close()" class="px-6 py-2 glass rounded-full hover:bg-white/10 transition-colors">
关闭窗口
</button>
</div>
</div>
`;
}
// 工具方法
scrollToBottom() {
this.elements.chatContent.scrollTop = this.elements.chatContent.scrollHeight;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
destroy() {
this.unsubscribe();
}
}
/**
* ==========================================
* 5. 初始化与事件绑定
* ==========================================
*/
// 初始化状态管理器和渲染器
const stateManager = new CallStateManager();
const renderer = new UIRenderer(stateManager);
// 全局函数供 HTML 调用
window.toggleMute = function(btn) {
const currentState = stateManager.getLocalUser().mediaState.audio;
stateManager.updateLocalMedia('audio', !currentState);
};
window.toggleVideo = function(btn) {
const currentState = stateManager.getLocalUser().mediaState.video;
stateManager.updateLocalMedia('video', !currentState);
};
window.toggleLocalVideo = function() {
window.toggleVideo();
};
window.toggleScreenShare = function(btn) {
const currentState = stateManager.getLocalUser().mediaState.screenShare;
stateManager.updateLocalMedia('screenShare', !currentState);
};
window.toggleSidebar = function() {
stateManager.toggleSidebar();
};
window.sendMessage = function() {
const input = document.getElementById('chatInput');
const content = input.value.trim();
if (!content) return;
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 = '';
// 模拟对方回复 (开发测试)
setTimeout(() => {
const replies = ['收到 👍', '明白', '好的,继续', '稍等,我记一下', '没问题'];
const replyMsg = {
id: `msg-${Date.now()}`,
senderId: stateManager.getRemoteUser().id,
senderName: stateManager.getRemoteUser().name,
senderAvatar: stateManager.getRemoteUser().avatar,
content: replies[Math.floor(Math.random() * replies.length)],
type: 'text',
timestamp: new Date().toISOString(),
isSelf: false
};
stateManager.addMessage(replyMsg);
}, 1500 + Math.random() * 2000);
};
window.handleChatSubmit = function(event) {
if (event.key === 'Enter') {
window.sendMessage();
}
};
window.endCall = function() {
if (confirm('确定要结束通话吗?')) {
stateManager.endCall();
}
};
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
e.preventDefault();
window.toggleMute();
}
if (e.code === 'KeyV' && e.ctrlKey) {
e.preventDefault();
window.toggleVideo();
}
});
// 通知函数
window.showNotification = function(text) {
const notification = document.getElementById('notification');
document.getElementById('notificationText').textContent = text;
notification.classList.remove('opacity-0', 'translate-y-[-20px]');
setTimeout(() => {
notification.classList.add('opacity-0', 'translate-y-[-20px]');
}, 3000);
};
// 监听状态变化显示通知
stateManager.subscribe((state, changes) => {
if (!changes) return;
if (changes.type === 'LOCAL_MEDIA_CHANGE') {
const actionMap = {
'audio': changes.value ? '麦克风已开启' : '麦克风已静音',
'video': changes.value ? '摄像头已开启' : '摄像头已关闭',
'screenShare': changes.value ? '开始共享屏幕' : '停止共享屏幕'
};
showNotification(actionMap[changes.mediaType]);
}
});
// 启动网络质量模拟 (开发测试)
stateManager.simulateNetworkChange();
// 暴露到全局供调试
window.callState = stateManager;
window.callRenderer = renderer;
</script>
</body>
</html>