直播系统
# 直播的历史
技术形式: 电视直播 -> PC端直播 -> 游戏直播 -> 移动直播 -> VR直播 商业形式: 电视 -> 美女/真人秀场直播 -> PC游戏直播 -> 移动游戏直播 -> 户外,财经,旅游,金融,购物 -> 1V1辅导
# 基础原理
# 3模块
主播端: 服务器端: 播放端:
# 基本概念
# WebRTC (opens new window)
# 录制音视频
- 服务端录制: 防止客户端崩溃,但是占用带宽。
- 客户端录制: 可以不占用额外的带宽。
# 桌面共享
- 远程桌面: 二个桌面图片,有差异才上传到控制端处理。不同直播每秒固定发送多少帧。
- 桌面共享:
# 开源的实时视频服务器
winlinvip/srs. : 支持RTMP/WebRTC/HLS/HTTP-FLV/SRT,这个在国内比较成熟。
ZLMediaKit : RTSP/RTMP/HLS/HTTP, 功能性能更好,但是成熟的稍微低。
Janus-gateway : 开发的WebRTC服务器,旨在成为通用服务器。 功能局限。
Red5 是一个采用 Java 开发开源的 Flash 流媒体服务器。它支持:把音频(MP3)和视频(FLV)转换成播放流; 录制客户端播放流(只支持 FLV);共享对象;现场直播流发布;远程调用。Red5 使用 RTMP, RTMPT, RTMPS, 和 RTMPE 作为流媒体传输协议,在其自带的一些示例中演示了在线录制,flash 流媒体播放,在线聊天,视频会议等一些基本功能。
Licode : node.js 的服务器获得。
mediasoup 是完全兼容webrtc的高性能sfu服务器,它由ts语言实现的master端和基于libuv的c++语言实现的work模块组成。
# SRS流媒体服务(四)WebRTC实现实时视频通话和低延时互动直播 (opens new window)
<template>
<div id="box">
<!-- 设置自动播放,否则不会显示视频流画面 -->
<video id="video" autoplay></video>
<div id="btn">
<button ref="button_one" @click="publish">开始直播</button>
<button ref="button_two" @click="close" >停止直播</button>
<button ref="button_three" @click="stopAudio" >关闭声音</button>
<button ref="button_four" @click="startAudio" >开启声音</button>
<button ref="button_five" @click="play" >播放直播</button>
</div>
<video id="video2" autoplay></video>
</div>
</template>
<script setup>
import { onMounted,ref } from 'vue';
// 定义全局属性
let videoStream = null;
let videoElement = null;
// 全局的RTCPeerConnection
let pc = null;
// 全局音频轨道,用于RTCRtpSender发送和停止对应轨道
let audioTrack = null;
// 全局的RTCRtpSender
let audioSender = null;
// 获取按钮元素
let button_one = ref(null);
let button_two = ref(null);
let button_three = ref(null);
let button_four = ref(null);
let button_five = ref(null);
const publish = async()=>{
if(pc!==null&& pc!==undefined){
console.log("已开始推流");
return ;
}
var httpURL = "http://192.168.5.104:1985/rtc/v1/publish/";
var webRTCURL = "webRTC://192.168.5.104/live/1";
var constraints = {
audio: {
echoCancellation : true, // 回声消除
noiseSuppression : true, // 降噪
autoGainControl : true // 自动增益
},
video: {
frameRate : { min : 30 }, // 最小帧率
width : { min : 640, ideal : 1080}, // 宽度
height : { min : 360, ideal : 720}, // 高度
aspectRadio : 16/9 // 宽高比
}
}
// 通过摄像头、麦克风获取音视频流
videoStream = await navigator.mediaDevices.getUserMedia(constraints);
// 获取video元素
videoElement = document.querySelector("#video")
//video播放流数据
videoElement.srcObject = videoStream;
// 静音
videoElement.volume=0;
// 创建RTC连接对象
pc = new RTCPeerConnection();
// RTCPeerConnection方法addTransceiver()创建一个新的RTCRtpTransceiver,并将其添加到与RTCPeerConnection关联的收发器集中。
// 每个收发器代表一个双向流,RTCRtpSender和RTCRtpReceiver都与之相关联。
// 注意添加顺序为audio、video,后续RTCPeerConnection创建offer时SDP的m线顺序遵循此顺序创建,SRS自带的信令服务器响应的SDP中m线总是先audio后video。
// 若本端SDP和远端SDP中的m线顺序不一直,则设置远端描述时会异常,显示offer中的m线与answer中的m线顺序不匹配
pc.addTransceiver("audio", {direction: "recvonly"});
pc.addTransceiver("video", {direction: "recvonly"});
// 遍历getUserMedia()获取到的流数据,拿到其中的音频轨道和视频轨道,加入到RTCPeerConnection连接的音频轨道和视频轨道中
videoStream.getTracks().forEach((track)=>{
pc.addTrack(track);
});
// 创建本端offer
var offer = await pc.createOffer();
// 设置本端
await pc.setLocalDescription(offer);
var data = {
"api": httpURL,
"streamurl":webRTCURL,
"sdp":offer.sdp
}
// SDP交换,请求SRS自带的信令服务器
httpApi(httpURL,data).then(async(data)=>{
console.log("answer",data);
// 设置远端描述,开始连接
await pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: data.sdp}));
button_one.value.disabled=true;
button_two.value.disabled=false;
button_three.value.disabled=false;
button_five.value.disabled=false;
}).catch((data)=>{
if(data.code===400){
console.log("SDP交换失败");
}
});
}
const play = async()=>{
var httpURL = "http://192.168.5.104:1985/rtc/v1/play/";
var webRTCURL = "webRTC://192.168.5.104/live/1";
// 创建RTCPeerConnection连接对象
var pc = new RTCPeerConnection();
// 创建媒体流对象
var stream = new MediaStream();
// 获取播放流的容器video
var videoElement2 = document.querySelector("#video2");
// 监听流
pc.ontrack = (event)=>{
// 监听到的流加入MediaStream对象中让video播放
stream.addTrack(event.track);
videoElement2.srcObject = stream;
}
// RTCPeerConnection方法addTransceiver()创建一个新的RTCRtpTransceiver,并将其添加到与RTCPeerConnection关联的收发器集中。
// 每个收发器代表一个双向流,RTCRtpSender和RTCRtpReceiver都与之相关联。
// 注意添加顺序为audio、video,后续RTCPeerConnection创建offer时SDP的m线顺序遵循此顺序创建,SRS自带的信令服务器响应的SDP中m线总是先audio后video。
// 若本端SDP和远端SDP中的m线顺序不一直,则设置远端描述时会异常,显示offer中的m线与answer中的m线顺序不匹配
pc.addTransceiver("audio", {direction: "recvonly"});
pc.addTransceiver("video", {direction: "recvonly"});
var offer =await pc.createOffer();
await pc.setLocalDescription(offer)
var data = {
"api": httpURL,
"streamurl":webRTCURL,
"sdp":offer.sdp
}
// SDP交换,请求SRS自带的信令服务器
httpApi(httpURL,data).then(async(data)=>{
console.log("answer",data);
// 设置远端描述,开始连接
await pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: data.sdp}));
button_five.value.disabled=true;
}).catch((data)=>{
if(data.code===400){
console.log("SDP交换失败");
}
});
}
// 关闭连接
const close = ()=>{
if(pc!==null&&pc!==undefined){
pc.close();
pc = null;
button_one.value.disabled=false;
button_two.value.disabled=true;
button_three.value.disabled=true;
button_four.value.disabled=true;
button_five.value.disabled=true;
}
}
// 关闭音频
const stopAudio = ()=>{
if(pc!==null&&pc!==undefined){
// RTCPeerConnection方法getSenders()返回RTCRtpSender对象的数组,
// 每个对象代表负责传输一个轨道的数据的RTP发送器。
// sender对象提供了检查和控制音轨数据的编码和传输的方法和属性。
pc.getSenders().forEach((sender)=>{
if(sender.track!==null&&sender.track.kind==="audio"){
// 拿到音频轨道
audioTrack = sender.track;
// 拿到音频轨道发送者对象RTCRtpSender
audioSender = sender;
// RTCRtpSender的replaceTrack()可以在无需重新媒体协商的情况下用另一个媒体轨道更换当前正在发送轨道
// 参数为空则将当前正在发送的轨道停止,比如关闭音频,再次开启时将音频轨道作为参数传入
audioSender.replaceTrack(null);
button_three.value.disabled=true;
button_four.value.disabled=false;
}
});
}
}
// 开启音频
const startAudio = ()=>{
console.log(audioSender);
if(pc!==null&&pc!==undefined){
if(audioSender.track===null){
audioSender.replaceTrack(audioTrack);
button_three.value.disabled=false;
button_four.value.disabled=true;
}
}
}
const httpApi = (httpURL,data)=>{
var promise = new Promise((resolve,reject)=>{
var xhr = new XMLHttpRequest();
xhr.open('POST', httpURL, true);
xhr.setRequestHeader('Content-type', 'application/json');
xhr.send(JSON.stringify(data));
xhr.onload = ()=>{
if (xhr.readyState !== xhr.DONE) reject(xhr);
if (xhr.status !== 200 && xhr.status !== 201) reject(xhr) ;
var data = JSON.parse(xhr.responseText);
if(data.code===0){
resolve(data);
}else{
reject(data)
}
}
});
return promise;
}
onMounted(()=>{
button_one.value.disabled=false;
button_two.value.disabled=true;
button_three.value.disabled=true;
button_four.value.disabled=true;
button_five.value.disabled=true;
});
</script>
<style lang="scss">
*{
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
#box{
width: 100%;
text-align: center;
}
video{
background-color: black;
width: 500px;
height: 400px;
object-fit: cover;
}
#btn{
width: 80%;
height: 100px;
display: flex;
margin:10px 10%;
}
button{
flex: 1;
height: 100px;
background-color: aqua;
border-radius: 20px;
margin-left: 10px;
}
button:nth-child(1){
margin-left: 0;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# 基于COTURN实现WebRTC的P2P项目 (opens new window)
pc.addIceCandidate(candidate); 添加ICE候选者
// 'use strict'
var localVideo = document.querySelector('video#localvideo');
var remoteVideo = document.querySelector('video#remotevideo');
var btnConn = document.querySelector('button#connserver');
var btnLeave = document.querySelector('button#leave');
var offer = document.querySelector('textarea#offer');
var answer = document.querySelector('textarea#answer');
//把coturn信息填上,用于交换candiate
var pcConfig = {
'iceServers': [{
'urls': 'turn:ghr2.top:3478',
'credential': "aaaa",
'username': "aaaa"
}]
};
var localStream = null;
var remoteStream = null;
var pc = null;
var roomid;
var socket = null;
var offerdesc = null;
var state = 'init';
// // 以下代码是从网上找的
// //=========================================================================================
// //如果返回的是false说明当前操作系统是手机端,如果返回的是true则说明当前的操作系统是电脑端
// function IsPC() {
// var userAgentInfo = navigator.userAgent;
// var Agents = ["Android", "iPhone","SymbianOS", "Windows Phone","iPad", "iPod"];
// var flag = true;
// for (var v = 0; v < Agents.length; v++) {
// if (userAgentInfo.indexOf(Agents[v]) > 0) {
// flag = false;
// break;
// }
// }
// return flag;
// }
// //如果返回true 则说明是Android false是ios
// function is_android() {
// var u = navigator.userAgent, app = navigator.appVersion;
// var isAndroid = u.indexOf('Android') > -1 || u.indexOf('Linux') > -1; //g
// var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端
// if (isAndroid) {
// //这个是安卓操作系统
// return true;
// }
// if (isIOS) {
// //这个是ios操作系统
// return false;
// }
// }
// //获取url参数
// function getQueryVariable(variable)
// {
// var query = window.location.search.substring(1);
// var vars = query.split("&");
// for (var i=0;i<vars.length;i++) {
// var pair = vars[i].split("=");
// if(pair[0] == variable){return pair[1];}
// }
// return(false);
// }
//=======================================================================
function sendMessage(roomid, data){
console.log('send message to other end', roomid, data);
if(!socket){
console.log('socket is null');
}
socket.emit('message', roomid, data);
}
function conn(){
socket = io.connect();
socket.on('joined', (roomid, id) => {
console.log('receive joined message!', roomid, id);
state = 'joined'
//如果是多人的话,第一个人不该在这里创建peerConnection
//都等到收到一个otherjoin时再创建
//所以,在这个消息里应该带当前房间的用户数
//
//create conn and bind media track
createPeerConnection();
bindTracks();
btnConn.disabled = true;
btnLeave.disabled = false;
console.log('receive joined message, state=', state);
});
socket.on('otherjoin', (roomid) => {
console.log('receive joined message:', roomid, state);
//如果是多人的话,每上来一个人都要创建一个新的 peerConnection
//
if(state === 'joined_unbind'){
createPeerConnection();
bindTracks();
}
state = 'joined_conn';
call();
console.log('receive other_join message, state=', state);
});
socket.on('full', (roomid, id) => {
console.log('receive full message', roomid, id);
hangup();
closeLocalMedia();
state = 'leaved';
console.log('receive full message, state=', state);
alert('the room is full!');
});
socket.on('leaved', (roomid, id) => {
console.log('receive leaved message', roomid, id);
state='leaved'
socket.disconnect();
console.log('receive leaved message, state=', state);
btnConn.disabled = false;
btnLeave.disabled = true;
});
socket.on('bye', (roomid, id) => {
console.log('receive bye message', roomid, id);
//state = 'created';
//当是多人通话时,应该带上当前房间的用户数
//如果当前房间用户不小于 2, 则不用修改状态
//并且,关闭的应该是对应用户的peerconnection
//在客户端应该维护一张peerconnection表,它是
//一个key:value的格式,key=userid, value=peerconnection
state = 'joined_unbind';
hangup();
offer.value = '';
answer.value = '';
console.log('receive bye message, state=', state);
});
socket.on('disconnect', (socket) => {
console.log('receive disconnect message!', roomid);
if(!(state === 'leaved')){
hangup();
closeLocalMedia();
}
state = 'leaved';
});
socket.on('message', (roomid, data) => {
console.log('receive message!', roomid, data);
if(data === null || data === undefined){
console.error('the message is invalid!');
return;
}
if(data.hasOwnProperty('type') && data.type === 'offer') {
offer.value = data.sdp;
pc.setRemoteDescription(new RTCSessionDescription(data));
//create answer
pc.createAnswer()
.then(getAnswer)
.catch(handleAnswerError);
}else if(data.hasOwnProperty('type') && data.type == 'answer'){
answer.value = data.sdp;
pc.setRemoteDescription(new RTCSessionDescription(data));
}else if (data.hasOwnProperty('type') && data.type === 'candidate'){
var candidate = new RTCIceCandidate({
sdpMLineIndex: data.label,
candidate: data.candidate
});
pc.addIceCandidate(candidate);
}else{
console.log('the message is invalid!', data);
}
});
//roomid = getQueryVariable('room');
socket.emit('join', roomid);
return true;
}
function connSignalServer(){
//开启本地视频
start();
return true;
}
function getMediaStream(stream){
if(localStream){
stream.getAudioTracks().forEach((track)=>{
localStream.addTrack(track);
stream.removeTrack(track);
});
}else{
localStream = stream;
}
localVideo.srcObject = localStream;
//这个函数的位置特别重要,
//一定要放到getMediaStream之后再调用
//否则就会出现绑定失败的情况
//
//setup connection
conn();
//btnStart.disabled = true;
//btnCall.disabled = true;
//btnHangup.disabled = true;
}
// function getDeskStream(stream){
// localStream = stream;
// }
function handleError(err){
console.error('Failed to get Media Stream!', err);
}
function shareDesk(){
if(IsPC()){
navigator.mediaDevices.getDisplayMedia({video: true})
.then(getDeskStream)
.catch(handleError);
return true;
}
return false;
}
function start(){
if(!navigator.mediaDevices ||
!navigator.mediaDevices.getUserMedia){
console.error('the getUserMedia is not supported!');
return;
}else {
var constraints;
constraints = {
video: true,
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
}
navigator.mediaDevices.getUserMedia(constraints)
.then(getMediaStream)
.catch(handleError);
}
}
function getRemoteStream(e){
remoteStream = e.streams[0];
remoteVideo.srcObject = e.streams[0];
}
function handleOfferError(err){
console.error('Failed to create offer:', err);
}
function handleAnswerError(err){
console.error('Failed to create answer:', err);
}
function getAnswer(desc){
pc.setLocalDescription(desc);
answer.value = desc.sdp;
//send answer sdp
sendMessage(roomid, desc);
}
function getOffer(desc){
pc.setLocalDescription(desc);
offer.value = desc.sdp;
offerdesc = desc;
//send offer sdp
sendMessage(roomid, offerdesc);
}
function createPeerConnection(){
//如果是多人的话,在这里要创建一个新的连接.
//新创建好的要放到一个map表中。
//key=userid, value=peerconnection
console.log('create RTCPeerConnection!');
if(!pc){
pc = new RTCPeerConnection(pcConfig);
pc.onicecandidate = (e)=>{
if(e.candidate) {
sendMessage(roomid, {
type: 'candidate',
label:event.candidate.sdpMLineIndex,
id:event.candidate.sdpMid,
candidate: event.candidate.candidate
});
}else{
console.log('this is the end candidate');
}
}
pc.ontrack = getRemoteStream;
}else {
console.warning('the pc have be created!');
}
return;
}
//绑定永远与 peerconnection在一起,
//所以没必要再单独做成一个函数
function bindTracks(){
console.log('bind tracks into RTCPeerConnection!');
if( pc === null || pc === undefined) {
console.error('pc is null or undefined!');
return;
}
if(localStream === null || localStream === undefined) {
console.error('localstream is null or undefined!');
return;
}
//add all track into peer connection
localStream.getTracks().forEach((track)=>{
pc.addTrack(track, localStream);
});
}
function call(){
if(state === 'joined_conn'){
var offerOptions = {
offerToRecieveAudio: 1,
offerToRecieveVideo: 1
}
pc.createOffer(offerOptions)
.then(getOffer)
.catch(handleOfferError);
}
}
function hangup(){
if(pc) {
offerdesc = null;
pc.close();
pc = null;
}
}
function closeLocalMedia(){
if(localStream && localStream.getTracks()){
localStream.getTracks().forEach((track)=>{
track.stop();
});
}
localStream = null;
}
function leave() {
if(socket){
socket.emit('leave', roomid); //notify server
}
hangup();
closeLocalMedia();
offer.value = '';
answer.value = '';
btnConn.disabled = false;
btnLeave.disabled = true;
}
btnConn.onclick = connSignalServer
btnLeave.onclick = leave;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# webrtc的候选机制
在WebRTC中,初始化RTCSessionDescription对象时,sdp内容是可以包含ICE候选信息的。在创建offer或answer的过程中,生成的SDP(Session Description Protocol)字符串中通常会包含ICE候选信息,这些信息是由浏览器的ICE引擎自动收集并填充的。
当通过pc.setLocalDescription()或pc.setRemoteDescription()方法设置SDP时,如果SDP中已经包含了ICE候选信息(即包含了a=candidate行),浏览器将会解析这些ICE候选并自动将其添加到RTCPeerConnection的ICE管理器中,进而参与ICE流程以确定最佳连接路径。
也就是说,在正常情况下,你无需手动逐个调用pc.addIceCandidate(candidate)来添加候选,除非你是在某个特殊场景下,比如非标准流程中动态获取到ICE候选,这时才需要手动添加。
然而,需要注意的是,ICE候选信息通常不是一次性全部出现在初始SDP中的,尤其是对于被动ICE候选(如TCP候选),这些候选可能在初始SDP之后产生,因此仍需要监听icecandidate事件,并在接收到新的候选时调用addIceCandidate()方法。但是在常规流程中,大部分ICE候选会在SDP协商阶段随着offer/answer一起交换。