webRTC实现web视频通信
webRTC实现web视频通信
-
-
部分代码如下,完整的代码在下边的仓库地址,可以自行领取
import { io, Socket } from 'socket.io-client';
// RTCConfiguration 类型定义
interface RTCIceServer {
urls: string | string[];
username?: string;
credential?: string;
}
// WebRTC配置
const rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' }
]
};
// 定义事件处理函数的类型
type EventCallback = (...args: any[]) => void;
// 定义事件映射类型
interface EventMap {
[event: string]: EventCallback[];
}
class WebRTCService {
socket: Socket | null;
localStream: MediaStream | null;
peerConnections: Record<string, RTCPeerConnection>;
roomId: string | null;
userId: string | null;
events: EventMap;
isConnected: boolean;
constructor() {
// socket ID
this.socket = null;
// 本地流
this.localStream = null;
// 存储与其他用户的连接
this.peerConnections = {};
// 房间ID
this.roomId = null;
// 用户ID
this.userId = null;
// 事件监听器
this.events = {};
// 连接状态
this.isConnected = false;
}
// 初始化socket连接
connect(): void {
// 防止重复连接
if (this.socket && this.isConnected) {
return;
}
// 连接服务器
this.socket = io('http://localhost:5000');
// 监听连接事件
this.socket.on('connect', () => {
console.log('已连接到信令服务器');
this.isConnected = true;
this.emit('connection-established');
});
// 监听连接断开事件
this.socket.on('disconnect', () => {
console.log('与信令服务器断开连接');
this.isConnected = false;
this.emit('connection-lost');
});
// 监听其他用户连接
this.socket.on('user-connected', (userId: string) => {
console.log('新用户已连接:', userId);
// 确保不处理自己的连接事件
if (userId !== this.userId) {
this.emit('user-connected', userId);
// 如果我们已经有本地流,则创建与新用户的对等连接
if (this.localStream) {
this.createPeerConnection(userId);
}
}
});
// 监听其他用户断开连接
this.socket.on('user-disconnected', (userId: string) => {
console.log('用户已断开连接:', userId);
// 确保不处理自己的断开连接事件
if (userId !== this.userId) {
this.emit('user-disconnected', userId);
this.closePeerConnection(userId);
}
});
// 监听获取房间现有用户信息事件
this.socket.on('existing-users', (userIds: string[]) => {
console.log('房间内现有的用户:', userIds);
// 过滤掉自己的ID
const filteredUserIds = userIds.filter(id => id !== this.userId);
console.log('过滤后的现有用户:', filteredUserIds);
this.emit('existing-users', filteredUserIds);
// 为每个现有用户创建对等连接
if (this.localStream) {
filteredUserIds.forEach(userId => {
setTimeout(() => {
this.createPeerConnection(userId);
}, 1000); // 延迟1秒创建连接,确保信令服务器已经处理用户加入事件
});
}
});
// 监听offer事件
this.socket.on('offer', async (offer: RTCSessionDescriptionInit, fromUserId: string, toUserId?: string) => {
console.log('收到offer:', fromUserId, '目标用户:', toUserId || '全部');
try {
// 只处理发送给当前用户的offer
if (toUserId && toUserId !== this.userId) {
return;
}
// 避免处理自己的offer
if (fromUserId === this.userId) {
return;
}
// 确保连接存在,如果不存在则创建
if (!this.peerConnections[fromUserId]) {
console.log(`为用户 ${fromUserId} 创建新的对等连接(响应offer)`);
await this.createPeerConnection(fromUserId, false); // 不自动发送offer
}
const pc = this.peerConnections[fromUserId];
// 检查是否已经设置了远程描述
const signalingState = pc.signalingState;
if (signalingState !== 'stable') {
console.log(`当前信令状态: ${signalingState},重置连接`);
await this.resetPeerConnection(fromUserId);
return; // 重置后会重新收到offer
}
console.log(`为用户 ${fromUserId} 设置远程描述(offer)`);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
console.log(`为用户 ${fromUserId} 创建answer`);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
console.log(`向用户 ${fromUserId} 发送answer`);
this.socket?.emit('answer', answer, this.roomId, this.userId, fromUserId);
} catch (error) {
console.error('处理offer时出错:', error);
// 尝试重置连接
this.resetPeerConnection(fromUserId);
}
});
// 监听answer事件
this.socket.on('answer', async (answer: RTCSessionDescriptionInit, fromUserId: string, toUserId?: string) => {
console.log('收到answer:', fromUserId, '目标用户:', toUserId || '全部');
try {
// 只处理发送给当前用户的answer
if (toUserId && toUserId !== this.userId) {
return;
}
// 避免处理自己的answer
if (fromUserId === this.userId) {
return;
}
// 判断链接是否存在
if (this.peerConnections[fromUserId]) {
const pc = this.peerConnections[fromUserId];
console.log('pc', pc)
// 检查信令状态
if (pc.signalingState === 'have-local-offer') {
console.log(`为用户 ${fromUserId} 设置远程描述(answer)`);
await pc.setRemoteDescription(new RTCSessionDescription(answer));
} else {
console.log(`信令状态不正确: ${pc.signalingState},无法设置远程描述`);
}
} else {
console.log(`没有找到与用户 ${fromUserId} 的连接,无法处理answer`);
}
} catch (error) {
console.error('处理answer时出错:', error);
}
});
// 监听ICE事件
this.socket.on('ice-candidate', async (candidate: RTCIceCandidateInit, fromUserId: string, toUserId?: string) => {
console.log('收到ICE候选:', fromUserId, '目标用户:', toUserId || '全部');
try {
// 只处理发送给当前用户的ice候选
if (toUserId && toUserId !== this.userId) {
return;
}
// 避免处理自己的ice候选
if (fromUserId === this.userId) {
return;
}
if (this.peerConnections[fromUserId]) {
const pc = this.peerConnections[fromUserId];
// 只有在连接已初始化后才添加ICE候选
if (pc.remoteDescription) {
console.log(`为用户 ${fromUserId} 添加ICE候选`);
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
console.log(`等待设置远程描述后再添加ICE候选`);
// 可以将候选缓存起来,稍后添加
}
} else {
console.log(`没有找到与用户 ${fromUserId} 的连接,无法处理ICE候选`);
}
} catch (error) {
console.error('处理ICE候选时出错:', error);
}
});
// 监听服务器信息和信令错误处理事件
this.socket.on('server-info', (info: any) => {
console.log('服务器信息:', info);
this.emit('server-info', info);
});
// 监听信号错误
this.socket.on('signal-error', (error: any) => {
console.error('信令错误:', error);
this.emit('signal-error', error);
// 如果是user-not-found错误,可能需要重新请求用户列表
if (error.error === 'user-not-found' && this.roomId) {
console.log('用户未找到,请求更新用户列表');
this.socket?.emit('request-users', this.roomId);
}
});
}
// 重置对等连接
async resetPeerConnection(userId: string): Promise<void> {
console.log(`重置与用户 ${userId} 的对等连接`);
// 关闭现有连接
this.closePeerConnection(userId);
// 等待一段时间,确保关闭完成
await new Promise(resolve => setTimeout(resolve, 1000));
// 重新创建连接
await this.createPeerConnection(userId);
}
// 加入房间
joinRoom(roomId: string, userId: string): void {
console.log(`加入房间: ${roomId}, 用户ID: ${userId}`);
this.roomId = roomId;
this.userId = userId;
// 确保socket已连接
if (this.socket && this.isConnected) {
// 发送加入房间消息
this.socket.emit('join-room', roomId, userId);
// 请求房间内现有用户列表
this.socket.emit('request-users', roomId);
} else {
console.error('无法加入房间: socket未连接');
this.connect(); // 尝试连接
// 连接成功后会触发connection-established事件,然后再加入房间
}
}
// 离开房间
leaveRoom(): void {
console.log('离开房间');
// 确保socket已连接
if (this.socket && this.isConnected && this.roomId) {
// 发送离开房间消息
this.socket.emit('leave-room', this.roomId, this.userId);
}
// 关闭所有对等连接
Object.keys(this.peerConnections).forEach(userId => {
this.closePeerConnection(userId);
});
// 停止本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
track.stop();
});
this.localStream = null;
}
// 重置状态
this.roomId = null;
this.userId = null;
}
// 创建与特定用户的WebRTC对等连接
async createPeerConnection(userId: string, initiateOffer: boolean = true): Promise<RTCPeerConnection> {
console.log(`创建与用户 ${userId} 的对等连接`);
// 确保本地流存在
if (!this.localStream) {
console.log('本地流不存在,尝试获取媒体流');
try {
this.localStream = await this.getLocalStream();
} catch (error) {
console.error('获取本地媒体流失败:', error);
throw new Error('无法创建对等连接: 本地媒体流不可用');
}
}
// 如果已经存在连接,先关闭
if (this.peerConnections[userId]) {
console.log(`关闭已存在的与用户 ${userId} 的连接`);
this.closePeerConnection(userId);
}
// 创建新的对等连接
const pc = new RTCPeerConnection(rtcConfig);
this.peerConnections[userId] = pc;
// 添加本地流轨道到连接
this.localStream.getTracks().forEach(track => {
console.log(`添加轨道到对等连接: ${track.kind}`);
pc.addTrack(track, this.localStream!);
});
// 处理ICE候选
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log(`为用户 ${userId} 生成ICE候选`);
this.socket?.emit('ice-candidate', event.candidate, this.roomId, this.userId, userId);
}
};
// 处理连接状态变化
pc.onconnectionstatechange = () => {
console.log(`与用户 ${userId} 的连接状态变化: ${pc.connectionState}`);
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
console.log(`连接已断开或失败: ${userId}`);
this.emit('peer-disconnected', userId);
} else if (pc.connectionState === 'connected') {
console.log(`连接已建立: ${userId}`);
this.emit('peer-connected', userId);
}
};
// 处理ICE连接状态变化
pc.oniceconnectionstatechange = () => {
console.log(`ICE连接状态变化: ${pc.iceConnectionState}`);
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
console.log(`ICE连接已断开或失败: ${userId}`);
}
};
// 处理接收到的轨道
pc.ontrack = (event) => {
console.log(`接收到用户 ${userId} 的轨道: ${event.track.kind}`);
if (event.streams && event.streams[0]) {
console.log(`将远程流与用户 ${userId} 关联`);
this.emit('track-added', { userId, stream: event.streams[0] });
}
};
// 如果需要主动发起连接,创建并发送offer
if (initiateOffer) {
try {
console.log(`为用户 ${userId} 创建offer`);
const offer = await pc.createOffer();
console.log(`为用户 ${userId} 设置本地描述(offer)`);
await pc.setLocalDescription(offer);
console.log(`向用户 ${userId} 发送offer`);
// 使用可选链运算符避免null错误
this.socket?.emit('offer', pc.localDescription, this.roomId, this.userId, userId);
} catch (error) {
console.error('创建或发送offer时出错:', error);
throw error;
}
}
return pc;
}
// 关闭与特定用户的对等连接
closePeerConnection(userId: string): void {
console.log(`关闭与用户 ${userId} 的对等连接`);
const pc = this.peerConnections[userId];
if (pc) {
// 关闭连接
pc.onicecandidate = null;
pc.ontrack = null;
pc.onconnectionstatechange = null;
pc.oniceconnectionstatechange = null;
// 关闭所有数据通道
const dataChannels = Object.values(pc);
for (const dc of dataChannels) {
if (dc && typeof dc === 'object' && 'close' in dc && typeof dc.close === 'function') {
try {
dc.close();
} catch (error) {
console.error('关闭数据通道时出错:', error);
}
}
}
// 关闭对等连接
try {
pc.close();
} catch (error) {
console.error('关闭对等连接时出错:', error);
}
// 从连接映射中删除
delete this.peerConnections[userId];
}
}
// 获取本地媒体流
async getLocalStream(constraints: MediaStreamConstraints = { audio: true, video: true }): Promise<MediaStream> {
console.log('请求本地媒体流, 约束:', constraints);
if (!this.localStream) {
console.log('获取新的媒体流');
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
}
return this.localStream;
}
// 添加事件监听器
on(event: string, callback: EventCallback): void {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
// 触发事件
emit(event: string, ...args: any[]): void {
const callbacks = this.events[event];
if (callbacks) {
callbacks.forEach(callback => {
try {
callback(...args);
} catch (error) {
console.error(`执行 ${event} 事件回调时出错:`, error);
}
});
}
}
}
// 创建并导出WebRTC服务单例
const webRTCService = new WebRTCService();
export default webRTCService;
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from 'vue';
import webRTCService from '../services/webrtc';
interface Props {
roomId: string;
userId: string;
}
const props = defineProps<Props>();
console.log('props', props)
const emit = defineEmits<{
'leave-room': []
}>();
const localVideoRef = ref<HTMLVideoElement | null>(null);
const remoteStreams = ref<Record<string, MediaStream>>({}); // 远程视频流映射 userId -> stream
const connectionStatus = ref<string>('正在连接...');
const isMuted = ref<boolean>(false);
const isVideoOff = ref<boolean>(false);
const errorMessage = ref<string>('');
const roomLink = ref<string>('');
const roomUsers = ref<string[]>([]); // 房间内其他用户列表
const debugInfo = ref<string>(''); // 调试信息
// 计算所有用户(包括本地用户)
const allUsers = computed<string[]>(() => {
// 创建集合以确保唯一性
console.log('当前用户:', props.userId, '房间用户:', roomUsers.value);
const uniqueUsers = [...new Set([props.userId, ...roomUsers.value])];
console.log('计算得到的所有用户:', uniqueUsers);
return uniqueUsers;
});
// 使用手动更新方法确保Vue的响应式更新
function updateRoomUsers(userIds: string[]): void {
console.log('手动更新房间用户列表:', userIds);
// 过滤掉自己的ID,因为不需要在用户列表中看到自己两次
const filteredIds = userIds.filter(id => id !== props.userId);
// 清空再设置,确保响应式更新
roomUsers.value = [];
// 使用nextTick确保DOM更新后再设置新值
nextTick(() => {
roomUsers.value = filteredIds;
console.log('更新后的房间用户列表:', roomUsers.value);
});
}
function updateRemoteStream(userId: string, stream: MediaStream): void {
console.log('更新远程流:', userId);
// 使用新对象替换旧对象,确保Vue检测到变化
const newStreams = { ...remoteStreams.value };
newStreams[userId] = stream;
remoteStreams.value = newStreams;
// 确保该用户也在用户列表中
if (!roomUsers.value.includes(userId) && userId !== props.userId) {
updateRoomUsers([...roomUsers.value, userId]);
}
}
function removeRemoteStream(userId: string): void {
console.log('移除远程流:', userId);
if (remoteStreams.value[userId]) {
const newStreams = { ...remoteStreams.value };
delete newStreams[userId];
remoteStreams.value = newStreams;
}
}
// 初始化WebRTC
async function initializeWebRTC(): Promise<void> {
try {
debugInfo.value = '正在初始化WebRTC...';
// 连接信令服务器
webRTCService.connect();
// 设置连接成功事件监听器
webRTCService.on('connection-established', () => {
console.log('信令服务器连接已建立');
connectionStatus.value = '已连接';
debugInfo.value += '\n信令服务器已连接';
// 加入房间
webRTCService.joinRoom(props.roomId, props.userId);
// 生成分享链接
const currentUrl = window.location.href.split('?')[0];
roomLink.value = `${currentUrl}?room=${props.roomId}`;
});
webRTCService.on('connection-lost', () => {
console.log('信令服务器连接已断开');
connectionStatus.value = '连接已断开,尝试重新连接...';
debugInfo.value += '\n信令服务器连接已断开';
});
webRTCService.on('track-added', ({ userId, stream }: { userId: string, stream: MediaStream }) => {
console.log(`添加远程流: ${userId}`, stream);
debugInfo.value += `\n接收到用户 ${userId} 的视频流`;
// 更新远程流
updateRemoteStream(userId, stream);
});
webRTCService.on('user-connected', (userId: string) => {
console.log(`用户连接: ${userId}`);
debugInfo.value += `\n用户 ${userId} 已连接`;
// 仅当用户不是自己且不在列表中时添加
if (userId !== props.userId && !roomUsers.value.includes(userId)) {
const newUsers = [...roomUsers.value, userId];
console.log('用户连接后更新用户列表:', newUsers);
updateRoomUsers(newUsers);
}
});
webRTCService.on('user-disconnected', (userId: string) => {
console.log(`用户断开连接: ${userId}`);
debugInfo.value += `\n用户 ${userId} 已断开连接`;
// 从用户列表中移除
const newUsers = roomUsers.value.filter(id => id !== userId);
console.log('用户断开后更新用户列表:', newUsers);
updateRoomUsers(newUsers);
// 删除该用户的远程流
removeRemoteStream(userId);
});
webRTCService.on('existing-users', (userIds: string[]) => {
console.log('接收到现有用户列表:', userIds);
debugInfo.value += `\n房间现有用户: ${userIds.join(', ')}`;
if (Array.isArray(userIds)) {
// 确保不包含当前用户ID并且没有重复项
const uniqueUserIds = [...new Set(userIds.filter(id => id !== props.userId))];
if (uniqueUserIds.length > 0) {
console.log('更新用户列表为:', uniqueUserIds);
updateRoomUsers(uniqueUserIds);
}
}
});
// 获取本地媒体流
debugInfo.value += '\n请求访问摄像头和麦克风...';
const localStream = await webRTCService.getLocalStream({
audio: true,
video: {
width: { ideal: 1280 },
height: { ideal: 720 }
} as MediaTrackConstraints
});
debugInfo.value += '\n成功获取本地媒体流';
// 显示本地视频
if (localVideoRef.value) {
localVideoRef.value.srcObject = localStream;
// 确保视频元素正确加载
localVideoRef.value.onloadedmetadata = () => {
console.log('本地视频元数据已加载');
localVideoRef.value?.play().catch(e => {
console.error('自动播放本地视频失败:', e);
debugInfo.value += '\n自动播放本地视频失败,请点击视频区域手动播放';
});
};
}
// 添加对服务器信息和信令错误的监听
webRTCService.on('server-info', (info: any) => {
console.log('收到服务器信息:', info);
debugInfo.value += `\n服务器信息: SocketID=${info.socketId}`;
});
webRTCService.on('signal-error', (error: { type: string, error: string }) => {
console.error('收到信令错误:', error);
debugInfo.value += `\n信令错误: ${error.type} - ${error.error}`;
// 如果是用户未找到错误,说明用户列表可能有问题,尝试刷新
// if (error.error === 'user-not-found') {
// console.log('尝试刷新用户列表...');
// if (webRTCService.socket && webRTCService.isConnected) {
// webRTCService.socket.emit('request-users', webRTCService.roomId);
// }
// }
});
} catch (error: unknown) {
console.error('初始化WebRTC失败:', error);
errorMessage.value = `无法访问摄像头或麦克风: ${error instanceof Error ? error.message : String(error)}`;
debugInfo.value += `\n错误: ${error instanceof Error ? error.message : String(error)}`;
}
}
// 将视频元素与远程流关联
function setVideoElement(element: HTMLVideoElement | null, userId: string): void {
if (element && remoteStreams.value[userId]) {
console.log(`设置远程视频元素 ${userId} 的srcObject`);
element.srcObject = remoteStreams.value[userId];
element.onloadedmetadata = () => {
console.log(`远程视频 ${userId} 元数据已加载`);
element.play().catch((e: unknown) => {
console.error(`自动播放远程视频 ${userId} 失败:`, e);
});
};
}
}
// 离开房间
function leaveRoom(): void {
console.log('离开房间');
webRTCService.leaveRoom();
emit('leave-room');
}
// 切换麦克风静音状态
function toggleMute(): void {
if (webRTCService.localStream) {
const audioTracks = webRTCService.localStream.getAudioTracks();
if (audioTracks.length > 0) {
const enabled = !audioTracks[0].enabled;
audioTracks.forEach(track => {
track.enabled = enabled;
});
isMuted.value = !enabled;
}
}
}
// 切换视频开关状态
function toggleVideo(): void {
if (webRTCService.localStream) {
const videoTracks = webRTCService.localStream.getVideoTracks();
if (videoTracks.length > 0) {
const enabled = !videoTracks[0].enabled;
videoTracks.forEach(track => {
track.enabled = enabled;
});
isVideoOff.value = !enabled;
}
}
}
// 复制房间链接到剪贴板
function copyRoomLink(): void {
if (navigator.clipboard && roomLink.value) {
navigator.clipboard.writeText(roomLink.value)
.then(() => {
alert('房间链接已复制到剪贴板');
})
.catch(err => {
console.error('无法复制链接:', err);
});
}
}
// 在组件挂载时初始化WebRTC
onMounted(() => {
console.log('VideoRoom组件已挂载');
initializeWebRTC();
});
// 在组件卸载时清理资源
onBeforeUnmount(() => {
console.log('VideoRoom组件将卸载');
webRTCService.leaveRoom();
});
</script>
<template>
<div class="video-room">
<div class="room-header">
<h2>房间: {{ roomId }}</h2>
<p class="connection-status">状态: {{ connectionStatus }}</p>
<div class="room-controls">
<button @click="toggleMute" :class="{ active: isMuted }" class="control-btn">
{{ isMuted ? '取消静音' : '静音' }}
</button>
<button @click="toggleVideo" :class="{ active: isVideoOff }" class="control-btn">
{{ isVideoOff ? '开启视频' : '关闭视频' }}
</button>
<button @click="copyRoomLink" class="control-btn" title="复制房间链接">
分享链接
</button>
<button @click="leaveRoom" class="leave-btn">离开房间</button>
</div>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div class="room-layout">
<div class="video-container">
<!-- 本地视频 -->
<div class="video-item local-video">
<video ref="localVideoRef" autoplay muted playsinline></video>
<div class="video-label">
{{ userId }} (我)
<span v-if="isMuted" class="status-icon">🔇</span>
<span v-if="isVideoOff" class="status-icon">🚫</span>
</div>
</div>
<!-- 远程视频 -->
<template v-for="remoteUserId in roomUsers" :key="remoteUserId">
<div class="video-item remote-video">
<video :id="`video-${remoteUserId}`" autoplay playsinline
ref="el => setVideoElement(el as HTMLVideoElement, remoteUserId)"></video>
<div class="video-label">{{ remoteUserId }}</div>
</div>
</template>
</div>
<!-- 用户列表 -->
<div class="users-panel">
<h3>房间用户 ({{ allUsers.length }})</h3>
<ul class="users-list">
<li v-for="user in allUsers" :key="user" :class="{ 'current-user': user === userId }">
{{ user }} {{ user === userId ? '(我)' : '' }}
<div class="user-status">
<span v-if="user === userId && isMuted" class="status-badge muted">已静音</span>
<span v-if="user === userId && isVideoOff" class="status-badge video-off">无视频</span>
<span v-if="user !== userId" class="status-badge connected">已连接</span>
</div>
</li>
</ul>
</div>
</div>
<div class="debug-panel">
<details>
<summary>调试信息</summary>
<pre>{{ debugInfo }}</pre>
</details>
</div>
</div>
</template>
<style scoped>
.video-room {
display: flex;
flex-direction: column;
height: 100%;
}
.room-header {
background-color: #f8f9fa;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.room-controls {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.control-btn,
.leave-btn {
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.control-btn {
background-color: #e9ecef;
color: #495057;
}
.control-btn:hover {
background-color: #dee2e6;
}
.control-btn.active {
background-color: #fd7e14;
color: white;
}
.leave-btn {
background-color: #dc3545;
color: white;
margin-left: auto;
}
.leave-btn:hover {
background-color: #c82333;
}
.connection-status {
color: #6c757d;
font-size: 14px;
margin: 5px 0;
}
.error-message {
background-color: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.room-layout {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.video-container {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.users-panel {
width: 250px;
background-color: #fff;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.users-panel h3 {
margin-top: 0;
margin-bottom: 15px;
font-size: 16px;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
.users-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
.users-list li {
padding: 10px;
border-radius: 4px;
margin-bottom: 5px;
background-color: #f8f9fa;
display: flex;
justify-content: space-between;
align-items: center;
}
.users-list li.current-user {
background-color: #e2f3ff;
font-weight: bold;
}
.user-status {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
font-weight: normal;
}
.status-badge.connected {
background-color: #28a745;
color: white;
}
.status-badge.muted {
background-color: #fd7e14;
color: white;
}
.status-badge.video-off {
background-color: #6c757d;
color: white;
}
.video-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background-color: #000;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.local-video {
width: 300px;
height: 225px;
}
.remote-video {
width: 300px;
height: 225px;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 5px 10px;
font-size: 14px;
}
.status-icon {
margin-left: 5px;
}
.debug-panel {
margin-top: auto;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
details {
color: #6c757d;
font-size: 14px;
}
pre {
white-space: pre-wrap;
font-size: 12px;
background-color: #f8f9fa;
padding: 10px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 768px) {
.room-layout {
flex-direction: column;
}
.users-panel {
width: 100%;
order: -1;
}
.video-container {
justify-content: center;
}
.local-video,
.remote-video {
width: 100%;
max-width: 400px;
height: auto;
aspect-ratio: 4/3;
}
}
</style>
仓库地址
链接: https://gitee.com/huang_zhan_le/web-video-call
ps:有问题可以留言