mistpe commited on
Commit
805637c
·
verified ·
1 Parent(s): a8ada20

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +285 -0
  2. requirements.txt +5 -0
  3. templates/index.html +1143 -0
app.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify
2
+ from flask_socketio import SocketIO, emit, join_room, leave_room
3
+ import os
4
+ import uuid
5
+ from datetime import datetime
6
+ import logging
7
+
8
+ # 配置日志
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+ app = Flask(__name__)
13
+ app.config['SECRET_KEY'] = os.urandom(24)
14
+ socketio = SocketIO(app, cors_allowed_origins="*")
15
+
16
+ # 内存中存储活跃的房间和参与者
17
+ active_rooms = {} # {room_id: {'created_at': timestamp, 'participants': {user_id: user_data}}}
18
+
19
+ @app.route('/')
20
+ def index():
21
+ """提供主页"""
22
+ return render_template('index.html')
23
+
24
+ @app.route('/api/room', methods=['POST'])
25
+ def create_room():
26
+ """创建新房间"""
27
+ room_id = str(uuid.uuid4())[:8] # 生成短的唯一ID
28
+ active_rooms[room_id] = {
29
+ 'created_at': datetime.now().isoformat(),
30
+ 'participants': {}
31
+ }
32
+ logger.info(f"创建房间: {room_id}")
33
+ return jsonify({'roomId': room_id})
34
+
35
+ @app.route('/api/room/<room_id>', methods=['GET'])
36
+ def check_room(room_id):
37
+ """检查房间是否存在"""
38
+ if room_id in active_rooms:
39
+ return jsonify({
40
+ 'exists': True,
41
+ 'participantCount': len(active_rooms[room_id]['participants'])
42
+ })
43
+ return jsonify({'exists': False})
44
+
45
+ # Socket.IO 事件处理器
46
+ @socketio.on('connect')
47
+ def handle_connect():
48
+ """处理客户端连接"""
49
+ logger.info(f"客户端连接: {request.sid}")
50
+ emit('connected', {'sid': request.sid})
51
+
52
+ @socketio.on('disconnect')
53
+ def handle_disconnect():
54
+ """处理客户端断开连接"""
55
+ logger.info(f"客户端断开连接: {request.sid}")
56
+ # 找到并从任何房间中移除用户
57
+ for room_id, room_data in list(active_rooms.items()):
58
+ for user_id, user_data in list(room_data['participants'].items()):
59
+ if user_data.get('sid') == request.sid:
60
+ # 通知房间中的其他人
61
+ emit('user_disconnected', {'userId': user_id}, room=room_id)
62
+ # 从房间中移除用户
63
+ del room_data['participants'][user_id]
64
+ logger.info(f"用户 {user_id} 已从房间 {room_id} 移除")
65
+ # 清理空房间
66
+ if not room_data['participants']:
67
+ del active_rooms[room_id]
68
+ logger.info(f"房间 {room_id} 已删除 (空)")
69
+ break
70
+
71
+ @socketio.on('join')
72
+ def handle_join(data):
73
+ """处理用户加入房间"""
74
+ room_id = data.get('roomId')
75
+ display_name = data.get('displayName')
76
+ user_id = data.get('userId', str(uuid.uuid4())[:8])
77
+
78
+ if not room_id or not display_name:
79
+ emit('error', {'message': '房间ID和显示名称是必需的'})
80
+ return
81
+
82
+ # 检查房间是否存在
83
+ if room_id not in active_rooms:
84
+ active_rooms[room_id] = {
85
+ 'created_at': datetime.now().isoformat(),
86
+ 'participants': {}
87
+ }
88
+ logger.info(f"在加入时创建房间: {room_id}")
89
+
90
+ # 将用户添加到房间
91
+ active_rooms[room_id]['participants'][user_id] = {
92
+ 'displayName': display_name,
93
+ 'sid': request.sid,
94
+ 'joinedAt': datetime.now().isoformat()
95
+ }
96
+
97
+ # 加入Socket.IO房间
98
+ join_room(room_id)
99
+
100
+ # 获取现有参与者列表发送给新用户
101
+ participants = []
102
+ for pid, pdata in active_rooms[room_id]['participants'].items():
103
+ if pid != user_id: # 不包括正在加入的用户
104
+ participants.append({
105
+ 'userId': pid,
106
+ 'displayName': pdata['displayName']
107
+ })
108
+
109
+ # 通知加入的用户
110
+ emit('joined', {
111
+ 'roomId': room_id,
112
+ 'userId': user_id,
113
+ 'participants': participants
114
+ })
115
+
116
+ # 通知其他参与者有新用户加入
117
+ emit('user_joined', {
118
+ 'userId': user_id,
119
+ 'displayName': display_name
120
+ }, room=room_id, include_self=False)
121
+
122
+ logger.info(f"用户 {user_id} ({display_name}) 加入房间 {room_id}")
123
+
124
+ @socketio.on('leave')
125
+ def handle_leave(data):
126
+ """处理用户离开房间"""
127
+ room_id = data.get('roomId')
128
+ user_id = data.get('userId')
129
+
130
+ if not room_id or not user_id:
131
+ emit('error', {'message': '房间ID和用户ID是必需的'})
132
+ return
133
+
134
+ if room_id in active_rooms and user_id in active_rooms[room_id]['participants']:
135
+ # 从房间数据中移除用户
136
+ del active_rooms[room_id]['participants'][user_id]
137
+
138
+ # 清理空房间
139
+ if not active_rooms[room_id]['participants']:
140
+ del active_rooms[room_id]
141
+ logger.info(f"房间 {room_id} 已删除 (空)")
142
+ else:
143
+ # 通知其他人用户已离开
144
+ emit('user_left', {'userId': user_id}, room=room_id)
145
+
146
+ # 离开Socket.IO房间
147
+ leave_room(room_id)
148
+ logger.info(f"用户 {user_id} 离开房间 {room_id}")
149
+
150
+ emit('left', {'roomId': room_id, 'userId': user_id})
151
+
152
+ # WebRTC信令事件
153
+ @socketio.on('offer')
154
+ def handle_offer(data):
155
+ """处理offer信令"""
156
+ room_id = data.get('roomId')
157
+ target_id = data.get('targetId')
158
+ from_id = data.get('fromId')
159
+ offer = data.get('offer')
160
+
161
+ if not all([room_id, target_id, from_id, offer]):
162
+ emit('error', {'message': 'offer缺少必要数据'})
163
+ return
164
+
165
+ # 查找目标用户的socket ID
166
+ if (room_id in active_rooms and
167
+ target_id in active_rooms[room_id]['participants']):
168
+ target_sid = active_rooms[room_id]['participants'][target_id]['sid']
169
+
170
+ # 将offer发送给目标用户
171
+ emit('offer', {
172
+ 'fromId': from_id,
173
+ 'offer': offer
174
+ }, room=target_sid)
175
+ logger.debug(f"已转发来自 {from_id} 的offer到 {target_id}")
176
+
177
+ @socketio.on('answer')
178
+ def handle_answer(data):
179
+ """处理answer信令"""
180
+ room_id = data.get('roomId')
181
+ target_id = data.get('targetId')
182
+ from_id = data.get('fromId')
183
+ answer = data.get('answer')
184
+
185
+ if not all([room_id, target_id, from_id, answer]):
186
+ emit('error', {'message': 'answer缺少必要数据'})
187
+ return
188
+
189
+ # 查找目标用户的socket ID
190
+ if (room_id in active_rooms and
191
+ target_id in active_rooms[room_id]['participants']):
192
+ target_sid = active_rooms[room_id]['participants'][target_id]['sid']
193
+
194
+ # 将answer发送给目标用户
195
+ emit('answer', {
196
+ 'fromId': from_id,
197
+ 'answer': answer
198
+ }, room=target_sid)
199
+ logger.debug(f"已转发来自 {from_id} 的answer到 {target_id}")
200
+
201
+ @socketio.on('ice_candidate')
202
+ def handle_ice_candidate(data):
203
+ """处理ICE候选者信令"""
204
+ room_id = data.get('roomId')
205
+ target_id = data.get('targetId')
206
+ from_id = data.get('fromId')
207
+ candidate = data.get('candidate')
208
+
209
+ if not all([room_id, target_id, from_id, candidate]):
210
+ emit('error', {'message': 'ICE候选者缺少必要数据'})
211
+ return
212
+
213
+ # 查找目标用户的socket ID
214
+ if (room_id in active_rooms and
215
+ target_id in active_rooms[room_id]['participants']):
216
+ target_sid = active_rooms[room_id]['participants'][target_id]['sid']
217
+
218
+ # 将ICE候选者发送给目标用户
219
+ emit('ice_candidate', {
220
+ 'fromId': from_id,
221
+ 'candidate': candidate
222
+ }, room=target_sid)
223
+ logger.debug(f"已转发来自 {from_id} 的ICE候选者到 {target_id}")
224
+
225
+ @socketio.on('chat_message')
226
+ def handle_chat_message(data):
227
+ """处理聊天消息"""
228
+ room_id = data.get('roomId')
229
+ from_id = data.get('fromId')
230
+ message = data.get('message')
231
+
232
+ if not all([room_id, from_id, message]):
233
+ emit('error', {'message': '聊天消息缺少必要数据'})
234
+ return
235
+
236
+ # 获取发送者的显示名称
237
+ if (room_id in active_rooms and
238
+ from_id in active_rooms[room_id]['participants']):
239
+ from_name = active_rooms[room_id]['participants'][from_id]['displayName']
240
+
241
+ # 向房间中的所有用户广播消息
242
+ emit('chat_message', {
243
+ 'fromId': from_id,
244
+ 'fromName': from_name,
245
+ 'message': message,
246
+ 'timestamp': datetime.now().isoformat()
247
+ }, room=room_id)
248
+ logger.debug(f"已广播来自 {from_id} 的聊天消息到房间 {room_id}")
249
+
250
+ @socketio.on('media_state_change')
251
+ def handle_media_state_change(data):
252
+ """处理媒体状态变化"""
253
+ room_id = data.get('roomId')
254
+ user_id = data.get('userId')
255
+ audio_enabled = data.get('audioEnabled')
256
+ video_enabled = data.get('videoEnabled')
257
+
258
+ if not all([room_id, user_id]) or audio_enabled is None or video_enabled is None:
259
+ emit('error', {'message': '媒体状态变化缺少必要数据'})
260
+ return
261
+
262
+ # 向房间中的所有用户广播媒体状态变化
263
+ emit('media_state_change', {
264
+ 'userId': user_id,
265
+ 'audioEnabled': audio_enabled,
266
+ 'videoEnabled': video_enabled
267
+ }, room=room_id)
268
+ logger.debug(f"已广播房间 {room_id} 中用户 {user_id} 的媒体状态变化")
269
+
270
+ # if __name__ == '__main__':
271
+ # # 确保templates目录存在
272
+ # os.makedirs('templates', exist_ok=True)
273
+
274
+ # port = int(os.environ.get('PORT', 5000))
275
+ # logger.info(f"在端口 {port} 上启动服务器")
276
+ # socketio.run(app, host='0.0.0.0', port=port, debug=True)
277
+
278
+ if __name__ == '__main__':
279
+ # 确保templates目录存在
280
+ os.makedirs('templates', exist_ok=True)
281
+
282
+ # 修改端口为7860
283
+ port = 7860
284
+ logger.info(f"在端口 {port} 上启动服务器")
285
+ socketio.run(app, host='0.0.0.0', port=port, debug=True)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ flask-socketio
3
+ python-engineio
4
+ python-socketio
5
+ eventlet
templates/index.html ADDED
@@ -0,0 +1,1143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>视频会议系统</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.4.1/socket.io.min.js"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.1/adapter.min.js"></script>
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ font-family: 'Arial', sans-serif;
15
+ }
16
+
17
+ body {
18
+ background-color: #f0f2f5;
19
+ color: #333;
20
+ }
21
+
22
+ .container {
23
+ max-width: 1200px;
24
+ margin: 0 auto;
25
+ padding: 20px;
26
+ }
27
+
28
+ header {
29
+ display: flex;
30
+ justify-content: space-between;
31
+ align-items: center;
32
+ padding: 15px 0;
33
+ border-bottom: 1px solid #ddd;
34
+ margin-bottom: 20px;
35
+ }
36
+
37
+ .logo {
38
+ font-size: 24px;
39
+ font-weight: bold;
40
+ color: #0066cc;
41
+ }
42
+
43
+ .controls {
44
+ display: flex;
45
+ gap: 10px;
46
+ }
47
+
48
+ button {
49
+ padding: 8px 16px;
50
+ background-color: #0066cc;
51
+ color: white;
52
+ border: none;
53
+ border-radius: 4px;
54
+ cursor: pointer;
55
+ font-size: 14px;
56
+ transition: background-color 0.3s;
57
+ }
58
+
59
+ button:hover {
60
+ background-color: #0055aa;
61
+ }
62
+
63
+ button.red {
64
+ background-color: #cc0000;
65
+ }
66
+
67
+ button.red:hover {
68
+ background-color: #aa0000;
69
+ }
70
+
71
+ .disabled {
72
+ background-color: #888;
73
+ cursor: not-allowed;
74
+ }
75
+
76
+ .disabled:hover {
77
+ background-color: #888;
78
+ }
79
+
80
+ .meeting-container {
81
+ display: grid;
82
+ grid-template-columns: 1fr 300px;
83
+ gap: 20px;
84
+ height: calc(100vh - 150px);
85
+ }
86
+
87
+ .video-grid {
88
+ display: grid;
89
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
90
+ grid-auto-rows: 1fr;
91
+ gap: 15px;
92
+ height: 100%;
93
+ overflow: auto;
94
+ }
95
+
96
+ .video-container {
97
+ background-color: #222;
98
+ border-radius: 8px;
99
+ overflow: hidden;
100
+ position: relative;
101
+ }
102
+
103
+ .video-container video {
104
+ width: 100%;
105
+ height: 100%;
106
+ object-fit: cover;
107
+ }
108
+
109
+ .user-name {
110
+ position: absolute;
111
+ bottom: 10px;
112
+ left: 10px;
113
+ color: white;
114
+ background-color: rgba(0, 0, 0, 0.5);
115
+ padding: 4px 8px;
116
+ border-radius: 4px;
117
+ font-size: 14px;
118
+ }
119
+
120
+ .mic-status {
121
+ position: absolute;
122
+ top: 10px;
123
+ right: 10px;
124
+ color: white;
125
+ padding: 4px;
126
+ border-radius: 50%;
127
+ }
128
+
129
+ .sidebar {
130
+ background-color: white;
131
+ border-radius: 8px;
132
+ display: flex;
133
+ flex-direction: column;
134
+ height: 100%;
135
+ }
136
+
137
+ .tabs {
138
+ display: flex;
139
+ border-bottom: 1px solid #ddd;
140
+ }
141
+
142
+ .tab {
143
+ padding: 10px 15px;
144
+ cursor: pointer;
145
+ }
146
+
147
+ .tab.active {
148
+ color: #0066cc;
149
+ border-bottom: 2px solid #0066cc;
150
+ }
151
+
152
+ .tab-content {
153
+ flex-grow: 1;
154
+ overflow: auto;
155
+ padding: 15px;
156
+ }
157
+
158
+ .participants-list {
159
+ list-style: none;
160
+ }
161
+
162
+ .participant {
163
+ display: flex;
164
+ align-items: center;
165
+ padding: 8px 0;
166
+ border-bottom: 1px solid #eee;
167
+ }
168
+
169
+ .participant-info {
170
+ flex-grow: 1;
171
+ }
172
+
173
+ .chat-container {
174
+ display: flex;
175
+ flex-direction: column;
176
+ height: 100%;
177
+ }
178
+
179
+ .messages {
180
+ flex-grow: 1;
181
+ overflow: auto;
182
+ padding: 10px;
183
+ }
184
+
185
+ .message {
186
+ margin-bottom: 10px;
187
+ padding: 8px 12px;
188
+ border-radius: 6px;
189
+ max-width: 80%;
190
+ }
191
+
192
+ .message.received {
193
+ background-color: #f1f1f1;
194
+ align-self: flex-start;
195
+ }
196
+
197
+ .message.sent {
198
+ background-color: #dcf8c6;
199
+ align-self: flex-end;
200
+ }
201
+
202
+ .sender {
203
+ font-size: 12px;
204
+ color: #555;
205
+ margin-bottom: 2px;
206
+ }
207
+
208
+ .chat-input {
209
+ display: flex;
210
+ padding: 10px;
211
+ border-top: 1px solid #ddd;
212
+ }
213
+
214
+ .chat-input input {
215
+ flex-grow: 1;
216
+ padding: 8px 12px;
217
+ border: 1px solid #ddd;
218
+ border-radius: 4px;
219
+ margin-right: 10px;
220
+ outline: none;
221
+ }
222
+
223
+ .join-form {
224
+ max-width: 400px;
225
+ margin: 100px auto;
226
+ background-color: white;
227
+ padding: 30px;
228
+ border-radius: 8px;
229
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
230
+ }
231
+
232
+ .form-group {
233
+ margin-bottom: 20px;
234
+ }
235
+
236
+ label {
237
+ display: block;
238
+ margin-bottom: 8px;
239
+ font-weight: bold;
240
+ }
241
+
242
+ input[type="text"],
243
+ input[type="password"] {
244
+ width: 100%;
245
+ padding: 10px;
246
+ border: 1px solid #ddd;
247
+ border-radius: 4px;
248
+ font-size: 16px;
249
+ }
250
+
251
+ .button-group {
252
+ display: flex;
253
+ justify-content: space-between;
254
+ }
255
+
256
+ .hidden {
257
+ display: none;
258
+ }
259
+
260
+ .video-off:after {
261
+ content: "视频已关闭";
262
+ position: absolute;
263
+ top: 50%;
264
+ left: 50%;
265
+ transform: translate(-50%, -50%);
266
+ color: white;
267
+ font-size: 16px;
268
+ }
269
+
270
+ /* 屏幕共享布局 */
271
+ .screen-share {
272
+ grid-column: span 2;
273
+ height: 100%;
274
+ background-color: #222;
275
+ border-radius: 8px;
276
+ overflow: hidden;
277
+ position: relative;
278
+ }
279
+
280
+ .screen-share video {
281
+ width: 100%;
282
+ height: 100%;
283
+ object-fit: contain;
284
+ }
285
+
286
+ .screen-share-label {
287
+ position: absolute;
288
+ top: 10px;
289
+ left: 10px;
290
+ color: white;
291
+ background-color: rgba(0, 0, 0, 0.5);
292
+ padding: 4px 8px;
293
+ border-radius: 4px;
294
+ font-size: 14px;
295
+ }
296
+
297
+ /* 响应式设计 */
298
+ @media (max-width: 768px) {
299
+ .meeting-container {
300
+ grid-template-columns: 1fr;
301
+ }
302
+
303
+ .sidebar {
304
+ margin-top: 20px;
305
+ height: 300px;
306
+ }
307
+ }
308
+ </style>
309
+ </head>
310
+ <body>
311
+ <!-- 登录表单 -->
312
+ <div class="join-form" id="joinForm">
313
+ <h2 style="text-align: center; margin-bottom: 20px;">加入会议</h2>
314
+ <div class="form-group">
315
+ <label for="displayName">您的姓名</label>
316
+ <input type="text" id="displayName" placeholder="请输入您的姓名">
317
+ </div>
318
+ <div class="form-group">
319
+ <label for="roomId">会议 ID</label>
320
+ <input type="text" id="roomId" placeholder="请输入会议 ID 或创建新会议">
321
+ </div>
322
+ <div class="button-group">
323
+ <button id="createMeeting">创建会议</button>
324
+ <button id="joinMeeting">加入会议</button>
325
+ </div>
326
+ </div>
327
+
328
+ <!-- 会议主界面 (默认隐藏) -->
329
+ <div class="container hidden" id="meetingContainer">
330
+ <header>
331
+ <div class="logo">视频会议</div>
332
+ <div class="meeting-info">
333
+ 会议 ID: <span id="currentRoomId"></span>
334
+ <button id="copyRoomId">复制</button>
335
+ </div>
336
+ <div class="controls">
337
+ <button id="toggleAudio"><i class="fas fa-microphone"></i> 麦克风</button>
338
+ <button id="toggleVideo"><i class="fas fa-video"></i> 摄像头</button>
339
+ <button id="toggleScreen"><i class="fas fa-desktop"></i> 屏幕共享</button>
340
+ <button id="leaveCall" class="red"><i class="fas fa-phone-slash"></i> 离开会议</button>
341
+ </div>
342
+ </header>
343
+
344
+ <div class="meeting-container">
345
+ <div class="video-grid" id="videoGrid">
346
+ <!-- 视频将在这里动态添加 -->
347
+ </div>
348
+
349
+ <div class="sidebar">
350
+ <div class="tabs">
351
+ <div class="tab active" data-tab="participants">参会者</div>
352
+ <div class="tab" data-tab="chat">聊天</div>
353
+ </div>
354
+
355
+ <div class="tab-content" id="participantsTab">
356
+ <ul class="participants-list" id="participantsList">
357
+ <!-- 参会者将在这里动态添加 -->
358
+ </ul>
359
+ </div>
360
+
361
+ <div class="tab-content hidden" id="chatTab">
362
+ <div class="chat-container">
363
+ <div class="messages" id="messages">
364
+ <!-- 消息将在这里动态添加 -->
365
+ </div>
366
+ <div class="chat-input">
367
+ <input type="text" id="messageInput" placeholder="输入消息...">
368
+ <button id="sendMessage">发送</button>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+
376
+ <script>
377
+ // RTCPeerConnection 配置
378
+ const rtcConfig = {
379
+ iceServers: [
380
+ { urls: 'stun:stun.l.google.com:19302' },
381
+ { urls: 'stun:stun1.l.google.com:19302' },
382
+ { urls: 'stun:stun2.l.google.com:19302' },
383
+ { urls: 'stun:stun3.l.google.com:19302' }
384
+ ]
385
+ };
386
+
387
+ // 全局变量
388
+ let socket;
389
+ let localStream;
390
+ let screenStream;
391
+ let peerConnections = {};
392
+ let dataChannels = {};
393
+ let currentRoom = null;
394
+ let localAudioEnabled = true;
395
+ let localVideoEnabled = true;
396
+
397
+ // DOM 元素
398
+ const joinForm = document.getElementById('joinForm');
399
+ const meetingContainer = document.getElementById('meetingContainer');
400
+ const displayNameInput = document.getElementById('displayName');
401
+ const roomIdInput = document.getElementById('roomId');
402
+ const createMeetingBtn = document.getElementById('createMeeting');
403
+ const joinMeetingBtn = document.getElementById('joinMeeting');
404
+ const currentRoomIdSpan = document.getElementById('currentRoomId');
405
+ const copyRoomIdBtn = document.getElementById('copyRoomId');
406
+ const toggleAudioBtn = document.getElementById('toggleAudio');
407
+ const toggleVideoBtn = document.getElementById('toggleVideo');
408
+ const toggleScreenBtn = document.getElementById('toggleScreen');
409
+ const leaveCallBtn = document.getElementById('leaveCall');
410
+ const videoGrid = document.getElementById('videoGrid');
411
+ const tabs = document.querySelectorAll('.tab');
412
+ const tabContents = document.querySelectorAll('.tab-content');
413
+ const participantsList = document.getElementById('participantsList');
414
+ const messages = document.getElementById('messages');
415
+ const messageInput = document.getElementById('messageInput');
416
+ const sendMessageBtn = document.getElementById('sendMessage');
417
+
418
+ // 连接到信令服务器
419
+ function connectSocket() {
420
+ socket = io();
421
+
422
+ // Socket 事件处理
423
+ socket.on('connected', (data) => {
424
+ console.log('已连接到信令服务器', data);
425
+ });
426
+
427
+ socket.on('error', (error) => {
428
+ console.error('Socket 错误:', error);
429
+ alert(`错误: ${error.message}`);
430
+ });
431
+
432
+ socket.on('joined', handleJoinedRoom);
433
+ socket.on('user_joined', handleUserJoined);
434
+ socket.on('user_left', handleUserLeft);
435
+ socket.on('user_disconnected', handleUserLeft);
436
+ socket.on('offer', handleOffer);
437
+ socket.on('answer', handleAnswer);
438
+ socket.on('ice_candidate', handleIceCandidate);
439
+ socket.on('chat_message', handleChatMessage);
440
+ socket.on('media_state_change', handleMediaStateChange);
441
+ }
442
+
443
+ // 生成随机 ID
444
+ function generateId() {
445
+ return Math.random().toString(36).substr(2, 9);
446
+ }
447
+
448
+ // 初始化媒体流
449
+ async function initLocalStream() {
450
+ try {
451
+ localStream = await navigator.mediaDevices.getUserMedia({
452
+ audio: true,
453
+ video: true
454
+ });
455
+
456
+ // 创建本地视频元素
457
+ const videoContainer = document.createElement('div');
458
+ videoContainer.className = 'video-container';
459
+ videoContainer.id = 'local-video-container';
460
+
461
+ const video = document.createElement('video');
462
+ video.srcObject = localStream;
463
+ video.autoplay = true;
464
+ video.muted = true; // 避免回声
465
+ video.playsInline = true;
466
+
467
+ const userName = document.createElement('div');
468
+ userName.className = 'user-name';
469
+ userName.textContent = displayNameInput.value + ' (你)';
470
+
471
+ const micStatus = document.createElement('div');
472
+ micStatus.className = 'mic-status';
473
+ micStatus.innerHTML = '<i class="fas fa-microphone"></i>';
474
+
475
+ videoContainer.appendChild(video);
476
+ videoContainer.appendChild(userName);
477
+ videoContainer.appendChild(micStatus);
478
+
479
+ videoGrid.appendChild(videoContainer);
480
+
481
+ return true;
482
+ } catch (error) {
483
+ console.error('无法获取媒体流', error);
484
+ alert('无法访问摄像头或麦克风,请确保它们已连接并授予访问权限。');
485
+ return false;
486
+ }
487
+ }
488
+
489
+ // 创建新会议
490
+ async function createMeeting() {
491
+ try {
492
+ const response = await fetch('/api/room', {
493
+ method: 'POST',
494
+ headers: { 'Content-Type': 'application/json' }
495
+ });
496
+
497
+ const data = await response.json();
498
+ roomIdInput.value = data.roomId;
499
+ joinMeeting();
500
+ } catch (error) {
501
+ console.error('创建会议时出错:', error);
502
+ alert('无法创建新会议。请重试。');
503
+ }
504
+ }
505
+
506
+ // 加入会议
507
+ async function joinMeeting() {
508
+ const displayName = displayNameInput.value.trim();
509
+ const roomId = roomIdInput.value.trim();
510
+
511
+ if (!displayName) {
512
+ alert('请输入您的姓名');
513
+ return;
514
+ }
515
+
516
+ if (!roomId) {
517
+ alert('请输入会议 ID');
518
+ return;
519
+ }
520
+
521
+ try {
522
+ // 检查房间是否存在
523
+ const response = await fetch(`/api/room/${roomId}`);
524
+ const roomData = await response.json();
525
+
526
+ if (!roomData.exists) {
527
+ const createRoom = confirm('此会议 ID 不存在。您想创建它吗?');
528
+ if (!createRoom) return;
529
+ }
530
+
531
+ // 连接到socket(如果尚未连接)
532
+ if (!socket) {
533
+ connectSocket();
534
+ }
535
+
536
+ // 初始化本地媒体流
537
+ const success = await initLocalStream();
538
+ if (!success) return;
539
+
540
+ // 生成用户 ID
541
+ const userId = generateId();
542
+
543
+ // 加入房间
544
+ socket.emit('join', {
545
+ roomId: roomId,
546
+ userId: userId,
547
+ displayName: displayName
548
+ });
549
+
550
+ currentRoom = {
551
+ id: roomId,
552
+ userId: userId,
553
+ displayName: displayName
554
+ };
555
+
556
+ // 显示房间 ID
557
+ currentRoomIdSpan.textContent = roomId;
558
+
559
+ // 切换到会议界面
560
+ joinForm.classList.add('hidden');
561
+ meetingContainer.classList.remove('hidden');
562
+ } catch (error) {
563
+ console.error('加入会议时出错:', error);
564
+ alert('无法加入会议。请重试。');
565
+ }
566
+ }
567
+
568
+ // 成功加入房间后的处理
569
+ function handleJoinedRoom(data) {
570
+ console.log('已加入房间:', data);
571
+
572
+ // 初始化与现有参与者的连接
573
+ data.participants.forEach(participant => {
574
+ createPeerConnection(participant.userId, participant.displayName, true);
575
+ });
576
+ }
577
+
578
+ // 处理新用户加入
579
+ function handleUserJoined(data) {
580
+ console.log('用户加入:', data);
581
+ createPeerConnection(data.userId, data.displayName, false);
582
+
583
+ // 添加到参与者列表
584
+ addParticipantToList(data.userId, data.displayName);
585
+ }
586
+
587
+ // 处理用户离开
588
+ function handleUserLeft(data) {
589
+ console.log('用户离开:', data);
590
+ removeParticipant(data.userId);
591
+ }
592
+
593
+ // 创建WebRTC对等连接
594
+ function createPeerConnection(peerId, peerName, isInitiator) {
595
+ console.log(`创建${isInitiator ? '发起方' : '接收方'}对等连接,用于 ${peerName} (${peerId})`);
596
+
597
+ // 创建RTCPeerConnection
598
+ const pc = new RTCPeerConnection(rtcConfig);
599
+ peerConnections[peerId] = pc;
600
+
601
+ // 将本地流轨道添加到连接
602
+ localStream.getTracks().forEach(track => {
603
+ pc.addTrack(track, localStream);
604
+ });
605
+
606
+ // 处理ICE候选者
607
+ pc.onicecandidate = (event) => {
608
+ if (event.candidate) {
609
+ socket.emit('ice_candidate', {
610
+ roomId: currentRoom.id,
611
+ fromId: currentRoom.userId,
612
+ targetId: peerId,
613
+ candidate: event.candidate
614
+ });
615
+ }
616
+ };
617
+
618
+ // 处理连接状态变化
619
+ pc.onconnectionstatechange = () => {
620
+ console.log(`与 ${peerId} 的连接状态: ${pc.connectionState}`);
621
+ };
622
+
623
+ // 处理ICE连接状态变化
624
+ pc.oniceconnectionstatechange = () => {
625
+ console.log(`与 ${peerId} 的ICE连接状态: ${pc.iceConnectionState}`);
626
+ if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'disconnected') {
627
+ // 如果连接失败,则重启ICE
628
+ pc.restartIce();
629
+ }
630
+ };
631
+
632
+ // 处理远程轨道
633
+ pc.ontrack = (event) => {
634
+ console.log('收到远程轨道:', event);
635
+ const stream = event.streams[0];
636
+
637
+ // 检查是否已存在此对等方的视频元素
638
+ const existingContainer = document.getElementById(`peer-${peerId}-container`);
639
+
640
+ if (!existingContainer) {
641
+ // 创建新的视频容器
642
+ const videoContainer = document.createElement('div');
643
+ videoContainer.className = 'video-container';
644
+ videoContainer.id = `peer-${peerId}-container`;
645
+
646
+ const video = document.createElement('video');
647
+ video.srcObject = stream;
648
+ video.id = `peer-${peerId}-video`;
649
+ video.autoplay = true;
650
+ video.playsInline = true;
651
+
652
+ const userName = document.createElement('div');
653
+ userName.className = 'user-name';
654
+ userName.textContent = peerName || '参与者';
655
+
656
+ const micStatus = document.createElement('div');
657
+ micStatus.className = 'mic-status';
658
+ micStatus.innerHTML = '<i class="fas fa-microphone"></i>';
659
+ micStatus.id = `peer-${peerId}-mic`;
660
+
661
+ videoContainer.appendChild(video);
662
+ videoContainer.appendChild(userName);
663
+ videoContainer.appendChild(micStatus);
664
+
665
+ videoGrid.appendChild(videoContainer);
666
+
667
+ // 如果尚未添加,则添加到参与者列表
668
+ addParticipantToList(peerId, peerName || '参与者');
669
+ } else {
670
+ // 更新现有视频元素
671
+ const video = document.getElementById(`peer-${peerId}-video`);
672
+ if (video.srcObject !== stream) {
673
+ video.srcObject = stream;
674
+ }
675
+ }
676
+ };
677
+
678
+ // 创建数据通道(用于聊天)
679
+ if (isInitiator) {
680
+ const dataChannel = pc.createDataChannel(`chat-${peerId}`);
681
+ setupDataChannel(dataChannel, peerId);
682
+ } else {
683
+ pc.ondatachannel = (event) => {
684
+ setupDataChannel(event.channel, peerId);
685
+ };
686
+ }
687
+
688
+ // 如果我们是发起方,创建并发送offer
689
+ if (isInitiator) {
690
+ pc.createOffer()
691
+ .then(offer => pc.setLocalDescription(offer))
692
+ .then(() => {
693
+ socket.emit('offer', {
694
+ roomId: currentRoom.id,
695
+ fromId: currentRoom.userId,
696
+ targetId: peerId,
697
+ offer: pc.localDescription
698
+ });
699
+ })
700
+ .catch(error => {
701
+ console.error('创建offer时出错:', error);
702
+ });
703
+ }
704
+
705
+ return pc;
706
+ }
707
+
708
+ // 设置数据通道
709
+ function setupDataChannel(channel, peerId) {
710
+ channel.onopen = () => {
711
+ console.log(`与 ${peerId} 的数据通道已打开`);
712
+ dataChannels[peerId] = channel;
713
+ };
714
+
715
+ channel.onclose = () => {
716
+ console.log(`与 ${peerId} 的数据通道已关闭`);
717
+ delete dataChannels[peerId];
718
+ };
719
+
720
+ channel.onmessage = (event) => {
721
+ try {
722
+ const message = JSON.parse(event.data);
723
+ displayMessage(peerId, message.sender, message.text);
724
+ } catch (error) {
725
+ console.error('解析消息时出错:', error);
726
+ }
727
+ };
728
+ }
729
+
730
+ // 处理接收到的offer
731
+ function handleOffer(data) {
732
+ console.log('收到来自以下用户的offer:', data.fromId);
733
+
734
+ const pc = peerConnections[data.fromId] || createPeerConnection(data.fromId, null, false);
735
+
736
+ pc.setRemoteDescription(new RTCSessionDescription(data.offer))
737
+ .then(() => pc.createAnswer())
738
+ .then(answer => pc.setLocalDescription(answer))
739
+ .then(() => {
740
+ socket.emit('answer', {
741
+ roomId: currentRoom.id,
742
+ fromId: currentRoom.userId,
743
+ targetId: data.fromId,
744
+ answer: pc.localDescription
745
+ });
746
+ })
747
+ .catch(error => {
748
+ console.error('处理offer时出错:', error);
749
+ });
750
+ }
751
+
752
+ // 处理接收到的answer
753
+ function handleAnswer(data) {
754
+ console.log('收到来自以下用户的answer:', data.fromId);
755
+
756
+ const pc = peerConnections[data.fromId];
757
+ if (pc) {
758
+ pc.setRemoteDescription(new RTCSessionDescription(data.answer))
759
+ .catch(error => {
760
+ console.error('处理answer时出错:', error);
761
+ });
762
+ }
763
+ }
764
+
765
+ // 处理接收到的ICE候选者
766
+ function handleIceCandidate(data) {
767
+ console.log('收到来自以下用户的ICE候选者:', data.fromId);
768
+
769
+ const pc = peerConnections[data.fromId];
770
+ if (pc) {
771
+ pc.addIceCandidate(new RTCIceCandidate(data.candidate))
772
+ .catch(error => {
773
+ console.error('添加ICE候选者时出错:', error);
774
+ });
775
+ }
776
+ }
777
+
778
+ // 处理接收到的聊天消息
779
+ function handleChatMessage(data) {
780
+ displayMessage(data.fromId, data.fromName, data.message);
781
+ }
782
+
783
+ // 处理媒体状态变化
784
+ function handleMediaStateChange(data) {
785
+ const micIcon = document.getElementById(`peer-${data.userId}-mic`);
786
+ if (micIcon) {
787
+ micIcon.innerHTML = data.audioEnabled ?
788
+ '<i class="fas fa-microphone"></i>' :
789
+ '<i class="fas fa-microphone-slash"></i>';
790
+ }
791
+
792
+ const videoContainer = document.getElementById(`peer-${data.userId}-container`);
793
+ if (videoContainer) {
794
+ if (!data.videoEnabled) {
795
+ videoContainer.classList.add('video-off');
796
+ } else {
797
+ videoContainer.classList.remove('video-off');
798
+ }
799
+ }
800
+ }
801
+
802
+ // 将参与者添加到列表
803
+ function addParticipantToList(userId, displayName) {
804
+ if (document.getElementById(`participant-${userId}`)) {
805
+ return; // 已在列表中
806
+ }
807
+
808
+ const participant = document.createElement('li');
809
+ participant.className = 'participant';
810
+ participant.id = `participant-${userId}`;
811
+
812
+ const participantInfo = document.createElement('div');
813
+ participantInfo.className = 'participant-info';
814
+ participantInfo.textContent = displayName;
815
+
816
+ participant.appendChild(participantInfo);
817
+ participantsList.appendChild(participant);
818
+ }
819
+
820
+ // 移除参与者
821
+ function removeParticipant(userId) {
822
+ // 移除对等连接
823
+ const pc = peerConnections[userId];
824
+ if (pc) {
825
+ pc.close();
826
+ delete peerConnections[userId];
827
+ }
828
+
829
+ // 移除数据通道
830
+ delete dataChannels[userId];
831
+
832
+ // 移除视频元素
833
+ const videoContainer = document.getElementById(`peer-${userId}-container`);
834
+ if (videoContainer) {
835
+ videoContainer.remove();
836
+ }
837
+
838
+ // 从参与者列表中移除
839
+ const participant = document.getElementById(`participant-${userId}`);
840
+ if (participant) {
841
+ participant.remove();
842
+ }
843
+ }
844
+
845
+ // 切换音频
846
+ function toggleAudio() {
847
+ const audioTracks = localStream.getAudioTracks();
848
+
849
+ if (audioTracks.length > 0) {
850
+ const track = audioTracks[0];
851
+ track.enabled = !track.enabled;
852
+ localAudioEnabled = track.enabled;
853
+
854
+ toggleAudioBtn.innerHTML = localAudioEnabled ?
855
+ '<i class="fas fa-microphone"></i> 麦克风' :
856
+ '<i class="fas fa-microphone-slash"></i> 麦克风 (已关闭)';
857
+
858
+ toggleAudioBtn.classList.toggle('disabled', !localAudioEnabled);
859
+
860
+ // 更新麦克风状态在UI中
861
+ const micStatus = document.querySelector('#local-video-container .mic-status');
862
+ if (micStatus) {
863
+ micStatus.innerHTML = localAudioEnabled ?
864
+ '<i class="fas fa-microphone"></i>' :
865
+ '<i class="fas fa-microphone-slash"></i>';
866
+ }
867
+
868
+ // 通知其他参与者
869
+ if (socket && currentRoom) {
870
+ socket.emit('media_state_change', {
871
+ roomId: currentRoom.id,
872
+ userId: currentRoom.userId,
873
+ audioEnabled: localAudioEnabled,
874
+ videoEnabled: localVideoEnabled
875
+ });
876
+ }
877
+ }
878
+ }
879
+
880
+ // 切换视频
881
+ function toggleVideo() {
882
+ const videoTracks = localStream.getVideoTracks();
883
+
884
+ if (videoTracks.length > 0) {
885
+ const track = videoTracks[0];
886
+ track.enabled = !track.enabled;
887
+ localVideoEnabled = track.enabled;
888
+
889
+ toggleVideoBtn.innerHTML = localVideoEnabled ?
890
+ '<i class="fas fa-video"></i> 摄像头' :
891
+ '<i class="fas fa-video-slash"></i> 摄像头 (已关闭)';
892
+
893
+ toggleVideoBtn.classList.toggle('disabled', !localVideoEnabled);
894
+
895
+ // 更新视频容器样式
896
+ const localVideoContainer = document.getElementById('local-video-container');
897
+ if (localVideoContainer) {
898
+ if (!localVideoEnabled) {
899
+ localVideoContainer.classList.add('video-off');
900
+ } else {
901
+ localVideoContainer.classList.remove('video-off');
902
+ }
903
+ }
904
+
905
+ // 通知其他参与者
906
+ if (socket && currentRoom) {
907
+ socket.emit('media_state_change', {
908
+ roomId: currentRoom.id,
909
+ userId: currentRoom.userId,
910
+ audioEnabled: localAudioEnabled,
911
+ videoEnabled: localVideoEnabled
912
+ });
913
+ }
914
+ }
915
+ }
916
+
917
+ // 切换屏幕共享
918
+ async function toggleScreen() {
919
+ if (!screenStream) {
920
+ try {
921
+ screenStream = await navigator.mediaDevices.getDisplayMedia({
922
+ video: true
923
+ });
924
+
925
+ // 在所有对等连接中替换视频轨道
926
+ const videoTrack = screenStream.getVideoTracks()[0];
927
+
928
+ Object.values(peerConnections).forEach(pc => {
929
+ const senders = pc.getSenders();
930
+ const sender = senders.find(s => s.track && s.track.kind === 'video');
931
+ if (sender) {
932
+ sender.replaceTrack(videoTrack);
933
+ }
934
+ });
935
+
936
+ // 更新本地视频
937
+ const localVideo = document.querySelector('#local-video-container video');
938
+ if (localVideo) {
939
+ localVideo.srcObject = screenStream;
940
+ }
941
+
942
+ toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 停止共享';
943
+ toggleScreenBtn.classList.add('red');
944
+
945
+ // 处理屏幕共享结束
946
+ videoTrack.onended = () => {
947
+ stopScreenSharing();
948
+ };
949
+ } catch (error) {
950
+ console.error('无法共享屏幕', error);
951
+ alert('无法共享屏幕,请确保您已授予权限。');
952
+ }
953
+ } else {
954
+ stopScreenSharing();
955
+ }
956
+ }
957
+
958
+ // 停止屏幕共享
959
+ function stopScreenSharing() {
960
+ if (screenStream) {
961
+ screenStream.getTracks().forEach(track => track.stop());
962
+ screenStream = null;
963
+
964
+ // 恢复摄像头视频
965
+ const videoTrack = localStream.getVideoTracks()[0];
966
+ if (videoTrack) {
967
+ Object.values(peerConnections).forEach(pc => {
968
+ const senders = pc.getSenders();
969
+ const sender = senders.find(s => s.track && s.track.kind === 'video');
970
+ if (sender) {
971
+ sender.replaceTrack(videoTrack);
972
+ }
973
+ });
974
+
975
+ // 更新本地视频
976
+ const localVideo = document.querySelector('#local-video-container video');
977
+ if (localVideo) {
978
+ localVideo.srcObject = localStream;
979
+ }
980
+ }
981
+
982
+ toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 屏幕共享';
983
+ toggleScreenBtn.classList.remove('red');
984
+ }
985
+ }
986
+
987
+ // 离开会议
988
+ function leaveCall() {
989
+ if (socket && currentRoom) {
990
+ socket.emit('leave', {
991
+ roomId: currentRoom.id,
992
+ userId: currentRoom.userId
993
+ });
994
+ }
995
+
996
+ // 关闭所有对等连接
997
+ Object.values(peerConnections).forEach(pc => pc.close());
998
+ peerConnections = {};
999
+
1000
+ // 关闭本地媒体流
1001
+ if (localStream) {
1002
+ localStream.getTracks().forEach(track => track.stop());
1003
+ localStream = null;
1004
+ }
1005
+
1006
+ // 关闭屏幕共享流
1007
+ if (screenStream) {
1008
+ screenStream.getTracks().forEach(track => track.stop());
1009
+ screenStream = null;
1010
+ }
1011
+
1012
+ // 清空视频网格
1013
+ videoGrid.innerHTML = '';
1014
+
1015
+ // 清空参与者列表
1016
+ participantsList.innerHTML = '';
1017
+
1018
+ // 清空聊天消息
1019
+ messages.innerHTML = '';
1020
+
1021
+ // 重置当前房间
1022
+ currentRoom = null;
1023
+
1024
+ // 切换回登录界面
1025
+ meetingContainer.classList.add('hidden');
1026
+ joinForm.classList.remove('hidden');
1027
+
1028
+ // 重置按钮状态
1029
+ toggleAudioBtn.innerHTML = '<i class="fas fa-microphone"></i> 麦克风';
1030
+ toggleAudioBtn.classList.remove('disabled');
1031
+
1032
+ toggleVideoBtn.innerHTML = '<i class="fas fa-video"></i> 摄像头';
1033
+ toggleVideoBtn.classList.remove('disabled');
1034
+
1035
+ toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 屏幕共享';
1036
+ toggleScreenBtn.classList.remove('red');
1037
+ }
1038
+
1039
+ // 发送聊天消息
1040
+ function sendMessage() {
1041
+ const messageText = messageInput.value.trim();
1042
+
1043
+ if (messageText && currentRoom) {
1044
+ const message = {
1045
+ text: messageText,
1046
+ sender: currentRoom.displayName
1047
+ };
1048
+
1049
+ // 显示自己的消息
1050
+ displayMessage(currentRoom.userId, currentRoom.displayName, messageText);
1051
+
1052
+ // 发送消息到服务器
1053
+ socket.emit('chat_message', {
1054
+ roomId: currentRoom.id,
1055
+ fromId: currentRoom.userId,
1056
+ message: messageText
1057
+ });
1058
+
1059
+ // 清空输入框
1060
+ messageInput.value = '';
1061
+ }
1062
+ }
1063
+
1064
+ // 显示聊天消息
1065
+ function displayMessage(senderId, senderName, text) {
1066
+ const isMe = senderId === currentRoom?.userId;
1067
+
1068
+ const messageElement = document.createElement('div');
1069
+ messageElement.className = `message ${isMe ? 'sent' : 'received'}`;
1070
+
1071
+ const senderElement = document.createElement('div');
1072
+ senderElement.className = 'sender';
1073
+ senderElement.textContent = isMe ? '你' : senderName;
1074
+
1075
+ const textElement = document.createElement('div');
1076
+ textElement.textContent = text;
1077
+
1078
+ messageElement.appendChild(senderElement);
1079
+ messageElement.appendChild(textElement);
1080
+
1081
+ messages.appendChild(messageElement);
1082
+ messages.scrollTop = messages.scrollHeight;
1083
+ }
1084
+
1085
+ // 复制会议 ID
1086
+ function copyRoomId() {
1087
+ const roomId = currentRoomIdSpan.textContent;
1088
+ navigator.clipboard.writeText(roomId)
1089
+ .then(() => {
1090
+ alert('会议 ID 已复制到剪贴板');
1091
+ })
1092
+ .catch(err => {
1093
+ console.error('无法复制会议 ID', err);
1094
+ alert('无法复制会议 ID,请手动选择并复制');
1095
+ });
1096
+ }
1097
+
1098
+ // 切换标签页
1099
+ function switchTab(tabName) {
1100
+ tabs.forEach(tab => {
1101
+ tab.classList.toggle('active', tab.dataset.tab === tabName);
1102
+ });
1103
+
1104
+ tabContents.forEach(content => {
1105
+ content.classList.toggle('hidden', content.id !== `${tabName}Tab`);
1106
+ });
1107
+ }
1108
+
1109
+ // 事件监听
1110
+ createMeetingBtn.addEventListener('click', createMeeting);
1111
+ joinMeetingBtn.addEventListener('click', joinMeeting);
1112
+ toggleAudioBtn.addEventListener('click', toggleAudio);
1113
+ toggleVideoBtn.addEventListener('click', toggleVideo);
1114
+ toggleScreenBtn.addEventListener('click', toggleScreen);
1115
+ leaveCallBtn.addEventListener('click', leaveCall);
1116
+ copyRoomIdBtn.addEventListener('click', copyRoomId);
1117
+
1118
+ tabs.forEach(tab => {
1119
+ tab.addEventListener('click', () => {
1120
+ switchTab(tab.dataset.tab);
1121
+ });
1122
+ });
1123
+
1124
+ sendMessageBtn.addEventListener('click', sendMessage);
1125
+
1126
+ messageInput.addEventListener('keypress', (event) => {
1127
+ if (event.key === 'Enter') {
1128
+ sendMessage();
1129
+ }
1130
+ });
1131
+
1132
+ // 确保在页面卸载前清理资源
1133
+ window.addEventListener('beforeunload', () => {
1134
+ if (currentRoom) {
1135
+ leaveCall();
1136
+ }
1137
+ });
1138
+ </script>
1139
+
1140
+ <!-- Font Awesome 图标 -->
1141
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js"></script>
1142
+ </body>
1143
+ </html>