meet / index.html
mistpe's picture
Update index.html
1756749 verified
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>视频会议应用</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.4.1/socket.io.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/webrtc-adapter/8.1.1/adapter.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Arial', sans-serif;
}
body {
background-color: #f0f2f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #ddd;
margin-bottom: 20px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: #0066cc;
}
.controls {
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background-color: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
button:hover {
background-color: #0055aa;
}
button.red {
background-color: #cc0000;
}
button.red:hover {
background-color: #aa0000;
}
.disabled {
background-color: #888;
cursor: not-allowed;
}
.disabled:hover {
background-color: #888;
}
.meeting-container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 20px;
height: calc(100vh - 150px);
}
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-auto-rows: 1fr;
gap: 15px;
height: 100%;
overflow: auto;
}
.video-container {
background-color: #222;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-name {
position: absolute;
bottom: 10px;
left: 10px;
color: white;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
.mic-status {
position: absolute;
top: 10px;
right: 10px;
color: white;
padding: 4px;
border-radius: 50%;
}
.sidebar {
background-color: white;
border-radius: 8px;
display: flex;
flex-direction: column;
height: 100%;
}
.tabs {
display: flex;
border-bottom: 1px solid #ddd;
}
.tab {
padding: 10px 15px;
cursor: pointer;
}
.tab.active {
color: #0066cc;
border-bottom: 2px solid #0066cc;
}
.tab-content {
flex-grow: 1;
overflow: auto;
padding: 15px;
}
.participants-list {
list-style: none;
}
.participant {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.participant-info {
flex-grow: 1;
}
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
.messages {
flex-grow: 1;
overflow: auto;
padding: 10px;
}
.message {
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 6px;
max-width: 80%;
}
.message.received {
background-color: #f1f1f1;
align-self: flex-start;
}
.message.sent {
background-color: #dcf8c6;
align-self: flex-end;
}
.sender {
font-size: 12px;
color: #555;
margin-bottom: 2px;
}
.chat-input {
display: flex;
padding: 10px;
border-top: 1px solid #ddd;
}
.chat-input input {
flex-grow: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
outline: none;
}
.join-form {
max-width: 400px;
margin: 100px auto;
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
}
.button-group {
display: flex;
justify-content: space-between;
}
.hidden {
display: none;
}
/* 屏幕共享布局 */
.screen-share {
grid-column: span 2;
height: 100%;
background-color: #222;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.screen-share video {
width: 100%;
height: 100%;
object-fit: contain;
}
.screen-share-label {
position: absolute;
top: 10px;
left: 10px;
color: white;
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.meeting-container {
grid-template-columns: 1fr;
}
.sidebar {
margin-top: 20px;
height: 300px;
}
}
</style>
</head>
<body>
<!-- 登录表单 -->
<div class="join-form" id="joinForm">
<h2 style="text-align: center; margin-bottom: 20px;">加入会议</h2>
<div class="form-group">
<label for="displayName">您的姓名</label>
<input type="text" id="displayName" placeholder="请输入您的姓名">
</div>
<div class="form-group">
<label for="roomId">会议 ID</label>
<input type="text" id="roomId" placeholder="请输入会议 ID 或创建新会议">
</div>
<div class="button-group">
<button id="createMeeting">创建会议</button>
<button id="joinMeeting">加入会议</button>
</div>
</div>
<!-- 会议主界面 (默认隐藏) -->
<div class="container hidden" id="meetingContainer">
<header>
<div class="logo">视频会议</div>
<div class="meeting-info">
会议 ID: <span id="currentRoomId"></span>
<button id="copyRoomId">复制</button>
</div>
<div class="controls">
<button id="toggleAudio"><i class="fas fa-microphone"></i> 麦克风</button>
<button id="toggleVideo"><i class="fas fa-video"></i> 摄像头</button>
<button id="toggleScreen"><i class="fas fa-desktop"></i> 屏幕共享</button>
<button id="leaveCall" class="red"><i class="fas fa-phone-slash"></i> 离开会议</button>
</div>
</header>
<div class="meeting-container">
<div class="video-grid" id="videoGrid">
<!-- 视频将在这里动态添加 -->
</div>
<div class="sidebar">
<div class="tabs">
<div class="tab active" data-tab="participants">参会者</div>
<div class="tab" data-tab="chat">聊天</div>
</div>
<div class="tab-content" id="participantsTab">
<ul class="participants-list" id="participantsList">
<!-- 参会者将在这里动态添加 -->
</ul>
</div>
<div class="tab-content hidden" id="chatTab">
<div class="chat-container">
<div class="messages" id="messages">
<!-- 消息将在这里动态添加 -->
</div>
<div class="chat-input">
<input type="text" id="messageInput" placeholder="输入消息...">
<button id="sendMessage">发送</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// DOM 元素
const joinForm = document.getElementById('joinForm');
const meetingContainer = document.getElementById('meetingContainer');
const displayNameInput = document.getElementById('displayName');
const roomIdInput = document.getElementById('roomId');
const createMeetingBtn = document.getElementById('createMeeting');
const joinMeetingBtn = document.getElementById('joinMeeting');
const currentRoomIdSpan = document.getElementById('currentRoomId');
const copyRoomIdBtn = document.getElementById('copyRoomId');
const toggleAudioBtn = document.getElementById('toggleAudio');
const toggleVideoBtn = document.getElementById('toggleVideo');
const toggleScreenBtn = document.getElementById('toggleScreen');
const leaveCallBtn = document.getElementById('leaveCall');
const videoGrid = document.getElementById('videoGrid');
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
const participantsList = document.getElementById('participantsList');
const messages = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendMessageBtn = document.getElementById('sendMessage');
// 状态变量
let currentRoom = null;
let localStream = null;
let screenStream = null;
let peerConnections = {};
let dataChannels = {};
let localAudioEnabled = true;
let localVideoEnabled = true;
// 模拟服务器功能 (实际应用中应该使用真实的信令服务器)
const mockServer = {
connections: {},
connect(roomId, userId) {
if (!this.connections[roomId]) {
this.connections[roomId] = {};
}
this.connections[roomId][userId] = {
onOffer: null,
onAnswer: null,
onIceCandidate: null,
sendOffer: (targetId, offer) => {
const target = this.connections[roomId][targetId];
if (target && target.onOffer) {
setTimeout(() => {
target.onOffer(userId, offer);
}, 100);
}
},
sendAnswer: (targetId, answer) => {
const target = this.connections[roomId][targetId];
if (target && target.onAnswer) {
setTimeout(() => {
target.onAnswer(userId, answer);
}, 100);
}
},
sendIceCandidate: (targetId, candidate) => {
const target = this.connections[roomId][targetId];
if (target && target.onIceCandidate) {
setTimeout(() => {
target.onIceCandidate(userId, candidate);
}, 100);
}
},
sendMessage: (targetId, message) => {
const target = this.connections[roomId][targetId];
if (target && target.onMessage) {
setTimeout(() => {
target.onMessage(userId, message);
}, 100);
}
},
onMessage: null
};
return {
userId,
peers: Object.keys(this.connections[roomId]).filter(id => id !== userId)
};
},
disconnect(roomId, userId) {
if (this.connections[roomId]) {
delete this.connections[roomId][userId];
if (Object.keys(this.connections[roomId]).length === 0) {
delete this.connections[roomId];
}
}
}
};
// 生成随机 ID
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
// 初始化媒体流
async function initLocalStream() {
try {
localStream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
// 创建本地视频元素
const videoContainer = document.createElement('div');
videoContainer.className = 'video-container';
videoContainer.id = 'local-video-container';
const video = document.createElement('video');
video.srcObject = localStream;
video.autoplay = true;
video.muted = true; // 避免回声
video.playsInline = true;
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = displayNameInput.value + ' (你)';
videoContainer.appendChild(video);
videoContainer.appendChild(userName);
videoGrid.appendChild(videoContainer);
return true;
} catch (error) {
console.error('无法获取媒体流', error);
alert('无法访问摄像头或麦克风,请确保它们已连接并授予访问权限。');
return false;
}
}
// 加入会议
async function joinMeeting() {
const displayName = displayNameInput.value.trim();
let roomId = roomIdInput.value.trim();
if (!displayName) {
alert('请输入您的姓名');
return;
}
if (!roomId) {
alert('请输入会议 ID');
return;
}
// 初始化本地媒体流
const success = await initLocalStream();
if (!success) return;
// 连接到"服务器"
const userId = generateId();
const { peers } = mockServer.connect(roomId, userId);
currentRoom = {
id: roomId,
userId,
displayName
};
// 显示会议 ID
currentRoomIdSpan.textContent = roomId;
// 为每个已存在的对等端创建连接
for (const peerId of peers) {
createPeerConnection(peerId);
}
// 设置信令回调
const connection = mockServer.connections[roomId][userId];
connection.onOffer = async (peerId, offer) => {
const pc = peerConnections[peerId] || createPeerConnection(peerId);
try {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
connection.sendAnswer(peerId, answer);
} catch (error) {
console.error('处理提议时出错', error);
}
};
connection.onAnswer = async (peerId, answer) => {
const pc = peerConnections[peerId];
if (pc) {
try {
await pc.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('处理应答时出错', error);
}
}
};
connection.onIceCandidate = (peerId, candidate) => {
const pc = peerConnections[peerId];
if (pc) {
try {
pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('添加 ICE 候选时出错', error);
}
}
};
connection.onMessage = (peerId, message) => {
displayMessage(peerId, message);
};
// 切换到会议界面
joinForm.classList.add('hidden');
meetingContainer.classList.remove('hidden');
}
// 创建 WebRTC 对等连接
function createPeerConnection(peerId) {
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
});
peerConnections[peerId] = pc;
// 添加本地流到连接
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 创建数据通道
const dataChannel = pc.createDataChannel(`chat-${peerId}`);
dataChannel.onopen = () => {
console.log(`与 ${peerId} 的数据通道已打开`);
dataChannels[peerId] = dataChannel;
};
dataChannel.onclose = () => {
console.log(`与 ${peerId} 的数据通道已关闭`);
delete dataChannels[peerId];
};
// 处理 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
const connection = mockServer.connections[currentRoom.id][currentRoom.userId];
connection.sendIceCandidate(peerId, event.candidate);
}
};
// 处理远程流
pc.ontrack = (event) => {
const stream = event.streams[0];
// 检查是否已经存在该对等方的视频元素
const existingContainer = document.getElementById(`peer-${peerId}-container`);
if (!existingContainer) {
// 创建新的视频容器
const videoContainer = document.createElement('div');
videoContainer.className = 'video-container';
videoContainer.id = `peer-${peerId}-container`;
const video = document.createElement('video');
video.srcObject = stream;
video.autoplay = true;
video.playsInline = true;
const userName = document.createElement('div');
userName.className = 'user-name';
userName.textContent = '参会者'; // 实际应用中应该从信令服务器获取名称
videoContainer.appendChild(video);
videoContainer.appendChild(userName);
videoGrid.appendChild(videoContainer);
// 添加到参会者列表
const participant = document.createElement('li');
participant.className = 'participant';
participant.id = `participant-${peerId}`;
const participantInfo = document.createElement('div');
participantInfo.className = 'participant-info';
participantInfo.textContent = '参会者';
participant.appendChild(participantInfo);
participantsList.appendChild(participant);
} else {
// 更新现有视频元素
const video = existingContainer.querySelector('video');
if (video.srcObject !== stream) {
video.srcObject = stream;
}
}
};
// 处理数据通道的接收
pc.ondatachannel = (event) => {
const receiveChannel = event.channel;
receiveChannel.onmessage = (messageEvent) => {
displayMessage(peerId, JSON.parse(messageEvent.data));
};
receiveChannel.onopen = () => {
console.log(`接收到来自 ${peerId} 的数据通道`);
dataChannels[peerId] = receiveChannel;
};
receiveChannel.onclose = () => {
console.log(`来自 ${peerId} 的数据通道已关闭`);
delete dataChannels[peerId];
};
};
// 创建并发送提议
pc.createOffer()
.then(offer => pc.setLocalDescription(offer))
.then(() => {
const connection = mockServer.connections[currentRoom.id][currentRoom.userId];
connection.sendOffer(peerId, pc.localDescription);
})
.catch(error => {
console.error('创建提议时出错', error);
});
return pc;
}
// 切换麦克风
function toggleAudio() {
const audioTracks = localStream.getAudioTracks();
if (audioTracks.length > 0) {
const track = audioTracks[0];
track.enabled = !track.enabled;
localAudioEnabled = track.enabled;
toggleAudioBtn.innerHTML = localAudioEnabled ?
'<i class="fas fa-microphone"></i> 麦克风' :
'<i class="fas fa-microphone-slash"></i> 麦克风 (已关闭)';
toggleAudioBtn.classList.toggle('disabled', !localAudioEnabled);
}
}
// 切换摄像头
function toggleVideo() {
const videoTracks = localStream.getVideoTracks();
if (videoTracks.length > 0) {
const track = videoTracks[0];
track.enabled = !track.enabled;
localVideoEnabled = track.enabled;
toggleVideoBtn.innerHTML = localVideoEnabled ?
'<i class="fas fa-video"></i> 摄像头' :
'<i class="fas fa-video-slash"></i> 摄像头 (已关闭)';
toggleVideoBtn.classList.toggle('disabled', !localVideoEnabled);
// 更新本地视频容器的样式
const localVideoContainer = document.getElementById('local-video-container');
if (localVideoContainer) {
localVideoContainer.style.backgroundColor = localVideoEnabled ? '#222' : '#444';
}
}
}
// 切换屏幕共享
async function toggleScreen() {
if (!screenStream) {
try {
screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true
});
// 替换所有连接中的视频轨道
const videoTrack = screenStream.getVideoTracks()[0];
Object.values(peerConnections).forEach(pc => {
const senders = pc.getSenders();
const sender = senders.find(s => s.track && s.track.kind === 'video');
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// 更新本地视频
const localVideo = document.querySelector('#local-video-container video');
if (localVideo) {
localVideo.srcObject = screenStream;
}
toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 停止共享';
toggleScreenBtn.classList.add('red');
// 处理屏幕共享结束
videoTrack.onended = () => {
stopScreenSharing();
};
} catch (error) {
console.error('无法共享屏幕', error);
alert('无法共享屏幕,请确保您已授予权限。');
}
} else {
stopScreenSharing();
}
}
// 停止屏幕共享
function stopScreenSharing() {
if (screenStream) {
screenStream.getTracks().forEach(track => track.stop());
screenStream = null;
// 恢复摄像头视频
const videoTrack = localStream.getVideoTracks()[0];
if (videoTrack) {
Object.values(peerConnections).forEach(pc => {
const senders = pc.getSenders();
const sender = senders.find(s => s.track && s.track.kind === 'video');
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// 更新本地视频
const localVideo = document.querySelector('#local-video-container video');
if (localVideo) {
localVideo.srcObject = localStream;
}
}
toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 屏幕共享';
toggleScreenBtn.classList.remove('red');
}
}
// 离开会议
function leaveCall() {
// 关闭所有对等连接
Object.values(peerConnections).forEach(pc => pc.close());
peerConnections = {};
// 关闭本地媒体流
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
// 关闭屏幕共享流
if (screenStream) {
screenStream.getTracks().forEach(track => track.stop());
screenStream = null;
}
// 从"服务器"断开连接
if (currentRoom) {
mockServer.disconnect(currentRoom.id, currentRoom.userId);
currentRoom = null;
}
// 清空视频网格
videoGrid.innerHTML = '';
// 清空参会者列表
participantsList.innerHTML = '';
// 清空聊天消息
messages.innerHTML = '';
// 切换回登录界面
meetingContainer.classList.add('hidden');
joinForm.classList.remove('hidden');
// 重置按钮状态
toggleAudioBtn.innerHTML = '<i class="fas fa-microphone"></i> 麦克风';
toggleAudioBtn.classList.remove('disabled');
toggleVideoBtn.innerHTML = '<i class="fas fa-video"></i> 摄像头';
toggleVideoBtn.classList.remove('disabled');
toggleScreenBtn.innerHTML = '<i class="fas fa-desktop"></i> 屏幕共享';
toggleScreenBtn.classList.remove('red');
}
// 发送聊天消息
function sendMessage() {
const messageText = messageInput.value.trim();
if (messageText && currentRoom) {
const message = {
text: messageText,
sender: currentRoom.displayName,
timestamp: new Date().toISOString()
};
// 显示自己的消息
displayMessage(currentRoom.userId, message);
// 发送消息给所有对等端
Object.keys(dataChannels).forEach(peerId => {
const channel = dataChannels[peerId];
if (channel.readyState === 'open') {
channel.send(JSON.stringify(message));
}
});
// 清空输入框
messageInput.value = '';
}
}
// 显示聊天消息
function displayMessage(senderId, message) {
const messageElement = document.createElement('div');
messageElement.className = `message ${senderId === currentRoom?.userId ? 'sent' : 'received'}`;
const senderElement = document.createElement('div');
senderElement.className = 'sender';
senderElement.textContent = message.sender;
const textElement = document.createElement('div');
textElement.textContent = message.text;
messageElement.appendChild(senderElement);
messageElement.appendChild(textElement);
messages.appendChild(messageElement);
messages.scrollTop = messages.scrollHeight;
}
// 复制会议 ID
function copyRoomId() {
const roomId = currentRoomIdSpan.textContent;
navigator.clipboard.writeText(roomId)
.then(() => {
alert('会议 ID 已复制到剪贴板');
})
.catch(err => {
console.error('无法复制会议 ID', err);
alert('无法复制会议 ID,请手动选择并复制');
});
}
// 切换标签页
function switchTab(tabName) {
tabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.tab === tabName);
});
tabContents.forEach(content => {
content.classList.toggle('hidden', content.id !== `${tabName}Tab`);
});
}
// 事件监听
createMeetingBtn.addEventListener('click', () => {
roomIdInput.value = generateId();
joinMeeting();
});
joinMeetingBtn.addEventListener('click', () => {
joinMeeting();
});
toggleAudioBtn.addEventListener('click', toggleAudio);
toggleVideoBtn.addEventListener('click', toggleVideo);
toggleScreenBtn.addEventListener('click', toggleScreen);
leaveCallBtn.addEventListener('click', leaveCall);
copyRoomIdBtn.addEventListener('click', copyRoomId);
tabs.forEach(tab => {
tab.addEventListener('click', () => {
switchTab(tab.dataset.tab);
});
});
sendMessageBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
sendMessage();
}
});
// 阻止表单提交刷新页面
document.addEventListener('submit', (event) => {
event.preventDefault();
});
</script>
<!-- Font Awesome 图标 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js"></script>
</body>
</html>