SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天

实时音视频聊天是当下社交、在线协作类应用的核心功能之一,WebRTC(Web Real-Time Communication)作为浏览器原生支持的实时通信技术,能让前端无需插件即可实现点对点音视频传输;而 Netty 作为高性能的 Java NIO 框架,可提供稳定的 WebSocket 通信通道,配合 SpringBoot 的快速开发能力和 Vue 的前端工程化能力,能快速搭建一套完整的视频聊天系统。本文将详细讲解如何基于这些技术栈实现一对一视频聊天功能。

一、SpringBoot+Vue+Netty+WebSocket+WebRTC 实现视频聊天

技术栈 核心作用
SpringBoot 后端快速开发框架,整合 Netty、配置 WebSocket,提供接口支撑
Vue 前端工程化框架,负责音视频界面渲染、WebRTC API 调用
Netty 高性能网络通信框架,实现 WebSocket 服务端,处理客户端连接和信令转发
WebSocket 全双工通信协议,用于前端和后端之间的信令(如呼叫、应答、ICE 候选)传输
WebRTC 实时通信标准,提供音视频采集、编码、点对点传输能力

整个视频聊天系统分为三层,核心是信令转发点对点音视频传输

  1. 前端层(Vue):采集音视频流、创建 WebRTC 连接、通过 WebSocket 发送 / 接收信令;
  2. 通信层(Netty+WebSocket):维护客户端连接、转发信令(如呼叫请求、ICE 候选信息);
  3. 后端层(SpringBoot):整合 Netty、管理用户连接状态、提供基础配置支撑。


WebRTC 是什么:是浏览器内置的实时通信技术,能让网页直接实现音视频通话、数据传输,无需安装插件。
ICE 是什么:ICE(Interactive Connectivity Establishment)是 WebRTC 中用于解决 NAT 穿透(简单说就是让不同网络下的设备能找到彼此)的框架,而 iceServers 就是给 ICE 提供 "辅助服务器" 的配置。
STUN 服务器: STUN(Session Traversal Utilities for NAT),直译是 "NAT 会话穿透工具",它是一种轻量级的网络服务器 ,核心作用是:帮助处于 NAT(网络地址转换)后的设备(比如你的电脑 / 手机),获取自己的公网 IP + 端口,以及 NAT 设备的类型,从而让不同 NAT 后的设备能找到彼此,建立点对点(P2P)连接。

先补个前提:为什么需要 STUN?

我们日常用的网络(比如家里的宽带、公司的内网),设备拿到的都是内网 IP(如 192.168.1.100),不是公网 IP。当两个内网设备要直接通信(比如 WebRTC 音视频通话),它们不知道对方的公网地址,就像两个人在不同的小区里,只知道自己的门牌号,却不知道小区的地址和对外的出入口 ------STUN 服务器就是帮它们查 "小区地址 + 出入口" 的工具。
用一个 "打电话" 的例子解释:

  1. 设备 A(内网) 向 STUN 服务器发送一个请求:"请告诉我,你看到的我的地址和端口是什么?"
  2. STUN 服务器 收到请求后,会记录下请求来源的公网 IP + 端口(这是 NAT 设备给设备 A 分配的对外端口),然后把这个信息返回给设备 A。
  3. 设备 A 拿到自己的公网地址后,通过信令服务器(比如 WebSocket)把这个地址告诉设备 B;同理,设备 B 也通过 STUN 服务器拿到自己的公网地址并告诉设备 A。
  4. 最后,设备 A 和设备 B 就可以用彼此的公网地址,直接建立 P2P 连接。

二、服务端开发

2.1 项目搭建与依赖配置

复制代码
<!-- SpringBoot 核心 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Netty WebSocket 依赖 -->
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.94.Final</version>
</dependency>
<!-- JSON 解析 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.25</version>
</dependency>

依赖作用:Netty 是核心,负责网络通信;FastJSON 用于处理前后端传输的 JSON 格式信令

2.2 核心实体类与消息处理器

1.创建消息实体类 Message.java

需求:信令消息需要包含"消息类型""发送方ID""接收方ID""消息内容",所以定义对应的属性

复制代码
public class Message {
    // 消息类型:register(注册)、call(呼叫)、answer(应答)、ice(ICE候选)
    private String type;
    // 发送方ID
    private String from;
    // 接收方ID
    private String to;
    // 消息内容(SDP/ICE 数据)
    private String data;
}

2.创建 Netty WebSocket 处理器

核心作用:处理 WebSocket 连接的建立、消息接收与转发、连接断开等事件,是服务端的核心逻辑

复制代码
import com.alibaba.fastjson.JSON;
import com.qcby.springboot.entity.Message;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 描述:
 */
@Configuration
@ChannelHandler.Sharable
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    // 存储用户ID与Channel的映射(线程安全)
    public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("与客户端建立连接,通道开启!");
    }
    /**
     * 处理接收到的文本消息
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
        // 解析JSON消息
        String text = msg.text();
        Message message = JSON.parseObject(text, Message.class);
        System.out.println("收到消息:" + text);

        switch (message.getType()) {
            case "register":
                // 注册用户ID与Channel的映射
                USER_CHANNEL_MAP.put(message.getFrom(), ctx.channel());
                System.out.println("用户 " + message.getFrom() + " 注册成功");
                break;
            case "call":
            case "answer":
            case "ice":
                // 转发消息到接收方
                Channel targetChannel = USER_CHANNEL_MAP.get(message.getTo());
                if (targetChannel != null && targetChannel.isActive()) {
                    targetChannel.writeAndFlush(new TextWebSocketFrame(text));
                    System.out.println("转发消息到用户 " + message.getTo());
                } else {
                    System.out.println("用户 " + message.getTo() + " 不在线");
                }
                break;
            default:
                System.out.println("未知消息类型:" + message.getType());
        }
    }

    /**
     * 处理连接断开事件
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("与客户端断开连接,通道关闭!");
    }

    /**
     * 处理异常
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("连接异常:" + cause.getMessage());
        USER_CHANNEL_MAP.entrySet().removeIf(entry -> entry.getValue() == ctx.channel());
        ctx.close();
    }
}

三、Netty 服务启动类与 SpringBoot 启动类

需求:需要在 SpringBoot 项目启动时,自动启动 Netty 的 WebSocket 服务,监听指定端口(8081)

复制代码
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
/**
Netty WebSocket 服务端*/
@Configurationpublic 
class NettyWebSocketServer {
    @Autowired
    private WebSocketHandler coordinationSocketHandler;
    public void start() throws Exception {
            EventLoopGroup bossGroup = new NioEventLoopGroup();
            EventLoopGroup group = new NioEventLoopGroup();
            try {
                ServerBootstrap sb = new ServerBootstrap();
                sb.option(ChannelOption.SO_BACKLOG, 1024);
                sb.group(group, bossGroup) // 绑定线程池
                    .channel(NioServerSocketChannel.class) // 指定使用的channel
                    .localAddress(8004)// 绑定监听端口
                    .childHandler(new ChannelInitializer<SocketChannel>() { // 绑定客户端连接时候触发操作
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                        //websocket协议本身是基于http协议的,所以这边也要使用http解编码器
                        ch.pipeline().addLast(new HttpServerCodec());
                        //以块的方式来写的处理器
                        ch.pipeline().addLast(new ChunkedWriteHandler());
                        ch.pipeline().addLast(new HttpObjectAggregator(8192));
                        ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws", "WebSocket", true, 65536 * 10));
                        ch.pipeline().addLast(coordinationSocketHandler);//自定义消息处理类
                }
            });
                ChannelFuture cf = sb.bind().sync(); // 服务器异步创建绑定
                cf.channel().closeFuture().sync(); // 关闭服务器通道
    } finally {
            group.shutdownGracefully().sync(); // 释放线程池资源
            bossGroup.shutdownGracefully().sync();
        }
    }
}
  • @PostConstruct 和 @PreDestroy:Spring 的注解,分别在项目启动后和销毁前执行,实现 Netty 服务的自动启停

  • EventLoopGroup 线程组:bossGroup 负责接收连接,workerGroup 负责处理读写,是 Netty 高性能的核心

  • 通道处理器链:按顺序添加处理器,WebSocket 基于 HTTP 握手,所以先添加 HTTP 相关处理器,最后添加自定义处理器

    import com.qcby.springboot.commun.NettyWebSocketServer;
    import org.mybatis.spring.annotation.MapperScan;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    @SpringBootApplication
    @MapperScan("com.qcby.springboot.dao")
    public class Application implements CommandLineRunner {
    public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
    }

    复制代码
      @Autowired
      private NettyWebSocketServer nettyServer;
      @Override
      public void run(String... args) throws Exception {
          nettyServer.start();
      }

    }

四、Vue前端开发

4.1 编辑模版template

复制代码
<template>
  <div class="container">
    <h2>WebRTC 视频聊天</h2>
    
    <!-- 1. 用户ID输入与连接服务器区域 -->
    <div class="input-group">
      <input 
        v-model="userId" 
        placeholder="输入你的用户ID(如user1)" 
        type="text"
      />
      <button @click="connect">连接服务器</button>
    </div>

    <!-- 2. 呼叫功能区域(仅连接成功后显示) -->
    <div class="input-group" v-if="socketConnected">
      <input 
        v-model="targetUserId" 
        placeholder="输入对方用户ID(如user2)" 
        type="text"
      />
      <button @click="call">发起视频呼叫</button>
    </div>

    <!-- 3. 视频展示区域 -->
    <div class="video-container">
      <div class="video-item">
        <p>本地视频</p>
        <!-- muted:本地视频静音,避免回声;autoplay:自动播放 -->
        <video ref="localVideo" autoplay muted></video>
      </div>
      <div class="video-item">
        <p>远程视频</p>
        <video ref="remoteVideo" autoplay></video>
      </div>
    </div>
  </div>
  
</template>
<style scoped>
/* 全局容器:居中+固定宽度,避免页面太宽/太窄 */
.container {
  width: 900px;
  margin: 20px auto;
  text-align: center;
  font-family: "Microsoft YaHei", sans-serif;
}

/* 输入框+按钮组样式:统一间距和样式 */
.input-group {
  margin: 25px 0;
}
input {
  padding: 10px 15px;
  width: 220px;
  margin-right: 10px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}
button {
  padding: 10px 20px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}
button:hover {
  background: #359469; /* 鼠标悬浮变色,提升交互体验 */
}

/* 视频容器:并排展示两个视频 */
.video-container {
  display: flex;
  justify-content: center;
  gap: 30px;
  margin-top: 30px;
}
.video-item {
  text-align: center;
}
.video-item p {
  margin-bottom: 8px;
  font-size: 16px;
  color: #333;
}
/* 视频标签样式:固定尺寸,加边框,提升美观度 */
video {
  width: 400px;
  height: 300px;
  border: 1px solid #ccc;
  border-radius: 8px;
  background-color: #f5f5f5; /* 未加载流时显示浅灰色背景 */
}
</style>

4.2 编写核心脚本(script setup),实现交互逻辑

步骤 1:导入依赖 + 定义基础变量

复制代码
<script setup>
// 1. 导入 Vue 内置的响应式变量和生命周期钩子
import { ref, onUnmounted } from 'vue';

// 2. 定义响应式变量(页面上用到的动态数据)
const userId = ref(''); // 本地用户ID
const targetUserId = ref(''); // 对方用户ID
const socketConnected = ref(false); // WebSocket连接状态(控制呼叫区域显示)

// 3. 定义视频DOM引用(用于绑定音视频流)
const localVideo = ref(null); // 本地视频DOM
const remoteVideo = ref(null); // 远程视频DOM

// 4. 定义非响应式全局变量(仅脚本内使用,无需页面响应)
let socket = null; // WebSocket实例
let peerConnection = null; // WebRTC核心实例
let localStream = null; // 本地音视频流(用于后续停止流)
</script>
  • ref:创建响应式变量,变量值变化时,页面会自动更新(比如 socketConnected 变为 true,呼叫区域会显示);
  • onUnmounted:页面销毁时执行的钩子,用于清理资源(避免内存泄漏);
  • localStream:额外定义本地流变量,方便后续 "挂断" 时停止摄像头 / 麦克风。

步骤 2:配置 STUN 服务器 + 编写 WebSocket 连接函数

复制代码
<script setup>
// (接上一步代码)

// 5. 配置 STUN 服务器(WebRTC 必需,用于获取公网ICE候选)
const iceServers = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }, // 谷歌免费STUN(需外网)
    { urls: 'stun:stun.qq.com:3478' }, // 腾讯免费STUN(国内更稳定)
    { urls: 'stun:stun.aliyun.com:3478' } // 阿里云免费STUN(备用)
  ]
};

// 6. 连接 WebSocket 服务器函数(点击"连接服务器"按钮触发)
const connect = () => {
  // 校验:用户ID不能为空
  if (!userId.value.trim()) {
    alert('请输入你的用户ID!(不能为空/仅空格)');
    return;
  }

  // 创建 WebSocket 连接(Netty服务端地址:ws://localhost:8081/ws)
  // 注意:如果服务端部署在其他机器,需替换为对应IP(如 ws://192.168.1.100:8081/ws)
  socket = new WebSocket(`ws://localhost:8081/ws`);

  // 6.1 连接成功回调
  socket.onopen = () => {
    console.log('✅ WebSocket连接成功');
    socketConnected.value = true; // 更新连接状态,显示呼叫区域
    // 发送注册消息:告诉服务端"我上线了"
    sendMessage({
      type: 'register',
      from: userId.value,
      to: '',
      data: ''
    });
  };

  // 6.2 接收服务端消息回调(核心:处理转发的信令)
  socket.onmessage = (e) => {
    try {
      const message = JSON.parse(e.data); // 解析JSON消息
      console.log('📥 收到服务端消息:', message);
      handleMessage(message); // 专门处理消息的函数(后续定义)
    } catch (err) {
      console.error('❌ 消息解析失败:', err);
    }
  };

  // 6.3 连接关闭回调
  socket.onclose = () => {
    console.log('❌ WebSocket连接关闭');
    socketConnected.value = false; // 更新连接状态,隐藏呼叫区域
  };

  // 6.4 连接错误回调
  socket.onerror = (err) => {
    console.error('❌ WebSocket连接错误:', err);
    socketConnected.value = false;
    alert('连接服务器失败!请检查服务端是否启动,端口是否正确。');
  };
};
</script>
  • STUN 服务器:必须配置,否则内网设备无法找到彼此的网络地址,导致视频无法连接;
  • trim():去除用户 ID 前后空格,避免用户输入空字符 / 仅空格;
  • try-catch:防止服务端返回非 JSON 格式消息,导致脚本崩溃;
  • WebSocket 地址:ws:// 对应 HTTP,wss:// 对应 HTTPS,本地测试用 ws:// 即可。

步骤3:编写通用消息发送函数

复制代码
<script setup>
// (接上一步代码)

// 7. 通用发送WebSocket消息函数(复用,避免重复代码)
const sendMessage = (message) => {
  // 校验:WebSocket必须处于打开状态
  if (socket && socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(message)); // 转为JSON字符串发送
    console.log('📤 发送消息:', message);
  } else {
    console.error('❌ WebSocket未连接,无法发送消息');
    alert('未连接服务器,请先点击"连接服务器"!');
  }
};
</script>
  • WebSocket.OPEN:状态码为 1,表示连接已打开;其他状态(0 = 连接中,2 = 关闭中,3 = 已关闭)都无法发送消息;
  • 封装成通用函数:后续发送 call/answer/ice 消息时,直接调用即可,减少重复代码。

步骤 4:编写消息处理函数

复制代码
<script setup>
// (接上一步代码)

// 8. 处理收到的信令消息
const handleMessage = async (message) => {
  switch (message.type) {
    case 'call':
      // 收到呼叫请求:自动应答(实际项目可加"是否接听"弹窗)
      await answerCall(message);
      break;
    case 'answer':
      // 收到应答消息:设置远程SDP
      await setRemoteSDP(message.data);
      break;
    case 'ice':
      // 收到ICE候选:添加到PeerConnection
      await addIceCandidate(message.data);
      break;
    default:
      console.log('📌 未知消息类型:', message.type);
  }
};
</script>

要点

  • 按消息类型分支处理:对应服务端的 register/call/answer/ice 四种类型;
  • async/await:后续操作(如设置 SDP)是异步的,需等待完成,避免报错。

步骤 5:编写 WebRTC 核心函数

复制代码
<script setup>
// (接上一步代码)

// 9. 初始化 PeerConnection(WebRTC核心,复用函数)
const initPeerConnection = async () => {
  // 如果已有PeerConnection,先关闭(避免重复创建)
  if (peerConnection) {
    peerConnection.close();
  }
  // 创建PeerConnection实例(传入STUN服务器配置)
  peerConnection = new RTCPeerConnection(iceServers);

  // 9.1 监听ICE候选生成事件(本地网络地址)
  peerConnection.onicecandidate = (e) => {
    if (e.candidate) {
      // 发送ICE候选给对方
      sendMessage({
        type: 'ice',
        from: userId.value,
        to: targetUserId.value,
        data: JSON.stringify(e.candidate)
      });
    }
  };

  // 9.2 监听远程音视频流到达事件(关键:显示对方视频)
  peerConnection.ontrack = (e) => {
    // 将远程流绑定到远程视频DOM
    remoteVideo.value.srcObject = e.streams[0];
    console.log('🎥 收到远程音视频流');
  };
};

// 10. 发起视频呼叫函数(点击"发起视频呼叫"按钮触发)
const call = async () => {
  // 校验:对方ID不能为空
  if (!targetUserId.value.trim()) {
    alert('请输入对方用户ID!');
    return;
  }

  try {
    // 初始化PeerConnection
    await initPeerConnection();
    // 获取本地音视频流(请求摄像头/麦克风权限)
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true, // 开启视频
      audio: true  // 开启音频
    });
    // 将本地流绑定到本地视频DOM
    localVideo.value.srcObject = localStream;
    // 将音视频轨道添加到PeerConnection(传给对方)
    localStream.getTracks().forEach(track => {
      peerConnection.addTrack(track, localStream);
    });

    // 创建SDP提议(offer):包含本地音视频配置
    const offer = await peerConnection.createOffer();
    // 设置本地SDP
    await peerConnection.setLocalDescription(offer);
    // 发送呼叫信令(含SDP offer)给对方
    sendMessage({
      type: 'call',
      from: userId.value,
      to: targetUserId.value,
      data: JSON.stringify(offer)
    });
    console.log('📞 发起视频呼叫:', targetUserId.value);
  } catch (err) {
    console.error('❌ 发起呼叫失败:', err);
    alert('发起呼叫失败!请检查摄像头/麦克风权限,或是否已连接服务器。');
  }
};

// 11. 应答呼叫请求函数
const answerCall = async (message) => {
  // 记录呼叫方ID(后续发送应答/ICE消息需要)
  targetUserId.value = message.from;
  try {
    // 初始化PeerConnection
    await initPeerConnection();
    // 获取本地音视频流
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    localVideo.value.srcObject = localStream;
    localStream.getTracks().forEach(track => {
      peerConnection.addTrack(track, localStream);
    });

    // 设置远程SDP(呼叫方的offer)
    await peerConnection.setRemoteDescription(JSON.parse(message.data));
    // 创建SDP应答(answer)
    const answer = await peerConnection.createAnswer();
    // 设置本地SDP
    await peerConnection.setLocalDescription(answer);
    // 发送应答信令给呼叫方
    sendMessage({
      type: 'answer',
      from: userId.value,
      to: targetUserId.value,
      data: JSON.stringify(answer)
    });
    console.log('📞 应答视频呼叫:', targetUserId.value);
  } catch (err) {
    console.error('❌ 应答呼叫失败:', err);
    alert('应答呼叫失败!');
  }
};

// 12. 设置远程SDP函数
const setRemoteSDP = async (sdpStr) => {
  try {
    const sdp = JSON.parse(sdpStr);
    await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
    console.log('✅ 设置远程SDP成功');
  } catch (err) {
    console.error('❌ 设置远程SDP失败:', err);
  }
};

// 13. 添加ICE候选函数
const addIceCandidate = async (iceStr) => {
  try {
    const ice = JSON.parse(iceStr);
    await peerConnection.addIceCandidate(new RTCIceCandidate(ice));
    console.log('✅ 添加ICE候选成功');
  } catch (err) {
    console.error('❌ 添加ICE候选失败:', err);
  }
};
</script>
  • getUserMedia:浏览器原生 API,请求音视频权限,返回本地流;如果用户拒绝权限,会抛出错误,所以用 try-catch 包裹;
  • PeerConnection:WebRTC 的核心对象,负责协商连接、传输音视频流;
  • ontrack 事件:对方的音视频流到达时触发,将流绑定到 remoteVideo 即可显示对方画面;
  • onicecandidate 事件:本地生成网络地址(ICE 候选)时触发,发送给对方,双方才能建立 P2P 连接。

步骤6:编写资源清理函数

复制代码
<script setup>
// (接上一步代码)

// 14. 页面销毁时清理资源(避免内存泄漏/设备占用)
onUnmounted(() => {
  // 关闭WebSocket连接
  if (socket) {
    socket.close();
    console.log('🔌 关闭WebSocket连接');
  }
  // 关闭PeerConnection
  if (peerConnection) {
    peerConnection.close();
    console.log('🔌 关闭PeerConnection');
  }
  // 停止本地音视频流(释放摄像头/麦克风)
  if (localStream) {
    localStream.getTracks().forEach(track => {
      track.stop();
      console.log('🔇 停止本地音视频流');
    });
  }
});
</script>
  • 必须停止本地流:否则页面关闭后,摄像头 / 麦克风仍会被占用(浏览器标签栏会显示摄像头图标);
  • onUnmounted:Vue3 的生命周期钩子,页面销毁时自动执行,确保资源全部清理。

4.3 补充 "挂断" 功能

在 video-container 上方添加:

复制代码
<!-- 挂断按钮(仅连接成功且有远程流时显示) -->
<div class="input-group" v-if="socketConnected && remoteVideo.value?.srcObject">  
    <button @click="hangUp" style="background: #e53935;">挂断通话</button>
</div>

脚本添加挂断函数

复制代码
<script setup>
// (添加在 initPeerConnection 之后)

// 15. 挂断通话函数
const hangUp = () => {
  // 停止本地流
  if (localStream) {
    localStream.getTracks().forEach(track => track.stop());
    localVideo.value.srcObject = null; // 清空本地视频
  }
  // 清空远程视频
  if (remoteVideo.value) {
    remoteVideo.value.srcObject = null;
  }
  // 关闭PeerConnection
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  // 重置目标用户ID
  targetUserId.value = '';
  console.log('📞 挂断通话');
  alert('已挂断通话!');
};
</script>
相关推荐
自在极意功。2 小时前
Spring Boot 自动配置原理基本理解
java·spring boot·后端·自动配置原理
lucky67072 小时前
Laravel7.X十大核心特性解析
spring boot·kafka·linq
Coder_Boy_2 小时前
以厨房连锁故事为引,梳理Java后端全技术脉络(JVM到云原生,总结篇)
java·jvm·spring boot·分布式·spring·云原生
六件套是我14 小时前
无法访问org.springframeword.beans.factory.annotation.Value
java·开发语言·spring boot
forestsea15 小时前
Spring Cloud Alibaba 2025.1.0.0 正式发布:拥抱 Spring Boot 4.0 与 Java 21+ 的新时代
java·spring boot·后端
IT枫斗者15 小时前
IntelliJ IDEA 2025.3史诗级更新:统一发行版+Spring Boot 4支持,这更新太香了!
java·开发语言·前端·javascript·spring boot·后端·intellij-idea
forestsea15 小时前
Spring Boot 4.0 + JDK 25 + GraalVM:下一代云原生Java应用架构
java·spring boot·云原生
♡喜欢做梦15 小时前
Spring Boot 日志实战:级别、持久化与 SLF4J 配置全指南
java·spring boot·后端·spring·java-ee·log4j
青衫码上行15 小时前
【项目部署】Spring Boot项目部署的四种方式
java·linux·服务器·spring boot·后端·docker·腾讯云