Files
webRtc/WebApp/client/public/bidirectional/index1.html
2026-03-02 18:23:28 +08:00

1405 lines
63 KiB
HTML
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.
<!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>