This commit is contained in:
acgist
2022-11-26 12:20:05 +08:00
parent 0f339f4aea
commit 474be08cc9
21 changed files with 493 additions and 362 deletions

View File

@@ -84,7 +84,7 @@ taoyao:
- stun:stun4.l.google.com:19302
- stun:stun.stunprotocol.org:3478
# 信令服务配置
host: localhost
host: 192.168.1.100
port: ${server.port:8888}
schema: wss
websocket: /websocket.signal

View File

@@ -1,5 +1,6 @@
/** 桃夭WebRTC终端核心功能 */
/** 兼容 */
const RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate || window.webkitRTCIceCandidate;
const RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
const RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription || window.webkitRTCSessionDescription;
/** 默认音频配置 */
@@ -70,6 +71,12 @@ const signalProtocol = {
},
/** 媒体信令 */
media: {
/** 发布 */
publish: 5000,
/** 订阅 */
subscribe: 5002,
/** 候选 */
candidate: 5004
},
/** 终端信令 */
client: {
@@ -108,12 +115,12 @@ const signalProtocol = {
return Date.now() + '' + this.index;
},
/** 生成信令消息 */
buildProtocol: function(sn, pid, body, id) {
buildProtocol: function(pid, body, id) {
let message = {
header: {
v: signalConfig.version,
id: id || this.buildId(),
sn: sn,
sn: signalConfig.sn,
pid: pid,
},
'body': body
@@ -156,9 +163,8 @@ const signalChannel = {
clearTimeout(self.heartbeatTimer);
}
self.heartbeatTimer = setTimeout(function() {
if (self.channel && self.channel.readyState == WebSocket.OPEN) {
if (self.channel && self.channel.readyState === WebSocket.OPEN) {
self.push(signalProtocol.buildProtocol(
signalConfig.sn,
signalProtocol.client.heartbeat,
{
signal: 100,
@@ -183,7 +189,6 @@ const signalChannel = {
console.debug('打开信令通道', e);
// 注册终端
self.push(signalProtocol.buildProtocol(
signalConfig.sn,
signalProtocol.client.register,
{
ip: null,
@@ -252,7 +257,7 @@ const signalChannel = {
}
self.lockReconnect = true;
// 关闭旧的通道
if(self.channel && self.channel.readyState == WebSocket.OPEN) {
if(self.channel && self.channel.readyState === WebSocket.OPEN) {
self.channel.close();
self.channel = null;
}
@@ -292,6 +297,15 @@ const signalChannel = {
defaultCallback: function(data) {
console.debug('没有适配信令消息默认处理', data);
switch(data.header.pid) {
case signalProtocol.media.publish:
this.defaultMediaPublish(data);
break;
case signalProtocol.media.subscribe:
this.defaultMediaSubscribe(data);
break;
case signalProtocol.media.candidate:
this.defaultMediaCandidate(data);
break;
case signalProtocol.client.register:
break;
case signalProtocol.client.config:
@@ -305,6 +319,7 @@ const signalChannel = {
case signalProtocol.meeting.create:
break;
case signalProtocol.meeting.enter:
this.defaultMeetingEnter(data);
break;
case signalProtocol.platform.error:
console.error('信令发生错误', data);
@@ -322,8 +337,43 @@ const signalChannel = {
location.reload();
},
/** 默认媒体回调 */
defaultMediaPublish: function(data) {
this.taoyao.localMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body));
},
defaultMediaSubscribe: function(data) {
let self = this;
const from = data.body.from;
let remote = this.taoyao.remoteClientFilter(from);
if(!remote) {
remote = new TaoyaoClient(from);
this.taoyao.remoteClient.push(remote);
}
self.taoyao.remoteMediaChannel.setRemoteDescription(new RTCSessionDescription(data.body));
self.taoyao.remoteMediaChannel.createAnswer().then(description => {
console.debug('Local Create Answer', description);
self.taoyao.remoteMediaChannel.setLocalDescription(description);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.publish,
{
to: from,
sdp: description.sdp,
type: description.type
}
));
});
},
defaultMediaCandidate: function(data) {
if(
!data.body.candidate ||
!data.body.candidate.candidate ||
!data.body.candidate.sdpMid ||
!data.body.candidate.sdpMLineIndex
) {
console.warn('候选缺失要素', data);
return;
}
let candidate = new RTCIceCandidate(data.body.candidate);
this.taoyao.remoteMediaChannel.addIceCandidate(candidate);
},
/** 会议默认回调 */
defaultMeetingEnter: function(data) {
@@ -347,6 +397,9 @@ function TaoyaoClient(
this.audioStatus = false;
this.videoStatus = false;
this.recordStatus = false;
/** 重置 */
this.reset = function() {
}
/** 播放视频 */
this.play = async function() {
await this.video.play();
@@ -368,29 +421,47 @@ function TaoyaoClient(
return this;
};
/** 设置视频对象 */
this.buildVideo = async function(videoId, stream) {
this.buildVideo = async function(videoId, stream, track) {
if(!this.video) {
this.video = document.getElementById(videoId);
}
await this.buildStream(stream);
await this.buildStream(stream, track);
return this;
};
/** 设置媒体流 */
this.buildStream = async function(stream) {
this.buildStream = async function(stream, track) {
if(stream) {
this.stream = stream;
this.video.srcObject = stream;
let audioTrack = stream.getAudioTracks();
let videoTrack = stream.getVideoTracks();
if(audioTrack && audioTrack.length) {
this.audioTrack = audioTrack;
this.audioStatus = true;
if(track) {
if(!this.stream) {
this.stream = stream;
this.video.srcObject = this.stream;
}
// TODO删除旧的
this.stream.addTrack(track);
if(track.kind === 'audio') {
this.audioTrack = track;
this.audioStatus = true;
} else if(track.kind === 'video') {
this.videoTrack = track;
this.videoStatus = true;
}
await this.video.load();
} else {
this.stream = stream;
this.video.srcObject = stream;
let audioTrack = stream.getAudioTracks();
let videoTrack = stream.getVideoTracks();
if(audioTrack && audioTrack.length) {
this.audioTrack = audioTrack;
this.audioStatus = true;
}
if(videoTrack && videoTrack.length) {
this.videoTrack = videoTrack;
this.videoStatus = true;
}
await this.video.load();
}
if(videoTrack && videoTrack.length) {
this.videoTrack = videoTrack;
this.videoStatus = true;
}
console.debug('设置媒体流', this.stream, this.audioTrack, this.videoTrack);
console.debug('设置媒体流', this.video, this.stream, this.audioTrack, this.videoTrack);
await this.play();
}
return this;
@@ -430,51 +501,6 @@ function Taoyao(
this.remoteMediaChannel = null;
/** 信令通道 */
this.signalChannel = null;
/** 检查设备 */
this.checkDevice = function() {
let self = this;
if(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia &&
navigator.mediaDevices.enumerateDevices
) {
navigator.mediaDevices.enumerateDevices()
.then(list => {
let audioDevice = false;
let videoDevice = false;
list.forEach(v => {
console.debug('终端媒体设备', v.kind, v.label);
switch(v.kind) {
case 'audioinput':
audioDevice = true;
break;
case 'videoinput':
videoDevice = true;
break;
default:
console.debug('没有适配设备', v.kind, v.label);
break;
}
});
if(!audioDevice) {
console.warn('终端没有音频输入设备');
self.audioEnabled = false;
}
if(!videoDevice) {
console.warn('终端没有视频输入设备');
self.videoEnabled = false;
}
})
.catch(e => {
console.error('检查终端设备异常', e);
self.videoEnabled = false;
self.videoEnabled = false;
});
} else {
throw new Error('不支持的终端设备');
}
return this;
};
/** 媒体配置 */
this.configMedia = function(audio = {}, video = {}) {
this.audioConfig = {...this.audioConfig, ...audio};
@@ -502,21 +528,60 @@ function Taoyao(
};
/** 打开本地媒体 */
this.buildLocalMedia = function() {
console.debug('打开终端媒体', this.audioConfig, this.videoConfig);
let self = this;
this.checkDevice();
return new Promise((resolve, reject) => {
navigator.mediaDevices.getUserMedia({
audio: self.audioConfig,
video: self.videoConfig
})
.then(resolve)
.catch(reject);
// 兼容旧版
// navigator.getUserMedia({
// audio: self.audioConfig,
// video: self.videoConfig
// }, resolve, reject);
if(
navigator.mediaDevices &&
navigator.mediaDevices.getUserMedia &&
navigator.mediaDevices.enumerateDevices
) {
navigator.mediaDevices.enumerateDevices()
.then(list => {
let audioEnabled = false;
let videoEnabled = false;
list.forEach(v => {
console.debug('终端媒体设备', v, v.kind, v.label);
switch(v.kind) {
case 'audioinput':
audioEnabled = true;
break;
case 'videoinput':
videoEnabled = true;
break;
default:
console.debug('没有适配设备', v.kind, v.label);
break;
}
});
if(!audioEnabled) {
console.warn('终端没有音频输入设备');
self.audioEnabled = false;
}
if(!videoEnabled) {
console.warn('终端没有视频输入设备');
self.videoEnabled = false;
}
console.debug('打开终端媒体', self.audioEnabled, self.videoEnabled, self.audioConfig, self.videoConfig);
navigator.mediaDevices.getUserMedia({
audio: self.audioEnabled ? self.audioConfig : false,
video: self.videoEnabled ? self.videoConfig : false
})
.then(resolve)
.catch(reject);
// 兼容旧版
// navigator.getUserMedia({
// audio: self.audioConfig,
// video: self.videoConfig
// }, resolve, reject);
})
.catch(e => {
console.error('检查终端设备异常', e);
self.videoEnabled = false;
self.videoEnabled = false;
});
} else {
throw new Error('不支持的终端设备');
}
});
};
/** 远程终端过滤 */
@@ -537,6 +602,7 @@ function Taoyao(
};
/** 打开媒体通道 */
this.buildMediaChannel = async function(localVideoId, stream) {
let self = this;
// 本地视频
this.localClient = new TaoyaoClient(signalConfig.sn);
await this.localClient.buildVideo(localVideoId, stream);
@@ -548,61 +614,92 @@ function Taoyao(
if(this.localClient.videoTrack) {
this.localClient.videoTrack.forEach(v => this.localMediaChannel.addTrack(v, this.localClient.stream));
}
this.localMediaChannel.ontrack = this.localMediaChannelTrack;
this.localMediaChannel.ondatachannel = this.localMediaChannelDataChannel;
this.localMediaChannel.onicecandidate = this.localMediaChannelIceCandidate;
this.localMediaChannel.ontrack = function(e) {
console.debug('Local Media Track', e);
};
this.localMediaChannel.ondatachannel = function(channel) {
channel.onopen = function() {
console.debug('Local DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('Local DataChannel Message', data);
}
channel.onclose = function() {
console.debug('Local DataChannel Close');
}
channel.onerror = function(e) {
console.debug('Local DataChannel Error', e);
}
};
this.localMediaChannel.onicecandidate = function(e) {
let sns = self.remoteClient.filter(v => v.video === null).map(v => v.sn);
console.debug('Local ICE Candidate', sns, e);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.candidate,
{
sns: sns,
candidate: e.candidate
}
));
};
// 远程通道
this.remoteMediaChannel = new RTCPeerConnection(defaultRPCConfig);
this.remoteMediaChannel.ontrack = this.remoteMediaChannelTrack;
this.remoteMediaChannel.ondatachannel = this.remoteMediaChannelDataChannel;
this.remoteMediaChannel.onicecandidate = this.remoteMediaChannelIceCandidate;
this.remoteMediaChannel.ontrack = function(e) {
console.debug('Remote Media Track', e);
// TODO匹配
let remote = self.remoteClient[0];
remote.buildVideo(remote.sn, e.streams[0], e.track);
};
this.remoteMediaChannel.ondatachannel = function(channel) {
channel.onopen = function() {
console.debug('Remote DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('Remote DataChannel Message', data);
}
channel.onclose = function() {
console.debug('Remote DataChannel Close');
}
channel.onerror = function(e) {
console.debug('Remote DataChannel Error', e);
}
};
this.remoteMediaChannel.onicecandidate = function(e) {
let sns = self.remoteClient.filter(v => v.video === null).map(v => v.sn);
console.debug('Remote ICE Candidate', sns, e);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.candidate,
{
sns: sns,
candidate: e.candidate
}
));
};
console.debug('打开媒体通道', this.localMediaChannel, this.remoteMediaChannel);
return this;
};
/** 本地 */
this.localMediaChannelTrack = function() {
};
this.localMediaChannelDataChannel = function(channel) {
channel.onopen = function() {
console.debug('DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('DataChannel Message', data);
}
channel.onclose = function() {
console.debug('DataChannel Close');
}
channel.onerror = function(e) {
console.debug('DataChannel Error', e);
}
};
this.localMediaChannelIceCandidate = function() {
};
/** 远程 */
this.localMediaChannelTrack = function() {
};
this.localMediaChannelDataChannel = function(channel) {
channel.onopen = function() {
console.debug('DataChannel Open');
}
channel.onmessage = function(data) {
console.debug('DataChannel Message', data);
}
channel.onclose = function() {
console.debug('DataChannel Close');
}
channel.onerror = function(e) {
console.debug('DataChannel Error', e);
}
};
this.localMediaChannelIceCandidate = function() {
};
/** 媒体信令 */
this.mediaSubscribe = function(sn, callback) {
let self = this;
let remote = self.remoteClientFilter(sn);
if(remote) {
remote.reset();
} else {
remote = new TaoyaoClient(sn);
this.remoteClient.push(remote);
}
if(self.webrtc.model === 'MESH') {
self.localMediaChannel.createOffer().then(description => {
console.debug('Local Create Offer', description);
self.localMediaChannel.setLocalDescription(description);
self.push(signalProtocol.buildProtocol(
signalProtocol.media.subscribe,
{
to: sn,
sdp: description.sdp,
type: description.type
}
), callback);
});
}
};
@@ -610,14 +707,12 @@ function Taoyao(
this.meetingCreate = function(callback) {
let self = this;
self.push(signalProtocol.buildProtocol(
signalConfig.sn,
signalProtocol.meeting.create,
signalProtocol.meeting.create
), callback);
}
this.meetingEnter = function(id, callback) {
let self = this;
self.push(signalProtocol.buildProtocol(
signalConfig.sn,
signalProtocol.meeting.enter,
{
id: id
@@ -625,206 +720,3 @@ function Taoyao(
), callback);
};
};
/*
var peer;
var socket; // WebSocket
var supportStream = false; // 是否支持使用数据流
var localVideo; // 本地视频
var localVideoStream; // 本地视频流
var remoteVideo; // 远程视频
var remoteVideoStream; // 远程视频流
var initiator = false; // 是否已经有人在等待
var started = false; // 是否开始
var channelReady = false; // 是否打开WebSocket通道
// 初始
function initialize() {
console.log("初始聊天");
// 获取视频
localVideo = document.getElementById("localVideo");
remoteVideo = document.getElementById("remoteVideo");
supportStream = "srcObject" in localVideo;
// 显示状态
if (initiator) {
setNotice("开始连接");
} else {
setNotice("加入聊天https://www.acgist.com/demo/video/?oid=FFB85D84AC56DAF88B7E22AFFA7533D3");
}
// 打开WebSocket
openChannel();
// 创建终端媒体
buildUserMedia();
}
function openChannel() {
console.log("打开WebSocket");
socket = new WebSocket("wss://www.acgist.com/video.ws/FFB85D84AC56DAF88B7E22AFFA7533D3");
socket.onopen = channelOpened;
socket.onmessage = channelMessage;
socket.onclose = channelClosed;
socket.onerror = channelError;
}
function channelOpened() {
console.log("打开WebSocket成功");
channelReady = true;
}
function channelMessage(message) {
console.log("收到消息:" + message.data);
var msg = JSON.parse(message.data);
if (msg.type === "offer") { // 处理Offer消息
if (!initiator && !started) {
connectPeer();
}
peer.setRemoteDescription(new RTCSessionDescription(msg));
peer.createAnswer().then(buildLocalDescription);
} else if (msg.type === "answer" && started) { // 处理Answer消息
peer.setRemoteDescription(new RTCSessionDescription(msg));
} else if (msg.type === "candidate" && started) {
var candidate = new RTCIceCandidate({
sdpMLineIndex : msg.label,
candidate : msg.candidate
});
peer.addIceCandidate(candidate);
} else if (msg.type === "bye" && started) {
onRemoteClose();
setNotice("对方已断开!");
} else if(msg.type === "nowaiting") {
onRemoteClose();
setNotice("对方已离开!");
}
}
function channelClosed() {
console.log("关闭WebSocket");
openChannel(); // 重新打开WebSocket
}
function channelError(event) {
console.log("WebSocket异常" + event);
}
function buildUserMedia() {
console.log("获取终端媒体");
if(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices.getUserMedia({
"audio" : true,
"video" : true
})
.then(onUserMediaSuccess)
.catch(onUserMediaError);
} else {
navigator.getUserMedia({
"audio" : true,
"video" : true
}, onUserMediaSuccess, onUserMediaError);
}
}
function onUserMediaSuccess(stream) {
localVideoStream = stream;
if (supportStream) {
localVideo.srcObject = localVideoStream;
} else {
localVideo.src = URL.createObjectURL(localVideoStream);
}
if (initiator) {
connectPeer();
}
}
function onUserMediaError(error) {
alert("请打开摄像头!");
}
function connectPeer() {
if (!started && localVideoStream && channelReady) {
console.log("开始连接Peer");
started = true;
buildPeerConnection();
peer.addStream(localVideoStream);
if (initiator) {
peer.createOffer().then(buildLocalDescription);
}
}
}
function buildPeerConnection() {
//var server = {"iceServers" : [{"url" : "stun:stun.l.google.com:19302"}]};
var server = {"iceServers" : [{"url" : "stun:stun1.l.google.com:19302"}]};
peer = new PeerConnection(server);
peer.onicecandidate = peerIceCandidate;
peer.onconnecting = peerConnecting;
peer.onopen = peerOpened;
peer.onaddstream = peerAddStream;
peer.onremovestream = peerRemoveStream;
}
function peerIceCandidate(event) {
if (event.candidate) {
sendMessage({
type : "candidate",
id : event.candidate.sdpMid,
label : event.candidate.sdpMLineIndex,
candidate : event.candidate.candidate
});
} else {
console.log("不支持的candidate");
}
}
function peerConnecting(message) {
console.log("Peer连接");
}
function peerOpened(message) {
console.log("Peer打开");
}
function peerAddStream(event) {
console.log("远程视频添加");
remoteVideoStream = event.stream;
if(supportStream) {
remoteVideo.srcObject = remoteVideoStream;
} else {
remoteVideo.src = URL.createObjectURL(remoteVideoStream);
}
setNotice("连接成功");
waitForRemoteVideo();
}
function peerRemoveStream(event) {
console.log("远程视频移除");
}
function buildLocalDescription(description) {
peer.setLocalDescription(description);
sendMessage(description);
}
function sendMessage(message) {
var msgJson = JSON.stringify(message);
socket.send(msgJson);
console.log("发送信息:" + msgJson);
}
function setNotice(msg) {
document.getElementById("footer").innerHTML = msg;
}
function onRemoteClose() {
started = false;
initiator = false;
if(supportStream) {
remoteVideo.srcObject = null;
} else {
remoteVideo.src = null;
}
peer.close();
}
function waitForRemoteVideo() {
if (remoteVideo.currentTime > 0) { // 判断远程视频长度
setNotice("连接成功!");
} else {
setTimeout(waitForRemoteVideo, 100);
}
}
window.onbeforeunload = function() {
sendMessage({type : "bye"});
if(peer) {
peer.close();
}
socket.close();
}
if(!WebSocket) {
alert("你的浏览器不支持WebSocket");
} else if(!PeerConnection) {
alert("你的浏览器不支持RTCPeerConnection");
} else {
setTimeout(initialize, 100); // 加载完成调用初始化方法
}
window.onbeforeunload = function() {
socket.close();
}
*/

View File

@@ -28,14 +28,14 @@
<a class="record icon-radio-checked" title="录制视频" @click="recordSelf"></a>
</div>
</div>
<div class="meeting" v-for="client in this.clients" :key="client.sn">
<div class="meeting" v-for="client in this.remoteClient" :key="client.sn">
<div class="video">
<video v-bind:id="client.sn"></video>
</div>
<div class="handler">
<a class="audio" title="音频状态" v-bind:class="client.audio?'icon-volume-medium':'icon-volume-mute2'" @click="audio(client.sn)"></a>
<a class="video" title="视频状态" v-bind:class="client.video?'icon-play2':'icon-stop'" @click="video(client.sn)"></a>
<a class="record icon-radio-checked" title="录制视频" v-bind:class="client.record?'active':''" @click="record(client.sn)"></a>
<a class="audio" title="音频状态" v-bind:class="client.audioStatus?'icon-volume-medium':'icon-volume-mute2'" @click="audio(client.sn)"></a>
<a class="video" title="视频状态" v-bind:class="client.videoStatus?'icon-play2':'icon-stop'" @click="video(client.sn)"></a>
<a class="record icon-radio-checked" title="录制视频" v-bind:class="client.recordStatus?'active':''" @click="record(client.sn)"></a>
<a class="expel icon-cancel-circle" title="踢出会议" @click="expel(client.sn)"></a>
</div>
</div>
@@ -45,30 +45,36 @@
const vue = new Vue({
el: "#app",
data: {
clients: [
{sn:"1", audio: true, video: true, record: false},
{sn:"2", audio: true, video: true, record: false},
{sn:"3", audio: true, video: true, record: false}
],
taoyao: null,
remoteClient: [],
meetingId: null
},
mounted() {
if(signalConfig.sn) {
// TODO修改sn
// 随机终端标识
if(signalConfig.sn === 'taoyao') {
let sn = localStorage.getItem('taoyao.sn');
if(sn) {
signalConfig.sn = sn;
}
console.debug('终端标识', sn);
}
let self = this;
this.taoyao = new Taoyao("wss://localhost:8888/websocket.signal");
this.taoyao = new Taoyao("wss://192.168.1.100:8888/websocket.signal");
this.remoteClient = this.taoyao.remoteClient;
// 打开信令通道
this.taoyao
.buildChannel(self.callback)
.then(e => console.debug('连接成功'));
.then(e => console.debug('信令通道连接成功'));
// 打开媒体通道
this.taoyao.buildLocalMedia()
.then(stream => {
self.taoyao.buildMediaChannel('local', stream);
})
.catch(e => console.error('打开终端媒体失败', e));
.catch(e => {
console.error('打开终端媒体失败', e);
// 方便相同电脑测试
self.taoyao.buildMediaChannel('local', null);
});
},
beforeDestroy() {
},
@@ -86,7 +92,6 @@
create: function(event) {
let self = this;
this.taoyao.meetingCreate(data => {
console.log(data)
self.taoyao.meetingEnter(data.body.id);
return true;
});