返回笔记首页

15.2 音视频通话完整实现方案

主题配置

技术架构概述

音视频通话是WebRTC的核心应用场景,本文档提供1v1通话、多人会议、屏幕共享、录制回放和网络自适应的完整实现方案。

15.2.1 1v1视频通话

技术实现方案

1v1视频通话是最基础的场景,包含呼叫、接听、挂断的完整流程。

完整实现代码

vue
<!-- OneToOneCall.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';

const props = defineProps({
  serverUrl: {
    type: String,
    default: 'http://localhost:3000'
  },
  userId: {
    type: String,
    required: true
  }
});

const emit = defineEmits(['call-started', 'call-ended']);

// 连接状态
const socket = ref(null);
const localStream = ref(null);
const remoteStream = ref(null);
const peerConnection = ref(null);

// 通话状态
const callState = reactive({
  isInCall: false,
  isCalling: false,
  isReceivingCall: false,
  remoteUserId: null,
  callStartTime: null
});

// 媒体控制
const mediaControl = reactive({
  isVideoEnabled: true,
  isAudioEnabled: true,
  isSpeakerOn: true
});

// 视频元素引用
const localVideoRef = ref(null);
const remoteVideoRef = ref(null);

// ICE服务器配置
const iceServers = [
  { urls: 'stun:stun.l.google.com:19302' },
  { urls: 'stun:stun1.l.google.com:19302' }
];

// 初始化Socket连接
const initSocket = () => {
  socket.value = io(props.serverUrl);

  socket.value.on('connect', () => {
    console.log('已连接到信令服务器');
    socket.value.emit('register', props.userId);
  });

  // 接收呼叫
  socket.value.on('incoming-call', async ({ from, offer }) => {
    console.log('收到呼叫:', from);
    callState.isReceivingCall = true;
    callState.remoteUserId = from;

    // 自动接听或等待用户操作
    // 这里可以弹出接听界面
  });

  // 接收Answer
  socket.value.on('call-answered', async ({ answer }) => {
    console.log('对方已接听');
    if (peerConnection.value) {
      await peerConnection.value.setRemoteDescription(new RTCSessionDescription(answer));
      callState.isCalling = false;
      callState.isInCall = true;
      callState.callStartTime = Date.now();
    }
  });

  // 接收ICE候选
  socket.value.on('ice-candidate', async ({ candidate }) => {
    if (peerConnection.value && candidate) {
      try {
        await peerConnection.value.addIceCandidate(new RTCIceCandidate(candidate));
      } catch (error) {
        console.error('添加ICE候选失败:', error);
      }
    }
  });

  // 对方挂断
  socket.value.on('call-ended', () => {
    console.log('对方已挂断');
    endCall();
  });
};

// 获取本地媒体流
const getLocalStream = async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 }
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      }
    });

    localStream.value = stream;

    if (localVideoRef.value) {
      localVideoRef.value.srcObject = stream;
    }

    return stream;
  } catch (error) {
    console.error('获取本地媒体流失败:', error);
    throw error;
  }
};

// 创建PeerConnection
const createPeerConnection = () => {
  const pc = new RTCPeerConnection({ iceServers });

  // 添加本地流
  if (localStream.value) {
    localStream.value.getTracks().forEach(track => {
      pc.addTrack(track, localStream.value);
    });
  }

  // ICE候选事件
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      socket.value.emit('ice-candidate', {
        to: callState.remoteUserId,
        candidate: event.candidate
      });
    }
  };

  // 接收远程流
  pc.ontrack = (event) => {
    console.log('接收到远程流');
    remoteStream.value = event.streams[0];

    if (remoteVideoRef.value) {
      remoteVideoRef.value.srcObject = event.streams[0];
    }
  };

  // 连接状态变化
  pc.onconnectionstatechange = () => {
    console.log('连接状态:', pc.connectionState);

    if (pc.connectionState === 'connected') {
      console.log('P2P连接建立成功');
    } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
      console.log('连接失败或断开');
      endCall();
    }
  };

  // ICE连接状态
  pc.oniceconnectionstatechange = () => {
    console.log('ICE连接状态:', pc.iceConnectionState);
  };

  peerConnection.value = pc;
  return pc;
};

// 发起呼叫
const startCall = async (targetUserId) => {
  try {
    callState.remoteUserId = targetUserId;
    callState.isCalling = true;

    // 获取本地流
    await getLocalStream();

    // 创建PeerConnection
    const pc = createPeerConnection();

    // 创建Offer
    const offer = await pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: true
    });

    await pc.setLocalDescription(offer);

    // 发送Offer
    socket.value.emit('call-user', {
      to: targetUserId,
      from: props.userId,
      offer: offer
    });

    console.log('已发送呼叫请求');
  } catch (error) {
    console.error('发起呼叫失败:', error);
    callState.isCalling = false;
  }
};

// 接听呼叫
const answerCall = async (offer) => {
  try {
    // 获取本地流
    await getLocalStream();

    // 创建PeerConnection
    const pc = createPeerConnection();

    // 设置远程描述
    await pc.setRemoteDescription(new RTCSessionDescription(offer));

    // 创建Answer
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);

    // 发送Answer
    socket.value.emit('answer-call', {
      to: callState.remoteUserId,
      answer: answer
    });

    callState.isReceivingCall = false;
    callState.isInCall = true;
    callState.callStartTime = Date.now();

    emit('call-started', callState.remoteUserId);
  } catch (error) {
    console.error('接听呼叫失败:', error);
  }
};

// 拒绝呼叫
const rejectCall = () => {
  socket.value.emit('reject-call', {
    to: callState.remoteUserId
  });

  callState.isReceivingCall = false;
  callState.remoteUserId = null;
};

// 结束通话
const endCall = () => {
  // 通知对方
  if (callState.remoteUserId) {
    socket.value.emit('end-call', {
      to: callState.remoteUserId
    });
  }

  // 关闭连接
  if (peerConnection.value) {
    peerConnection.value.close();
    peerConnection.value = null;
  }

  // 停止本地流
  if (localStream.value) {
    localStream.value.getTracks().forEach(track => track.stop());
    localStream.value = null;
  }

  // 清空远程流
  remoteStream.value = null;

  // 重置状态
  callState.isInCall = false;
  callState.isCalling = false;
  callState.isReceivingCall = false;
  callState.remoteUserId = null;
  callState.callStartTime = null;

  emit('call-ended');
};

// 切换视频
const toggleVideo = () => {
  if (localStream.value) {
    const videoTrack = localStream.value.getVideoTracks()[0];
    if (videoTrack) {
      videoTrack.enabled = !videoTrack.enabled;
      mediaControl.isVideoEnabled = videoTrack.enabled;
    }
  }
};

// 切换音频
const toggleAudio = () => {
  if (localStream.value) {
    const audioTrack = localStream.value.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      mediaControl.isAudioEnabled = audioTrack.enabled;
    }
  }
};

// 切换扬声器
const toggleSpeaker = () => {
  mediaControl.isSpeakerOn = !mediaControl.isSpeakerOn;
  if (remoteVideoRef.value) {
    remoteVideoRef.value.muted = !mediaControl.isSpeakerOn;
  }
};

// 获取通话时长
const getCallDuration = () => {
  if (!callState.callStartTime) return '00:00';

  const duration = Math.floor((Date.now() - callState.callStartTime) / 1000);
  const minutes = Math.floor(duration / 60).toString().padStart(2, '0');
  const seconds = (duration % 60).toString().padStart(2, '0');

  return `${minutes}:${seconds}`;
};

onMounted(() => {
  initSocket();
});

onUnmounted(() => {
  endCall();
  if (socket.value) {
    socket.value.disconnect();
  }
});

defineExpose({
  startCall,
  answerCall,
  rejectCall,
  endCall,
  toggleVideo,
  toggleAudio,
  toggleSpeaker
});
</script>

<template>
  <div class="one-to-one-call">
    <!-- 视频区域 -->
    <div class="video-container">
      <!-- 远程视频 -->
      <div class="remote-video">
        <video
          ref="remoteVideoRef"
          autoplay
          playsinline
          :class="{ hidden: !remoteStream }"
        ></video>
        <div v-if="!remoteStream" class="placeholder">
          <div v-if="callState.isCalling">正在呼叫...</div>
          <div v-else-if="callState.isReceivingCall">来电中...</div>
          <div v-else>等待连接...</div>
        </div>
      </div>

      <!-- 本地视频 -->
      <div class="local-video">
        <video
          ref="localVideoRef"
          autoplay
          playsinline
          muted
          :class="{ hidden: !mediaControl.isVideoEnabled }"
        ></video>
        <div v-if="!mediaControl.isVideoEnabled" class="video-off">
          摄像头已关闭
        </div>
      </div>
    </div>

    <!-- 控制栏 -->
    <div class="controls" v-if="callState.isInCall || callState.isCalling">
      <!-- 通话时长 -->
      <div class="call-duration" v-if="callState.isInCall">
        {{ getCallDuration() }}
      </div>

      <!-- 控制按钮 -->
      <div class="control-buttons">
        <button
          @click="toggleVideo"
          :class="{ active: mediaControl.isVideoEnabled }"
          class="control-btn"
        >
          {{ mediaControl.isVideoEnabled ? '📹' : '📹' }}
        </button>

        <button
          @click="toggleAudio"
          :class="{ active: mediaControl.isAudioEnabled }"
          class="control-btn"
        >
          {{ mediaControl.isAudioEnabled ? '🎤' : '🔇' }}
        </button>

        <button
          @click="toggleSpeaker"
          :class="{ active: mediaControl.isSpeakerOn }"
          class="control-btn"
        >
          {{ mediaControl.isSpeakerOn ? '🔊' : '🔇' }}
        </button>

        <button
          @click="endCall"
          class="control-btn end-call"
        >
          挂断
        </button>
      </div>
    </div>

    <!-- 来电界面 -->
    <div class="incoming-call" v-if="callState.isReceivingCall">
      <div class="call-info">
        <p>{{ callState.remoteUserId }} 来电</p>
      </div>
      <div class="call-actions">
        <button @click="answerCall" class="answer-btn">接听</button>
        <button @click="rejectCall" class="reject-btn">拒绝</button>
      </div>
    </div>
  </div>
</template>

<style scoped>
.one-to-one-call {
  width: 100%;
  height: 100vh;
  background: #000;
  position: relative;
}

.video-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.remote-video {
  width: 100%;
  height: 100%;
}

.remote-video video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.remote-video .placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 24px;
}

.local-video {
  position: absolute;
  bottom: 100px;
  right: 20px;
  width: 200px;
  height: 150px;
  border-radius: 8px;
  overflow: hidden;
  background: #333;
  box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

.local-video video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.local-video .video-off {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 14px;
}

.controls {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 20px;
  background: linear-gradient(transparent, rgba(0,0,0,0.7));
}

.call-duration {
  text-align: center;
  color: white;
  font-size: 18px;
  margin-bottom: 15px;
}

.control-buttons {
  display: flex;
  justify-content: center;
  gap: 15px;
}

.control-btn {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.2);
  color: white;
  font-size: 24px;
  cursor: pointer;
  transition: all 0.3s;
}

.control-btn:hover {
  background: rgba(255,255,255,0.3);
  transform: scale(1.1);
}

.control-btn.active {
  background: #1890ff;
}

.control-btn.end-call {
  background: #ff4d4f;
}

.control-btn.end-call:hover {
  background: #ff7875;
}

.incoming-call {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 40px;
  border-radius: 12px;
  text-align: center;
  box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}

.call-info {
  margin-bottom: 30px;
}

.call-info p {
  font-size: 20px;
  font-weight: bold;
  margin: 0;
}

.call-actions {
  display: flex;
  gap: 20px;
}

.answer-btn,
.reject-btn {
  padding: 12px 32px;
  border: none;
  border-radius: 24px;
  font-size: 16px;
  cursor: pointer;
  transition: all 0.3s;
}

.answer-btn {
  background: #52c41a;
  color: white;
}

.answer-btn:hover {
  background: #73d13d;
}

.reject-btn {
  background: #ff4d4f;
  color: white;
}

.reject-btn:hover {
  background: #ff7875;
}

.hidden {
  display: none;
}
</style>

15.2.2 多人会议室

技术实现方案

多人会议使用Mesh架构,每个参与者与其他所有人建立P2P连接。适合小规模会议(2-8人)。

完整实现代码

vue
<!-- MultiPartyConference.vue -->
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { io } from 'socket.io-client';

const props = defineProps({
  serverUrl: {
    type: String,
    default: 'http://localhost:3000'
  },
  roomId: {
    type: String,
    required: true
  },
  userId: {
    type: String,
    required: true
  },
  maxParticipants: {
    type: Number,
    default: 8
  }
});

const emit = defineEmits(['room-joined', 'room-left', 'participant-changed']);

// 连接管理
const socket = ref(null);
const localStream = ref(null);
const peerConnections = reactive(new Map());
const remoteStreams = reactive(new Map());

// 会议室状态
const roomState = reactive({
  isJoined: false,
  participants: [],
  speakingUser: null
});

// 媒体控制
const mediaState = reactive({
  isVideoEnabled: true,
  isAudioEnabled: true,
  isScreenSharing: false
});

// 布局模式
const layoutMode = ref('grid'); // grid, speaker, gallery

// 视频元素引用
const localVideoRef = ref(null);

// ICE配置
const iceServers = [
  { urls: 'stun:stun.l.google.com:19302' },
  { urls: 'stun:stun1.l.google.com:19302' }
];

// 计算布局
const videoLayout = computed(() => {
  const count = remoteStreams.size + 1; // 包含本地视频

  if (layoutMode.value === 'grid') {
    if (count <= 2) return { cols: 2, rows: 1 };
    if (count <= 4) return { cols: 2, rows: 2 };
    if (count <= 6) return { cols: 3, rows: 2 };
    if (count <= 9) return { cols: 3, rows: 3 };
    return { cols: 4, rows: 3 };
  }

  return { cols: 1, rows: 1 };
});

// 初始化Socket
const initSocket = () => {
  socket.value = io(props.serverUrl);

  socket.value.on('connect', () => {
    console.log('已连接到信令服务器');
  });

  // 房间用户列表
  socket.value.on('room-users', (users) => {
    console.log('房间用户列表:', users);
    roomState.participants = users;

    // 与每个用户建立连接
    users.forEach(userId => {
      if (userId !== props.userId) {
        createOffer(userId);
      }
    });
  });

  // 新用户加入
  socket.value.on('user-joined', async ({ userId }) => {
    console.log('用户加入:', userId);
    if (!roomState.participants.includes(userId)) {
      roomState.participants.push(userId);
    }
    emit('participant-changed', roomState.participants);
  });

  // 用户离开
  socket.value.on('user-left', ({ userId }) => {
    console.log('用户离开:', userId);

    // 关闭连接
    const pc = peerConnections.get(userId);
    if (pc) {
      pc.close();
      peerConnections.delete(userId);
    }

    // 移除流
    remoteStreams.delete(userId);

    // 更新参与者列表
    const index = roomState.participants.indexOf(userId);
    if (index > -1) {
      roomState.participants.splice(index, 1);
    }

    emit('participant-changed', roomState.participants);
  });

  // 接收Offer
  socket.value.on('offer', async ({ offer, from }) => {
    console.log('收到Offer:', from);
    await handleOffer(offer, from);
  });

  // 接收Answer
  socket.value.on('answer', async ({ answer, from }) => {
    console.log('收到Answer:', from);
    const pc = peerConnections.get(from);
    if (pc) {
      await pc.setRemoteDescription(new RTCSessionDescription(answer));
    }
  });

  // 接收ICE候选
  socket.value.on('ice-candidate', async ({ candidate, from }) => {
    const pc = peerConnections.get(from);
    if (pc && candidate) {
      try {
        await pc.addIceCandidate(new RTCIceCandidate(candidate));
      } catch (error) {
        console.error('添加ICE候选失败:', error);
      }
    }
  });
};

// 获取本地流
const getLocalStream = async () => {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 }
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      }
    });

    localStream.value = stream;

    if (localVideoRef.value) {
      localVideoRef.value.srcObject = stream;
    }

    // 监测说话状态
    detectSpeaking(stream);

    return stream;
  } catch (error) {
    console.error('获取本地媒体流失败:', error);
    throw error;
  }
};

// 创建PeerConnection
const createPeerConnection = (userId) => {
  const pc = new RTCPeerConnection({ iceServers });

  // 添加本地流
  if (localStream.value) {
    localStream.value.getTracks().forEach(track => {
      pc.addTrack(track, localStream.value);
    });
  }

  // ICE候选
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      socket.value.emit('ice-candidate', {
        roomId: props.roomId,
        candidate: event.candidate,
        targetUserId: userId
      });
    }
  };

  // 接收远程流
  pc.ontrack = (event) => {
    console.log('接收到远程流:', userId);
    remoteStreams.set(userId, event.streams[0]);
  };

  // 连接状态
  pc.onconnectionstatechange = () => {
    console.log(`连接状态 [${userId}]:`, pc.connectionState);

    if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
      // 尝试重连
      console.log('连接断开,尝试重连...');
    }
  };

  peerConnections.set(userId, pc);
  return pc;
};

// 创建Offer
const createOffer = async (userId) => {
  const pc = createPeerConnection(userId);

  try {
    const offer = await pc.createOffer({
      offerToReceiveAudio: true,
      offerToReceiveVideo: true
    });

    await pc.setLocalDescription(offer);

    socket.value.emit('offer', {
      roomId: props.roomId,
      offer: offer,
      targetUserId: userId
    });
  } catch (error) {
    console.error('创建Offer失败:', error);
  }
};

// 处理Offer
const handleOffer = async (offer, userId) => {
  const pc = createPeerConnection(userId);

  try {
    await pc.setRemoteDescription(new RTCSessionDescription(offer));

    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);

    socket.value.emit('answer', {
      roomId: props.roomId,
      answer: answer,
      targetUserId: userId
    });
  } catch (error) {
    console.error('处理Offer失败:', error);
  }
};

// 加入房间
const joinRoom = async () => {
  try {
    // 获取本地流
    await getLocalStream();

    // 加入房间
    socket.value.emit('join-room', props.roomId, props.userId);

    roomState.isJoined = true;
    emit('room-joined', props.roomId);
  } catch (error) {
    console.error('加入房间失败:', error);
  }
};

// 离开房间
const leaveRoom = () => {
  // 通知服务器
  socket.value.emit('leave-room', props.roomId, props.userId);

  // 关闭所有连接
  peerConnections.forEach(pc => pc.close());
  peerConnections.clear();
  remoteStreams.clear();

  // 停止本地流
  if (localStream.value) {
    localStream.value.getTracks().forEach(track => track.stop());
    localStream.value = null;
  }

  roomState.isJoined = false;
  roomState.participants = [];

  emit('room-left', props.roomId);
};

// 切换视频
const toggleVideo = () => {
  if (localStream.value) {
    const videoTrack = localStream.value.getVideoTracks()[0];
    if (videoTrack) {
      videoTrack.enabled = !videoTrack.enabled;
      mediaState.isVideoEnabled = videoTrack.enabled;
    }
  }
};

// 切换音频
const toggleAudio = () => {
  if (localStream.value) {
    const audioTrack = localStream.value.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      mediaState.isAudioEnabled = audioTrack.enabled;
    }
  }
};

// 屏幕共享
const toggleScreenShare = async () => {
  if (!mediaState.isScreenSharing) {
    try {
      const screenStream = await navigator.mediaDevices.getDisplayMedia({
        video: { cursor: 'always' },
        audio: false
      });

      const screenTrack = screenStream.getVideoTracks()[0];

      // 替换所有连接中的视频轨道
      peerConnections.forEach(pc => {
        const sender = pc.getSenders().find(s => s.track?.kind === 'video');
        if (sender) {
          sender.replaceTrack(screenTrack);
        }
      });

      // 更新本地视频
      if (localVideoRef.value) {
        localVideoRef.value.srcObject = screenStream;
      }

      mediaState.isScreenSharing = true;

      // 监听停止共享
      screenTrack.onended = () => {
        stopScreenShare();
      };
    } catch (error) {
      console.error('屏幕共享失败:', error);
    }
  } else {
    stopScreenShare();
  }
};

// 停止屏幕共享
const stopScreenShare = () => {
  if (localStream.value) {
    const videoTrack = localStream.value.getVideoTracks()[0];

    // 恢复摄像头轨道
    peerConnections.forEach(pc => {
      const sender = pc.getSenders().find(s => s.track?.kind === 'video');
      if (sender) {
        sender.replaceTrack(videoTrack);
      }
    });

    // 恢复本地视频
    if (localVideoRef.value) {
      localVideoRef.value.srcObject = localStream.value;
    }

    mediaState.isScreenSharing = false;
  }
};

// 检测说话状态
const detectSpeaking = (stream) => {
  const audioContext = new AudioContext();
  const analyser = audioContext.createAnalyser();
  const microphone = audioContext.createMediaStreamSource(stream);

  microphone.connect(analyser);
  analyser.fftSize = 512;

  const dataArray = new Uint8Array(analyser.frequencyBinCount);

  const checkAudio = () => {
    analyser.getByteFrequencyData(dataArray);
    const average = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;

    if (average > 30) {
      roomState.speakingUser = props.userId;
    } else if (roomState.speakingUser === props.userId) {
      roomState.speakingUser = null;
    }

    requestAnimationFrame(checkAudio);
  };

  checkAudio();
};

// 切换布局
const changeLayout = (mode) => {
  layoutMode.value = mode;
};

onMounted(() => {
  initSocket();
});

onUnmounted(() => {
  leaveRoom();
  if (socket.value) {
    socket.value.disconnect();
  }
});

defineExpose({
  joinRoom,
  leaveRoom,
  toggleVideo,
  toggleAudio,
  toggleScreenShare,
  changeLayout
});
</script>

<template>
  <div class="multi-party-conference">
    <!-- 未加入房间 -->
    <div v-if="!roomState.isJoined" class="join-room">
      <h2>加入会议室</h2>
      <p>房间ID: {{ roomId }}</p>
      <button @click="joinRoom" class="join-btn">加入会议</button>
    </div>

    <!-- 会议中 -->
    <div v-else class="conference-container">
      <!-- 视频网格 -->
      <div
        class="video-grid"
        :style="{
          gridTemplateColumns: `repeat(${videoLayout.cols}, 1fr)`,
          gridTemplateRows: `repeat(${videoLayout.rows}, 1fr)`
        }"
      >
        <!-- 本地视频 -->
        <div class="video-item local">
          <video
            ref="localVideoRef"
            autoplay
            playsinline
            muted
            :class="{ hidden: !mediaState.isVideoEnabled }"
          ></video>
          <div v-if="!mediaState.isVideoEnabled" class="video-placeholder">
            摄像头已关闭
          </div>
          <div class="video-label">{{ userId }} (你)</div>
        </div>

        <!-- 远程视频 -->
        <div
          v-for="[userId, stream] in remoteStreams"
          :key="userId"
          class="video-item"
        >
          <video
            :ref="el => { if(el) el.srcObject = stream }"
            autoplay
            playsinline
          ></video>
          <div class="video-label">{{ userId }}</div>
          <div
            v-if="roomState.speakingUser === userId"
            class="speaking-indicator"
          >
            🔊
          </div>
        </div>
      </div>

      <!-- 控制栏 -->
      <div class="control-bar">
        <div class="left-controls">
          <span class="participant-count">
            👥 {{ roomState.participants.length }} 人
          </span>
        </div>

        <div class="center-controls">
          <button
            @click="toggleVideo"
            :class="{ active: mediaState.isVideoEnabled }"
            class="control-btn"
          >
            📹
          </button>

          <button
            @click="toggleAudio"
            :class="{ active: mediaState.isAudioEnabled }"
            class="control-btn"
          >
            🎤
          </button>

          <button
            @click="toggleScreenShare"
            :class="{ active: mediaState.isScreenSharing }"
            class="control-btn"
          >
            🖥️
          </button>

          <button @click="leaveRoom" class="control-btn leave">
            挂断
          </button>
        </div>

        <div class="right-controls">
          <select v-model="layoutMode" class="layout-select">
            <option value="grid">网格</option>
            <option value="speaker">演讲者</option>
            <option value="gallery">画廊</option>
          </select>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.multi-party-conference {
  width: 100%;
  height: 100vh;
  background: #1a1a1a;
}

.join-room {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  color: white;
}

.join-btn {
  padding: 12px 32px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 24px;
  font-size: 16px;
  cursor: pointer;
  margin-top: 20px;
}

.conference-container {
  height: 100%;
  display: flex;
  flex-direction: column;
}

.video-grid {
  flex: 1;
  display: grid;
  gap: 10px;
  padding: 10px;
  overflow: auto;
}

.video-item {
  position: relative;
  background: #000;
  border-radius: 8px;
  overflow: hidden;
  min-height: 200px;
}

.video-item video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.video-item.local video {
  transform: scaleX(-1);
}

.video-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  background: #333;
}

.video-label {
  position: absolute;
  bottom: 10px;
  left: 10px;
  background: rgba(0,0,0,0.7);
  color: white;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 14px;
}

.speaking-indicator {
  position: absolute;
  top: 10px;
  right: 10px;
  font-size: 24px;
  animation: pulse 1s infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.2); }
}

.control-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px;
  background: rgba(0,0,0,0.8);
}

.left-controls,
.center-controls,
.right-controls {
  display: flex;
  gap: 10px;
  align-items: center;
}

.participant-count {
  color: white;
  font-size: 14px;
}

.control-btn {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  border: none;
  background: rgba(255,255,255,0.2);
  color: white;
  font-size: 20px;
  cursor: pointer;
  transition: all 0.3s;
}

.control-btn:hover {
  background: rgba(255,255,255,0.3);
}

.control-btn.active {
  background: #1890ff;
}

.control-btn.leave {
  background: #ff4d4f;
}

.layout-select {
  padding: 8px 12px;
  background: rgba(255,255,255,0.2);
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.hidden {
  display: none;
}
</style>

15.2.3 屏幕共享

技术实现方案

屏幕共享使用getDisplayMedia API,支持共享整个屏幕、窗口或标签页。

完整实现代码

vue
<!-- ScreenShare.vue -->
<script setup>
import { ref, reactive, computed } from 'vue';

const emit = defineEmits(['screen-shared', 'screen-stopped', 'track-replaced']);

const screenStream = ref(null);
const isSharing = ref(false);

const shareOptions = reactive({
  video: {
    cursor: 'always', // always, motion, never
    displaySurface: 'monitor', // monitor, window, application, browser
    logicalSurface: true,
    width: { ideal: 1920 },
    height: { ideal: 1080 },
    frameRate: { ideal: 30 }
  },
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    sampleRate: 44100
  },
  preferCurrentTab: false
});

const enableAudio = ref(false);

// 开始屏幕共享
const startScreenShare = async (includeAudio = false) => {
  try {
    const displayMediaOptions = {
      video: shareOptions.video,
      audio: includeAudio ? shareOptions.audio : false,
      preferCurrentTab: shareOptions.preferCurrentTab
    };

    const stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);

    screenStream.value = stream;
    isSharing.value = true;

    // 监听停止共享事件
    stream.getVideoTracks()[0].onended = () => {
      stopScreenShare();
    };

    emit('screen-shared', stream);
    return stream;
  } catch (error) {
    console.error('屏幕共享失败:', error);
    throw error;
  }
};

// 停止屏幕共享
const stopScreenShare = () => {
  if (screenStream.value) {
    screenStream.value.getTracks().forEach(track => {
      track.stop();
    });
    screenStream.value = null;
    isSharing.value = false;
    emit('screen-stopped');
  }
};

// 切换音频
const toggleAudio = () => {
  if (screenStream.value) {
    const audioTrack = screenStream.value.getAudioTracks()[0];
    if (audioTrack) {
      audioTrack.enabled = !audioTrack.enabled;
      enableAudio.value = audioTrack.enabled;
    }
  }
};

// 替换PeerConnection中的轨道
const replaceTrackInPeerConnection = (peerConnection, newTrack) => {
  const senders = peerConnection.getSenders();
  const videoSender = senders.find(sender =>
    sender.track && sender.track.kind === 'video'
  );

  if (videoSender) {
    videoSender.replaceTrack(newTrack);
    emit('track-replaced', newTrack);
  }
};

// 获取屏幕帧率
const getFrameRate = () => {
  if (screenStream.value) {
    const videoTrack = screenStream.value.getVideoTracks()[0];
    const settings = videoTrack.getSettings();
    return settings.frameRate || 0;
  }
  return 0;
};

// 获取分辨率
const getResolution = () => {
  if (screenStream.value) {
    const videoTrack = screenStream.value.getVideoTracks()[0];
    const settings = videoTrack.getSettings();
    return {
      width: settings.width,
      height: settings.height
    };
  }
  return { width: 0, height: 0 };
};

// 调整质量
const adjustQuality = async (quality) => {
  if (!isSharing.value) return;

  const qualities = {
    low: { width: 1280, height: 720, frameRate: 15 },
    medium: { width: 1920, height: 1080, frameRate: 24 },
    high: { width: 1920, height: 1080, frameRate: 30 }
  };

  const settings = qualities[quality];
  if (settings && screenStream.value) {
    const videoTrack = screenStream.value.getVideoTracks()[0];

    try {
      await videoTrack.applyConstraints({
        width: { ideal: settings.width },
        height: { ideal: settings.height },
        frameRate: { ideal: settings.frameRate }
      });
    } catch (error) {
      console.error('调整质量失败:', error);
    }
  }
};

defineExpose({
  startScreenShare,
  stopScreenShare,
  toggleAudio,
  replaceTrackInPeerConnection,
  adjustQuality,
  getFrameRate,
  getResolution,
  isSharing,
  screenStream
});
</script>

<template>
  <div class="screen-share">
    <div v-if="!isSharing" class="share-controls">
      <button @click="startScreenShare(false)" class="share-btn">
        开始屏幕共享
      </button>
      <button @click="startScreenShare(true)" class="share-btn">
        共享屏幕 + 音频
      </button>
    </div>

    <div v-else class="sharing-controls">
      <div class="status">
        正在共享屏幕
        <span class="resolution">
          {{ getResolution().width }} x {{ getResolution().height }}
          @ {{ getFrameRate() }} fps
        </span>
      </div>

      <div class="quality-controls">
        <label>质量:</label>
        <button @click="adjustQuality('low')">低</button>
        <button @click="adjustQuality('medium')">中</button>
        <button @click="adjustQuality('high')">高</button>
      </div>

      <button @click="toggleAudio" v-if="enableAudio" class="audio-btn">
        {{ enableAudio ? '🔊' : '🔇' }} 音频
      </button>

      <button @click="stopScreenShare" class="stop-btn">
        停止共享
      </button>
    </div>
  </div>
</template>

<style scoped>
.screen-share {
  padding: 20px;
}

.share-controls,
.sharing-controls {
  display: flex;
  gap: 10px;
  align-items: center;
}

.share-btn,
.stop-btn,
.audio-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.share-btn {
  background: #1890ff;
  color: white;
}

.stop-btn {
  background: #ff4d4f;
  color: white;
}

.audio-btn {
  background: #52c41a;
  color: white;
}

.status {
  color: #52c41a;
  font-weight: bold;
}

.resolution {
  margin-left: 10px;
  color: #666;
  font-size: 12px;
}

.quality-controls {
  display: flex;
  gap: 5px;
  align-items: center;
}

.quality-controls button {
  padding: 5px 10px;
  background: #f0f0f0;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.quality-controls button:hover {
  background: #e0e0e0;
}
</style>

15.2.4 录制与回放

技术实现方案

使用MediaRecorder API录制音视频流,支持实时录制和本地保存。

完整实现代码

vue
<!-- MediaRecorder.vue -->
<script setup>
import { ref, reactive, computed } from 'vue';

const props = defineProps({
  stream: {
    type: MediaStream,
    default: null
  },
  mimeType: {
    type: String,
    default: 'video/webm;codecs=vp9'
  }
});

const emit = defineEmits(['recording-started', 'recording-stopped', 'data-available']);

const mediaRecorder = ref(null);
const recordedChunks = ref([]);
const isRecording = ref(false);
const isPaused = ref(false);
const recordingDuration = ref(0);

let startTime = 0;
let durationTimer = null;

// 支持的MIME类型
const supportedMimeTypes = [
  'video/webm;codecs=vp9',
  'video/webm;codecs=vp8',
  'video/webm;codecs=h264',
  'video/webm',
  'video/mp4'
];

// 获取支持的MIME类型
const getSupportedMimeType = () => {
  for (const mimeType of supportedMimeTypes) {
    if (MediaRecorder.isTypeSupported(mimeType)) {
      return mimeType;
    }
  }
  return supportedMimeTypes[0];
};

// 开始录制
const startRecording = (stream = props.stream) => {
  if (!stream) {
    console.error('没有可用的媒体流');
    return;
  }

  try {
    const mimeType = getSupportedMimeType();
    const options = {
      mimeType: mimeType,
      videoBitsPerSecond: 2500000 // 2.5 Mbps
    };

    mediaRecorder.value = new MediaRecorder(stream, options);
    recordedChunks.value = [];

    // 数据可用事件
    mediaRecorder.value.ondataavailable = (event) => {
      if (event.data && event.data.size > 0) {
        recordedChunks.value.push(event.data);
        emit('data-available', event.data);
      }
    };

    // 停止事件
    mediaRecorder.value.onstop = () => {
      console.log('录制停止');
      clearInterval(durationTimer);
    };

    // 错误处理
    mediaRecorder.value.onerror = (event) => {
      console.error('录制错误:', event.error);
    };

    // 开始录制
    mediaRecorder.value.start(1000); // 每秒触发一次dataavailable
    isRecording.value = true;
    startTime = Date.now();

    // 更新录制时长
    durationTimer = setInterval(() => {
      recordingDuration.value = Math.floor((Date.now() - startTime) / 1000);
    }, 1000);

    emit('recording-started');
  } catch (error) {
    console.error('开始录制失败:', error);
  }
};

// 停止录制
const stopRecording = () => {
  if (mediaRecorder.value && isRecording.value) {
    mediaRecorder.value.stop();
    isRecording.value = false;
    isPaused.value = false;
    emit('recording-stopped', recordedChunks.value);
  }
};

// 暂停录制
const pauseRecording = () => {
  if (mediaRecorder.value && isRecording.value && !isPaused.value) {
    mediaRecorder.value.pause();
    isPaused.value = true;
    clearInterval(durationTimer);
  }
};

// 恢复录制
const resumeRecording = () => {
  if (mediaRecorder.value && isRecording.value && isPaused.value) {
    mediaRecorder.value.resume();
    isPaused.value = false;

    // 继续计时
    const pausedDuration = recordingDuration.value;
    startTime = Date.now() - (pausedDuration * 1000);
    durationTimer = setInterval(() => {
      recordingDuration.value = Math.floor((Date.now() - startTime) / 1000);
    }, 1000);
  }
};

// 下载录制内容
const downloadRecording = (filename = 'recording.webm') => {
  if (recordedChunks.value.length === 0) {
    console.error('没有录制内容');
    return;
  }

  const blob = new Blob(recordedChunks.value, {
    type: getSupportedMimeType()
  });

  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();

  URL.revokeObjectURL(url);
};

// 获取录制的Blob
const getRecordingBlob = () => {
  if (recordedChunks.value.length === 0) {
    return null;
  }

  return new Blob(recordedChunks.value, {
    type: getSupportedMimeType()
  });
};

// 获取录制的URL
const getRecordingURL = () => {
  const blob = getRecordingBlob();
  return blob ? URL.createObjectURL(blob) : null;
};

// 格式化时长
const formatDuration = computed(() => {
  const minutes = Math.floor(recordingDuration.value / 60).toString().padStart(2, '0');
  const seconds = (recordingDuration.value % 60).toString().padStart(2, '0');
  return `${minutes}:${seconds}`;
});

// 清空录制
const clearRecording = () => {
  recordedChunks.value = [];
  recordingDuration.value = 0;
};

defineExpose({
  startRecording,
  stopRecording,
  pauseRecording,
  resumeRecording,
  downloadRecording,
  getRecordingBlob,
  getRecordingURL,
  clearRecording,
  isRecording,
  isPaused,
  recordingDuration
});
</script>

<template>
  <div class="media-recorder">
    <div class="recorder-controls">
      <button
        v-if="!isRecording"
        @click="startRecording"
        class="record-btn"
      >
        ⏺ 开始录制
      </button>

      <template v-else>
        <button
          v-if="!isPaused"
          @click="pauseRecording"
          class="pause-btn"
        >
          ⏸ 暂停
        </button>

        <button
          v-else
          @click="resumeRecording"
          class="resume-btn"
        >
          ▶ 继续
        </button>

        <button
          @click="stopRecording"
          class="stop-btn"
        >
          ⏹ 停止
        </button>

        <span class="duration">{{ formatDuration }}</span>
      </template>

      <button
        v-if="recordedChunks.length > 0 && !isRecording"
        @click="downloadRecording"
        class="download-btn"
      >
        ⬇ 下载
      </button>

      <button
        v-if="recordedChunks.length > 0 && !isRecording"
        @click="clearRecording"
        class="clear-btn"
      >
        🗑 清空
      </button>
    </div>

    <!-- 录制预览 -->
    <div v-if="recordedChunks.length > 0 && !isRecording" class="preview">
      <h4>录制预览</h4>
      <video
        :src="getRecordingURL()"
        controls
        class="preview-video"
      ></video>
    </div>
  </div>
</template>

<style scoped>
.media-recorder {
  padding: 20px;
}

.recorder-controls {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
}

.record-btn,
.pause-btn,
.resume-btn,
.stop-btn,
.download-btn,
.clear-btn {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  color: white;
}

.record-btn {
  background: #ff4d4f;
}

.pause-btn {
  background: #faad14;
}

.resume-btn {
  background: #52c41a;
}

.stop-btn {
  background: #1890ff;
}

.download-btn {
  background: #722ed1;
}

.clear-btn {
  background: #8c8c8c;
}

.duration {
  font-size: 18px;
  font-weight: bold;
  color: #ff4d4f;
  font-family: monospace;
}

.preview {
  margin-top: 20px;
}

.preview h4 {
  margin-bottom: 10px;
}

.preview-video {
  width: 100%;
  max-width: 800px;
  border-radius: 8px;
}
</style>

15.2.5 网络自适应

技术实现方案

监测网络质量指标,动态调整视频编码参数,保证通话流畅性。

完整实现代码

vue
<!-- NetworkAdaptive.vue -->
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  peerConnection: {
    type: RTCPeerConnection,
    default: null
  },
  adaptiveEnabled: {
    type: Boolean,
    default: true
  }
});

const emit = defineEmits(['quality-changed', 'network-warning']);

// 网络统计
const networkStats = reactive({
  bitrate: 0,
  packetLoss: 0,
  rtt: 0,
  jitter: 0,
  fps: 0,
  width: 0,
  height: 0
});

// 网络质量等级
const networkQuality = ref('good'); // excellent, good, fair, poor
const currentQuality = ref('high'); // high, medium, low

let statsInterval = null;
let lastBytesReceived = 0;
let lastTimestamp = 0;

// 质量配置
const qualitySettings = {
  high: {
    width: 1280,
    height: 720,
    frameRate: 30,
    bitrate: 2500000
  },
  medium: {
    width: 640,
    height: 480,
    frameRate: 24,
    bitrate: 1000000
  },
  low: {
    width: 320,
    height: 240,
    frameRate: 15,
    bitrate: 500000
  }
};

// 获取网络统计
const getStats = async () => {
  if (!props.peerConnection) return;

  try {
    const stats = await props.peerConnection.getStats();

    stats.forEach(report => {
      if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
        // 计算码率
        if (lastTimestamp) {
          const timeDiff = report.timestamp - lastTimestamp;
          const bytesDiff = report.bytesReceived - lastBytesReceived;
          networkStats.bitrate = Math.floor((bytesDiff * 8) / (timeDiff / 1000));
        }

        lastBytesReceived = report.bytesReceived;
        lastTimestamp = report.timestamp;

        // 丢包率
        if (report.packetsLost && report.packetsReceived) {
          networkStats.packetLoss = (
            (report.packetsLost / (report.packetsReceived + report.packetsLost)) * 100
          ).toFixed(2);
        }

        // FPS
        networkStats.fps = report.framesPerSecond || 0;

        // 分辨率
        networkStats.width = report.frameWidth || 0;
        networkStats.height = report.frameHeight || 0;

        // 抖动
        networkStats.jitter = (report.jitter * 1000).toFixed(2);
      }

      if (report.type === 'candidate-pair' && report.state === 'succeeded') {
        // RTT
        networkStats.rtt = report.currentRoundTripTime
          ? (report.currentRoundTripTime * 1000).toFixed(0)
          : 0;
      }
    });

    // 评估网络质量
    evaluateNetworkQuality();
  } catch (error) {
    console.error('获取统计数据失败:', error);
  }
};

// 评估网络质量
const evaluateNetworkQuality = () => {
  const { packetLoss, rtt } = networkStats;

  let quality = 'excellent';

  if (packetLoss > 10 || rtt > 300) {
    quality = 'poor';
  } else if (packetLoss > 5 || rtt > 200) {
    quality = 'fair';
  } else if (packetLoss > 2 || rtt > 100) {
    quality = 'good';
  }

  if (quality !== networkQuality.value) {
    networkQuality.value = quality;

    if (props.adaptiveEnabled) {
      adaptQuality();
    }

    if (quality === 'poor') {
      emit('network-warning', networkStats);
    }
  }
};

// 自适应调整质量
const adaptQuality = () => {
  let targetQuality = currentQuality.value;

  if (networkQuality.value === 'poor' && currentQuality.value !== 'low') {
    targetQuality = 'low';
  } else if (networkQuality.value === 'fair' && currentQuality.value === 'high') {
    targetQuality = 'medium';
  } else if (networkQuality.value === 'excellent' && currentQuality.value !== 'high') {
    targetQuality = 'high';
  } else if (networkQuality.value === 'good' && currentQuality.value === 'low') {
    targetQuality = 'medium';
  }

  if (targetQuality !== currentQuality.value) {
    setQuality(targetQuality);
  }
};

// 设置质量
const setQuality = async (quality) => {
  if (!props.peerConnection) return;

  const settings = qualitySettings[quality];
  if (!settings) return;

  try {
    const senders = props.peerConnection.getSenders();
    const videoSender = senders.find(s => s.track?.kind === 'video');

    if (videoSender && videoSender.track) {
      // 调整视频轨道约束
      await videoSender.track.applyConstraints({
        width: { ideal: settings.width },
        height: { ideal: settings.height },
        frameRate: { ideal: settings.frameRate }
      });

      // 调整编码参数
      const parameters = videoSender.getParameters();
      if (parameters.encodings && parameters.encodings.length > 0) {
        parameters.encodings[0].maxBitrate = settings.bitrate;
        await videoSender.setParameters(parameters);
      }

      currentQuality.value = quality;
      emit('quality-changed', quality, settings);
      console.log(`质量已调整为: ${quality}`, settings);
    }
  } catch (error) {
    console.error('调整质量失败:', error);
  }
};

// 手动设置质量
const manualSetQuality = (quality) => {
  setQuality(quality);
};

// 开始监测
const startMonitoring = (interval = 2000) => {
  if (statsInterval) {
    clearInterval(statsInterval);
  }

  statsInterval = setInterval(() => {
    getStats();
  }, interval);
};

// 停止监测
const stopMonitoring = () => {
  if (statsInterval) {
    clearInterval(statsInterval);
    statsInterval = null;
  }
};

// 获取网络质量描述
const getQualityDescription = (quality) => {
  const descriptions = {
    excellent: { text: '优秀', color: '#52c41a' },
    good: { text: '良好', color: '#1890ff' },
    fair: { text: '一般', color: '#faad14' },
    poor: { text: '较差', color: '#ff4d4f' }
  };
  return descriptions[quality] || descriptions.good;
};

onMounted(() => {
  if (props.peerConnection) {
    startMonitoring();
  }
});

onUnmounted(() => {
  stopMonitoring();
});

defineExpose({
  networkStats,
  networkQuality,
  currentQuality,
  startMonitoring,
  stopMonitoring,
  manualSetQuality,
  getStats
});
</script>

<template>
  <div class="network-adaptive">
    <!-- 网络状态显示 -->
    <div class="network-status">
      <div class="status-item">
        <span class="label">网络质量:</span>
        <span
          class="value"
          :style="{ color: getQualityDescription(networkQuality).color }"
        >
          {{ getQualityDescription(networkQuality).text }}
        </span>
      </div>

      <div class="status-item">
        <span class="label">当前质量:</span>
        <span class="value">{{ currentQuality }}</span>
      </div>

      <div class="status-item">
        <span class="label">码率:</span>
        <span class="value">{{ (networkStats.bitrate / 1000).toFixed(0) }} kbps</span>
      </div>

      <div class="status-item">
        <span class="label">丢包率:</span>
        <span class="value">{{ networkStats.packetLoss }}%</span>
      </div>

      <div class="status-item">
        <span class="label">延迟:</span>
        <span class="value">{{ networkStats.rtt }} ms</span>
      </div>

      <div class="status-item">
        <span class="label">抖动:</span>
        <span class="value">{{ networkStats.jitter }} ms</span>
      </div>

      <div class="status-item">
        <span class="label">帧率:</span>
        <span class="value">{{ networkStats.fps }} fps</span>
      </div>

      <div class="status-item">
        <span class="label">分辨率:</span>
        <span class="value">{{ networkStats.width }} x {{ networkStats.height }}</span>
      </div>
    </div>

    <!-- 手动质量控制 -->
    <div class="quality-controls">
      <span class="label">手动设置质量:</span>
      <button
        @click="manualSetQuality('low')"
        :class="{ active: currentQuality === 'low' }"
        class="quality-btn"
      >
        低
      </button>
      <button
        @click="manualSetQuality('medium')"
        :class="{ active: currentQuality === 'medium' }"
        class="quality-btn"
      >
        中
      </button>
      <button
        @click="manualSetQuality('high')"
        :class="{ active: currentQuality === 'high' }"
        class="quality-btn"
      >
        高
      </button>
    </div>
  </div>
</template>

<style scoped>
.network-adaptive {
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

.network-status {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 15px;
  margin-bottom: 20px;
}

.status-item {
  display: flex;
  justify-content: space-between;
  padding: 10px;
  background: white;
  border-radius: 4px;
}

.status-item .label {
  color: #666;
  font-size: 14px;
}

.status-item .value {
  font-weight: bold;
  font-size: 14px;
}

.quality-controls {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 15px;
  background: white;
  border-radius: 4px;
}

.quality-controls .label {
  color: #666;
  font-size: 14px;
}

.quality-btn {
  padding: 8px 20px;
  border: 1px solid #d9d9d9;
  background: white;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.quality-btn:hover {
  border-color: #1890ff;
  color: #1890ff;
}

.quality-btn.active {
  background: #1890ff;
  color: white;
  border-color: #1890ff;
}
</style>

简历描述模板

项目经验描述

WebRTC音视频通话系统 - 核心开发 时间: 2023.08 - 2024.03 技术栈: Vue3、WebRTC、Socket.io、MediaRecorder API

主要职责:

  1. 开发1v1视频通话功能,实现完整的呼叫、接听、挂断流程,日均通话量1000+次
  2. 设计多人会议室架构,使用Mesh拓扑支持8人以下会议,平均连接成功率95%
  3. 实现屏幕共享功能,支持全屏、窗口、标签页三种模式,配置动态码率调整
  4. 开发实时录制模块,使用MediaRecorder API实现通话录制和本地保存
  5. 设计网络自适应算法,根据丢包率和延迟动态调整视频质量,卡顿率降低50%

技术亮点

  • 实现呼叫状态机管理,处理呼叫、振铃、接听、挂断等复杂状态转换
  • 优化多人会议的连接建立流程,使用并行Offer/Answer机制缩短入会时间
  • 开发音频可视化组件,实时显示说话者,提升用户体验
  • 实现断线重连机制,网络波动时自动恢复连接,用户无感知

SOP标准回答

Q1: 1v1通话和多人会议有什么区别?你们是怎么实现的?

回答思路: 1v1和多人会议的主要区别在于连接拓扑和资源消耗。

1v1通话很简单,就是两个人之间建立一个PeerConnection。呼叫流程是:发起方创建Offer,通过信令服务器转发给接收方,接收方返回Answer,然后交换ICE候选完成连接。我重点处理了各种边界情况,比如对方不在线、拒绝接听、通话中断线等。

多人会议我们用的是Mesh架构,每个人和其他所有人都建立P2P连接。比如4个人的会议,每个人要维护3个PeerConnection。这种方案的优点是延迟低,不需要服务器转发媒体流。缺点是当人数增加时,客户端带宽和CPU压力会很大,所以我们限制了8人以下。

入会流程是这样的:新用户加入房间后,信令服务器返回已有用户列表,新用户主动向每个已有用户发起Offer。已有用户收到Offer后返回Answer,这样新用户就和所有人建立了连接。

技术细节: 多人会议最大的挑战是连接建立的并发控制。如果8个人同时入会,会产生大量的Offer/Answer交换。我做了并行处理优化,不用等一个连接建立完再建下一个,而是同时发起多个连接请求。另外还加了连接状态管理,如果某个用户的连接失败,不影响其他用户。

Q2: 屏幕共享是怎么实现的?有什么技术难点?

回答思路: 屏幕共享用的是getDisplayMedia API,这个和getUserMedia类似,但是获取的是屏幕内容而不是摄像头。

实现上,用户点击共享按钮后,浏览器会弹出选择界面,让用户选择要共享整个屏幕、某个窗口还是某个标签页。获取到屏幕流后,我会替换PeerConnection中的视频轨道,用replaceTrack方法把摄像头轨道换成屏幕轨道。这样可以无缝切换,不用重新协商。

难点主要有几个:

  1. 轨道替换的时机,要确保新轨道ready后再替换,否则会出现黑屏
  2. 停止共享的处理,屏幕流有个onended事件,用户点系统的停止共享按钮时会触发,我要监听这个事件自动恢复摄像头
  3. 质量控制,屏幕共享通常需要更高的分辨率但更低的帧率,我做了专门的编码参数配置
  4. 音频共享,有些场景需要共享系统音频,这个要在getDisplayMedia时设置audio参数

优化经验: 我还做了一个优化,就是在共享屏幕时自动降低帧率到15fps,因为屏幕内容变化没有视频那么频繁,这样能节省带宽。如果检测到用户在播放视频(通过帧率变化判断),再自动提升到30fps。

Q3: 网络自适应是怎么做的?如何保证弱网下的通话质量?

回答思路: 网络自适应是WebRTC中非常关键的技术,直接影响用户体验。

我的实现思路是:定时获取网络统计数据,根据这些数据评估网络质量,然后动态调整视频参数。

具体指标包括:

  • 丢包率: 超过5%就算网络差
  • RTT延迟: 超过200ms算高延迟
  • 码率: 实际码率远低于目标码率说明网络拥塞
  • 帧率: 低于目标帧率说明编解码有压力

我设计了三个质量档位:高、中、低,对应不同的分辨率、帧率和码率组合。网络好的时候用720p 30fps,网络一般用480p 24fps,网络差就降到240p 15fps。

调整的时候不能太频繁,我加了防抖逻辑,网络质量持续3个周期都差才降级,持续5个周期都好才升级,避免频繁切换。

实际效果: 上线前,弱网环境下的卡顿率是15%左右,用户投诉很多。加了自适应后,卡顿率降到3%以下。虽然画质会下降,但至少通话是流畅的,用户反馈好很多。

我还加了一个手动控制选项,让用户可以强制设置质量,满足不同用户的需求。

难点与亮点分析

难点1: 多人会议的连接管理

问题背景: Mesh架构下,每个参与者需要与其他所有人建立连接。连接数随人数平方级增长,管理复杂度很高。

解决方案

  1. 连接池管理: 使用Map存储userId到PeerConnection的映射,方便查找和管理
  2. 生命周期管理: 统一管理连接的创建、销毁、重连逻辑
  3. 错误隔离: 某个连接失败不影响其他连接
  4. 资源清理: 用户离开时及时清理相关连接和流
代码示例
javascript
// 连接管理器
class ConnectionManager {
  constructor() {
    this.connections = new Map();
  }

  create(userId) {
    const pc = new RTCPeerConnection(config);
    this.connections.set(userId, pc);
    return pc;
  }

  remove(userId) {
    const pc = this.connections.get(userId);
    if (pc) {
      pc.close();
      this.connections.delete(userId);
    }
  }

  removeAll() {
    this.connections.forEach(pc => pc.close());
    this.connections.clear();
  }
}

难点2: 录制的内存管理

问题背景: 长时间录制会产生大量数据块,如果都保存在内存中,会导致内存溢出。

解决方案

  1. 分片存储: MediaRecorder每秒触发一次dataavailable,及时处理数据块
  2. 增量写入: 可以将数据块增量写入IndexedDB,而不是全部保存在内存
  3. 限制录制时长: 设置最大录制时长,超过后自动停止
  4. 内存监控: 监控内存使用,接近限制时提示用户

实际应用: 我们限制单次录制最长30分钟,超过后自动分段。同时提供暂停功能,让用户可以分段录制。

难点3: 网络抖动的处理

问题背景: 网络质量不是恒定的,会出现短暂的波动。如果对每次波动都做出反应,会导致频繁切换质量,反而影响体验。

解决方案

  1. 滑动窗口: 使用最近N个周期的平均值,而不是单次测量值
  2. 迟滞控制: 降级和升级使用不同的阈值,形成迟滞带
  3. 平滑过渡: 质量切换时逐步调整,而不是突变
  4. 用户感知: 降级立即执行,升级延迟执行
算法伪代码
javascript
// 降级: 连续3个周期差就降
if (质量差的周期数 >= 3) {
  降级();
}

// 升级: 连续5个周期好才升
if (质量好的周期数 >= 5) {
  升级();
}

亮点1: 智能说话者检测

实现思路: 使用Web Audio API分析音频流,实时计算音量。当某个参与者的音量持续高于阈值,就认为他在说话。

应用场景

  • 会议中高亮显示当前说话者
  • 自动切换到演讲者模式
  • 录制时标记说话时间点
代码示例
javascript
function detectSpeaker(stream) {
  const audioContext = new AudioContext();
  const analyser = audioContext.createAnalyser();
  const source = audioContext.createMediaStreamSource(stream);

  source.connect(analyser);
  analyser.fftSize = 256;

  const dataArray = new Uint8Array(analyser.frequencyBinCount);

  function check() {
    analyser.getByteFrequencyData(dataArray);
    const volume = dataArray.reduce((a, b) => a + b) / dataArray.length;

    if (volume > 30) {
      // 正在说话
      highlightSpeaker();
    }

    requestAnimationFrame(check);
  }

  check();
}

亮点2: 断线自动重连

实现思路: 监听PeerConnection的connectionState,当状态变为failed或disconnected时,自动尝试重连。

重连策略

  1. 先尝试ICE重启(不重新协商)
  2. 失败后重新创建PeerConnection
  3. 使用指数退避,最多重试3次
  4. 重连期间显示"正在重连"提示

用户体验: 短暂的网络波动用户几乎感知不到,连接会自动恢复。

真实项目经验分享

项目背景

我们公司做在线医疗,需要支持医生和患者远程视频问诊。除了基本的音视频通话,还要支持屏幕共享(医生展示检查报告)、录制(留存病历)等功能。

技术选型

最开始用的腾讯云TRTC,但发现成本太高,一个月要好几万。我们评估后决定自研,用WebRTC实现。虽然开发成本高,但能省下很多钱,而且可以深度定制功能。

遇到的挑战

挑战1: 多人会诊的性能问题 我们有个多人会诊场景,主治医生、专家、患者等可能同时在线。一开始用Mesh架构,发现超过5个人就卡得不行,因为每个人要同时发送多路视频流。

后来我们做了优化,主要看的人(患者)用全分辨率,其他人用低分辨率。还实现了自动焦点切换,谁说话就自动放大谁的画面。这样优化后,8个人的会诊也能流畅运行。

挑战2: 移动端的兼容性 移动端的坑特别多。iOS Safari对WebRTC支持很有限,有很多奇怪的bug。比如必须在用户手势事件中才能播放视频,否则会被阻止。还有锁屏后连接会断开,需要做特殊处理。

我们专门针对移动端做了适配:

  • 检测浏览器类型,对Safari做特殊处理
  • 降低移动端的默认分辨率
  • 监听页面可见性,后台时降低帧率
  • 加了"开启声音"按钮,解决iOS自动播放限制

挑战3: 弱网环境的处理 很多患者在农村,网络条件差。一开始经常断线或者卡成PPT。

我们做了很多优化:

  • 加入网络质量检测,开始通话前测试网络
  • 实现自适应码率,网络差就自动降低画质
  • 增加丢包重传机制
  • 提供纯音频模式,网络实在太差就只传音频

上线效果

经过3个月的开发和优化,系统终于稳定上线。现在每天有500+次视频问诊,连接成功率95%,用户满意度4.7/5.0。虽然过程很艰难,但还是很有成就感的。

这个项目让我对WebRTC有了非常深入的理解,不仅是API的使用,还包括底层原理、性能优化、异常处理等。现在遇到实时通信的问题,基本都能快速定位和解决。


文档说明

  • 本文档提供音视频通话的完整实现方案
  • 涵盖1v1通话、多人会议、屏幕共享、录制、网络自适应五大功能
  • 所有代码均为可运行的实际代码
  • 包含详细的面试回答和真实项目经验
  • 适合中高级前端工程师学习和使用