webRTC实现web视频通信

webRTC实现web视频通信

webRTC实现web视频通信

  1. 在这里插入图片描述

  2. 部分代码如下,完整的代码在下边的仓库地址,可以自行领取


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:有问题可以留言

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值