Java+Netty+WebRTC、语音、视频、屏幕共享【聊天室设计实践】

2023-05-16

背景

本文使用webtrc实现了一个简单的语音视频聊天室、支持多人音视频聊天、屏幕共享。

环境配置

音视频功能需要在有Https协议的域名下才能获取到设备信息,

测试环境搭建Https服务参考Windows下Nginx配置SSL实现Https访问(包含openssl证书生成)_殷长庆的博客-CSDN博客

正式环境可以申请一个免费的证书 

复杂网络环境下需要自己搭建turnserver,网络上搜索大多是使用coturn来搭建turn服务 

turn默认监听端口3478,可以使用webrtc.github.io 测试服务是否可用

本文在局域网内测试,不必要部署turn,使用的谷歌的stun:stun.l.google.com:19302

webrtc参考文章

WebRTC技术简介 - 知乎 (zhihu.com)

实现 

服务端 

服务端使用netty构建一个websocket服务,用来完成为音视频传递ICE信息等工作。 

maven配置

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.luck.cc</groupId>
	<artifactId>cc-im</artifactId>
	<version>1.0-SNAPSHOT</version>
	<name>cc-im</name>
	<url>http://maven.apache.org</url>

	<properties>
		<java.home>${env.JAVA_HOME}</java.home>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<dependency>
			<groupId>io.netty</groupId>
			<artifactId>netty-all</artifactId>
			<version>4.1.74.Final</version>
		</dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.5.7</version>
        </dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>1.8</source>
					<target>1.8</target>
				</configuration>
			</plugin>
	        <plugin>
	            <artifactId>maven-assembly-plugin</artifactId>
	            <version>3.0.0</version>
	            <configuration>
	                <archive>
	                    <manifest>
	                        <mainClass>com.luck.im.ServerStart</mainClass>
	                    </manifest>
	                </archive>
	                <descriptorRefs>
	                    <descriptorRef>jar-with-dependencies</descriptorRef>
	                </descriptorRefs>
	            </configuration>
	            <executions>
	                <execution>
	                    <id>make-assembly</id>
	                    <phase>package</phase>
	                    <goals>
	                        <goal>single</goal>
	                    </goals>
	                </execution>
	            </executions>
	        </plugin>
		</plugins>
	</build>
</project>

 JAVA代码

 聊天室服务

package com.luck.im;

import java.util.List;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
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.MessageToMessageCodec;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;

public class ChatSocket {
	private static EventLoopGroup bossGroup = new NioEventLoopGroup();
	private static EventLoopGroup workerGroup = new NioEventLoopGroup();
	private static ChannelFuture channelFuture;

	/**
	 * 启动服务代理
	 * 
	 * @throws Exception
	 */
	public static void startServer() throws Exception {
		try {
			ServerBootstrap b = new ServerBootstrap();
			b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
					.childHandler(new ChannelInitializer<SocketChannel>() {
						@Override
						public void initChannel(SocketChannel ch) throws Exception {
							ChannelPipeline pipeline = ch.pipeline();
							pipeline.addLast(new HttpServerCodec());
							pipeline.addLast(
									new WebSocketServerProtocolHandler("/myim", null, true, Integer.MAX_VALUE, false));
							pipeline.addLast(new MessageToMessageCodec<TextWebSocketFrame, String>() {
								@Override
								protected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame,
										List<Object> list) throws Exception {
									list.add(frame.text());
								}

								@Override
								protected void encode(ChannelHandlerContext ctx, String msg, List<Object> list)
										throws Exception {
									list.add(new TextWebSocketFrame(msg));
								}
							});
							pipeline.addLast(new ChatHandler());
						}
					});
			channelFuture = b.bind(8321).sync();

			channelFuture.channel().closeFuture().sync();
		} finally {
			shutdown();
			// 服务器已关闭
		}
	}

	public static void shutdown() {
		if (channelFuture != null) {
			channelFuture.channel().close().syncUninterruptibly();
		}
		if ((bossGroup != null) && (!bossGroup.isShutdown())) {
			bossGroup.shutdownGracefully();
		}
		if ((workerGroup != null) && (!workerGroup.isShutdown())) {
			workerGroup.shutdownGracefully();
		}
	}

}

聊天室业务 

package com.luck.im;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.AttributeKey;
import io.netty.util.internal.StringUtil;

public class ChatHandler extends SimpleChannelInboundHandler<String> {

	/** 用户集合 */
	private static Map<String, Channel> umap = new ConcurrentHashMap<>();

	/** channel绑定自己的用户ID */
	public static final AttributeKey<String> UID = AttributeKey.newInstance("uid");

	@Override
	public void channelRead0(ChannelHandlerContext ctx, String msg) {
		JSONObject parseObj = JSONUtil.parseObj(msg);
		Integer type = parseObj.getInt("t");
		String uid = parseObj.getStr("uid");
		String tid = parseObj.getStr("tid");
		switch (type) {
		case 0:
			// 心跳
			break;
		case 1:
			// 用户加入聊天室
			umap.put(uid, ctx.channel());
			ctx.channel().attr(UID).set(uid);
			umap.forEach((x, y) -> {
				if (!x.equals(uid)) {
					JSONObject json = new JSONObject();
					json.set("t", 2);
					json.set("uid", uid);
					json.set("type", "join");
					y.writeAndFlush(json.toString());
				}
			});
			break;
		case 2:
			Channel uc = umap.get(tid);
			if (null != uc) {
				uc.writeAndFlush(msg);
			}
			break;
		case 9:
			// 用户退出聊天室
			umap.remove(uid);
			leave(ctx, uid);
			ctx.close();
			break;
		default:
			break;
		}
	}

	@Override
	public void channelInactive(ChannelHandlerContext ctx) throws Exception {
		String uid = ctx.channel().attr(UID).get();
		if (StringUtil.isNullOrEmpty(uid)) {
			super.channelInactive(ctx);
			return;
		}
		ctx.channel().attr(UID).set(null);
		umap.remove(uid);
		leave(ctx, uid);
		super.channelInactive(ctx);
	}

	/**
	 * 用户退出
	 * 
	 * @param ctx
	 * @param uid
	 */
	private void leave(ChannelHandlerContext ctx, String uid) {
		umap.forEach((x, y) -> {
			if (!x.equals(uid)) {
				// 把数据转到用户服务
				JSONObject json = new JSONObject();
				json.set("t", 9);
				json.set("uid", uid);
				y.writeAndFlush(json.toString());
			}
		});
	}

	@Override
	public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
		cause.printStackTrace();
		ctx.close();
	}
}

启动类

package com.luck.im;

public class ServerStart {
	public static void main(String[] args) throws Exception {
		// 启动聊天室
		ChatSocket.startServer();
	}
}

前端

网页主要使用了adapter-latest.js,下载地址webrtc.github.io

github访问不了可以用webrtc/adapter-latest.js-Javascript文档类资源-CSDN文库 

index.html 

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>聊天室</title>
	<style>video{width:100px;height:100px}</style>
</head>
<body>
<video id="localVideo" autoplay playsinline></video>
<video id="screenVideo" autoplay playsinline></video>
<div id="videos"></div>
<div id="screenVideos"></div>
<div>
<button onclick="startScreen()">开启屏幕共享</button>
<button onclick="closeScreen()">关闭屏幕共享</button>
<button onclick="startVideo()">开启视频</button>
<button onclick="closeVideo()">关闭视频</button>
<button onclick="startAudio()">开启语音</button>
<button onclick="closeAudio()">关闭语音</button>
<button onclick="leave()">退出</button>
</div>
</body>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script>
function getUid(id){
	return id?id:uid;
}
// 开启屏幕共享
function startScreen(id){
	id=getUid(id);
	if(id!=uid){
		sendMsg(id,{type:'startScreen'})
		return false;
	}
	if(!screenVideo.srcObject){
		let options = {audio: false, video: true};
		navigator.mediaDevices.getDisplayMedia(options)
		.then(stream => {
			screenVideo.srcObject = stream;
			for(let i in remotes){
				onmessage({uid:i,t:2,type:'s_join'});
			}
			stream.getVideoTracks()[0].addEventListener('ended', () => {
				closeScreen();
			});
		}) 
	}
}
function selfCloseScreen(ot){
	screenVideo.srcObject.getVideoTracks()[0].stop()
	for(let i in remotes){
		sendMsg(i,{type:'closeScreen',ot:ot})
	}
	screenVideo.srcObject=null;
}
// 关闭屏幕共享
function closeScreen(id,ot){
	id=getUid(id);
	ot=(ot?ot:1);
	if(id!=uid){
		if(ot==1&&remotes[id].screenVideo){
			remotes[id].screenVideo.srcObject=null;
		}else{
			sendMsg(id,{type:'closeScreen',ot:2})
		}
		return false;
	}
	if(screenVideo.srcObject&&ot==1){
		selfCloseScreen(ot)
	}
}
// 开启视频
function startVideo(id){
	id=getUid(id);
	if(id!=uid){
		sendMsg(id,{type:'startVideo'})
		return false;
	}
	let v = localVideo.srcObject.getVideoTracks();
	if(v&&v.length>0&&!v[0].enabled){
		v[0].enabled=true;
	}
}
// 关闭视频
function closeVideo(id){
	id=getUid(id);
	if(id!=uid){
		sendMsg(id,{type:'closeVideo'})
		return false;
	}
	let v = localVideo.srcObject.getVideoTracks();
	if(v&&v.length>0&&v[0].enabled){
		v[0].enabled=false;
	}
}
// 开启语音
function startAudio(id){
	id=getUid(id);
	if(id!=uid){
		sendMsg(id,{type:'startAudio'})
		return false;
	}
	let v = localVideo.srcObject.getAudioTracks();
	if(v&&v.length>0&&!v[0].enabled){
		v[0].enabled=true;
	}
}
// 关闭语音
function closeAudio(id){
	id=getUid(id);
	if(id!=uid){
		sendMsg(id,{type:'closeAudio'})
		return false;
	}
	let v = localVideo.srcObject.getAudioTracks();
	if(v&&v.length>0&&v[0].enabled){
		v[0].enabled=false;
	}
}
// 存储通信方信息 
const remotes = {}
// 本地视频预览 
const localVideo = document.querySelector('#localVideo')
// 视频列表区域 
const videos = document.querySelector('#videos')
// 同屏视频预览 
const screenVideo = document.querySelector('#screenVideo')
// 同屏视频列表区域 
const screenVideos = document.querySelector('#screenVideos')
// 用户ID
var uid = new Date().getTime() + '';
var ws = new WebSocket('wss://127.0.0.1/myim');
ws.onopen = function() {
	heartBeat();
	onopen();
}
// 心跳保持ws连接
function heartBeat(){
	setInterval(()=>{
		ws.send(JSON.stringify({ t: 0 }))
	},3000);
}
function onopen() {
	navigator.mediaDevices
	.getUserMedia({
		audio: true, // 本地测试防止回声 
		video: true
	})
	.then(stream => {
		localVideo.srcObject = stream;
		ws.send(JSON.stringify({ t: 1, uid: uid }));
		ws.onmessage = function(event) {
			onmessage(JSON.parse(event.data));
		}
	}) 
}
async function onmessage(message) {
	if(message.t==9){
		onleave(message.uid);
	}
	if(message.t==2&&message.type)
	switch (message.type) {
		case 'join': {
			// 有新的人加入就重新设置会话,重新与新加入的人建立新会话 
			createRTC(message.uid,localVideo.srcObject,1)
			const pc = remotes[message.uid].pc
			const offer = await pc.createOffer()
			pc.setLocalDescription(offer)
			sendMsg(message.uid, { type: 'offer', offer })
			if(screenVideo.srcObject){
				onmessage({uid:message.uid,t:2,type:'s_join'});
			}
			break
		}
		case 'offer': {
			createRTC(message.uid,localVideo.srcObject,1)
			const pc = remotes[message.uid].pc
			pc.setRemoteDescription(new RTCSessionDescription(message.offer))
			const answer = await pc.createAnswer()
			pc.setLocalDescription(answer)
			sendMsg(message.uid, { type: 'answer', answer })
			break
		}
		case 'answer': {
			const pc = remotes[message.uid].pc
			pc.setRemoteDescription(new RTCSessionDescription(message.answer))
			break
		}
		case 'candidate': {
			const pc = remotes[message.uid].pc
			pc.addIceCandidate(new RTCIceCandidate(message.candidate))
			break
		}
		case 's_join': {
			createRTC(message.uid,screenVideo.srcObject,2)
			const pc = remotes[message.uid].s_pc
			const offer = await pc.createOffer()
			pc.setLocalDescription(offer)
			sendMsg(message.uid, { type: 's_offer', offer })
			break
		}
		case 's_offer': {
			createRTC(message.uid,screenVideo.srcObject,2)
			const pc = remotes[message.uid].s_pc
			pc.setRemoteDescription(new RTCSessionDescription(message.offer))
			const answer = await pc.createAnswer()
			pc.setLocalDescription(answer)
			sendMsg(message.uid, { type: 's_answer', answer })
			break
		}
		case 's_answer': {
			const pc = remotes[message.uid].s_pc
			pc.setRemoteDescription(new RTCSessionDescription(message.answer))
			break
		}
		case 's_candidate': {
			const pc = remotes[message.uid].s_pc
			pc.addIceCandidate(new RTCIceCandidate(message.candidate))
			break
		}
		case 'startScreen': {
			startScreen()
			break
		}
		case 'closeScreen': {
			const ot = message.ot
			if(ot==1){
				closeScreen(message.uid,1)
			}else{
				closeScreen(uid,1)
			}
			break
		}
		case 'startVideo': {
			startVideo()
			break
		}
		case 'closeVideo': {
			closeVideo()
			break
		}
		case 'startAudio': {
			startAudio()
			break
		}
		case 'closeAudio': {
			closeAudio()
			break
		}
		default:
			console.log(message)
			break
	}
}
function removeScreenVideo(id){
	if(remotes[id].s_pc){
		remotes[id].s_pc.close()
		if(remotes[id].screenVideo)
		screenVideos.removeChild(remotes[id].screenVideo)
	}
}
function onleave(id) {
	if (remotes[id]) {
		remotes[id].pc.close()
		videos.removeChild(remotes[id].video)
		removeScreenVideo(id)
		delete remotes[id]
	}
}
function leave() {
	ws.send(JSON.stringify({ t: 9, uid: uid }));
}

// socket发送消息 
function sendMsg(tid, msg) {
	msg.t = 2;
	msg.tid=tid;
	msg.uid=uid;
	ws.send(JSON.stringify(msg))
}
// 创建RTC对象,一个RTC对象只能与一个远端连接 
function createRTC(id,stream,type) {
	const pc = new RTCPeerConnection({
		iceServers: [
			{
				urls: 'stun:stun.l.google.com:19302'
			}
		]
	})

	// 获取本地网络信息,并发送给通信方 
	pc.addEventListener('icecandidate', event => {
		if (event.candidate) {
			// 发送自身的网络信息到通信方 
			sendMsg(id, {
				type: (type==1?'candidate':'s_candidate'),
				candidate: {
					sdpMLineIndex: event.candidate.sdpMLineIndex,
					sdpMid: event.candidate.sdpMid,
					candidate: event.candidate.candidate
				}
			})
		}
	})

	// 有远程视频流时,显示远程视频流 
	pc.addEventListener('track', event => {
		if(type==1){
			if(!remotes[id].video){
				const video = createVideo()
				videos.append(video)
				remotes[id].video=video
			}
			remotes[id].video.srcObject = event.streams[0]
		}else{
			if(!remotes[id].screenVideo){
				const video = createVideo()
				screenVideos.append(video)
				remotes[id].screenVideo=video
			}
			remotes[id].screenVideo.srcObject = event.streams[0]
		}
	})

	// 添加本地视频流到会话中 
	if(stream){
		stream.getTracks().forEach(track => pc.addTrack(track, stream))
	}

	if(!remotes[id]){remotes[id]={}}
	if(type==1){
		remotes[id].pc=pc
	}else{
		remotes[id].s_pc=pc
	}
}
function createVideo(){
	const video = document.createElement('video')
	video.setAttribute('autoplay', true)
	video.setAttribute('playsinline', true)
	return video
}
</script>
</html>

Nginx配置

上面的index.html文件放到D盘根目录下了,然后配置一下websocket

    server {
        listen       443 ssl;
        server_name    mytest.com;
    
        ssl_certificate      lee/lee.crt;
        ssl_certificate_key  lee/lee.key;
    
        ssl_session_cache    shared:SSL:1m;
        ssl_session_timeout  5m;
    
        ssl_ciphers  HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers  on;
    
        location / {
            root   d:/;
            index  index.html index.htm index.php;
        }
    
        location /myim {
            proxy_pass http://127.0.0.1:8321/myim;
        }
    }

运行 

java启动

java -jar cc-im.jar

网页访问

https://127.0.0.1/index.html
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Java+Netty+WebRTC、语音、视频、屏幕共享【聊天室设计实践】 的相关文章

  • C语言 字符串-字符串的复制

    字符串复制函数 xff1a strcpy 目标字符串的首地址 被复制的字符串的首地址 复制p到a xff1a char p 61 34 12345 34 char a 20 strcpy a p printf 34 s n 34 a str
  • cmake是什么,为什么现在都用cmake,cmake编译原理和跨平台示例

    一 cmake是什么 xff1f CMake是一个开源 跨平台的工具系列 xff0c 是用来构建 测试和打包软件 CMake使用平台无关的配置文件来控制软件编译过程 xff0c 并生成可在您选择的编译器环境中使用项目文件 xff0c 比如可
  • cmake和makefile区别和cmake指定编译器(cmake -G)

    一 cmake和makefile区别 要说明区别 xff0c 我们先要区分下面三类工具 xff1a 1 项目构建生成工具 首先cmake是项目构建生成工具 xff0c cmake的代码可以与平台系统和编译器无关 类似cmake的工具还有au
  • PHP+iis部署最干货的步骤

    1 php gt gt gt 下载 官网下载 gt gt gt 部署 解压到文件夹d xff0c 然后找文件php ini production xff0c 开发者模式 修改文件为php ini作为配置文件 date timezone 61
  • spring cloud - consul 之 网关(3)

    spring cloud consul 之 网关 xff08 3 xff09 1 添加maven 依赖 lt dependency gt lt groupId gt org springframework boot lt groupId g
  • SpringCloud - consul 服务注册生产(2)

    SpringCloud consul 服务注册生产 xff08 2 xff09 1 添加maven 依赖 lt dependency gt lt groupId gt org springframework boot lt groupId
  • springcloud consul 搭建docker 集群(1)

    springcloud consul 搭建docker 集群 xff08 1 xff09 1 建3个server节点 和1个client节点 配置docker 网络 sudo docker network create subnet 61
  • Spring+OpenCV+Linux(libopencv_java460.so、opencv-460.jar)人脸识别、人脸对比实现

    准备工作 Linux生成libopencv java460 so opencv 460 jar lbpcascade frontalface xml文件 opencv源码地址 xff1a https github com opencv op
  • net core3.0 修改 web 端口

    修改程序发布之后的端口 新建项目 xff0c 啥都不干启动成功后 xff0c 在 appsettings json 中 添加配置 urls http 8080 34 Logging 34 34 LogLevel 34 34 Default
  • go 语法入门

    go 语法 循环 package main import 34 fmt 34 func main i 61 0 for i lt 1000 i 43 43 fmt Println 34 循环次数 xff1a 34 i 定义局部变量 var
  • windows vscode mingw c++入门(1)

    windows vscode mingw c 43 43 入门 xff08 1 xff09 安装 mingw32 https osdn net projects mingw downloads 68260 mingw get setup e
  • windows vscode mingw c++入门(2)

    windows vscode mingw c 43 43 入门 xff08 2 xff09 导包 新建文件夹 first 与 mian cpp 同级新建文件夹 first新建两个文件q h pragma once 防止重复导入 void q
  • netcore3 sqlite

    net core3 1 EF 43 SQLite nuget 安装这3个包 microsoft EntityFrameworkCore Microsoft EntityFrameworkCore Sqlite Microsoft Entit
  • windows 上 consul

    本机开发模式 consul官网 本机开发者模式 consul exe agent dev 局域网 consul exe agent dev client 0 0 0 0
  • go 通道(channel),go 线程间通信

    go 通道 xff08 channel xff09 是用来传递数据的一个数据结构 通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯 操作符 lt 用于指定通道的方向 xff0c 发送或接收 span clas
  • windows 批量关闭 python 程序工具

    windows 批量关闭 python 程序工具 在windows上启动大量pythonw 后台程序 xff0c 都不知道哪个是哪个 l par query par a show all lk par 终止查询到的程序 python 源码
  • windows+vscode+MinGW+cmake(学习1)

    统一下载 64 位安装 1 安装vscode 官网 安装插件 2 安装 mingw 官网 或者 3 安装cmake 官网 安装好之后 创建 项目文件夹 cmaketest1 创建文件 xff08 1 xff09 main c span cl
  • Springcloud、Springmvc+Nacos注册中心实现服务注册

    目录 背景 实现 Nacos环境搭建 Springcloud服务注册 Maven配置 代码实现 Springmvc服务注册 Maven依赖 代码实现 背景 不管是springcloud还是springmvc实现服务的自动注册 xff0c 都
  • go 并发学习-互斥锁

    go 并发学习 互斥锁 并发输出 inums 自增编写代码运行输出并不能正常输出 使用互斥锁修改代码结果结果正常 使用锁时注意上锁的资源独立函数有些时候用读写锁如果可以改为使用 channel 并发输出 inums 自增 编写代码 pack
  • Ava Trader MT4 Terminal EA交易(1)运行第一个程序

    Ava Trader MT4 Terminal EA交易 xff08 1 xff09 运行第一个程序 1 打开程序化交易编译器 2 文件 新建 下一步 下一步 一直下一步 43 43 11 mq4 Copyright

随机推荐