一、WebRTC前言:
1.什么是WebRTC
Web-RTC(Web Real-Time Communication)就是网页实时通信技术,怎么理解这句话呢?在网页中能够打语音电话,直播视频不就是一个实时性的操作么?当然,很多人就索性把直播和语音电话当作WEB-RTC了,但是其实我们通过Web Socket 进行的一些短消息啊之类的操作其实也属于webrtc的内容,所以直播和语音电话只能算作是这个领域的对应应用,所以格局打开,不要那么狭隘!
WebRTC (Web Real-Time Communications) 是一项实时通讯技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。WebRTC 包含的这些标准使用户在无需安装任何插件或者第三方的软件的情况下,创建点对点(Peer-to-Peer)的数据分享和电话会议成为可能。
2.如何实现视频直播
先脱离代码的思路,我们先从设计者的角度去思考这一整个流程是怎么回事,作为一个第一次开发视频直播的小白,就算不会敲代码,但也要有对应的实现步骤吧?我想大概步骤应该是像这张图一样:
3.ffmpeg和WebRTC开源库的区别?
ffmpeg对视频的编解码,以及视频的后期处理,webrtc是对网络的 评估,降噪等处理
4.webRTc能做什么?
- 音视频实时互动
- 游戏,即时通讯,文件传输等
- 它是一个百宝箱,传输,音视频处理(回音消除,降噪)
5.WebRTC概述
出现的目的:在浏览器之间实现实时通信 。
音视频处理+即时通讯的开源库,由Google在2010年将其开源,webRTC是一个非常优秀的多媒体框架,具有跨平台优势!
二、FFmpeg平台框架
choco官网https://docs.chocolatey.org/en-us/choco/setup#more-install-options
choco软件包管理器
choco install ffmpeg
1.安装模块
1.使用choco对ffmpeg快速安装,并复制以下命令。(choco官网)
2.在本地cmd使用管理员模式打开 ,并执行上述命令。
@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "[System.Net.ServicePointManager]::SecurityProtocol = 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
2.转换命令
ffmpeg -i music.mp3 music.ogg 转换音频格式
ffmpeg -i test.mp4 -c:v libx264 -hls_time
60 -hls_list _size 0 -c:a aac -strict -2 -f hls test.m3u8
将MP4文件进行切割hls处理转换成m3u8, ts文件
ffmpeg -i video.webm -vcodec copy video.mp4 // 快速转换
3.布署项目
因为需要使用到本地的媒体设备,所以布署上线后,会因安全策略导致无法获取。
只有以下三种情况有效
localhost
https://
file://
4.vue-soket.io模块
MetinSeylan/Vue-Socket.io: Vuejs 和 Vuex 的 Socket.io 实现
安装模块
yarn add vue-socket.io
Vue2的写法
main.js的写法
-- main.js
import Vue from 'vue'
import store from './store'
import App from './App.vue'
import VueSocketIO from 'vue-socket.io'
Vue.use(new VueSocketIO({
debug: false,
connection: 'http://metinseylan.com:1992',
}))
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
Vue3的写法
注意:每添加一新模块,都会刷新原有的配置
(vue3版本)修改的部分
vue-socket.io下的dist/vue-socketio.js
将下面的内容替换
t.prototype.$socket=this.io,t.prototype.$vueSocketIo=this
下面的是新内容
t.config.globalProperties.$socket=this.io,t.config.globalProperties.$vueSocketIo = this
prototype => config.globalProperties
main.js的配置文件
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import VueSocketIO from 'vue-socket.io'
const SocketIo = new VueSocketIO({
debug: false, // 是否显示连接信息
connection: 'http://121.40.248.136:3000/', // 连接后端socket的地址(不需要跨域)
})
createApp(App)
.use(SocketIo)
.use(store)
.use(router).mount('#app')
组件中使用
<template>
<div class="hello">
这是测试
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
},
sockets: {
// 不要在全局定义的sockets中使用箭头函数,因为this会进入指向全局对象中
// 封装的sockets 监听函数,只能接收一个数组形参
// this.$socket.emit可以发送两个参数
connect: function () {
// 连接socketio
console.log('socket 已经连接')
},
disconnect:function(){
// 检测socket断开连接
console.log('socket 已经断开连接')
},
reconnect:function(){
// 重新连接socket事件
console.log('socket 重新连接')
},
// 监听事件
joined:function(){
console.log("已经加入房间")
}
},
data(){
return {
}
},
mounted(){
// 编译完成后,立即请求加入socket
this.join();
},
methods: {
join: function () {
// $socket是vue-socket.io的全局属性,已经被添加到vue实例中。
this.$socket.emit('join','123456');
console.log(`正在请求加入${'123456'}房间。。。`)
}
},
}
</script>
<style scoped lang="scss">
</style>
5.nodejs后端实时获取
1.node-schedule定时任务
node-schedule原理:利用setTimeOut和event事件进行管理,对所有加入的事件进行排序,并且计算当前时间和最近一个事件发生时间的时间间隔,然后调用setTimeOut设置回调。总的来说分两种事件,一种是一次性的,一种是周期性的,一次性任务调用完就结束,周期性的会不断地循环调用,当一个周期性事件被调用后,会根据周期生成下一个周期任务,并添加到任务列表中,重新排序。每个任务调用结束,都会计算并准备下一个任务。
(1.安装模块
yarn add node-schedule
(2.使用方法
import schedule from 'node-schedule';
let rule = new schedule.RecurrenceRule()
/**每天的凌晨12点更新代码*/
rule.hour = 0
rule.minute = 0
rule.second = 0
/**启动任务*/
schedule.scheduleJob(rule, () => {
console.log('代码更新了!');
// 操作mysql数据
//client.query("select * from books ",function(err,results){
// console.log(results)
//})
})
2.mysql-events监听mysql二进制文件
二进制日志包含描述数据库更改(例如表创建操作或表数据更改)的“事件”。它还包含针对可能进行了更改的语句的事件(例如,[DELETE](https://dev.mysql.com/doc/refman/8.0/en/delete.html) 不匹配任何行),除非使用基于行的日志记录。二进制日志还包含有关每个语句花费该更新数据多长时间的信息。
通过Nodejs和二进制日志实时监视MySQL数据更改 - 简书
(1.安装模块
yarn add @rodrigogs/mysql-events
yarn add ora@5.4.1
(2.设置mysql日志
在开始之前,我们需要通过my.ini 在Windows和my.cnf ubuntu中更改文件来启用MySQL中的二进制日志。
位置:C:\ProgramData\MySQL\MySQL Server 5.7\my.ini
(2.1.配置my.ini文件
我们需要 在**[mysqld]**部分下添加以下行,同级目录下创建www文件夹,然后重新启动mysql。
# 二进制日志
log-bin=C:/ProgramData/MySQL/MySQL Server 5.7/www/bin.log
log-bin-index=C:/ProgramData/MySQL/MySQL Server 5.7/www/bin-log.index
max_binlog_size=100M
binlog_format=row
socket=mysql.sock
设置定时自动清除日志
(Windows下为 my.ini, Linux下为 my.cnf )
在my.cnf中,添加或修改expire_logs_days的值 ,(这里设置的自动删除时间为1天, 默认为0不自动删除)
方法一:
mysql> show variables like '%log%';
mysql> set global expire_logs_days = 10;
方法二:
# 自动清除日志,1天清除一次
expire_logs_days=1
(2.2.管理员重新启动mysql服务
net stop mysql (或者MySQL57)
net start mysql (或者MySQL57)
(3.使用方法
监听mylibrary数据库下的所有表的变化
// mysql-events 监听mysql事件
// 模块监听mysql的二进制日志文件,以达到监听数据的变化
const mysql = require('mysql');
const MySQLEvents = require('@rodrigogs/mysql-events');
const ora = require('ora'); // cool spinner
const spinner = ora({
text: '🛸 Waiting for database events... 🛸',
color: 'blue',
spinner: 'dots2'
});
const program = async () => {
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'xxx'
});
const instance = new MySQLEvents(connection, {
startAtEnd: true
// 要只记录新的二进制日志,如果设置为false或您没有提供它,所有事件将是控制台。 启动应用程序后登录
});
await instance.start();
instance.addTrigger({
name: 'monitoring all statments',
expression: 'mylibrary.*', // listen to TEST database !!!
statement: MySQLEvents.STATEMENTS.ALL,
// 你可以选择只插入,例如MySQLEvents.STATEMENTS。 zoe:在这里,我们什么都选
onEvent: e => {
console.log(e);
spinner.succeed('👽 _EVENT_ 👽');
spinner.start();
}
});
instance.on(MySQLEvents.EVENTS.CONNECTION_ERROR, console.error);
instance.on(MySQLEvents.EVENTS.ZONGJI_ERROR, console.error);
};
program()
.then(spinner.start.bind(spinner))
.catch(console.error);
三、WebRTC Api的使用
1.audio音频的播放
<audio controls >
<source src="music.mp3"> // 优先选择第一个source文件
<source src="music.ogg">
</audio>
ffmpeg -i music.mp3 music.ogg 转换音频格式
常用的音频文件是 mp3, ogg
2.video视频的播放
<video controls >
<source src="video.mp4"> // 优先选择第一个source文件
<source src="video.ogv">
</video>
ffmpeg -i video.mp4 video.ogv 转换音频格式
常用的视频格式是mp4, ogv, webm
3.getUserMedia麦克风的播放1
MediaDevices.getUserMedia() - Web API 接口参考 | MDN
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:true})
document.querySelector("audio").srcObject = stream;
let devices = await navigator.mediaDevices.enumerateDevices()
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:curDeviceInfo})
<audio controls src=""></audio>
<select name="select-device" id="select-device"> </select>
<script>
(async function(){
// 方法一:播放默认的设备
// 获取音频流(询问用户权限获取)
// let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:true})
// document.querySelector("audio").srcObject = stream;
// 方法二:动态选择播放设备
// 1.获取所有设备
let devices = await navigator.mediaDevices.enumerateDevices()
let optionsSelect = $("#select-device");
let audioInputDevice = devices.filter(value=>{
if(value.kind === 'audioinput'){
return value;
}
// mediaDeviceInfo :
// deviceID 设备ID label 设备的名字
// kind 设备的种类 groupID 两个设备groupID相同
})
// 2.将设备选项添加到页面上
let optionsStr = ''
audioInputDevice.forEach((value,index)=>{
optionsStr += `
<option value="${index}" >${value.label}</option>
`
})
optionsSelect.html(optionsStr);
setMediaVices(audioInputDevice[0])
// 3.监听选项被点击(获取指定的播放音频的设备)
optionsSelect.change(async function(){
let index = optionsSelect.val()
let curDeviceInfo = audioInputDevice[index];
setMediaVices(curDeviceInfo)
})
// 4.设置播放音频的设备
async function setMediaVices(curDeviceInfo){
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:curDeviceInfo})
document.querySelector("audio").srcObject = stream;
}
})()
</script>
4.getUserMedia摄像头的播放2
<video controls src=""></video>
<select name="select-device" id="select-device"> </select>
<script>
(async function(){
// 方法一:播放默认的设备
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getUserMedia({video:true,audio:true})
document.querySelector("video").srcObject = stream;
// 方法二:同音频的方法二
})()
</script>
5.canvas拍照的使用3
<video controls autoplay src="" width="600" height="400"></video>
<button id="capture-btn">点击拍照</button>
<canvas width="600" height="400"></canvas>
<script>
(async function(){
// 方法一:播放默认的设备
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getUserMedia({video:true,audio:true})
document.querySelector("video").srcObject = stream;
// 查看宽,高
console.log(videoStream.getVideoTracks()[0].getSettings().height) // 640
console.log(videoStream.getVideoTracks()[0].getSettings().width) // 480
// 视频宽度 video.width, video.height
// canvas 标签的width,height, 和画布的绘制有关,最重要的是像素点的限制。
// css的width, height会影响像素点
$('#capture-btn').click(()=>{
let canvas = $('canvas')[0];
let video = $('video')[0];
if(canvas.getContext('2d')){
// 可以用画布绘制video视频播放的瞬时画面
let ctx = canvas.getContext('2d');
ctx.drawImage(video,0,0,600,400)
}else{
alert("无法使用画布元素!")
}
})
})()
</script>
6.MediaRecorder录制声音1
MediaRecorder - Web API 接口参考 | MDN
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:true})
let recorder = new MediaRecorder(stream,{mimeType:'video/webm;codecs=h264'});
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
// 也可以转换成二进制流
// e.data.arrayBuffer();
// 浏览器不能调用fs模块进行下载。所以不能在浏览器中直接下载。
// 利用Electron桌面客户端就可以。
// fs.writeFileSync(path,new Uni8Array(await e.data.arrayBuffer()))
};
recorder.start();
recorder.pause();
recorder.resume();
recorder.stop();
let url = URL.createObjectURL(currentWebData)
document.querySelector("audio").src = url;
<audio controls src=""></audio>
<button id="record">录音</button>
<button id="pause">暂停</button>
<button id="resume">继续</button>
<button id="stop">停止</button>
<button id="play">播放</button>
<script>
(async function(){
// 1.初始化媒体录音,创建实例。
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:true})
// 检测类型
// MediaRecorder.isTypeSupported("video/webm"); // true
// MediaRecorder.isTypeSupported("video/mp4"); // false
// MediaRecorder.isTypeSupported("video/webm;codecs=h264"); // true
let recorder = new MediaRecorder(stream,{mimeType:'video/webm;codecs=h264'});
// 记录缓存的数据(支持webm)
let currentWebData = null;
// 响应的回调函数
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
};
// 2.监听点击事件,触发相应的功能。
$('#record').click(()=>{
recorder.start();
console.log("已经开始录制")
})
$('#pause').click(()=>{
recorder.pause();
console.log("已经暂停录制")
})
$('#resume').click(()=>{
recorder.resume();
console.log("已经继续录制")
})
$('#stop').click(()=>{
recorder.stop();
console.log("已经结束录制")
})
$('#play').click(()=>{
// 创建一个音频流的url
let url = URL.createObjectURL(currentWebData)
setMediaUrl(url);
console.log("已经开始播放录制内容")
})
// 3.播放音频设备
function setMediaUrl(url){
document.querySelector("audio").src = url;
}
})()
</script>
7.MediaRecorder录制视频2
<video controls autoplay id="preview-video" src=""></video>
<video controls id="current-video" src=""></video>
<button id="record">录音</button>
<button id="pause">暂停</button>
<button id="resume">继续</button>
<button id="stop">停止</button>
<button id="play">播放</button>
<script>
(async function(){
// 1.初始化媒体录音,创建实例。
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getUserMedia({video:true,audio:true})
document.querySelector("#preview-video").srcObject = stream;
// 检测类型
// MediaRecorder.isTypeSupported("video/webm"); // true
// MediaRecorder.isTypeSupported("video/mp4"); // false
// MediaRecorder.isTypeSupported("video/webm;codecs=h264"); // true
let recorder = new MediaRecorder(stream,{mimeType:'video/webm;codecs=h264'});
// 记录缓存的数据(支持webm)
let currentWebData = null;
// 响应的回调函数
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
};
// 2.监听点击事件,触发相应的功能。
$('#record').click(()=>{
recorder.start();
console.log("已经开始录制")
})
$('#pause').click(()=>{
recorder.pause();
console.log("已经暂停录制")
})
$('#resume').click(()=>{
recorder.resume();
console.log("已经继续录制")
})
$('#stop').click(()=>{
recorder.stop();
console.log("已经结束录制")
})
$('#play').click(()=>{
// 创建一个音频流的url
let url = URL.createObjectURL(currentWebData)
setMediaUrl(url);
console.log("已经开始播放录制内容")
})
// 3.播放音频设备
function setMediaUrl(url){
document.querySelector("#current-video").src = url;
}
})()
</script>
8.getDisplayMedia录制屏幕3.1
MediaDevices.getDisplayMedia() - Web APIs | MDN
let stream = await navigator.mediaDevices.getDisplayMedia() 无法同时采集音频
桌面是否可以调整分辨率, 共享整个桌面,某个应用,某块区域。
ffmpeg -i video.webm -vcodec copy video.mp4 // 快速转换
<video controls autoplay width="640" height="480" id="preview-video" src=""></video>
<video controls width="640" height="480" id="current-video" src=""></video>
<button id="record">录音</button>
<button id="pause">暂停</button>
<button id="resume">继续</button>
<button id="stop">停止</button>
<button id="play">播放</button>
<script>
(async function(){
// 1.初始化媒体录音,创建实例。
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getDisplayMedia()
document.querySelector("#preview-video").srcObject = stream;
// 检测类型
// MediaRecorder.isTypeSupported("video/webm"); // true
// MediaRecorder.isTypeSupported("video/mp4"); // false
// MediaRecorder.isTypeSupported("video/webm;codecs=h264"); // true
let recorder = new MediaRecorder(stream,{mimeType:'video/webm;codecs=h264'});
// 记录缓存的数据(支持webm)
let currentWebData = null;
// 响应的回调函数
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
};
// 2.监听点击事件,触发相应的功能。
$('#record').click(()=>{
recorder.start();
console.log("已经开始录制")
})
$('#pause').click(()=>{
recorder.pause();
console.log("已经暂停录制")
})
$('#resume').click(()=>{
recorder.resume();
console.log("已经继续录制")
})
$('#stop').click(()=>{
recorder.stop();
console.log("已经结束录制")
})
$('#play').click(()=>{
// 创建一个音频流的url
let url = URL.createObjectURL(currentWebData)
setMediaUrl(url);
console.log("已经开始播放录制内容")
})
// 3.播放音频设备
function setMediaUrl(url){
document.querySelector("#current-video").src = url;
}
})()
</script>
9.getDisplayMedia录制屏幕3.2
MediaStream.getVideoTracks() - Web APIs | MDN
// 获取用户音频数据 ,混合音频与视频,将视频轨道添加到音频上。(音频流为主)
let audioStream = await navigator.mediaDevices.getUserMedia({video:false,audio:true});
let videoTracks = stream.getVideoTracks()
videoTracks.forEach(item=>{
audioStream.addTrack(item);
});
MediaStream.addTrack() 添加媒体轨道
MediaStream.removeTrack() 移除媒体轨道
MediaStream.getVideoTrack() 获取视频轨道
MediaStream.getAudioTrack() 获取音频轨道
MediaStream.onaddtrack 监听添加轨道事件
MediaStream.onremovetrack 监听添加轨道事件
MediaStream.onended 监听添加轨道事件
<video controls autoplay width="640" height="480" id="preview-video" src=""></video>
<video controls width="640" height="480" id="current-video" src=""></video>
<button id="record">录音</button>
<button id="pause">暂停</button>
<button id="resume">继续</button>
<button id="stop">停止</button>
<button id="play">播放</button>
<script>
(async function(){
// 1.初始化媒体录音,创建实例。
// 获取音频流(询问用户权限获取)
let stream = await navigator.mediaDevices.getDisplayMedia();
document.querySelector("#preview-video").srcObject = stream;
// 获取用户音频数据 ,混合音频与视频,将视频轨道添加到音频上。(音频流为主)
let audioStream = await navigator.mediaDevices.getUserMedia({video:false,audio:true});
let videoTracks = stream.getVideoTracks()
videoTracks.forEach(item=>{
audioStream.addTrack(item);
});
// 检测类型
// MediaRecorder.isTypeSupported("video/webm"); // true
// MediaRecorder.isTypeSupported("video/mp4"); // false
// MediaRecorder.isTypeSupported("video/webm;codecs=h264"); // true
let recorder = new MediaRecorder(audioStream,{mimeType:'video/webm;codecs=h264'});
// 记录缓存的数据(支持webm)
let currentWebData = null;
// 响应的回调函数
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
};
// 2.监听点击事件,触发相应的功能。
$('#record').click(()=>{
recorder.start();
console.log("已经开始录制")
})
$('#pause').click(()=>{
recorder.pause();
console.log("已经暂停录制")
})
$('#resume').click(()=>{
recorder.resume();
console.log("已经继续录制")
})
$('#stop').click(()=>{
recorder.stop();
console.log("已经结束录制")
})
$('#play').click(()=>{
// 创建一个音频流的url
let url = URL.createObjectURL(currentWebData)
setMediaUrl(url);
console.log("已经开始播放录制内容")
})
// 3.播放音频设备
function setMediaUrl(url){
document.querySelector("#current-video").src = url;
}
})()
</script>
10.requestAnimationFrame刷新
rafId = requestAnimationFrame(animloop); 请求开启动画(不断重新调用animaloop函数)
cancelAnimationFrame(rafId) 取消动画
大多数屏幕渲染的时间间隔是每秒60帧,所以每秒的时间间隔是1000/60.
它是一种和setInterval同类型的存在,等级高于setInterval.
1.requestAnimationFrame 比起 setTimeout、setInterval的优势主要有两点:
1、requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。
2、在隐藏或不可见的元素中,requestAnimationFrame将不会进行重绘或回流,这当然就意味着更少的的cpu,gpu和内存使用量。
2.同步时差的理论依据
解决方案是 当和服务端通信时 记录下一个时间差,(时间差等于服务端时间-本地时间)不管正负我们只要这个时间差。这样每当我们接受到消息 或者发送消息的时候我们就拿本地时间和是价差相加。这样就可以保证和服务端时间是一致的了,思路是不是很牛逼哈哈。
3.减少requestAnimationFrame的间隔
加快执行页面,进行推动动画的快速变化。时间间隔越少,变化的帧数越多,表现的动画更加细腻
<!doctype html>
<html lang="en">
<head>
<title>Document</title>
<style>
#e{
width: 100px;
height: 100px;
background: red;
position: absolute;
left: 0;
top: 0;
zoom: 1;
}
</style>
</head>
<body>
<div id="e"></div>
<script>
var e = document.getElementById("e");
var flag = true;
var left = 0;
var rafId = null
//当前执行时间
var nowTime = 0;
//记录每次动画执行结束的时间
var lastTime = Date.now();
//我们自己定义的动画时间差值
var diffTime = 40;
function render() {
if(flag == true){
if(left>=100){
flag = false
}
e.style.left = ` ${left++}px`
}else{
if(left<=0){
flag = true
}
e.style.left = ` ${left--}px`
}
}
// 1.定时器动画
// 方法一:requestAnimationFrame动画
animloop1();
function animloop1(time) {
console.log(time,Date.now())
// 开启渲染
render();
// 请求开启动画,(回调执行animloop),回调中有个默认时间参数
// 如果页面渲染了,就会开启动画将所有的动画延迟到下一个页面刷新中。
rafId = requestAnimationFrame(animloop1);
// 取消动画,添加一个id
if(left == 50){
cancelAnimationFrame(rafId)
}
};
// 方法二:setInterval动画
// 注意:在setInterval里面不可以被直接清除自身的定时器。
// animloop2();
function animloop2(){
//setInterval效果
rafId = setInterval(function(){
render()
console.log(Date.now())
// 取消动画,添加一个id
if(left == 50){
clearAnimloop2(rafId)
}
},1000/60)
}
function clearAnimloop2(id){
clearInterval(rafId)
}
// 2.减少requestAnimationFrame的时间间隔(达到更加细腻的页面)
animloop3()
function animloop3() {
//记录当前时间
nowTime = Date.now()
// 当前时间-上次执行时间如果大于diffTime,那么执行动画,并更新上次执行时间
// 快速执行页面更新
if(nowTime-lastTime > diffTime){
lastTime = nowTime
render();
}
requestAnimationFrame(animloop3);
}
</script>
</body>
</html>
11.MediaStream合成双视频
MediaStream()
MediaStream() - Web API 接口参考 | MDN
构造函数**MediaStream() ** 返回新建的 MediaStream 实例,该实例作为媒体流的内容的集合载体,其可能包含多个媒体数据轨,每个数据轨则由一个 MediaStreamTrack 对象表示。如果给出相应参数,在指定的数据轨则被添加到新的流中。否则,该流中不包含任何数据轨。
// 开启动画
requestAnimationFrame(this._animationFrameHandler.bind(this)) this._context2d.drawImage(this._cameraVideo,0,0,this._canvasWidth,this._canvasHeight)
this._context2d.drawImage(this._cameraVideo,
this._canvasWidth-this._CAMERA_VIDEO_WIDTH,
this._canvasHeight-this._CAMERA_VIDEO_HEIGHT,
this._canvasWidth,this._canvasHeight)
// 总媒体流
let stream = new MediaStream();
// 创建一个整体的画布
let _playerCanvas = new PlayerCanvas(SCREEN_WIDTH,SCREEN_HEIGHT)
// 将媒体流放在播放器中(画布的流)
let playerCanvasStream = playerCanvas.canvas.captureStream()
// 将音频流添加到媒体流中
audioStream.getAudioTracks().forEach(value=>stream.addTrack(value))
// 将视频流添加到媒体流中
playerCanvasStream.getTracks().forEach(value=>stream.addTrack(value))
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
<video controls autoplay width="640" height="480" id="preview-video" src=""></video>
<video controls width="640" height="480" id="current-video" src=""></video>
<button id="record">录音</button>
<button id="pause">暂停</button>
<button id="resume">继续</button>
<button id="stop">停止</button>
<button id="play">播放</button>
<script>
// 创建画布
class PlayerCanvas{
constructor(width,height){
this._canvas = document.createElement("canvas");
this._canvas.width = width;
this._canvas.height = height;
this._canvasWidth = width;
this._canvasHeight = height;
this._CAMERA_VIDEO_WIDTH = 200;
this._CAMERA_VIDEO_HEIGHT = 150;
this._context2d = this._canvas.getContext("2d")
// 开启动画,动画运行,不断运行回调中的函数
requestAnimationFrame(this._animationFrameHandler.bind(this))
}
setScreenVideo(video){
this._screenVideo = video;
}
setCameraVideo(video){
this._cameraVideo = video;
}
_animationFrameHandler(){
console.log(">>>>")
if(this._screenVideo){
this._context2d.drawImage(this._screenVideo,0,0,this._canvasWidth,this._canvasHeight)
}
if(this._cameraVideo){
this._context2d.drawImage(this._cameraVideo,
this._canvasWidth-this._CAMERA_VIDEO_WIDTH,
this._canvasHeight-this._CAMERA_VIDEO_HEIGHT,
this._canvasWidth,this._canvasHeight)
}
// 不断地进行转换
requestAnimationFrame(this._animationFrameHandler.bind(this));
}
// 声明获取属性
get canvas(){
return this._canvas;
}
}
render()
// 开始渲染
async function render(){
let SCREEN_WIDTH = 1024;
let SCREEN_HEIGHT = 640;
// 总媒体流
let stream = new MediaStream();
// 创建一个整体的画布
let playerCanvas = new PlayerCanvas(SCREEN_WIDTH,SCREEN_HEIGHT)
// 获取屏幕的视频流
let screenStream = await navigator.mediaDevices.getDisplayMedia(); // 获取屏幕的视频流
// 音频,摄像头数据
let audioStream = await navigator.mediaDevices.getUserMedia({video:false,audio:true}) // 用户声音
let cameraStream = await navigator.mediaDevices.getUserMedia({video:true,audio:false}) // 用户摄像头
// 设置屏幕视频的播放配置,
playerCanvas.setScreenVideo(createVideoElementWithStream(screenStream));
// 设置摄像头的播放配置
playerCanvas.setCameraVideo(createVideoElementWithStream(cameraStream))
// 将媒体流放在播放器中(画布的流)
let playerCanvasStream = playerCanvas.canvas.captureStream()
// 将音频流添加到媒体流中
audioStream.getAudioTracks().forEach(value=>stream.addTrack(value))
// 将视频流添加到媒体流中
playerCanvasStream.getTracks().forEach(value=>stream.addTrack(value))
// 预览整合的视频
$("#preview-video")[0].srcObject = playerCanvasStream;
// 开启录制功能(待写)
startRecord(stream)
}
// 创建video元素
function createVideoElementWithStream(stream){
let video = document.createElement("video")
video.srcObject = stream
video.autoplay = true;
return video
}
// 开启录制器
function startRecord(stream){
// 检测类型
// MediaRecorder.isTypeSupported("video/webm"); // true
// MediaRecorder.isTypeSupported("video/mp4"); // false
// MediaRecorder.isTypeSupported("video/webm;codecs=h264"); // true
let recorder = new MediaRecorder(stream,{mimeType:'video/webm;codecs=h264'});
// 记录缓存的数据(支持webm)
let currentWebData = null;
// 响应的回调函数
recorder.ondataavailable = function(e){
console.log(e)
currentWebData = e.data
};
// 2.监听点击事件,触发相应的功能。
$('#record').click(()=>{
recorder.start();
console.log("已经开始录制")
})
$('#pause').click(()=>{
recorder.pause();
console.log("已经暂停录制")
})
$('#resume').click(()=>{
recorder.resume();
console.log("已经继续录制")
})
$('#stop').click(()=>{
recorder.stop();
console.log("已经结束录制")
})
$('#play').click(()=>{
// 创建一个音频流的url
let url = URL.createObjectURL(currentWebData)
setMediaUrl(url);
console.log("已经开始播放录制内容")
})
// 3.播放音频设备
function setMediaUrl(url){
document.querySelector("#current-video").src = url;
}
}
</script>
</body>
</html>
12.createImageData视频滤镜
// 1.获取图片数据
let srcImageData = context2d.getImageData( 0,0,previewWidth,previewHeight)
// 2.创建图片对象
let destImageData = context2d.createImageData( srcImageData.width,srcImageData.height)
// 4.设置图片数据
context2d_1.putImageData(destImageData,0,0)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>
<canvas width="640" height="480" id="preview" ></canvas>
<button id="play">播放</button>
<button id="stop">停止</button>
<canvas width="640" height="480" id="result" ></canvas>
<script>
let video = document.createElement("video");
let context2d = $("#preview")[0].getContext("2d")
let context2d_1 = $("#result")[0].getContext("2d")
let previewWidth = $("#preview").width();
let previewHeight = $("#preview").height();
// 1.设置视频路径
video.src = "./test.mp4"
// 2.不断地执行回调,定时器
function animationFrameHandler(){
context2d.drawImage(video, 0,0,previewWidth,previewHeight)
// 1.获取图片数据
let srcImageData = context2d.getImageData( 0,0,previewWidth,previewHeight)
// 2.创建图片对象
let destImageData = context2d.createImageData( srcImageData.width,srcImageData.height)
// 3. 对图片数据进行加工
let length = srcImageData.data.byteLength
let rawData = srcImageData.data
for(let i = 0;i< length;i++){
let c = Math.floor((rawData[i] + rawData[i+1] + rawData[i+2])/3)
destImageData.data[i] = c; // 去色
destImageData.data[i + 1] = c;
destImageData.data[i + 2] = c;
destImageData.data[i + 3] = 255;
}
// 4.设置图片数据
context2d_1.putImageData(destImageData,0,0)
requestAnimationFrame(animationFrameHandler)
}
// 3.执行定时器
requestAnimationFrame(animationFrameHandler)
// 4.监听点击事件
$("#play").click(()=>{
video.play()
})
$("#stop").click(()=>{
video.pause()
})
</script>
</body>
</html>
13.基于css视频灰度特效
-
CssFilter , -webkit-fileter/filter -
OpenGL/Metal [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JcpENlAf-1661530821292)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609000903146.png)]
.none {
-webkit-filter:none
}
.blur {
-webkit-filter:blur(3px)
}
.grayscale {
-webkit-filter:grayscale(1)
}
.inver {
-webkit-filter:invert(1)
}
.sepia {
-webkit-filter:sepia(1)
}
14.getUserMedia的适配*
// 手动使用各种的接口
var getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia
// 使用Google开源库adapter.js进行适配
<script crossorigin="anonymous" integrity="sha384-WkBOXiehccshFjuJ0bBZ+TnCwjUUBu0S3/dfMuNEK8bEYHHidYw7RDcbial94EuJ" src="https://lib.baomitu.com/adapterjs/0.15.5/adapter.min.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
github文档:https://github.com/Temasys/AdapterJS
// 使用方法
getUserMedia({ audio: true, video: true }, function (stream) {
attachMediaStream(videoElm, stream);
}, ...);
15.getUserMedia视频约束
-
width 宽度 优先级低于css样式 -
height 调试 -
aspectRatio 比率 -
frameRate 帧率 -
facingMode 可以选取的值 ? user: 前置摄像头 environment: 后置摄像头 left : 前置左侧摄像头 right: 前置右侧摄像头 -
resizeMode 裁剪
// 默认的设置
let stream = await navigator.mediaDevices.getUserMedia({video:false,audio:true})
// 使用视频约束后的效果
let stream = await navigator.mediaDevices.getUserMedia({
video:{
width:320;
height:240
},audio:true})
四、WebRTC原理与架构
1.WebSocket与HTTP
Websocket是HTML5新增的一种全双工通信协议,客户端和服务端基于TCP握手连接成功后,两者之间就可以建立持久性的连接,实现双向数据传输。
如何实现数据双向传递? 在没有WebSocket的时候,通过HTTP请求模拟双向数据传递的方式效http+Poling(轮询)和1http+Long Poling(长轮询),以及使用流技术(Http Streaming)来实现双向数据传递的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y1xXaekp-1661530821292)(C:\Users\Icy-yun\AppData\Roaming\Typora\typora-user-images\image-20220408150547349.png)]
Websocket的使用
综合上面轮询的种种问题,Websocket终于华丽的登上了舞台。
前人推出那么多的解决方案,想要解决的唯一的问题便是怎么让 server将最新的数据以最快的速度发送给client。但HTTP是个懒惰的协议,server只有收到请求才会做出回应,否则什么事都不干。因此,为了彻底解决这个server主动向client发送数据的问题,W3C在HTML5中提供了一种client 与server间进行全双工通讯的网络技术WebSocket。WebSocket是一个全新的、独立的协议,基于TCP协议,与HTTP协议兼容却不会融入HTTP协议,仅仅作为HTML5的一部分。
那WebSocket与HTTP什么关系呢?简单来说,WebSocket是一种协议,是一种与HTTP同等的网络协议,两者都是应用层协议,都基于TCP协议。但是Websocket是一种双向通信协议,在建立连接之后,WebSocket的server与client都能主动向对方发送或接收数据。同时,WebSocket在建立连接时需要借助HTTP协议,连接建立好了之后client与server之间的双向通信就与HTTP无关了。
相比于传统HTTP的每次“请求-应答"都要client与 server建立连接的模式,WebSocket是一种长连接的模式。具体什么意思呢?就是一旦WebSocket连接建立后,除非 client或者server中有一端主动断开连接,否则每次数据传输之前都不需要HTTP那样请求数据。从上面的图可以看出,client第一次需要与server建立连接,当server确认连接之后,两者便一直处于连接状态。直到一方断开连接,WebSocket 连接才断开。
WebSocket与HTTP的区别
相同点: 1.都需要建立TCP连接
2都是属于七层协议中的应用层协议
不同点: 1.HTTP是单向数据流,客户端向服务端发送请求,服务端响应并返回数据;Websocket连接后可以实现客户端和服务端双向数据传递。
2.由于是新的协议,HTTP的url使用"“http//或”“https/”“开头;Websocket的url使用”"ws//开头。
WebSocket与Socket
网络应用中,两个应用程序同时需要向对方发送消息的能力(即全双工通信),所利用到的技术就是socket,其能够提供端对端的通信。对于程序员而言,其需要在A端创建一个socket实例,并为这个实例提供其所要连接的B端的IP地址和端口号,而在B端创建另一个socket实例,并且绑定本地端口号来进行监听。当A和B建立连接后,双方就建立了一个端对端的TCP连接,从而可以进行双向通信。
WebSocekt 是HTML5规范中的一部分,其借鉴了socket 的思想,为client和server之间提供了类似的双向通信机制。
同时,WebSocket又是一种新的应用层协议,包含一套标准的API;
而socket并不是一个协议,而是一组方直接使用更底层的协议的接口, (比如TCP或UDP)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZkX1dGYH-1661530821294)(C:\Users\Icy-yun\AppData\Roaming\Typora\typora-user-images\image-20220408151429624.png)]
Socket.io框架 Socketio是一个封装了Websocket、基于Node的JavaScript框架,包含client 的JavaScript和server的Node。其屏蔽了所有底层细节,让顶层调用非常简单。
另外,Socket.io还有一个非常重要的好处。其不仅支持WebSocket,还支持许多种轮询机制以及其他实时通信方式,并封装了通用的接口。这些方式包含Adobe Flash Socket、Ajax长轮询、Ajax multipart streaming、持久lirame、JSONP轮询等。换句话说,当Socket.io检测到当前环境不支持WebSocket时,能够自动地选择最佳的方式来实现网络的实时通信。
Socket.io不是Websocket,它只是将Websocket和轮询(Poling)机制以及其它的实时通信方式封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。也就是说,Websocket仅是Socketio实现实时通信的一个子集。因此Websocket客户端连接不上Socket.io服务端,当然Socket.io客户端也连接不上Websocket服务端。
2.WebRTC实时通信协议
以下是使用的底层协议
ICE 集合框架
交互式连接建立 (ICE)是一个允许您的 Web 浏览器与对等点连接的框架。从对等点 A 到对等点 B 的直接连接不起作用的原因有很多。它需要绕过阻止打开连接的防火墙,如果像大多数情况下你的设备没有公共 IP 地址,它需要给你一个唯一的地址,如果你的路由器不允许你直接与对等方连接,它需要通过服务器中继数据. ICE 使用 STUN 和/或 TURN 服务器来完成此操作,如下所述。
NAT协议 (Network Address Translator)
网络地址转换 (NAT)用于为您的设备提供公共 IP 地址。路由器将有一个公共 IP 地址,连接到路由器的每个设备都将有一个私有 IP 地址。请求将从设备的私有 IP 转换为具有唯一端口的路由器的公共 IP。这样您就不需要为每台设备设置一个唯一的公共 IP,但仍然可以在 Internet 上被发现。
一些路由器会对谁可以连接到网络上的设备有限制。这可能意味着即使我们有 STUN 服务器找到的公共 IP 地址,也不是任何人都可以创建连接。在这种情况下,我们需要使用 TURN。
STUN协议 (Simple Traversal of UDP Through NAT)
用于 NAT 的会话穿越功能 (STUN)是一种协议,用于发现您的公共地址并确定路由器中任何会阻止与对等方直接连接的限制。
客户端将向 Internet 上的 STUN 服务器发送请求,该服务器将回复客户端的公共地址以及客户端是否可以通过路由器的 NAT 访问。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mo0RfEJ2-1661530821295)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220603093801761.png)]
TURN协议 (Traversal Using Relays around NAT)
一些使用 NAT 的路由器采用称为“对称 NAT”的限制。这意味着路由器将只接受来自您之前连接的对等方的连接。
Traversal Using Relays around NAT (TURN)旨在通过打开与 TURN 服务器的连接并通过该服务器中继所有信息来绕过对称 NAT 限制。您将创建与 TURN 服务器的连接,并告诉所有对等方将数据包发送到服务器,然后将其转发给您。这显然会带来一些开销,因此只有在没有其他选择的情况下才使用它。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZbjObvrK-1661530821295)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220603093927096.png)]
SDP协议(Session Description protocol)
会话描述协议 (SDP)是用于描述连接的多媒体内容的标准,例如分辨率、格式、编解码器、加密等,以便在数据传输时双方都可以相互理解。这本质上是描述内容的元数据,而不是媒体内容本身。
因此,从技术上讲,SDP 并不是真正的协议,而是一种用于描述在设备之间共享媒体的连接的数据格式
3.WebRTC整体架构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5PSjg2a8-1661530821296)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608192244010.png)]
-
对外的接口层 -
session层,所有的逻辑层(呼叫和协商) -
核心引擎层,音频引擎,视频引擎,传输层 -
设备层,发送和接收层
核心引擎层:
-
首先拿到数据后,进行NetEQ,进行平滑处理, 然后经过 Opus/iLBC Codec 编解码器, 进行编码。 -
SRTP,保证传输的安全性。多路复用,如果有多条音频和视频后,可以使用一条路连接。进行传输。 -
经过jitter Buffer 对视频进行平滑处理, 然后经过 Vp8,Vp9 编解码器。进行解码。然后进行图像增强。 -
然后进行P2P, 端对端的连接。 -
最终经过3A算法,将音频渲染出来。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y4idGzbP-1661530821297)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608192603265.png)]
4.WebRTC目录结构
WebRTC目录结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-olBXPfi0-1661530821298)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608192615958.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ORIM5Xim-1661530821299)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608192819509.png)]
modules目录结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mIEGMZzE-1661530821299)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608193300124.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cFVa0t9T-1661530821300)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608193517732.png)]
5.WebRTC运行机制
轨与流
- track
- mediastream
webrtc中重要的类
-
MediaStream -
RTCPeerConnection 里面包含大量的方法,去实现各种功能 -
RTCDataChannel 非音视频数据,一般是文本,文件等二进制数据
PeerConnection调用过程
多个轨道,添加到媒体流中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uXDKQ9GN-1661530821301)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608194442144.png)]
6.Nodejs的基础知识
wget -c 二进行包的地址 在服务器中下载
tar -zvxf 压缩包目录 解压目录
./configure --prefix=/user/local/nodejs 安装到指定目录下
1.javascript运行原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dakkc3ub-1661530821302)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608215048347.png)]
js代码会被解析成抽象语法数据,然后经过编译器,经过代码优化,最后形成优化的代码。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YooaAaOM-1661530821303)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608225642392.png)]
2.nodejs运行原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AQackPYZ-1661530821309)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608215417745.png)]
3.V8引擎
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-miLmh2NW-1661530821309)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608215512127.png)]
在请求前,需要运行Node的v8引擎, chrome的v8引擎获取数据后,在客户将数据进行渲染,以及一些事件的交互。
4.使用https服务
- 个人隐私及安全原因
- https是未来的趋势,越来越多的网站都是使用https
HTTPS = HTTP + TLS /SSL
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9v1bILRq-1661530821310)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220608222527978.png)]
5.使用nodejs搭建HTTPS服务
- 生成Https证书
- 引入https模块
- 指定证书位置 ,并创建HTTPs服务
nodejs的模块,类似于pm2, 以服务的方式启动商品。
forever start app.js
forever stop app.js
7.聊天室实战项目(新版本)
安装模块
npm install socket.io
npm install express
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kC2lCMkY-1661530821311)(C:\Users\Icy-yun\AppData\Roaming\Typora\typora-user-images\image-20220408170613671.png)]
建立server.js文件
const express = require('express')
const app = express()
// 使用http模块创建server,同时传入express app
const server = require("http").createServer(app)
// 引入socket.io,将server作为参数(和http server进行关联)
const io = require("socket.io")(server)
// 配置静态资源目录,(客户端页面)
app.use(express.static(__dirname + '/public'))
// 监听connection事件,这个事件在客户端与服务端建立链接时自动触发
// 倾向于是服务器监听所有的客户端,返回链接的对象
io.on('connection',(socket)=>{
// socket.id 是每个客户端有唯一的socket.id, 每次建立连接的时候生成
console.log(socket.id+"连接了socket.io");
// on和emit可以实现服务端与客户端之间双向通信
// 不管是服务端还是客户端,都有这两个函数 on 和 emit 函数, 这也是socket.io的核心
// 使用emit触发客户端监听的事件
// io.emit() 向所有的客户端广播,所有的客户端都会接收到信息
// io.emit("hello",'你好,我是服务端的数据!');
// socket.emit() 向指定的客户端广播, 只有建立连接的客户端接收到信息
// socket.emit("hello",'你好,我是单独的服务端的数据!');
// socket.broadcast.emit()向除去建立连接的客户端外的所有客户端广播(不给自己广播)
socket.broadcast.emit("hello",'你好,我是其余的的服务端的数据!')
// 使用on监听事件
socket.on('client',data =>{
console.log("客户端发来的数据:"+data);
})
})
// 启动服务
server.listen(9000,()=>{
console.log("server is run 9000, http://localhost:9000");
})
项目下建立 public/index.html文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<!--可以访问到npm包中的socket.io中的数据-->
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<script>
// 客户端连接服务端
// 使用on监听事件
const socket = io()
socket.on('hello',data=>{
console.log("服务端发送的消息是" + data)
})
socket.emit('client','服务端,你好')
</script>
</body>
</html>
五、WebRTC信令服务器
1.信令服务器原理
- 双方需要了解编码,解码器。
- 然后需要判断客户端是否支持音频和视频的播放
- 将网络信息通过信息服务器连接
- 信令服务器连接是使用TCP,必须使用稳定的连接。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OmkdapGY-1661530821312)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609003116493.png)]
2.ICE框架原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yTNWY5VO-1661530821313)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609214848162.png)]
stun server 服务器,会在穿越net的时候,生成公网ip
relay server 服务器也有通信的功能
信令是通过云端网络通信的。
1.通信流程:
-
终端A 得到所有的可以连接终端B通路, -
第一条路是局域网通信。如果两个终端在同一个局域 网内,就可以通过本地的ip地址进行通信 。 -
第二条路是穿越net通信。终端A穿越net, 通过stun服务器,获取net映射后的地址,然后将这 个地址传输给终端B, 同理,终端B也将映射后的地址传输给终端A. -
双方拿到对方的外网Ip地址,然后尝试进行p2p的穿越。如果穿越成功,就可以通过net进行通信 -
第三条路是通过中继服务器。 net将终端A传输给中继服务器,然后中继服务器向另外一个服务 端转发数据
2.ICE candidate 候选者
- 每个candidate是一个地址
- 例如: a = candidate:… UDP … 192.169.1.2 1816 typ host
- 每个候选者包括:协议,IP,端口和类型
- Candidate类型:主机候选者, 反射候选者, 中继候选者
3.ICE具体部分
- 收集Candidate候选者
- Host Candidate:本机所有IP和指定端口
- Reflexive Candidate: TRUN/TURN
- Relay Candidate: TURN
- 对Candidate Pair排序
- 一方收集到所有候选者后,通过信令传给对方
- 同样。另一方收到候选者后,也做收集工作
- 当双方拿到全部列表后,将候选者形成匹配对儿
- 进行连通性检查
- 对候选对进行优先级排序
- 对每个候选对进行发送检查
- 对每个候选对进行接收检查
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HxQUEDAN-1661530821314)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609221132519.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XrMGlLuB-1661530821315)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609223812980.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xNIhB38A-1661530821315)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609224242414.png)]
3.Net穿越原理
Net产生的原理
- 由于IPv4的地址不够
- 出于网络安全的原因(必须经过防火墙,经过网关)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TwM01CD4-1661530821317)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609224342099.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-si3lDAvY-1661530821317)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609235735639.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uv9ctkAI-1661530821318)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220610000607081.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PvhfXvdi-1661530821319)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220610000426952.png)]
1.穿越类型
-
完全锥型NAT(简易,安全性差) 原理:内网主机有自己内网ip和端口, 然后通过防火墙生成了外网的ip和端口。 通信流程:首先,内网主机向外网主机发送请求,会在net服务上打洞,形成外网Ip和端口,其它主机无论通过 什么方式,只要获取到ip地址和端口,都可以向它发送数据。 -
地址限制锥型NAT(严格,同ip地址) 原理:外网映射后的公网ip和端口,形成映射表 通信流程:首先,也需要内网主机向外网主机 发送请求,然后在net服务上形成映射表,外网主机可以和内网主机进行通信。外网主机可以自由向内网主机发送数据,但其它的主机会因为ip和端口而无法进行通信,具有 一对一的关系映射。 主要区别是,内网主机需要对每个外网主机发送请求,才会产生映射表,进行通信。 -
端口限制锥型NAT(同端口) 原理:在地址限制锥型的基础上,又对端口进行限制 通信流程: 在防火墙里面有指定的ip,端口映射表 -
对称型NAT(地址对) 原理:会形成多个ip,端口对。 通信流程:向M,n主机进行通信时,形成C,d对, 其余的主机都无法通信。而对于 P,q主机 ,则会形成A,b对。有更为复杂的流程。需要对每个ip,端口进行猜测。
2.穿越原理
-
C1, C2向STUN发消息 -
交换公网ip及端口 -
C1->C2, C2-> C1 , 甚至是端口猜测。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1ub4Uwr0-1661530821320)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220610001415284.png)]
3.NET类型检测
第一步判断:从客户端和服务端发送ECHO请求,服务端收到请求后,以同样的ip和端口返回数据。
然后客户端进行等待,设置个超时器,如果所有数据没有返回,则证明UDP是无法通信的。
如果收到服务端的响应,则可以获取到公网Ip地址。如果和本机的地址相同,则当前的ip地址就是公网ip地址。
第二步判断:再发送一个echo请求,如果其它主机向当前客户端发送请求,如果可以接收到,则证明当前的ip地址就是公网ip, 如果没有获取到数据,则是通过防火墙实现通信,并且和服务端是对称方式通信。
第三步判断:如果收到的和本机的地址不相同,说明是在NET之后。然后再向第一个ip地址和端口发起ECHO请求,如果是第二个端口返回请求,则判断是通过完全锥型的方式通信
第四步判断:如果没有收到响应,则向第二个IP地址和端口发送Echo请求.如果返回的公网ip地址和和第一次返回的ip地址不一致,则判断是通过对称型方式通信
第五步判断:如果收到响应,则判断是限制型。现在需要判断是地址限制型,还是端口限制型。然后向第一个ip地址和端口发起echo请求,如果是不一样的端口返回,则判断是地址限制型。否则 就是端口限制型
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-USTSdxhT-1661530821321)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220610003420588.png)]
4.STUN协议原理
- STUN存在的目的就是进行NAT穿越
- STUN是典型的客户端、服务器模式,客户端发送请求,服务端进行响应
1.RFC STUN规范
-
RFC3489/STUN Simple Traversal of UDP Through NAT
? 将STUN通过 UDP进行NAT穿越。有些路由器对UDP限制多,所以失败率高
-
RFC5389/STUN Session Traversal Utilities for NAT 这个协议是通过UDP,和TCP进行NAT穿越的
差异:新的是以00开头,96位,中32位单独划分为magic Cookie, 而老的是128位
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eS8BQtns-1661530821322)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220610004705790.png)]
2.STUN协议
- 包括20字节的STUN header
- 其中有2个字节(16Bit)类型
- 2个字节(16bit)消息长度,不包括消息头
- 16个字节(128bit)事务ID, 用于请求与响应匹配。事务ID相同
- Body中可以有0个或者多个Atrribute
- 消息头后有0或多个属性
- 每个属性进行TLV编码: Type,Length, Value
3.STUN header详情
-
STUN Message Type
-
C0C1
-
0b00 表示是一个 -
0b01 表示一个指示 -
0b10 表示是请求成功的响应 -
0b11 表示是请求失败的响应 -
大小端模式
-
大端模式:数据的高字节保存在内存的低地址中 -
小端模式:数据的高字节保存在内存的高地址中 -
网络字节顺序:采用大端排序方式
-
Transaction ID
4. STUN body详情
5.TURN协议原理
- 目的是解决对称NET无法穿越的问题
- 其建立在STUN之上,消息格式使用STUN格式消息
- TURN Client要求服务端分配一个公共IP和Port用于接收或发送数据
1.执行过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ochYDHtA-1661530821322)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611091047336.png)]
- 穿越NET,形成映射IP地址
- TURN Server会创建两个端口
2.Turn使用的传输协议
- TURN Client 到 TURN Server
- TURN Server到 Peer
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aoU5osG8-1661530821323)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611091625704.png)]
-
Turn client 可以向 Turn server (中继)发送数据, 然后通过50000端口向Peer转发数据 同理,Peer也可以通过50000端口向Turn client转发数据 -
Turn client需要向Turn server发送一个请求,执行保活机制。
3.执行过程
- Client发送一个绑定,进行打通,拿到映射 的Ip地址
- 让Turn Server开辟一个服务,用于接收Client的Ip和端口
- Caller通过SDP发送给被调用者
- Callee也需要发送给Turn Server服务,然后进行响应
- 交换候选者的Ip地址
- 然后通过ICE检测是否可以通过P2P连接
(P2P端对端连接是最高效的,没有额外的协议,不需要通过第三方服务器,也不需要带宽要求。)
- 如果不成功的话,就需要通过中继服务器转发数据
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZrBwmtw9-1661530821324)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611093144938.png)]
6.SDP交换传输协议
-
SDP(Session Description Protocol)它只是一种信息格式的描述标准, -
本身不属于传输协议,但是可以被其他传输协议用来交换必要的信息。 -
SDP五大组成部分: 会话元,网络描述 ,流描述,安全描述,服务质量
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YtzdNjdw-1661530821325)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611233633872.png)]
1.组成部分概览
-
会话层:
-
类似全局变量 -
会话的名称 ,目的,存活时间, -
会话包括多个媒体信息
-
.session Description的表示
- v=(protocol version)
- o=(owner/create and session identifier)
- s=(session name)
- c=(conn info - optional if included at session-level)
- a=(zero or more session attribute lines)
-
time description的表示
- t=(time the session is active)
- r=*(zero or more repeat times)
-
媒体层:
2.SDP的格式
-
一个会话级描述 -
多个媒体级描述 -
由多个 = 组成
3.字段的含义
-
Version必选:
-
Session Name必选:
-
Origion/Owner必选:
-
Connection Data可选:
-
Media Announcements 必选:
- m= <fmt/payload type list>
-
Suggested Attributes可选:
-
rtpmap可选:
- a=rtpmap:<fmt/payload type> /[/]
-
fmtp可选:
- a=fmtp:<format/payload type> parameters
4.分析 Offer SDP
v=0
o=- 3243108166895838038 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=extmap-allow-mixed
a=msid-semantic: WMS 2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 121 125 107 108 109 124 120 123 119 35 36 41 42 114 115 116 117 118
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:8KIA
a=ice-pwd:nvfezkha2DjVu3a0SemI+ZUu
a=ice-options:trickle
a=fingerprint:sha-256 C3:CE:0D:3F:7E:88:0D:77:6C:93:95:B0:7C:22:D6:FD:30:93:59:3B:97:D2:CA:D7:06:D0:CF:11:CC:92:A5:A6
a=setup:actpass
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:toffset
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 urn:3gpp:video-orientation
a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type
a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing
a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space
a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid
a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
a=sendrecv
a=msid:2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO 506a62c1-ac33-4164-b9cf-d45091265000
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:98 VP9/90000
a=rtcp-fb:98 goog-remb
a=rtcp-fb:98 transport-cc
a=rtcp-fb:98 ccm fir
a=rtcp-fb:98 nack
a=rtcp-fb:98 nack pli
a=fmtp:98 profile-id=0
a=rtpmap:99 rtx/90000
a=fmtp:99 apt=98
a=rtpmap:100 VP9/90000
a=rtcp-fb:100 goog-remb
a=rtcp-fb:100 transport-cc
a=rtcp-fb:100 ccm fir
a=rtcp-fb:100 nack
a=rtcp-fb:100 nack pli
a=fmtp:100 profile-id=2
a=rtpmap:101 rtx/90000
a=fmtp:101 apt=100
a=rtpmap:127 H264/90000
a=rtcp-fb:127 goog-remb
a=rtcp-fb:127 transport-cc
a=rtcp-fb:127 ccm fir
a=rtcp-fb:127 nack
a=rtcp-fb:127 nack pli
a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
a=rtpmap:121 rtx/90000
a=fmtp:121 apt=127
a=rtpmap:125 H264/90000
a=rtcp-fb:125 goog-remb
a=rtcp-fb:125 transport-cc
a=rtcp-fb:125 ccm fir
a=rtcp-fb:125 nack
a=rtcp-fb:125 nack pli
a=fmtp:125 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f
a=rtpmap:107 rtx/90000
a=fmtp:107 apt=125
a=rtpmap:108 H264/90000
a=rtcp-fb:108 goog-remb
a=rtcp-fb:108 transport-cc
a=rtcp-fb:108 ccm fir
a=rtcp-fb:108 nack
a=rtcp-fb:108 nack pli
a=fmtp:108 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
a=rtpmap:109 rtx/90000
a=fmtp:109 apt=108
a=rtpmap:124 H264/90000
a=rtcp-fb:124 goog-remb
a=rtcp-fb:124 transport-cc
a=rtcp-fb:124 ccm fir
a=rtcp-fb:124 nack
a=rtcp-fb:124 nack pli
a=fmtp:124 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f
a=rtpmap:120 rtx/90000
a=fmtp:120 apt=124
a=rtpmap:123 H264/90000
a=rtcp-fb:123 goog-remb
a=rtcp-fb:123 transport-cc
a=rtcp-fb:123 ccm fir
a=rtcp-fb:123 nack
a=rtcp-fb:123 nack pli
a=fmtp:123 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f
a=rtpmap:119 rtx/90000
a=fmtp:119 apt=123
a=rtpmap:35 H264/90000
a=rtcp-fb:35 goog-remb
a=rtcp-fb:35 transport-cc
a=rtcp-fb:35 ccm fir
a=rtcp-fb:35 nack
a=rtcp-fb:35 nack pli
a=fmtp:35 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f
a=rtpmap:36 rtx/90000
a=fmtp:36 apt=35
a=rtpmap:41 AV1/90000
a=rtcp-fb:41 goog-remb
a=rtcp-fb:41 transport-cc
a=rtcp-fb:41 ccm fir
a=rtcp-fb:41 nack
a=rtcp-fb:41 nack pli
a=rtpmap:42 rtx/90000
a=fmtp:42 apt=41
a=rtpmap:114 H264/90000
a=rtcp-fb:114 goog-remb
a=rtcp-fb:114 transport-cc
a=rtcp-fb:114 ccm fir
a=rtcp-fb:114 nack
a=rtcp-fb:114 nack pli
a=fmtp:114 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f
a=rtpmap:115 rtx/90000
a=fmtp:115 apt=114
a=rtpmap:116 red/90000
a=rtpmap:117 rtx/90000
a=fmtp:117 apt=116
a=rtpmap:118 ulpfec/90000
a=ssrc-group:FID 1461799759 2256014517
a=ssrc:1461799759 cname:LsoWPp8Y4867Aao3
a=ssrc:1461799759 msid:2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO 506a62c1-ac33-4164-b9cf-d45091265000
a=ssrc:1461799759 mslabel:2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO
a=ssrc:1461799759 label:506a62c1-ac33-4164-b9cf-d45091265000
a=ssrc:2256014517 cname:LsoWPp8Y4867Aao3
a=ssrc:2256014517 msid:2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO 506a62c1-ac33-4164-b9cf-d45091265000
a=ssrc:2256014517 mslabel:2Uo1JeD3gI4gT8k5LiAaKT9fKuaLbbj6YlEO
a=ssrc:2256014517 label:506a62c1-ac33-4164-b9cf-d45091265000
7.WireShark网络分析
-
Linux服务端用tcpdump
-
tcpdump -i eth0 src port 80 -xx -Xs 0 -w test.cap -
-i 指定网卡 src :指明包的来源 port :指明端口号 -xx∶指抓到的包以16进制显示 -X∶指以ASCII码显示 -s 0 ∶指明抓整个包 -w :写到文件中 -
window端WireShark
-
逻辑语句 :
-
与: and 或 && -
或: or 或 || -
非: not 或者 ! -
判断语句
-
等于: eq 或者 == -
小于:lt 或 < -
大于:gt 或> -
小于等于: le 或 <= -
大于等于: ge 或 >= -
不等于:ne 或 != -
按照协议过滤
- stun , tcp, udp
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OifWaoNS-1661530821326)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611104221621.png)]
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gCsHz0QU-1661530821326)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619002308918.png)]
-
按IP过滤
- ip.dst == 192.168.1.2
- ip.src == 192.168.1.2
- ip.addr == 192.168.1.2
-
按端口过滤
- tcp.port == 8080
- udp.port == 3478
- udp.dstport == 3478
- udp.srcport == 3478 源端口
-
按照长度过滤
- udp.length<30
- tcp.length <30
- http.content_length < 30
-
可以采用语句合并查询:
- udp.srcport == 3478 and ip.src == 192.168.1.2
- udp.srcport == 3478 and !( ip.src == 192.168.1.2)
8.socket.io信令服务器(实战)
Server Installation | Socket.IO
-
socket.io是webSocket超集 -
socket.io有房间的概念 -
socket.io跨平台,跨终端,跨语言
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vXcL2TBz-1661530821327)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220609003616476.png)]
-- 聊天室的socketio
// 监听connection事件,这个事件在客户端与服务端建立链接时自动触发
// 倾向于是服务器监听所有的客户端,返回链接的对象
io.on('connection',(socket)=>{ })
// 当关闭连接时,disconnect事件自动触发,用户离开信息通知
socket.on('disconnect',() => {})
// on和emit可以实现服务端与客户端之间双向通信
// 不管是服务端还是客户端,都有这两个函数 on 和 emit 函数, 这也是socket.io的核心
// 使用emit触发客户端监听的事件
// 1.io.emit() 向所有的客户端广播,所有的客户端都会接收到信息
// io.emit("hello",'你好,我是服务端的数据!');
// 2.socket.emit() 向指定的客户端广播, 只有建立连接的客户端接收到信息
// socket.emit("hello",'你好,我是单独的服务端的数据!');
// 3.socket.broadcast.emit()向除去建立连接的客户端外的所有客户端广播(不给自己广播)
socket.broadcast.emit("hello",'你好,我是其余的的服务端的数据!')
1.socketio发送信息
- 给本次连接发消息 socket.emit()
- 给某个房间内所有人发消息 io.in(room).emit()
- 除本连接外,给某个房间内所有人发消息 socket.to(room).emit()
- 除本连接外,给所有房间所有人发消息 socket.broadcast.emit()
2.socketio处理消息
-
发送action命令 S:socket.emit(“action”) C:socket.on(“action”,function(){ }) -
发送了一个action命令,还有data数据 S: socket.emit(“action”,data) C: socket.on(“action”,function(data){ }) -
发送了一个action命令,还有两个数据 S: socket.emit(“action”,data1,data2) C: socket.on(“action”,function(data1,data2){ }) -
发送了一个action命令,在emit方法中包含回调函数
? S: socket.emit(“action”,data,function(arg1, arg2){ … })
? C: socket.on(“action”,function(data, fn){ fn(‘a’, ‘b’) })
3.socketio信令服务器实现
注意:客户端和服务端的socketio版本一定要是socket.io@2.0.3
(1.客户端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>聊天室的开发</title>
<!--<script crossorigin="anonymous" integrity="sha384-vWEKq27AC3ilTDGLb/2WLv9V1/YW8uDNW+D6r3QPZxxzp9gjKkvHSXJJUiK+c8JS" src="https://lib.baomitu.com/socket.io/0.9.17/socket.io.js"></script>-->
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js" integrity="sha512-9mpsATI0KClwt+xVZfbcf2lJ8IFBAwsubJ6mI3rtULwyM3fBmQFzj0It4tGqxLOGQwGfJdk/G+fANnxfq9/cew==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>-->
<!--<script src="/socket.io/socket.io.js"></script>-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
</head>
<body>
<table align="center">
<tr>
<td>
<label for="username">Username</label>
<input type="text" id="username"/>
</td>
</tr>
<tr>
<td>
<label for="room">Room:</label>
<input type="text" id="room"/>
<button id="connect">Connect</button>
</td>
</tr>
<tr>
<td>
<label for="content">Content:</label>
<textarea rows="10" cols="80" id="output" disabled></textarea>
</td>
</tr>
<tr>
<td>
<label for="input">Input:</label>
<textarea rows="2" cols="80" id="input"></textarea>
</td>
</tr>
<tr>
<td><button id="send">Send</button></td>
</tr>
</table>
</body>
<script>
var username = document.querySelector("input#username");
var inputRoom = document.querySelector("input#room");
var btnConnect = document.querySelector("button#connect");
var outputArea = document.querySelector("textarea#output");
var inputArea = document.querySelector("textarea#input");
var btnSend = document.querySelector("button#send");
let room = null;
var socket = null;
btnConnect.onclick = function(){
// 连接socketio@2.0.3
socket = io.connect();
// 发送房间信息
room = inputRoom.value
socket.emit('join',room);
console.log(`正在请求加入${room}房间。。。`,socket)
// 监听事件
socket.on("joined",(room,id)=>{
console.log(`当前的用户已经加入了${room}`)
btnConnect.disabled = true
inputArea.disabled = false
btnSend.disabled = false
})
socket.on("leaved",(room,id)=>{
console.log(`当前用户已经离开了${room}`)
btnConnect.disabled = false
inputArea.disabled = true
btnSend.disabled = true
})
socket.on("message",(room,id,data)=>{
console.log(`当前用户发送信息${room}`)
outputArea.value = outputArea.value + '\n' + data
})
}
btnSend.onclick = ()=>{
var data = inputArea.value;
data = username.value + ':'+data;
socket.emit('message',room,data)
inputArea.value = ''
}
</script>
</html>
(2.服务端
const http = require("http")
const express = require('express')
const socketIo = require("socket.io")
// const log4js = require("log4js")
// 创建express实例
const app = express()
// 创建http服务
const serve = http.createServer(app)
// 配置静态资源目录,(客户端页面)
app.use(express.static(__dirname + '/../public'))
// 让socketio和Http进行绑定
// var io = socketIo.listen(app);
var io = socketIo(serve);
// socketIO监听连接
io.sockets.on('connection',(socket)=>{
console.log("新用户已经加入了",socket.id)
// 监听进入房间
socket.on('join',(room)=>{
socket.join(room);
console.log("房间是",room)
var myRoom = io.sockets.adapter.rooms[room];
var users = Object.keys(myRoom.sockets).length;
console.log("当前房间的人数是",users)
// // logger.log("当前房间的人数是"+users)
// // socket.emit('joined',room,socket.id) // 给本次连接的指定的客户端发送消息
io.in(room).emit('joined',room,socket.id) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // socket.to(room).emit('joined',room,socket.id) // 除自己以外的当前房间内的发送信息
// // socket.broadcast.emit('joined',room,socket.id) // 除自己的所有房间发送信息
})
// 监听离开房间
socket.on('leave',(room)=>{
var myRoom = io.sockets.adapter.rooms[room];
var users = Object.keys(myRoom.sockets).length;
// users - 1
console.log("当前房间的人数是"+users-1)
socket.leave(room)
// // logger.log("当前房间的人数是"+users)
// // socket.emit('leaved',room,socket.id) // 给本次连接的指定的客户端发送消息
io.in(room).emit('leaved',room,socket.id) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // socket.to(room).emit('leaved',room,socket.id) // 除自己以外的当前房间内的发送信息
// // socket.broadcast.emit('leaved',room,socket.id) // 除自己的所有房间发送信息
})
// 监听发送消息
socket.on('message',(room,data)=>{
var myRoom = io.sockets.adapter.rooms[room];
// users - 1
console.log(`当前房间${room}发送信息:${data}`)
// // logger.log("当前房间的人数是"+users)
// // socket.emit('message',room,socket.id) // 给本次连接的指定的客户端发送消息
io.in(room).emit('message',room,socket.id,data) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // socket.to(room).emit('message',room,socket.id) // 除自己以外的当前房间内的发送信息
// // socket.broadcast.emit('message',room,socket.id) // 除自己的所有房间发送信息
})
})
serve.listen(3000,function(){
console.log("项目已经启动 http://localhost:3000")
})
六、p2p端对端连接原理
1.1对1连接流程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gAZ1d1aE-1661530821328)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611134758958.png)]
-
呼叫端A与信令服务器建立连接,然后,呼叫端B也要与信令服务器建立连接,然后它们就可以通过信令服务器的跳转。 -
A需要先创建PeerConnect对象的实例,然后拿到本地的视频流,将这个流添加到连接流中。然后调用PeerConnect的CreateOffer方法去创建一个offer的SDP, 然后调用 setLocalDescription去设置localDescription. -
然后在底层会向Stun/Turn服务器发送一个bind request请求,收集所有的候选者。并且需要将前面创建的SDP Offer发送给信令服务器。B端就可以通过信令服务器获取传递的SDP Offer -
B收到SDP的offer,就会创建一个PeerConnect连接对象的实例。然后调用setRemoteDescription将收到的SDP作为参数进行设置。设置完成后,需要给一个应答。B此时可以进行媒体协商,然后调用 Create Answer创建本机的SDP, 也需要调用 SetLocalDescription设置localDescription, -
然后B需要向STun/Turn服务器发送一个Bind Request请求。去收集B与A可以进行通讯的所有候选者。然后需要将B创建的SDP通过信令服务器发送给A, A收到后调用setRemoteDescription, 此时A也可以进行媒体协商。A与B的媒体协商也就此完成。 -
然后在Stun/Turn服务器也会触发onIceCandidate事件,A收到候选者后,将候选者通过信令服务器发送给B, B收到后,需要调用AddIceCandidate方法将它们添加到对端的连接通路的候选者列表中。 -
在B收到候选者后,STurn/Turn服务器也会触发onIceCandidate事件,然后B通过信令服务器,将B的候选者传递给A, A也将调用AddleCandidate方法将它们添加到对端 的连接通路的候选者列表中。此时,双方者拿到对方的候选者。 -
然后,会在底层形成一对对candidate pear 候选者对,进行排序,然后会进行连接检测,然后进行一系列的检测。找到一个最优的线路后,A与B就可以进行通讯 了。首先是A将数据流发送给B。B收到数据流后,B需要将数据添加到本地的视频流中。
2.媒体协商过程
1.RTCPeerConnection类
- 它是一个上层的接口,底层有非常多的处理过程。是webRTC的核心类
- 媒体协商,流和轨道的处理,(接收与发送)传输相关的方法,(统计数据)统计相关的方法都是由它处理。
- 基本格式: pc = new RTCPeerConnection([configuration])
- configuration可选:
- iceServers
- 由RTCIceSer组成,每个RTCIceServer都是一个ICE代理
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-04p5tQUj-1661530821329)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220616103526906.png)]
- iceTransportPolicy 传输策略: all
- relay:只使用中继候选者(一般都是使用这种类型)
- all: 可以使用任何类型
- bundlePolicy bundle策略: balanced
- Balanced:音频与视频轨使用各自的传输通道
- max-compat:每个轨使用自己的传输通道
- max-bundle:都绑定到同一个传输通道(建议使用)
- rtcpMuxPolicy rtcpMux策略: require
- 该选项在收集ICE候选者时使用
- negotiate:收集RTCP与RTP复用的ICE候选者,如果RTCP能复用就与RTP复用,如果不能复用,就将它们音独使用。
- require:只能收集RTCP与RTP复用的ICE候选者,如果RTCP不能复用,则失败。
- peerIdentity
- certificates 证书
- iceCandidatePoolSize 候选者空间: 0
- 16位的整数值,用于指定预取反ice候选者的个数。如果该值发生变化,它会触发重新收集候选者
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FTw2tDiK-1661530821329)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611143035919.png)]
setLocalDescription, 触发收集当前端的候选者。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RLi6PN5t-1661530821330)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611190459131.png)]
其中还有一条路线:设置预处理应答,中间状态,不携带数据。
2.媒体协商方法
-
createOffer
- 基本格式:aPromise = myPeerConnection.createOffer([options])
- options可选:
- iceRestart:该选项会重启ICE,重新进行Candidate收集
- voiceActivityDetection:是否开启静音检测,默认开启。(杂音不传输)
- 功能:
- 过程:
- 接收远端音频,接收远端视频,静音检测,ICE restart
- ICE restart : 在媒体协商交换sdp的时候,通信的双方都发现ice-uflag, ice-pwd, 它们是用于验证连通性。当它们发现变化时,就需要重新检测. 在检测的时候,发现有一条新的通路,然后就会把老的数据切换到新的链路上。
-
createAnswer
- 基本格式:aPromise = myPeerConnection.createAnswer([options])
-
setLocalDescription
- 基本格式:aPromise = myPeerConnection.setLocalDescription([sessionDescription])
- sessionDescription是对端的传递数据
-
setRemoteDescription
-
addTrack
- 基本格式:rtpSender= myPeerConnection.addTrack(track,stream,…)
- track:是添加到RTCPeerConnection中的媒体轨
- stream:是track所在的stream
-
removeTrack
- 基本格式:myPeerConnection.removeTrack(rtpSender)
-
addIceCandidate
- 基本格式 aPromise = myPeerConnection.addIceCandidate(candidate)
- candidate参数
- candidate 候选者描述信息
- sdpMid 与候选者相关的媒体流的识别标签
- sdpMLineIndex 在SDP中m=的索引值
- usernameFragment 包括了远端的唯一标识
3.媒体协商触发的事件
- onnegotiationneeded
- onicecandidate
3.本机1对1视频互通(实战)
? -
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div>
<video id="localvideo" autoplay playsinline></video>
<video id="remotevideo" autoplay playsinline></video>
</div>
<div>
<button id="start">Start</button>
<button id="call">Call</button>
<button id="hangup">HangUp</button>
</div>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
<script>
var localVideo = document.querySelector("video#localvideo")
var remoteVideo = document.querySelector("video#remotevideo")
var btnStart = document.querySelector("button#start")
var btnCall = document.querySelector("button#call")
var btnHangup = document.querySelector("button#hangup")
var localStream;
var pc1;
var pc2;
// 开启本地视频
btnStart.onclick = function(){
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
console.error("浏览器不支持获取视频")
return
}else{
var constraint = {
video:true,
audio:false
}
navigator.mediaDevices.getUserMedia(constraint)
.then(getMediaStream).catch(handleError)
}
}
function getMediaStream(stream){
localVideo.srcObject = stream;
localStream = stream;
}
function handleError(err){
console.error("failed to get Media Stream",err)
}
// 开启call
btnCall.onclick = function(){
pc1 = new RTCPeerConnection();
pc2 = new RTCPeerConnection();
// 收集候选者
pc1.onicecandidate = (e)=>{
pc2.addIceCandidate(e.candidate)
}
pc2.onicecandidate = (e)=>{
pc1.addIceCandidate(e.candidate)
}
// 获取远程的流
pc2.ontrack = getRemoteStream
// 获取所有的轨道
localStream.getTracks().forEach(track=>{
pc1.addTrack(track,localStream)
})
// 开始媒体协商
var offerOptions = {
offerToRecieveAudio:0,
offerToRecieveVidio:1,
}
pc1.createOffer(offerOptions)
.then(getOffer)
.catch(handleAnswerError)
}
function getRemoteStream(e){
remoteVideo.srcObject = e.streams[0]
}
function handleOfferError(err){
console.error("failed to get offer Stream",err)
}
function handleAnswerError(err){
console.error("failed to get answer Stream",err)
}
function getOffer(desc){
pc1.setLocalDescription(desc);
pc2.setRemoteDescription(desc);
pc2.createAnswer()
.then(getAnswer)
.catch(handleOfferError)
// 显示 SDP
console.log(desc.sdp)
}
function getAnswer(desc){
pc2.setLocalDescription(desc);
pc1.setRemoteDescription(desc);
// 显示 SDP
console.log(desc.sdp)
}
// 关闭call
btnHangup.onclick = function(){
pc1.close()
pc2.close()
pc1 = null
pc2 = null
}
</script>
</body>
</html>
七、Stun/Turn中继服务器
1.webrtc客户端交互原理
1.客户端状态机
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cv7AlGI2-1661530821332)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220615114644977.png)]
-
首先用户处理初始状态,然后发送一个join消息,服务端会响应一个joined,因此,客户端收到信息后,就成为加入状态。用户也可以选择离开,离开后又会回到初始化状态。 -
当用户处于joined状态时,又有其它用户加入,就会收到other_joined消息,然后就会转变成joined_conn状态,这个状态下,可以与对方进行通过,发送信息。然而,后加入的用户还是joined状态。两个用户属于不同的状态。 -
当第一个用户处于joined_conn状态下,离开的时候会发送一个信息。 当用户离开时,第二个用户就会从joined状态转换成joined_unbind, 清除连接的通路,释放用户之间的连接。 -
当第三个用户加入的时候 ,第二个用户就会从joined_unbind状态转换成joined_conn状态。如果第三个用户离开,则第二个用户就会再转换成joined_unbind状态。
2.客户端流程图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mRmieqIN-1661530821333)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220615133156406.png)]
- 首先拿到音视频数据,然后与信念服务器连接,并注册信息函数,然后会触发相应 的事件。
- 如果有用户加入,则会触发joined状态,设置状态joined,创建pc并绑定媒体流。
- 如果房间内有其它用户,则会触发otherjoin事件。首先判断是否处于joined_unbind状态,如果是,则需要创建Pc并绑定媒体流,否则直接设置状态joined_conn,然后开始媒体协商
- 如果房间满了,则会设置状态full, 然后关闭pc,关闭本地媒体流。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K9FrOQUi-1661530821333)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220615133833351.png)]
- 用户离开的时候,会变更状态leaved, 然后关闭连接
- 如果是主动的发起方,关闭pc, (peerConnection) 并且关闭始体流。
- 如果收到对方的离开信息后,就会变更状态为joined_unbind,也会关闭 PeerConnection
如果其它的用户添加,则会创建PeerConnection,进行连接。
3.客户端信息消息
- join加入房间
- leave离开房间
- message端到端消息
- offer消息
- answer消息
- candidate消息
4.服务端信息消息
- joined已加入房间
- otherjoin其它用户加入房间
- full房间人数已满
- leaved已离开房间
- bye对方离开房间
5.直播系统消息处理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qs9pxoYC-1661530821334)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220615202546816.png)]
-
呼叫者caller与信令服务器建立连接,然后被呼叫者callee也与信念服务器建立连接 -
呼叫者先发送一个join信令,表示“我要加入房间”,信令服务器返回一个joined消息,表示“我已经知道你要加入房间”, 被呼叫者也发送join信令,信令服务器执行相同的操作 -
同时,在被呼叫者加入房间的时候,信令服务器会向第一个进入房间的用户,发送otherjoin消息。此时,它们已经知道可以进行“ 媒体协商”。 -
此时,如果有第三个用户想要加入房间。信令服务器从列表中查询,发现房间已满,则返回full信息,表示“房间已满,拒绝加入”,然后会释放资源,断开与第三个用户的连接。 -
第一个用户向信令服务器发送message信息,offer信息,通过信令服务器向第二个用户发送信息,然后响应一个answer消息. 最后 可以通过p2p端对端连接,或者stun/turn服务器进行音视频 的转发. -
如果第二个用户需要离开,则会向信令服务器发送一个leave信令, ,向第一个用户发送bye消息. 告诉对端"我要离开了". 然后信令服务器响应一个leaved消息. 同理, 如果第一个用户需要离开,也是一样的过程 .
(转)WebRTC信令控制与STUN/TURN服务器搭建
2.node-turn服务器搭建(实战)
- rfc5766-turn-server (功能不全)
- coTurn (优化第一个服务器)
- RestTurn (非常老的服务器)
方法一:windows下使用nodejs的stun/turn模块
1.安装模块
Atlantis-Software/node-turn:Node-turn 是 Node.JS 的 STUN/TURN 服务器
// $ npm i -g node-turn
// $ npm run app.js
// 会在本地开启一个trun服务 # started on udp/0.0.0.0:3478
2.使用方法
var Turn = require('node-turn');
var server = new Turn({
// set options
authMech: 'long-term',
credentials: {
username: "root",
password:'123456'
}
});
// 设置提示信息
console.log("turn项目已经启动")
// 默认开启端口3478
server.start();
3.测试turn服务器
// 测试 本地turn服务器
// turn:172.18.6.217:3478
// username: root
// password: 123456
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lc8piBql-1661530821334)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220617124548894.png)]
方法二:Linux平台上使用coturn
1.coTurn服务器搭建
- 在linux系统下载coTurn :地址:https://github.com/coturn/coturn
- 指定安装地址 ./configure --prefix=/usr/local/coturn
- -alt Makefile
- make -j 4 (核数*2)
2.配置文件
etc/turnserver.conf
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RIYiPKXw-1661530821335)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220614013631037.png)]
3.启动服务
turnserver -c ./etc/turn 启动turn服务
4.测试服务
https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
turn:stun.gl.learningrtc.cn:3478
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sa07u6Vb-1661530821336)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220614014522526.png)]
3.直播系统的连接(实战)*
注意要点:
-
网络连接要在音视频数据获取到之后,否则有可能绑定音视频流失败 -
当一端退出房间后,另一端的PeerConnection要关闭重建,否则与新用户互通时媒体协商会失败。 -
上面都是异步事件,并非同步事件。
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js></script>
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iQNzi3hQ-1661530821336)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220611134758958.png)]
(1.index.html直播页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.row{
display: flex;
justify-content: space-around;
}
.row .item{
margin-top:20px;
border:1px solid red;
}
</style>
</head>
<body>
<div id="container">
<div>
<button id="connserver">connect server</button>
<button id="leave" disabled>leave</button>
</div>
<div class="row" id="preview">
<div class="item">
<h2>Local:</h2>
<video id="localvideo" autoplay playsinline width="600" height="400"></video>
</div>
<div class="item">
<h2>Remote:</h2>
<video id="remotevideo" autoplay playsinline width="600" height="400"></video>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
<script>
// 基本配置,开始
let localVideo = document.querySelector("video#localvideo")
let remoteVideo = document.querySelector("video#remotevideo")
let btnConn = document.querySelector("button#connserver")
let btnLeave = document.querySelector("button#leave")
let localStream = null;
let roomid = '123456';
let socket = null;
let state = 'init'
let pc = null;
let pcConfig = {
// 注意:需要提前开启本地的turn服务器
'iceServers':[{
'urls':'turn:172.18.6.217:3478', // 服务器地址
// 'credential':'mypasswd',
// 'username':'garrylea'
// 'credentials': {
// 'username': "root",
// 'password':'123456'
// }
'username':'root', // 用户名
'credential':'123456', // 密码
}]
}
// 基本配置,结束
// 基本连接,开始
// 一、开启连接
btnConn.onclick = function (){
// 兼容性
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
console.err('无法使用用户信息')
}else{
navigator.mediaDevices.getUserMedia({
video:true,
audio:false
}).then(getMediaStream)
.catch(handleError)
}
}
function getMediaStream(stream){
localVideo.srcObject = stream;
localStream = stream;
conn()
}
function handleError(err){
console.error("failed to get Media Stream",err)
}
// 1.连接socket.io
function conn(){
// 与socket.io连接
socket = io.connect();
// 加入房间
socket.emit('join',roomid);
console.log(`当前用户尝试进入${roomid}房间。。。`)
// 监听事件
socket.on("joined",(roomid,id)=>{
btnConn.disabled=true;
btnLeave.disabled=false
state = 'joined'
console.log("收到join消息",roomid,id)
createPeerConnection();
})
socket.on("otherjoin",(roomid,id)=>{
console.log("收到otherjoin消息",roomid,id)
if(state === 'joined_unbind'){
createPeerConnection()
}
state = 'joined_conn'
// 媒体协商
console.log("开始媒体协商!",roomid,id)
call()
})
socket.on("full",(roomid,id)=>{
console.log("收到full消息",roomid,id)
state = 'leaved'
socket.disconnect()
console.log("房间已满!",roomid,id)
alert("房间已满")
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("leaved",(roomid,id)=>{
console.log("收到leaved消息",roomid,id)
state = 'leaved'
socket.disconnect()
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("bye",(roomid,id)=>{
console.log("收到bye消息",roomid,id)
state = 'joined_unbind'
closePeerConnection();
})
// 接收信息
socket.on("message",(roomid,data)=>{
console.log("收到message消息",roomid,data)
// 媒体协商
if(data){
if(data.type === 'offer'){
pc.setRemoteDescription(new RTCSessionDescription(data));
pc.createAnswer()
.then(getAnswer)
.catch(handleAnswerError)
}else if(data.type === 'answer'){
console.log("接收到响应。。。")
pc.setRemoteDescription(new RTCSessionDescription(data))
}else if(data.type === 'candidate'){
let candidate = new RTCIceCandidate({
sdpMLineIndex:data.label,
candidate:data.candidate,
type:'candidate',
sdpMid:data.id,
})
pc.addIceCandidate(candidate)
}else{
console.error("传递的信息错误!",data)
}
}
})
}
// 2.建立peerConnection
function createPeerConnection(){
console.log("尝试创建RTCPeerConnection。。。")
if(!pc){
// (1.创建连接
pc = new RTCPeerConnection(pcConfig);
console.log("已经创建RTCPeerConnection连接",pc)
// (2.收集候选者
pc.onicecandidate = (e)=>{
console.log("尝试收集候选者",e.candidate)
if(e.candidate){
console.log("正在查询新的候选者!")
sendMessage(roomid,{
type:'candidate',
label:e.candidate.sdpMLineIndex,
id:e.candidate.sdpMid,
candidate:e.candidate.candidate
})
}
}
// (3.获取对端信息(或者流)
pc.ontrack = (e)=>{
console.log("尝试获取对端的轨道信息")
remoteVideo.srcObject = e.streams[0]
}
}// (4.如果有这个媒体,才添加相应的轨道
if(localStream){
console.log("设置轨道")
localStream.getTracks().forEach((track)=>{
pc.addTrack(track,localStream)
})
}
}
// 3.释放peerConnection
function closePeerConnection(){
console.log("关闭RTCPeerConnection")
if(pc){
pc.close()
pc = null;
}
}
// 4.媒体协商
function call(){
console.log("已经开始call",state)
// if(state === 'joined'){
if(pc){
var options = {
offerToRecieveAudio:0,
offerToRecieveVidio:1,
}
pc.createOffer(options)
.then(getOffer)
.catch(handleOfferError)
}
// }
}
function sendMessage(roomid,data){
console.log("发送p2p消息",roomid,data)
if(socket){
socket.emit('message',roomid,data)
}
}
// (1.获取offer信息
function getOffer(desc){
console.log("设置offer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleOfferError(err){
console.error("getOffer出现异常",err)
}
// (2.获取answer信息
function getAnswer(desc){
console.log("设置answer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleAnswerError(err){
console.error("getAnswer出现异常",err)
}
// 二、关闭连接
btnLeave.onclick=function(){
if(socket){
socket.emit('leave',123456)
}
btnConn.disabled=false;
btnLeave.disabled=true
// 释放资源
closePeerConnection();
closeLocalMedia();
}
function closeLocalMedia(){
if(localStream && localStream.getTracks()){
localStream.getTracks().forEach((track)=>{
track.stop()
})
}
localStream = null;
}
// 基本连接,结束
</script>
</body>
</html>
(2.app.js信令服务器
const http = require("http")
const express = require('express')
const socketIo = require("socket.io")
// 1.创建express实例
const app = express()
// 2.创建http服务
const serve = http.createServer(app)
// 3.socketio和Http进行绑定
const io = socketIo(serve);
// 4.配置静态资源目录,(客户端页面)
app.use(express.static(__dirname + '/../public'))
// 5.全局变量设置
// (1.用户数量
const USERCOUNT = 2
// 6.socketIO监听连接
io.sockets.on('connection',(socket)=>{
console.log("新用户已经加入了",socket.id)
// (1.监听进入房间
socket.on('join',(room)=>{
socket.join(room);
console.log("房间是",room)
var myRoom = io.sockets.adapter.rooms[room];
var users = myRoom ? Object.keys(myRoom.sockets).length : 0;
console.log("当前房间的人数是",users)
if(users<=USERCOUNT){
socket.emit("joined",room,socket.id)
if(users > 1){
socket.to(room).emit("otherjoin",room,socket.id)
}
}else{
socket.leave(room)
socket.emit("full",room,socket.id)
}
// // logger.log("当前房间的人数是"+users)
// // socket.emit('joined',room,socket.id) // 给本次连接的指定的客户端发送消息
// io.in(room).emit('joined',room,socket.id) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // socket.to(room).emit('joined',room,socket.id) // 除自己以外的当前房间内的发送信息
// // socket.broadcast.emit('joined',room,socket.id) // 除自己的所有房间发送信息
})
// (2.监听离开房间
socket.on('leave',(room)=>{
var myRoom = io.sockets.adapter.rooms[room];
var users = myRoom ? Object.keys(myRoom.sockets).length : 0;
// users - 1
console.log("当前房间的人数是"+users-1)
socket.leave(room)
socket.to(room).emit('bye',room,socket.id)
socket.emit('leaved',room,socket.id)
// // // logger.log("当前房间的人数是"+users)
// // // socket.emit('leaved',room,socket.id) // 给本次连接的指定的客户端发送消息
// io.in(room).emit('leaved',room,socket.id) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // // socket.to(room).emit('leaved',room,socket.id) // 除自己以外的当前房间内的发送信息
// // // socket.broadcast.emit('leaved',room,socket.id) // 除自己的所有房间发送信息
})
// (3.监听发送消息(转发信息)
socket.on('message',(room,data)=>{
console.log(`当前房间${room}发送信息:${data}`)
socket.to(room).emit('message',room,data)
// // logger.log("当前房间的人数是"+users)
// // socket.emit('message',room,socket.id) // 给本次连接的指定的客户端发送消息
// io.in(room).emit('message',room,socket.id,data) // 给当前房间内所有人消息(io是最优秀的,可以管理所有用户)
// // socket.to(room).emit('message',room,socket.id) // 除自己以外的当前房间内的发送信息
// // socket.broadcast.emit('message',room,socket.id) // 除自己的所有房间发送信息
})
})
serve.listen(3000,function(){
console.log("项目已启动 http://localhost:3000")
})
(3.server.js中继服务器
var Turn = require('node-turn');
var server = new Turn({
authMech: 'long-term',
credentials: {
username: "root",
password:'123456'
}
});
// 默认开启端口3478
server.start();
// 设置提示信息
console.log("项目已启动turn:0.0.0.0:3478")
4.package.json命令
"scripts": {
"app": "nodemon src/app.js",
"server": "nodemon src/server.js"
},
八、RTP媒体控制与数据统计
1.RTP Media原理
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nQtn1xts-1661530821337)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220618100031702.png)]
-
RTCRtpParameters是基类,RTCRtpSendParameters继承基类 -
RTCRtpHeaderExtensionParameters RTP扩展头参数; RTCRtcpParameters 当带宽不够使用,然后就需要减少RTCP的流量,则会去除新增加的,保留最基本的Rtcp基本消息。 RTCRtpCodecPatameters 与编码相关的参数 -
transactionID唯一标识 -
降低码流的方法:1.降低帧率 2.降低分辨率 3.平衡(都降低)
2.Receiver和Sender
-
getReceivers
-
getSenders
-
RTCRtpReceiver/RTCRtpSender属性
- MediaStreamTrack 媒体轨
- RTCDtlsTransport Transport媒体数据传输相关的属性
- RTCDtlsTransport rtcpTransport与rtcp传输相关的属性
-
RTCRtpReceiver实例方法
- getParameters 返回RTCRtpParameter对象
- getSynchronizationSources 返回一组SynchronizationSource实例
- getContributingSources 返回一组getContributingSources实例
- getStats RTCStatsReport,里面包括输入流统计信息
- 基本格式: var promise = sender.getStatus()
- getCapabilities 返回系统能接收的媒体能力(音频,视频)
-
RTCRtpSender实例方法
- getParameters 返回RTCRtpParameter对象
- setParameters 设置RTP传输相关的参数
- getStats 提供了输出流的统计数据
- replaceTrack 用另一个track替换现在的track,如切换摄像头
- getCapabilities 按类型(音频,视频)返回系统发送媒体的能力
-
RTCRtpTransceiver
- getTransceivers
- 原理:从PC获得一组RTCRtpTransceiver对象, 每个RTCRtpTransceiver是RTCRtpSender和RTCRtpReciver对
- 方法:stop : 停止发送和接收媒体数据
3.WebRTC统计信息(实战)*
chrome://webrtc-internals webrtc码流信息查看
log4js - npm
下载第三方模块
yarn add log4js 日志文件
使用本地的graph.js
<script src="/js/graph.js"></script>
实现代码
1.index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.row{
display: flex;
justify-content: space-around;
}
.row .item{
margin-top:20px;
border:1px solid red;
}
.graph-container{
width:600px;
height:400px;
border:1px solid red;
}
</style>
</head>
<body>
<div id="container">
<div>
<button id="connserver">connect server</button>
<button id="leave" disabled>leave</button>
<select id="bandwidth" >
<option value="unlimited" selected>unlimited</option>
<option value="2000">2000</option>
<option value="1000">1000</option>
<option value="500">500</option>
<option value="200">200</option>
<option value="100">100</option>
</select>
</div>
<div class="row" id="preview">
<div class="item">
<h2>Local:</h2>
<video id="localvideo" autoplay playsinline width="600" height="400"></video>
</div>
<div class="item">
<h2>Remote:</h2>
<video id="remotevideo" autoplay playsinline width="600" height="400"></video>
</div>
</div>
<div class="row">
<div class="item graph-container" id="bitrateGraph">
<div>Bitrate</div>
<canvas id="bitrateCanvas"></canvas>
</div>
<div class="item graph-container" id="packetGraph">
<div>packet</div>
<canvas id="packetCanvas"></canvas>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
<script src="/js/graph.js"></script>
<script>
// 基本配置,开始
let localVideo = document.querySelector("video#localvideo")
let remoteVideo = document.querySelector("video#remotevideo")
let btnConn = document.querySelector("button#connserver")
let btnLeave = document.querySelector("button#leave")
let localStream = null;
let roomid = '123456';
let socket = null;
let state = 'init'
let pc = null;
let pcConfig = {
// 注意:需要提前开启本地的turn服务器
'iceServers':[{
'urls':'turn:172.18.6.217:3478', // 服务器地址
// 'credential':'mypasswd',
// 'username':'garrylea'
// 'credentials': {
// 'username': "root",
// 'password':'123456'
// }
'username':'root', // 用户名
'credential':'123456', // 密码
}]
}
// 基本配置,结束
// 选择码率
let optBw = document.querySelector('select#bandwidth')
let bitrateCanvas = document.querySelector('canvas#bitrateCanvas')
let packetCanvas = document.querySelector('canvas#packetCanvas')
let bitrateGraph = null;
let bitrateSeries = null;
let packetGraph = null;
let packetSeries = null;
let lastResult = null;
let timerInterval = null;
let newrate = 'unlimited'
// 基本连接,开始
// 一、开启连接
btnConn.onclick = function (){
// 兼容性
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
console.err('无法使用用户信息')
}else{
navigator.mediaDevices.getUserMedia({
video:true,
audio:false
}).then(getMediaStream)
.catch(handleError)
}
}
function getMediaStream(stream){
localVideo.srcObject = stream;
localStream = stream;
// 开始连接socket.io
conn()
// 同时创建图表
drawGraph()
// 开启监听码流
starthandleRate()
}
function handleError(err){
console.error("failed to get Media Stream",err)
}
// 1.连接socket.io
function conn(){
// 与socket.io连接
socket = io.connect();
// 加入房间
socket.emit('join',roomid);
console.log(`当前用户尝试进入${roomid}房间。。。`)
// 监听事件
socket.on("joined",(roomid,id)=>{
btnConn.disabled=true;
btnLeave.disabled=false
state = 'joined'
console.log("收到join消息",roomid,id)
createPeerConnection();
})
socket.on("otherjoin",(roomid,id)=>{
console.log("收到otherjoin消息",roomid,id)
if(state === 'joined_unbind'){
createPeerConnection()
}
state = 'joined_conn'
// 设置码流
setVideoRate(newrate);
// 媒体协商
console.log("开始媒体协商!",roomid,id)
call()
})
socket.on("full",(roomid,id)=>{
console.log("收到full消息",roomid,id)
state = 'leaved'
socket.disconnect()
console.log("房间已满!",roomid,id)
alert("房间已满")
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("leaved",(roomid,id)=>{
console.log("收到leaved消息",roomid,id)
state = 'leaved'
socket.disconnect()
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("bye",(roomid,id)=>{
console.log("收到bye消息",roomid,id)
state = 'joined_unbind'
closePeerConnection();
})
// 接收信息
socket.on("message",(roomid,data)=>{
console.log("收到message消息",roomid,data)
// 媒体协商
if(data){
if(data.type === 'offer'){
pc.setRemoteDescription(new RTCSessionDescription(data));
pc.createAnswer()
.then(getAnswer)
.catch(handleAnswerError)
}else if(data.type === 'answer'){
console.log("接收到响应。。。")
pc.setRemoteDescription(new RTCSessionDescription(data))
}else if(data.type === 'candidate'){
let candidate = new RTCIceCandidate({
sdpMLineIndex:data.label,
candidate:data.candidate,
type:'candidate',
sdpMid:data.id,
})
pc.addIceCandidate(candidate)
}else{
console.error("传递的信息错误!",data)
}
}
})
}
// 2.建立peerConnection
function createPeerConnection(){
console.log("尝试创建RTCPeerConnection。。。")
if(!pc){
// (1.创建连接
pc = new RTCPeerConnection(pcConfig);
console.log("已经创建RTCPeerConnection连接",pc)
// (2.收集候选者
pc.onicecandidate = (e)=>{
console.log("尝试收集候选者",e.candidate)
if(e.candidate){
console.log("正在查询新的候选者!")
sendMessage(roomid,{
type:'candidate',
label:e.candidate.sdpMLineIndex,
id:e.candidate.sdpMid,
candidate:e.candidate.candidate
})
}
}
// (3.获取对端信息(或者流)
pc.ontrack = (e)=>{
console.log("尝试获取对端的轨道信息")
remoteVideo.srcObject = e.streams[0]
}
}// (4.如果有这个媒体,才添加相应的轨道
if(localStream){
console.log("设置轨道")
localStream.getTracks().forEach((track)=>{
pc.addTrack(track,localStream)
})
}
}
// 3.释放peerConnection
function closePeerConnection(){
console.log("关闭RTCPeerConnection")
if(pc){
pc.close()
pc = null;
}
}
// 4.媒体协商
function call(){
console.log("已经开始call",state)
// if(state === 'joined'){
if(pc){
var options = {
offerToRecieveAudio:0,
offerToRecieveVidio:1,
}
pc.createOffer(options)
.then(getOffer)
.catch(handleOfferError)
}
// }
}
function sendMessage(roomid,data){
console.log("发送p2p消息",roomid,data)
if(socket){
socket.emit('message',roomid,data)
}
}
// (1.获取offer信息
function getOffer(desc){
console.log("设置offer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleOfferError(err){
console.error("getOffer出现异常",err)
}
// (2.获取answer信息
function getAnswer(desc){
console.log("设置answer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleAnswerError(err){
console.error("getAnswer出现异常",err)
}
// 二、关闭连接
btnLeave.onclick=function(){
if(socket){
socket.emit('leave',123456)
}
btnConn.disabled=false;
btnLeave.disabled=true
// 释放资源
closePeerConnection();
closeLocalMedia();
// 关闭监听码流
closeHandleRate()
}
function closeLocalMedia(){
if(localStream && localStream.getTracks()){
localStream.getTracks().forEach((track)=>{
track.stop()
})
}
localStream = null;
}
// 基本连接,结束
// 三、调整码流
optBw.onchange = function(){
newrate = optBw.options[optBw.selectedIndex].value;
console.log("选择的是",newrate);
if(pc){
// 设置码率
setVideoRate(newrate);
}
}
function setVideoRate(bw){
var senders = pc.getSenders();
let vsender;
console.log("注意:选择的结果是",bw)
senders.forEach(sender=>{
if(senders && sender.track.kind === 'video'){
vsender = sender
}
})
if(!vsender){
return;
}
var parameters = vsender.getParameters();
if(!parameters.encodings){
return;
}
if(bw === 'unlimited'){
return;
}
parameters.encodings[0].maxBitrate = bw * 1000
vsender.setParameters(parameters)
.then(res=>{
console.log("已经成功设置parameters")
})
.catch(err=>{
console.error(err)
})
}
// 四.监听码流的变化
function starthandleRate(){
console.log("正在开启监听码流数据。。。")
timerInterval = setInterval(function(){
if(!pc){
return;
}
var sender = pc.getSenders()[0]
if(!sender){
return;
}
sender.getStats()
.then(getReports)
.catch(getReportsError)
},1000)
}
function closeHandleRate(){
console.log("已经关闭监听码流数据!")
if(!timerInterval){
clearInterval(timerInterval)
timerInterval = null;
}
}
// (1.获取码流数据
function getReports(reports){
reports.forEach(report =>{
if(report.type === 'outbound-rtp'){
if(report.isRemote){
return;
}
// 计算数据
var curTs = report.timestamp;
var bytes = report.bytesSent;
var packets = report.packetsSent;
if(lastResult && lastResult.has(report.id)){
var bitrate = 8 * (bytes - lastResult.get(report.id).bytesSent)/
(curTs - lastResult.get(report.id).timestamp);
// 更新图表
updateGraph(report,curTs,bitrate,packets)
}
}
})
lastResult = reports
}
function getReportsError(err){
console.error("getReports出现异常",err)
}
// (2.创建图表
function drawGraph(){
bitrateSeries = new TimelineDataSeries();
bitrateGraph = new TimelineGraphView('bitrateCanvas','bitrateCanvas')
bitrateGraph.updateEndDate()
packetSeries = new TimelineDataSeries();
packetGraph = new TimelineGraphView('packetCanvas','packetCanvas')
packetGraph.updateEndDate()
}
// (3.更新图表
function updateGraph(report,curTs,bitrate,packets){
console.log("数据是",curTs,bitrate,packets - lastResult.get(report.id).packetsSent)
bitrateSeries.addPoint(curTs,bitrate);
bitrateGraph.setDataSeries([bitrateSeries]);
bitrateGraph.updateEndDate();
packetSeries.addPoint(curTs,packets - lastResult.get(report.id).packetsSent);
packetGraph.setDataSeries([packetSeries]);
packetGraph.updateEndDate();
}
</script>
</body>
</html>
信令服务器,和turn服务器,同 ”直播系统的连接“
九、文本,文件,非音视频传输
1.createDataChannel通道
-
基本格式:aPromise = myPeerConnection.createDataChannel(label,options) -
参数 :
-
label: 字符串 -
options 可选
- ordered 排序
- maxPacketLifeTime/maxRetransmits 最大存活时间,最大次数
- negotiated :如果为false,一端使用createDataChannel创建通道,另一端监听ondatachannel事件; 如果为true,两端都可以调用createDataChannel创建通道,通过id来标识同一个通道。
-
实例:
-
var ch = myPeerConnection.createDataChannel({ ? “chat”,{negotiated: true , id:0} }) -
监听事件
- onmessage 当有数据传输的时候,会触发
- onopen 当打开的时候
- onclose 当关闭的时候
- onerror 当出错的时候
-
传输方式
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-28capOkf-1661530821338)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220618165730626.png)]
2.实时文本聊天(实战)*
注意:需要在媒体协商前创建channel对象的实例
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.row{
display: flex;
justify-content: space-around;
}
.row .item{
margin-top:20px;
border:1px solid red;
}
.graph-container,.wrap{
width:600px;
height:400px;
border:1px solid red;
}
.wrap textarea{
width:90%;
height:80%;
resize:none
}
.wrap textarea#sendtxt{
width:60%;
height:60px;
}
</style>
</head>
<body>
<div id="container">
<div>
<button id="connserver">connect server</button>
<button id="leave" disabled>leave</button>
<select id="bandwidth" >
<option value="unlimited" selected>unlimited</option>
<option value="2000">2000</option>
<option value="1000">1000</option>
<option value="500">500</option>
<option value="200">200</option>
<option value="100">100</option>
</select>
</div>
<div class="row" id="preview">
<div class="item">
<h2>Local:</h2>
<video id="localvideo" autoplay playsinline width="600" height="400"></video>
</div>
<div class="item">
<h2>Remote:</h2>
<video id="remotevideo" autoplay playsinline width="600" height="400"></video>
</div>
</div>
<div class="row">
<div class="item graph-container" id="bitrateGraph">
<div>Bitrate</div>
<canvas id="bitrateCanvas"></canvas>
</div>
<div class="item graph-container" id="packetGraph">
<div>packet</div>
<canvas id="packetCanvas"></canvas>
</div>
</div>
<div class="row">
<div class="item wrap">
<h2>Chat:</h2>
<textarea name="" id="chat" cols="30" rows="10" ></textarea>
</div>
<div class="item wrap">
<h2>sendtxt:</h2>
<div class="row">
<textarea name="" id="sendtxt" cols="30" rows="10" ></textarea>
<button id="send" >send</button>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
<script src="/js/graph.js"></script>
<script>
// 基本配置,开始
let localVideo = document.querySelector("video#localvideo")
let remoteVideo = document.querySelector("video#remotevideo")
let btnConn = document.querySelector("button#connserver")
let btnLeave = document.querySelector("button#leave")
let localStream = null;
let roomid = '123456';
let socket = null;
let state = 'init'
let pc = null;
let pcConfig = {
// 注意:需要提前开启本地的turn服务器
'iceServers':[{
'urls':'turn:172.18.6.217:3478', // 服务器地址
// 'credential':'mypasswd',
// 'username':'garrylea'
// 'credentials': {
// 'username': "root",
// 'password':'123456'
// }
'username':'root', // 用户名
'credential':'123456', // 密码
}]
}
// 基本配置,结束
// 选择码率
let optBw = document.querySelector('select#bandwidth')
let bitrateCanvas = document.querySelector('canvas#bitrateCanvas')
let packetCanvas = document.querySelector('canvas#packetCanvas')
let bitrateGraph = null;
let bitrateSeries = null;
let packetGraph = null;
let packetSeries = null;
let lastResult = null;
let timerInterval = null;
let newrate = 'unlimited'
// 文本聊天
let chat = document.querySelector('textarea#chat')
let sendTxt = document.querySelector('textarea#sendtxt')
let btnSend = document.querySelector('button#send')
let dc = null;
// 基本连接,开始
// 一、开启连接
btnConn.onclick = function (){
// 兼容性
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
console.err('无法使用用户信息')
}else{
navigator.mediaDevices.getUserMedia({
video:true,
audio:false
}).then(getMediaStream)
.catch(handleError)
}
}
function getMediaStream(stream){
localVideo.srcObject = stream;
localStream = stream;
// 开始连接socket.io
conn()
// 同时创建图表
drawGraph()
// 开启监听码流
starthandleRate()
}
function handleError(err){
console.error("failed to get Media Stream",err)
}
// 1.连接socket.io
function conn(){
// 与socket.io连接
socket = io.connect();
// 加入房间
socket.emit('join',roomid);
console.log(`当前用户尝试进入${roomid}房间。。。`)
// 监听事件
socket.on("joined",(roomid,id)=>{
btnConn.disabled=true;
btnLeave.disabled=false
state = 'joined'
console.log("收到join消息",roomid,id)
createPeerConnection();
})
socket.on("otherjoin",(roomid,id)=>{
console.log("收到otherjoin消息",roomid,id)
if(state === 'joined_unbind'){
createPeerConnection()
}
state = 'joined_conn'
// (1.只有在双方已经通信时,才允许设置码流
// 设置码流
setVideoRate(newrate);
// (2.创建channel
// 开启文本聊天
dc = pc.createDataChannel('chat')
dc.onmessage = receiveMessage;
dc.onopen = dataChannelStateOpen;
dc.onclose = dataChannelStateClose;
console.log("已经创建channel",dc.onmessage)
// 媒体协商
console.log("开始媒体协商!",roomid,id)
call()
})
socket.on("full",(roomid,id)=>{
console.log("收到full消息",roomid,id)
state = 'leaved'
socket.disconnect()
console.log("房间已满!",roomid,id)
alert("房间已满")
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("leaved",(roomid,id)=>{
console.log("收到leaved消息",roomid,id)
state = 'leaved'
socket.disconnect()
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("bye",(roomid,id)=>{
console.log("收到bye消息",roomid,id)
state = 'joined_unbind'
closePeerConnection();
})
// 接收信息
socket.on("message",(roomid,data)=>{
console.log("收到message消息",roomid,data)
// 媒体协商
if(data){
if(data.type === 'offer'){
pc.setRemoteDescription(new RTCSessionDescription(data));
pc.createAnswer()
.then(getAnswer)
.catch(handleAnswerError)
}else if(data.type === 'answer'){
console.log("接收到响应。。。")
pc.setRemoteDescription(new RTCSessionDescription(data))
}else if(data.type === 'candidate'){
let candidate = new RTCIceCandidate({
sdpMLineIndex:data.label,
candidate:data.candidate,
type:'candidate',
sdpMid:data.id,
})
pc.addIceCandidate(candidate)
}else{
console.error("传递的信息错误!",data)
}
}
})
}
// 2.建立peerConnection
function createPeerConnection(){
console.log("尝试创建RTCPeerConnection。。。")
if(!pc){
// (1.创建连接
pc = new RTCPeerConnection(pcConfig);
console.log("已经创建RTCPeerConnection连接",pc)
// (2.收集候选者
pc.onicecandidate = (e)=>{
console.log("尝试收集候选者",e.candidate)
if(e.candidate){
console.log("正在查询新的候选者!")
sendMessage(roomid,{
type:'candidate',
label:e.candidate.sdpMLineIndex,
id:e.candidate.sdpMid,
candidate:e.candidate.candidate
})
}
}
// (3.获取对端信息(或者流)
pc.ontrack = (e)=>{
console.log("尝试获取对端的轨道信息")
remoteVideo.srcObject = e.streams[0]
}
// (4.当收到文本信息
// 对端获取文本信息
pc.ondatachannel = (e)=>{
console.log("已经开启channel")
if(!dc){
dc = e.channel;
dc.onmessage = receiveMessage;
dc.onopen = dataChannelStateOpen;
dc.onclose = dataChannelStateClose;
}
}
}// (5.如果有这个媒体,才添加相应的轨道
if(localStream){
console.log("设置轨道")
localStream.getTracks().forEach((track)=>{
pc.addTrack(track,localStream)
})
}
}
// 3.释放peerConnection
function closePeerConnection(){
console.log("关闭RTCPeerConnection")
if(pc){
pc.close()
pc = null;
}
}
// 4.媒体协商
function call(){
console.log("已经开始call",state)
// if(state === 'joined'){
if(pc){
var options = {
offerToRecieveAudio:0,
offerToRecieveVidio:1,
}
pc.createOffer(options)
.then(getOffer)
.catch(handleOfferError)
}
// }
}
function sendMessage(roomid,data){
console.log("发送p2p消息",roomid,data)
if(socket){
socket.emit('message',roomid,data)
}
}
// (1.获取offer信息
function getOffer(desc){
console.log("设置offer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleOfferError(err){
console.error("getOffer出现异常",err)
}
// (2.获取answer信息
function getAnswer(desc){
console.log("设置answer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleAnswerError(err){
console.error("getAnswer出现异常",err)
}
// 二、关闭连接
btnLeave.onclick=function(){
if(socket){
socket.emit('leave',123456)
}
btnConn.disabled=false;
btnLeave.disabled=true
// 释放资源
closePeerConnection();
closeLocalMedia();
// 关闭监听码流
closeHandleRate()
// 关闭文本聊天
btnSend.disabled = true;
sendTxt.disabled = true;
}
function closeLocalMedia(){
if(localStream && localStream.getTracks()){
localStream.getTracks().forEach((track)=>{
track.stop()
})
}
localStream = null;
}
// 基本连接,结束
// 三、调整码流
optBw.onchange = function(){
newrate = optBw.options[optBw.selectedIndex].value;
console.log("选择的是",newrate);
if(pc){
// 设置码率
setVideoRate(newrate);
}
}
function setVideoRate(bw){
var senders = pc.getSenders();
let vsender;
console.log("注意:选择的结果是",bw)
senders.forEach(sender=>{
if(senders && sender.track.kind === 'video'){
vsender = sender
}
})
if(!vsender){
return;
}
var parameters = vsender.getParameters();
if(!parameters.encodings){
return;
}
if(bw === 'unlimited'){
return;
}
parameters.encodings[0].maxBitrate = bw * 1000
vsender.setParameters(parameters)
.then(res=>{
console.log("已经成功设置parameters")
})
.catch(err=>{
console.error(err)
})
}
// 四.监听码流的变化
function starthandleRate(){
console.log("正在开启监听码流数据。。。")
timerInterval = setInterval(function(){
if(!pc){
return;
}
var sender = pc.getSenders()[0]
if(!sender){
return;
}
sender.getStats()
.then(getReports)
.catch(getReportsError)
},1000)
}
function closeHandleRate(){
console.log("已经关闭监听码流数据!")
if(!timerInterval){
clearInterval(timerInterval)
timerInterval = null;
}
}
// (1.获取码流数据
function getReports(reports){
reports.forEach(report =>{
if(report.type === 'outbound-rtp'){
if(report.isRemote){
return;
}
// 计算数据
var curTs = report.timestamp;
var bytes = report.bytesSent;
var packets = report.packetsSent;
if(lastResult && lastResult.has(report.id)){
var bitrate = 8 * (bytes - lastResult.get(report.id).bytesSent)/
(curTs - lastResult.get(report.id).timestamp);
// 更新图表
updateGraph(report,curTs,bitrate,packets)
}
}
})
lastResult = reports
}
function getReportsError(err){
console.error("getReports出现异常",err)
}
// (2.创建图表
function drawGraph(){
bitrateSeries = new TimelineDataSeries();
bitrateGraph = new TimelineGraphView('bitrateCanvas','bitrateCanvas')
bitrateGraph.updateEndDate()
packetSeries = new TimelineDataSeries();
packetGraph = new TimelineGraphView('packetCanvas','packetCanvas')
packetGraph.updateEndDate()
}
// (3.更新图表
function updateGraph(report,curTs,bitrate,packets){
console.log("数据是",curTs,bitrate,packets - lastResult.get(report.id).packetsSent)
bitrateSeries.addPoint(curTs,bitrate);
bitrateGraph.setDataSeries([bitrateSeries]);
bitrateGraph.updateEndDate();
packetSeries.addPoint(curTs,packets - lastResult.get(report.id).packetsSent);
packetGraph.setDataSeries([packetSeries]);
packetGraph.updateEndDate();
}
// 五、文本聊天
btnSend.onclick = function(){
let data = sendTxt.value;
if(data && dc){
// 发送信息
dc.send(data)
sendTxt.value = ''
chat.value += '<-' + data + '\r\n'
}else{
alert("当前没有对端连接")
}
}
// (1.发送端收到信息
function receiveMessage(e){
let msg = e.data;
if(msg){
chat.value += '->' + msg + '\r\n'
}else{
console.error('文件聊天出现异常!')
}
}
function dataChannelStateOpen(){
var readyState = dc.readyState;
if(readyState === 'open'){
console.log("当前已经可以进行文本聊天")
}
}
function dataChannelStateClose(){
var readyState = dc.readyState;
if(readyState === 'close'){
console.log("当前已经关闭文本聊天")
alert("对端已经离开,文本聊天关闭")
}
}
</script>
</body>
</html>
信令服务器,和turn服务器,同 ”直播系统的连接“
3.实时文件传输(实战)*
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AsIya2Qc-1661530821339)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220618204439841.png)]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.row{
display: flex;
justify-content: space-around;
}
.row .item{
margin-top:20px;
border:1px solid red;
}
.graph-container{
width:600px;
height:400px;
border:1px solid red;
}
</style>
</head>
<body>
<div id="container">
<div>
<button id="connserver">connect server</button>
<button id="leave" disabled>leave</button>
<select id="bandwidth" >
<option value="unlimited" selected>unlimited</option>
<option value="2000">2000</option>
<option value="1000">1000</option>
<option value="500">500</option>
<option value="200">200</option>
<option value="100">100</option>
</select>
</div>
<div class="row" id="preview">
<div class="item">
<h2>Local:</h2>
<video id="localvideo" autoplay playsinline width="600" height="400"></video>
</div>
<div class="item">
<h2>Remote:</h2>
<video id="remotevideo" autoplay playsinline width="600" height="400"></video>
</div>
</div>
<div class="row">
<div class="item graph-container" id="bitrateGraph">
<div>Bitrate</div>
<canvas id="bitrateCanvas"></canvas>
</div>
<div class="item graph-container" id="packetGraph">
<div>packet</div>
<canvas id="packetCanvas"></canvas>
</div>
</div>
<div class="row">
<div >
<form id="fileInfo">
<input type="file" id="fileInput" name="files" />
</form>
<button disabled id="sendFile">Send</button>
<button disabled id="abortButton">Abort</button>
</div>
<div class="progress">
<div class="label">Send progress: </div>
<progress id="sendProgress" max="0" value="0"></progress>
</div>
<div class="progress">
<div class="label">Receive progress: </div>
<progress id="receiveProgress" max="0" value="0"></progress>
</div>
<div class="row">
<div id="bitrate"></div>
<a id="download"></a>
<span id="status"></span>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.0.3/socket.io.js"></script>
<script src="https://webrtc.github.io/adapter/adapter-latest.js"> </script>
<script src="/js/graph.js"></script>
<script>
// 基本配置,开始
let localVideo = document.querySelector("video#localvideo")
let remoteVideo = document.querySelector("video#remotevideo")
let btnConn = document.querySelector("button#connserver")
let btnLeave = document.querySelector("button#leave")
let localStream = null;
let roomid = '123456';
let socket = null;
let state = 'init'
let pc = null;
let pcConfig = {
// 注意:需要提前开启本地的turn服务器
'iceServers':[{
'urls':'turn:172.18.6.217:3478', // 服务器地址
// 'credential':'mypasswd',
// 'username':'garrylea'
// 'credentials': {
// 'username': "root",
// 'password':'123456'
// }
'username':'root', // 用户名
'credential':'123456', // 密码
}]
}
// 基本配置,结束
// 选择码率
let optBw = document.querySelector('select#bandwidth')
let bitrateCanvas = document.querySelector('canvas#bitrateCanvas')
let packetCanvas = document.querySelector('canvas#packetCanvas')
let bitrateGraph = null;
let bitrateSeries = null;
let packetGraph = null;
let packetSeries = null;
let lastResult = null;
let timerInterval = null;
let newrate = 'unlimited'
// 文件传输
const bitrateDiv = document.querySelector('div#bitrate');
const fileInput = document.querySelector('input#fileInput');
const statusMessage = document.querySelector('span#status');
const downloadAnchor = document.querySelector('a#download');
const sendProgress = document.querySelector('progress#sendProgress');
const receiveProgress = document.querySelector('progress#receiveProgress');
const btnSendFile = document.querySelector('button#sendFile');
const btnAbort = document.querySelector('button#abortButton');
var fileReader = null;
var fileName = "";
var fileSize = 0;
var lastModifyTime = 0;
var fileType = "data";
var receiveBuffer = [];
var receivedSize = 0;
let dc = null;
// 基本连接,开始
// 一、开启连接
btnConn.onclick = function (){
// 兼容性
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
console.err('无法使用用户信息')
}else{
navigator.mediaDevices.getUserMedia({
video:true,
audio:false
}).then(getMediaStream)
.catch(handleError)
}
}
function getMediaStream(stream){
localVideo.srcObject = stream;
localStream = stream;
// 开始连接socket.io
conn()
// 同时创建图表
drawGraph()
// 开启监听码流
starthandleRate()
}
function handleError(err){
console.error("failed to get Media Stream",err)
}
// 1.连接socket.io
function conn(){
// 与socket.io连接
socket = io.connect();
// 加入房间
socket.emit('join',roomid);
console.log(`当前用户尝试进入${roomid}房间。。。`)
// 监听事件
socket.on("joined",(roomid,id)=>{
btnConn.disabled=true;
btnLeave.disabled=false
state = 'joined'
console.log("收到join消息",roomid,id)
createPeerConnection();
})
socket.on("otherjoin",(roomid,id)=>{
console.log("收到otherjoin消息",roomid,id)
if(state === 'joined_unbind'){
createPeerConnection()
}
state = 'joined_conn'
//文件传输
dc = pc.createDataChannel('chatchannel');
dc.onmessage = receivemsg;
dc.onopen = dataChannelStateChange;
dc.onclose = dataChannelStateChange;
// 媒体协商
console.log("开始媒体协商!",roomid,id)
call()
})
socket.on("full",(roomid,id)=>{
console.log("收到full消息",roomid,id)
state = 'leaved'
socket.disconnect()
console.log("房间已满!",roomid,id)
alert("房间已满")
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("leaved",(roomid,id)=>{
console.log("收到leaved消息",roomid,id)
state = 'leaved'
socket.disconnect()
btnConn.disabled = false;
btnLeave.disabled = true;
})
socket.on("bye",(roomid,id)=>{
console.log("收到bye消息",roomid,id)
state = 'joined_unbind'
closePeerConnection();
})
// 接收信息
socket.on("message",(roomid,data)=>{
console.log("收到message消息",roomid,data)
// 媒体协商
if(data){
if(data.type === 'offer'){
pc.setRemoteDescription(new RTCSessionDescription(data));
pc.createAnswer()
.then(getAnswer)
.catch(handleAnswerError)
}else if(data.type === 'answer'){
console.log("接收到响应。。。")
pc.setRemoteDescription(new RTCSessionDescription(data))
}else if(data.type === 'candidate'){
let candidate = new RTCIceCandidate({
sdpMLineIndex:data.label,
candidate:data.candidate,
type:'candidate',
sdpMid:data.id,
})
pc.addIceCandidate(candidate)
}else if(data.hasOwnProperty('type') && data.type === 'fileinfo'){
// 接收到文件
fileName = data.name;
fileType = data.filetype;
fileSize = data.size;
lastModifyTime = data.lastModify;
receiveProgress.max = fileSize;
} else{
console.error("传递的信息错误!",data)
}
}
})
}
// 2.建立peerConnection
function createPeerConnection(){
console.log("尝试创建RTCPeerConnection。。。")
if(!pc){
// (1.创建连接
pc = new RTCPeerConnection(pcConfig);
console.log("已经创建RTCPeerConnection连接",pc)
// (2.收集候选者
pc.onicecandidate = (e)=>{
console.log("尝试收集候选者",e.candidate)
if(e.candidate){
console.log("正在查询新的候选者!")
sendMessage(roomid,{
type:'candidate',
label:e.candidate.sdpMLineIndex,
id:e.candidate.sdpMid,
candidate:e.candidate.candidate
})
}
}
// (3.获取对端信息(或者流)
pc.ontrack = (e)=>{
console.log("尝试获取对端的轨道信息")
remoteVideo.srcObject = e.streams[0]
}
// (4.文件传输
pc.ondatachannel = e=> {
if(!dc){
dc = e.channel;
dc.onmessage = receivemsg;
dc.onopen = dataChannelStateChange;
dc.onclose = dataChannelStateChange;
}
}
}// (4.如果有这个媒体,才添加相应的轨道
if(localStream){
console.log("设置轨道")
localStream.getTracks().forEach((track)=>{
pc.addTrack(track,localStream)
})
}
}
// 3.释放peerConnection
function closePeerConnection(){
console.log("关闭RTCPeerConnection")
if(pc){
pc.close()
pc = null;
}
}
// 4.媒体协商
function call(){
console.log("已经开始call",state)
// if(state === 'joined'){
if(pc){
var options = {
offerToRecieveAudio:0,
offerToRecieveVidio:1,
}
pc.createOffer(options)
.then(getOffer)
.catch(handleOfferError)
}
// }
}
function sendMessage(roomid,data){
console.log("发送p2p消息",roomid,data)
if(socket){
socket.emit('message',roomid,data)
}
}
// (1.获取offer信息
function getOffer(desc){
console.log("设置offer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
}
function handleOfferError(err){
console.error("getOffer出现异常",err)
}
// (2.获取answer信息
function getAnswer(desc){
console.log("设置answer")
pc.setLocalDescription(desc);
sendMessage(roomid,desc)
// 只有在双方已经通信时,才允许设置码流
// 设置码流
setVideoRate(newrate);
}
function handleAnswerError(err){
console.error("getAnswer出现异常",err)
}
// 二、关闭连接
btnLeave.onclick=function(){
if(socket){
socket.emit('leave',123456)
}
btnConn.disabled=false;
btnLeave.disabled=true
// 释放资源
closePeerConnection();
closeLocalMedia();
// 关闭监听码流
closeHandleRate()
}
function closeLocalMedia(){
if(localStream && localStream.getTracks()){
localStream.getTracks().forEach((track)=>{
track.stop()
})
}
localStream = null;
}
// 基本连接,结束
// 三、调整码流
// 前提是,双方已经经过媒体协商,可以进行通信
optBw.onchange = function(){
newrate = optBw.options[optBw.selectedIndex].value;
console.log("选择的是",newrate);
if(pc){
// 设置码率
setVideoRate(newrate);
}
}
function setVideoRate(bw){
var senders = pc.getSenders();
let vsender;
console.log("注意:选择的结果是",bw)
senders.forEach(sender=>{
if(senders && sender.track.kind === 'video'){
vsender = sender
}
})
if(!vsender){
return;
}
var parameters = vsender.getParameters();
if(!parameters.encodings){
return;
}
if(bw === 'unlimited'){
return;
}
parameters.encodings[0].maxBitrate = bw * 1000
vsender.setParameters(parameters)
.then(res=>{
console.log("已经成功设置parameters")
})
.catch(err=>{
console.error(err)
})
}
// 四.监听码流的变化
function starthandleRate(){
console.log("正在开启监听码流数据。。。")
timerInterval = setInterval(function(){
if(!pc){
return;
}
var sender = pc.getSenders()[0]
if(!sender){
return;
}
sender.getStats()
.then(getReports)
.catch(getReportsError)
},1000)
}
function closeHandleRate(){
console.log("已经关闭监听码流数据!")
if(!timerInterval){
clearInterval(timerInterval)
timerInterval = null;
}
}
// (1.获取码流数据
function getReports(reports){
reports.forEach(report =>{
if(report.type === 'outbound-rtp'){
if(report.isRemote){
return;
}
// 计算数据
var curTs = report.timestamp;
var bytes = report.bytesSent;
var packets = report.packetsSent;
if(lastResult && lastResult.has(report.id)){
var bitrate = 8 * (bytes - lastResult.get(report.id).bytesSent)/
(curTs - lastResult.get(report.id).timestamp);
// 更新图表
updateGraph(report,curTs,bitrate,packets)
}
}
})
lastResult = reports
}
function getReportsError(err){
console.error("getReports出现异常",err)
}
// (2.创建图表
function drawGraph(){
bitrateSeries = new TimelineDataSeries();
bitrateGraph = new TimelineGraphView('bitrateCanvas','bitrateCanvas')
bitrateGraph.updateEndDate()
packetSeries = new TimelineDataSeries();
packetGraph = new TimelineGraphView('packetCanvas','packetCanvas')
packetGraph.updateEndDate()
}
// (3.更新图表
function updateGraph(report,curTs,bitrate,packets){
console.log("数据是",curTs,bitrate,packets - lastResult.get(report.id).packetsSent)
bitrateSeries.addPoint(curTs,bitrate);
bitrateGraph.setDataSeries([bitrateSeries]);
bitrateGraph.updateEndDate();
packetSeries.addPoint(curTs,packets - lastResult.get(report.id).packetsSent);
packetGraph.setDataSeries([packetSeries]);
packetGraph.updateEndDate();
}
// 五、实时文件传输
btnSendFile.onclick=function (){
sendData();
btnSendFile.disabled = true;
};
btnAbort.onclick= function(){
if(fileReader && fileReader.readyState === 1){
console.log('abort read');
fileReader.abort();
}
};
fileInput.onchange = function(){
var file = fileInput.files[0];
if (!file) {
alert("没有选择文件 ")
console.log('No file chosen');
} else {
fileName = file.name;
fileSize = file.size;
fileType = file.type;
lastModifyTime = file.lastModified;
// 向信令服务器发送信息
sendMessage(roomid, {
type: 'fileinfo',
name: file.name,
size: file.size,
filetype: file.type,
lastmodify: file.lastModified
});
btnSendFile.disabled = false;
sendProgress.value = 0;
receiveProgress.value = 0;
receiveBuffer = [];
receivedSize = 0;
}
}
// (1.文件传输
function sendData(){
var offset = 0;
var chunkSize = 16384;
var file = fileInput.files[0];
console.log(`File is ${[file.name, file.size, file.type, file.lastModified].join(' ')}`);
// Handle 0 size files.
statusMessage.textContent = '';
downloadAnchor.textContent = '';
if (file.size === 0) {
bitrateDiv.innerHTML = '';
statusMessage.textContent = 'File is empty, please select a non-empty file';
return;
}
sendProgress.max = file.size;
fileReader = new FileReader();
fileReader.onerror = error => console.error('Error reading file:', error);
fileReader.onabort = event => console.log('File reading aborted:', event);
fileReader.onload = e => {
console.log('FileRead.onload ', e);
dc.send(e.target.result);
offset += e.target.result.byteLength;
sendProgress.value = offset;
if (offset < file.size) {
readSlice(offset);
}
}
var readSlice = o => {
console.log('readSlice ', o);
const slice = file.slice(offset, o + chunkSize);
fileReader.readAsArrayBuffer(slice);
};
readSlice(0);
}
// (2.接收文件
// 通过 a标签下载
function receivemsg(e){
console.log(`Received Message ${event.data.byteLength}`);
receiveBuffer.push(event.data);
receivedSize += event.data.byteLength;
receiveProgress.value = receivedSize;
console.log(`Received Message ${receivedSize}`);
if (receivedSize === fileSize) {
var received = new Blob(receiveBuffer);
receiveBuffer = [];
downloadAnchor.href = URL.createObjectURL(received);
downloadAnchor.download = fileName;
downloadAnchor.textContent =
`Click to download '${fileName}' (${fileSize} bytes)`;
downloadAnchor.style.display = 'block';
}
}
function dataChannelStateChange(){
if(dc){
var readyState = dc.readyState;
console.log('Send channel state is: ' + readyState);
if (readyState === 'open') {
fileInput.disabled = false;
} else {
fileInput.disabled = true;
}
}else{
fileInput.disabled = true;
}
}
</script>
</body>
</html>
信令服务器,和turn服务器,同 ”直播系统的连接“
十、IOS,Android平台开发
1.Android开发
1.获取权限
- camera, Audio, Internet权限
- 申请静态权限
- uses-permissionandroid:name=“android.permission.CAMERA”
- 申请动态权限
- void requestPermissions(String[] permissions,intrequestCode)
2.引入库(WebRTC,socket.io)
-
WebRTC
- implementation ‘org.webrtc:google-webrtc:1.0.+’
-
socket.io
- implementation ‘org.socket:socket.io-client:1.0.+’
-
easypermission
- implementation ‘pub.devrel:easypermissions:1.1.3’
3.信令处理
- 客户端消息
- join, leave, message(offer, answer, candidate)
- 服务端消息
- joined,leaved, other_joined, bye, full
4.处理流程
-
发消息
- socket.emit(‘join’, args)
-
收消息
- socket.on(“joined”,listener)
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7S4Zynqg-1661530821340)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619173739223.png)]
-
收发信令
- 实现Activity的切换
- 编写signal类使用socket.io收发信令
- 在CallActivity中使用signal对象
5.WebRTC Native开发逻辑
- 结构图
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RZVvIvqp-1661530821341)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619212000054.png)]
- 呼叫端时序图
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7zp4EtoL-1661530821341)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619212247524.png)]
- 应用启动后,首先创建工厂,然后由工厂创建PeerConnection, 然后创建CreateLocalMediaStream, CreateLocalVideoTrack, CreateLocalAudioTrack, 最后 将轨道添加到流里面。
- 然后将流添加到PeerConnection里面,有了视频,音频轨道 ,就进行媒体协商,将信令传输给远程端口,远程进行视频的渲染。
- 被呼叫端时序图
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wGlr0TWu-1661530821342)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619212936567.png)]
- 对端首先发送了一个offer, 当应用程序收到offer后,就会创建PeerConnection工厂,然后创建PeerConnection, 将信息发送给PeerConnection,
- 最后需要回复一个Answer
- 关闭时序图
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xXlE27pE-1661530821343)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619213403276.png)]
- 程序退出时,发送一个close, 关闭流,发送shutdown, 发送一个信令给对端,接收到响应后,就已经释放资源。
- 处理流程
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1kwNVQnL-1661530821343)(C:/Users/Icy-yun/AppData/Roaming/Typora/typora-user-images/image-20220619213724010.png)]
- 首先捕获数据源,然后封装成track, 最后 进行渲染。
- 重要的类
- PeerConnectionFactory
- PeerConnection
- MediaStream
- VideoCapture
- VideoSource/Video Track
- AudioSource/Audio Track
- 两个观察者
- PeerConnection.Observer
- SdpObserver
十一、实际项目运用
1.音频流的转换
1.blob转换成data数据
function changeRealUrl(blobUrl){
// var blob = new Blob(["Hello, world!"], { type: 'text/plain' });
// var blobUrl = URL.createObjectURL(blob);
var xhr = new XMLHttpRequest;
xhr.responseType = 'blob';
xhr.onload = function() {
var recoveredBlob = xhr.response;
var reader = new FileReader;
reader.onload = function() {
var blobAsDataUrl = reader.result;
//window.location = blobAsDataUrl;
console.log("结果是",blobAsDataUrl);
};
reader.readAsDataURL(recoveredBlob);
};
xhr.open('GET', blobUrl);
xhr.send();
}
2.项目经验
1.canvas验证码
2.bilibili视频播放,小米TS播放器
3.music音乐播放网站
4.Django音频识别项目
5.Django,php分片式视频播放
6.websocket聊天室
3.WebRTC播放器
1.渐近式和流式播放概念
渐近式:需要等待前面的数据加载完毕,才能访问后面的视频数据
流式:选择一个点,直接进行播放。
2.hls视频流播放 *
hls.js播放模块:video-dev/hls.js: HLS.js is a JavaScript library that plays HLS in browsers with support for MSE.
非常nice的开源项目:html5-dash-hls-rtmp: HTML5播放器、M3U8直播和点播、RTMP直播、低延迟、推流/播流地址鉴权、优化浏览器兼容性,HLS+扩展。
ffpeg转换成m3u8:ffmpeg处理mp4文件转换hls使用Video.js展示_Zsiyuan的博客-CSDN博客_ffmpeg转hls
安装hls.js
npm install --save hls.js
嵌入 HLS.js(官网示例代码)
在页面的脚本标签中直接包含 dist/hls.js 或 dist/hls.min.js。此设置将 HLS.js MSE 播放优先于 HTMLMediaElements 中 HLS 播放的原生浏览器支持:
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<!-- Or if you want the latest version from the main branch -->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@canary"></script> -->
<video id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
}
// HLS.js is not supported on platforms that do not have Media Source
// Extensions (MSE) enabled.
//
// When the browser has built-in HLS support (check using `canPlayType`),
// we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
// element through the `src` property. This is using the built-in support
// of the plain video element, without using HLS.js.
//
// Note: it would be more normal to wait on the 'canplay' event below however
// on Safari (where you are most likely to find built-in HLS support) the
// video.src URL must be on the user-driven white-list before a 'canplay'
// event will be emitted; the last video event that can be reliably
// listened-for when the URL is not on the white-list is 'loadedmetadata'.
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
}
</script>
替代设置(官网示例代码)
要首先检查本机浏览器支持然后回退到 HLS.js,请交换这些条件。请参阅此评论以了解一些权衡。
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<!-- Or if you want the latest version from the main branch -->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@canary"></script> -->
<video id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
//
// First check for native browser HLS support
//
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
//
// If no native HLS support, check if HLS.js is supported
//
} else if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
}
</script>
ffmpeg转换成ts,m3u8命令
在cmd窗口中输入以下命令:
ffmpeg -i xxxxxxx.mp4 -c:v libx264 -hls_time
5 -hls_list_size 0 -c:a aac -strict -2 -f hls xxxxxxx.m3u8
其中:
地址可以是相对路径,也可以是绝对路径
指令具体的含义可以参考官方文档
参数解析:
-re :该参数表示ffmpeg将会按照当前视频的播放速率进行转码,这样就不会说切片的速度和播放速度不一致。不加这个参数,切片速度会非常快,客户端还来不及播放,列表已经被更新了。
-hls_time n :设置每片的长度,默认值为2,单位为秒。
-hls_list_size n :设置m3u8文件播放列表保存的最多条目,设置为0会保存有所片信息,默认值为5。一般用于直播流,点播文件可以设置成0,即全部保存。
-hls_wrap n :设置多少片之后开始覆盖,设置为0则不会覆盖,默认值为0。这个选项能够避免在磁盘上存储过多的片,而且能够限制写入磁盘的最多的片的数量。
以上参数可以自己尝试调整看看效果。
执行成功以后如果在目标文件机夹下会出现若干ts文件和一个m3u8文件
加载页面后运行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<!--最新版本-->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@canary"></script> -->
<video controls id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = './test.m3u8';
// 添加兼容性
if(video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
video.addEventListener('loadedmetadata',function(){
video.play()
})
console.log("硬解") // 支持性高
}else if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
//谷歌浏览器只有在静音的前提下才能自动播放
video.volume = 0;
hls.on(Hls.Events.MANIFEST_PARSED,function(){
video.play()
})
console.log("软解")
} else{
alert("不支持播放该视频")
}
</script>
</body>
</html>
3.自建stun,turn服务器(略)
安装模块
npm install --save stun
用于请求服务器。
const stun = require("stun")
// 访问谷歌服务器,容易访问超时
// stun.l.google.com:19302
// 本地的stun服务器
// 127.0.0.1
stun.request('127.0.0.1',(err,res)=>{
if(err){
console.log(err)
}else{
console.log(res)
console.log("your ip",res.getXorAddress().address)
}
})
coturn软件
turnserver 开启服务器
用于在本地开启stun, turn服务器
使用webrtc的小伙伴一定是离不开stun/turn服务器的,而coturn是linux下免费的stun/turn服务器,下面小沃就简单教教大家如何配置这个stun/turn服务器。
1、安装
apt-get install coturn
2、配置
安装完成后,配置文件地址有两个一个是/etc/turnserver.conf,另一个是/etc/default/coturn
在debian9中,需要手动修改/etc/default/coturn,设置TURNSERVER_ENABLED=1,debian10中默认为1。
还需要修改/etc/turnserver.conf,设置turn服务器的账号密码,可设置多组,如:
user=username1:password1
user=username2:password2
user=username3:password3
设置realm,这里可以随意设置,但是必须设置,否则服务器不工作,如:
realm=www.worldflying.cn
网上别的文章还写着需要设置external-ip,但是小沃实际实验结论是不设置也没关系。
coturn的默认端口为3478,如果需要修改请在配置文件中修改。
3、重启服务
运行systemctl restart coturn
4.搭建rtmp服务器(略)
安装模块
arut/nginx-rtmp-module:基于 NGINX 的媒体流服务器
nginx
nginx-rtmp-module
rtmp协议,Adobe Flasher的退出渐渐淡出人们的视野。但仍然是流媒体最有效的传输方式。
nginx的config配置(示例)
rtmp {
server {
listen 1935;
chunk_size 4000;
# TV mode: one publisher, many subscribers
application mytv {
# enable live streaming
live on;
# record first 1K of stream
record all;
record_path /tmp/av;
record_max_size 1K;
# append current timestamp to each flv
record_unique on;
# publish only from localhost
allow publish 127.0.0.1;
deny publish all;
#allow play all;
}
# Transcoding (ffmpeg needed)
application big {
live on;
# On every pusblished stream run this command (ffmpeg)
# with substitutions: $app/${app}, $name/${name} for application & stream name.
#
# This ffmpeg call receives stream from this application &
# reduces the resolution down to 32x32. The stream is the published to
# 'small' application (see below) under the same name.
#
# ffmpeg can do anything with the stream like video/audio
# transcoding, resizing, altering container/codec params etc
#
# Multiple exec lines can be specified.
exec ffmpeg -re -i rtmp://localhost:1935/$app/$name -vcodec flv -acodec copy -s 32x32
-f flv rtmp://localhost:1935/small/${name};
}
application small {
live on;
# Video with reduced resolution comes here from ffmpeg
}
application webcam {
live on;
# Stream from local webcam
exec_static ffmpeg -f video4linux2 -i /dev/video0 -c:v libx264 -an
-f flv rtmp://localhost:1935/webcam/mystream;
}
application mypush {
live on;
# Every stream published here
# is automatically pushed to
# these two machines
push rtmp1.example.com;
push rtmp2.example.com:1934;
}
application mypull {
live on;
# Pull all streams from remote machine
# and play locally
pull rtmp://rtmp3.example.com pageUrl=www.example.com/index.html;
}
application mystaticpull {
live on;
# Static pull is started at nginx start
pull rtmp://rtmp4.example.com pageUrl=www.example.com/index.html name=mystream static;
}
# video on demand
application vod {
play /var/flvs;
}
application vod2 {
play /var/mp4s;
}
# Many publishers, many subscribers
# no checks, no recording
application videochat {
live on;
# The following notifications receive all
# the session variables as well as
# particular call arguments in HTTP POST
# request
# Make HTTP request & use HTTP retcode
# to decide whether to allow publishing
# from this connection or not
on_publish http://localhost:8080/publish;
# Same with playing
on_play http://localhost:8080/play;
# Publish/play end (repeats on disconnect)
on_done http://localhost:8080/done;
# All above mentioned notifications receive
# standard connect() arguments as well as
# play/publish ones. If any arguments are sent
# with GET-style syntax to play & publish
# these are also included.
# Example URL:
# rtmp://localhost/myapp/mystream?a=b&c=d
# record 10 video keyframes (no audio) every 2 minutes
record keyframes;
record_path /tmp/vc;
record_max_frames 10;
record_interval 2m;
# Async notify about an flv recorded
on_record_done http://localhost:8080/record_done;
}
# HLS
# For HLS to work please create a directory in tmpfs (/tmp/hls here)
# for the fragments. The directory contents is served via HTTP (see
# http{} section in config)
#
# Incoming stream must be in H264/AAC. For iPhones use baseline H264
# profile (see ffmpeg example).
# This example creates RTMP stream from movie ready for HLS:
#
# ffmpeg -loglevel verbose -re -i movie.avi -vcodec libx264
# -vprofile baseline -acodec libmp3lame -ar 44100 -ac 1
# -f flv rtmp://localhost:1935/hls/movie
#
# If you need to transcode live stream use 'exec' feature.
#
application hls {
live on;
hls on;
hls_path /tmp/hls;
}
# MPEG-DASH is similar to HLS
application dash {
live on;
dash on;
dash_path /tmp/dash;
}
}
}
# HTTP can be used for accessing RTMP stats
http {
server {
listen 8080;
# This URL provides RTMP statistics in XML
location /stat {
rtmp_stat all;
# Use this stylesheet to view XML as web page
# in browser
rtmp_stat_stylesheet stat.xsl;
}
location /stat.xsl {
# XML stylesheet to view RTMP stats.
# Copy stat.xsl wherever you want
# and put the full directory path here
root /path/to/stat.xsl/;
}
location /hls {
# Serve HLS fragments
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
root /tmp;
add_header Cache-Control no-cache;
}
location /dash {
# Serve DASH fragments
root /tmp;
add_header Cache-Control no-cache;
}
}
}
|