準備:
demo github 地址。
為了使 demo 儘量簡單,功能頁面如下,即包含登入、通過對方手機號撥打電話的功能。在實際生成過程中,未必使用的手機號,可能是任何能代表使用者身份的字串。
程式碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div style="margin: 20px">
<label for="loginAccount">登入賬號</label><input id="loginAccount" name="loginAccount" placeholder="請輸入手機號"
type="text">
<button id="login" onclick="login()" type="button">登入</button>
</div>
<div style="margin: 20px">
<video autoplay controls height="360px" id="localVideo" width="640px"></video>
<video autoplay controls height="360px" id="remoteVideo" width="640px"></video>
</div>
<div style="margin: 20px">
<label for="toAccount">對方賬號</label>
<input id="toAccount" name="toAccount" placeholder="請輸入對方手機號" type="text">
<button id="requestVideo" onclick="requestVideo()" type="button">請求視訊通話</button>
</div>
<div style="margin: 20px">
<fieldset>
<button id="accept" type="button">接通</button>
<button id="hangup" type="button">結束通話</button>
</fieldset>
</div>
<div style="margin: 20px">
<fieldset>
<div>
錄製格式: <select disabled id="codecPreferences"></select>
</div>
<button id="startRecord" onclick="startRecording()" type="button">開始錄製視訊</button>
<button id="stopRecord" onclick="stopRecording()" type="button">停止錄製視訊</button>
<button id="downloadRecord" onclick="download()" type="button">下載</button>
</fieldset>
</div>
</body>
<script>
let config = {
iceServers: [
{
'urls': 'turn:turn.wildfirechat.cn:3478',
'credential': 'wfchat',
'username': 'wfchat'
}
]
}
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const requestVideoButton = document.getElementById('requestVideo');
const acceptButton = document.getElementById('accept');
const hangupButton = document.getElementById('hangup');
const codecPreferences = document.querySelector('#codecPreferences');
const recordButton = document.getElementById('startRecord')
const stopRecordButton = document.getElementById('stopRecord')
const downloadButton = document.getElementById('downloadRecord')
const wsAddress = 'ws://localhost:9113/ws';
let loginAttemptCount = 0;
let myId, toId;
let pc, localStream, ws;
let mediaRecorder;
let recordedBlobs;
function login() {
loginAttemptCount = 0;
myId = document.getElementById('loginAccount').value;
ws = new WebSocket(wsAddress);
ws.onopen = function () {
console.log("WebSocket is open now.");
connect();
alert("登入成功");
};
ws.onmessage = function (message) {
let msg = JSON.parse(message.data);
console.log("ws 收到訊息:" + msg.type);
switch (msg.type) {
case "offline": {
if (loginAttemptCount < 10) {
setTimeout(() => {
loginAttemptCount++;
watch();
}, 1000);
}
break;
}
case "watch": {
handleWatch(msg);
break;
}
case "offer": {
handleOffer(msg);
break;
}
case "answer": {
handleAnswer(msg);
break;
}
case "candidate": {
handleCandidate(msg);
break;
}
case "hangup": {
handleHangup(msg);
break;
}
}
};
}
requestVideoButton.onclick = async () => {
toId = document.getElementById('toAccount').value;
if (!myId) {
alert('請先登入');
return;
}
if (!toId) {
alert('請輸入對方手機號');
return;
}
watch();
localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
localVideo.srcObject = localStream;
createPeerConnection();
}
function connect() {
send({
type: "connect",
from: myId
});
}
function handleWatch(msg) {
toId = msg.from;
}
acceptButton.onclick = async () => {
localStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true});
localVideo.srcObject = localStream;
createPeerConnection();
pc.createOffer().then(offer => {
pc.setLocalDescription(offer);
send({
type: 'offer',
from: myId,
to: toId,
data: offer
});
});
}
function handleOffer(msg) {
pc.setRemoteDescription(msg.data);
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer);
send({
type: "answer",
from: myId,
to: toId,
data: answer
});
});
}
function watch() {
send({
type: 'watch',
from: myId,
to: toId
});
}
function handleAnswer(msg) {
if (!pc) {
console.error('no peer connection');
return;
}
pc.setRemoteDescription(msg.data);
}
function handleCandidate(msg) {
if (!pc) {
console.error('no peer connection');
return;
}
pc.addIceCandidate(new RTCIceCandidate(msg.data)).then(() => {
console.log('candidate新增成功')
}).catch(handleError)
}
function handleError(error) {
console.log(error);
}
function createPeerConnection() {
pc = new RTCPeerConnection(config);
pc.onicecandidate = e => {
if (e.candidate) {
send({
type: "candidate",
from: myId,
to: toId,
data: e.candidate
});
}
};
pc.ontrack = e => remoteVideo.srcObject = e.streams[0];
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
}
hangupButton.onclick = async () => {
if (pc) {
pc.close();
pc = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
send({
type: "hangup",
from: myId,
to: toId
});
}
function handleHangup() {
if (!pc) {
console.error('no peer connection');
return;
}
pc.close();
pc = null;
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
console.log('hangup');
}
function send(msg) {
ws.send(JSON.stringify(msg));
}
function getSupportedMimeTypes() {
const possibleTypes = [
'video/webm;codecs=vp9,opus',
'video/webm;codecs=vp8,opus',
'video/webm;codecs=h264,opus',
'video/mp4;codecs=h264,aac',
];
return possibleTypes.filter(mimeType => {
return MediaRecorder.isTypeSupported(mimeType);
});
}
function startRecording() {
recordedBlobs = [];
getSupportedMimeTypes().forEach(mimeType => {
const option = document.createElement('option');
option.value = mimeType;
option.innerText = option.value;
codecPreferences.appendChild(option);
});
const mimeType = codecPreferences.options[codecPreferences.selectedIndex].value;
const options = {mimeType};
try {
mediaRecorder = new MediaRecorder(remoteVideo.srcObject, options);
} catch (e) {
console.error('Exception while creating MediaRecorder:', e);
alert('Exception while creating MediaRecorder: ' + e);
return;
}
console.log('Created MediaRecorder', mediaRecorder, 'with options', options);
recordButton.textContent = 'Stop Recording';
mediaRecorder.onstop = (event) => {
console.log('Recorder stopped: ', event);
console.log('Recorded Blobs: ', recordedBlobs);
};
mediaRecorder.ondataavailable = handleDataAvailable;
mediaRecorder.start();
console.log('MediaRecorder started', mediaRecorder);
}
function handleDataAvailable(event) {
console.log('handleDataAvailable', event);
if (event.data && event.data.size > 0) {
recordedBlobs.push(event.data);
}
}
function stopRecording() {
mediaRecorder.stop();
}
function download() {
const blob = new Blob(recordedBlobs, {type: 'video/webm'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = 'test.webm';
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
}
</script>
</html>
基於 JDK 1.8 Spring Boot、Netty 搭建,主要用於解決兩個問題:
主要功能如下:
switch (event.getType()) {
case "connect": {
USER_MAP.put(event.getFrom(), ctx);
break;
}
case "watch": {
WebRtcEvent watchRequest = new WebRtcEvent();
if (USER_MAP.containsKey(event.getTo())) {
watchRequest.setType("watch");
watchRequest.setFrom(event.getFrom());
watchRequest.setTo(event.getTo());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(watchRequest)));
} else {
watchRequest.setType("offline");
USER_MAP.get(event.getFrom()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(watchRequest)));
}
break;
}
case "offer": {
WebRtcEvent offerRequest = new WebRtcEvent();
offerRequest.setType("offer");
offerRequest.setFrom(event.getFrom());
offerRequest.setTo(event.getTo());
offerRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(offerRequest)));
break;
}
case "answer": {
WebRtcEvent answerRequest = new WebRtcEvent();
answerRequest.setType("answer");
answerRequest.setFrom(event.getFrom());
answerRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(answerRequest)));
break;
}
case "candidate": {
WebRtcEvent candidateRequest = new WebRtcEvent();
candidateRequest.setType("candidate");
candidateRequest.setFrom(event.getFrom());
candidateRequest.setData(event.getData());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(candidateRequest)));
break;
}
case "hangup": {
WebRtcEvent hangupRequest = new WebRtcEvent();
hangupRequest.setType("hangup");
hangupRequest.setFrom(event.getFrom());
hangupRequest.setTo(event.getTo());
USER_MAP.get(event.getTo()).writeAndFlush(new TextWebSocketFrame(JSONObject.toJSONString(hangupRequest)));
break;
}
}
與 html 頁面中的「登入」按鈕對應,當輸入手機號後,點選登入,手機號將會在信令服務中存到 map 中,以待後續操作使用。
如下圖所示,至少兩個使用者端登入以後,才能正常視訊通話。
點選 watch 按鈕後,前端將傳送一個事件到信令服務中,結構如下:
{
type: 'watch', //事件型別
from: 13789122381, // 我的賬號,比如 13789122381
to: 1323493929 // 對方的賬號,比如 1323493929
}
此時輸入的對方賬號對應 「to」 欄位。
信令伺服器收到 watch 事件後,從 map 中找出對應的線上使用者端,將該事件轉發至相應的使用者端中。
對於接收者來說,點選「接通」按鈕以後,webRTC 將開始建立通訊隧道。
接通的 json 結構如下:
{
type: 'offer',
from: myId,
to: toId,
data: offer
}
整個撥打電話、接通的流程如下:
在 html 中還需要設定 coturn TURN 服務 地址,我在 demo 中使用的地址是測試地址,所以請不要在生產中使用。