效果图
前置知识
利用renderjs在app端加载for web库
JsSIP+FreeSwitch+Vue实现WebRtc音视频通话
原始模块
<template>
<view
class="test-sip"
:userExtension="userExtension"
:change:userExtension="JsSIP.handleUserExtenSionChange"
:targetExtension="targetExtension"
:change:targetExtension="JsSIP.handleTargetExtensionChange"
:logFlag="logFlag"
:change:logFlag="JsSIP.handleLogFlagChange"
:jsSipTestLocalStream="jsSipTestLocalStream"
:change:jsSipTestLocalStream="JsSIP.handleTestLocalStreamChange"
:jsSipIsRegisted="jsSipIsRegisted"
:change:jsSipIsRegisted="JsSIP.handleSipRegistedChange"
:jsSipCallByAudio="jsSipCallByAudio"
:change:jsSipCallByAudio="JsSIP.handleCallByAudio"
:jsSipCallByVideo="jsSipCallByVideo"
:change:jsSipCallByVideo="JsSIP.handleCallByVideo"
:jsSipHangup="jsSipHangup"
:change:jsSipHangup="JsSIP.handleJsSipHangupChange"
>
<view class="log-box">
<view class="log-item" :style="`color: ${!logFlag?'#2979ff':'#333'}`">关闭日志</view>
<u-switch class="log-item" v-model="logFlag"></u-switch>
<view class="log-item" :style="`color: ${logFlag?'#2979ff':'#333'}`">打开日志</view>
</view>
<view class="step">
<view class="step-title">步骤 1:输入自己的分机号(1001-1019)</view>
<u--input v-model="userExtension" border="surround" placeholder="请输入自己的分机号(1001-1019)"
:disabled="hasLocalStream" class="mb-10" :customStyle="{border: '1px solid #e0e0e0'}"
/>
<u-button type="primary" :disabled="!userExtension || isRegisted" @click="registerUser">注册</u-button>
</view>
<view class="step">
<view class="step-title">步骤 2:输入要呼叫的分机号(1001-1019)</view>
<u--input
v-model="targetExtension" border="surround" placeholder="请输入要呼叫的分机号(1001-1019)" :disabled="!isRegisted"
class="mb-10" :customStyle="{border: '1px solid #e0e0e0'}"
/>
<u-button type="primary" class="mb-10" :disabled="!targetExtension || hasCurrentSession" @click="startCall(false)">拨打语音电话</u-button>
<u-button type="primary" :disabled="!targetExtension || hasCurrentSession" @click="startCall(true)">拨打视频电话</u-button>
</view>
<view class="step">
<view class="step-title">其他操作</view>
<u-button type="primary" class="mb-10" :disabled="!hasCurrentSession" @click="jsSipHangup=true">挂断</u-button>
<u-button type="primary" class="mb-10" :disabled="!isRegisted" @click="jsSipIsRegisted=false">取消注册</u-button>
<u-button type="primary" v-if="!jsSipTestLocalStream" :disabled="hasCurrentSession" @click="jsSipTestLocalStream=true">测试本地设备</u-button>
<u-button type="primary" v-else :disabled="hasCurrentSession" @click="jsSipTestLocalStream=false">停止测试本地设备</u-button>
</view>
<view class="step" id="audio-container">
<!-- <view class="step-title">音频</view> -->
</view>
<view class="step" id="video-container">
<view class="step-title">视频</view>
</view>
<u-notify ref="uNotify"></u-notify>
</view>
</template>
<script>
import { requestCameraPermission, requestRecordAudioPermission } from '@/utils/request-android-permission.js'
export default {
data() {
return {
userExtension: "", // 当前用户分机号
targetExtension: "", // 目标用户分机号
logFlag: false,
hasCurrentSession: false,
jsSipTestLocalStream: false,
hasLocalStream: false,
jsSipIsRegisted: false,
isRegisted: false,
jsSipCallByAudio: false,
jsSipCallByVideo: false,
jsSipHangup: false
};
},
mounted() {
requestRecordAudioPermission(() => {
requestCameraPermission()
})
},
methods: {
isValidExtension(extension) {
const extNumber = parseInt(extension, 10);
return extNumber >= 1001 && extNumber <= 1019;
},
registerUser() {
if (!this.isValidExtension(this.userExtension)) {
this.showError("分机号无效,请输入1001-1019之间的分机号");
return;
}
this.jsSipIsRegisted = true
},
startCall(flag) {
if (!this.isValidExtension(this.targetExtension)) {
this.showError("分机号无效,请输入1001-1019之间的分机号");
return;
}
flag ? this.jsSipCallByVideo = true : this.jsSipCallByAudio = true
},
/* 接收 renderjs 传过来的数据 */
reciveMessage(msgObj) {
console.log('view reciveMsg:', msgObj);
const { msg, data } = msgObj
switch (msg) {
case 'notify':
this.$refs.uNotify[data.type](data.message)
break
case 'changeViewData':
this[data.key] = data.value === 'true' ? true : data.value === 'false' ? false : data.value
}
}
},
};
</script>
<script module="JsSIP" lang="renderjs">
import renderjs from './jsSipRender.js'
export default renderjs
</script>
<style lang="scss" scoped>
.test-sip {
padding: 30px;
.log-box {
display: flex;
margin-bottom: 10px;
.log-item {
margin-right: 10px;
}
}
.step {
margin-bottom: 20px;
.mb-10 {
margin-bottom: 10px;
}
.step-title {
margin-bottom: 10px;
}
}
}
</style>
renderjs
模块
import JsSIP from 'jssip'
const testMp3 = './static/media/test.mp3'
const testMp4 = './static/media/test.mp4'
export default {
data() {
return {
userAgent: null, // 用户代理实例
incomingSession: null,
currentSession: null,
outgoingSession: null,
password: "xxxxx", // 密码
serverIp: "xxxxxxx", // 服务器ip
audio: null,
meVideo: null,
remoteVideo: null,
localStream: null,
constraints: {
audio: true,
video: {
width: { max: 1280 },
height: { max: 720 },
},
},
myHangup: false,
}
},
computed: {
ws_url() {
return `ws://${this.serverIp}:5066`;
}
},
mounted() {
this.audio = document.createElement('audio')
this.audio.autoplay = true
// this.audio.src = testMp3
document.getElementById('audio-container').appendChild(this.audio)
this.meVideo = document.createElement('video')
this.meVideo.autoplay = true
this.meVideo.playsinline = true
// this.meVideo.src = testMp4
document.getElementById('video-container').appendChild(this.meVideo)
this.remoteVideo = document.createElement('video')
this.remoteVideo.autoplay = true
this.remoteVideo.playsinline = true
// this.remoteVideo.src = testMp4
document.getElementById('video-container').appendChild(this.remoteVideo)
const styleObj = {
width: '150px',
'background-color': '#333',
border: '2px solid blue',
margin: '0 5px'
}
Object.keys(styleObj).forEach(key => {
this.meVideo.style[key] = styleObj[key]
this.remoteVideo.style[key] = styleObj[key]
})
},
methods: {
handleLogFlagChange(nV, oV) {
nV ? JsSIP.debug.enable("JsSIP:*") : JsSIP.debug.disable("JsSIP:*");
// this.log('logFlag', nV, oV)
/* if(oV !== undefined) {
this.log('logFlag', nV, oV)
} */
},
handleUserExtenSionChange(nV, oV) {
if(oV !== undefined) {
// this.log('userExtenSion', nV, oV)
}
},
handleTargetExtensionChange(nV, oV) {
if(oV !== undefined) {
// this.log('targetExtenSion', nV, oV)
}
},
handleTestLocalStreamChange(nV, oV) {
if(oV !== undefined) {
// this.log('jsSipTestLocalStream', nV, oV)
if(nV) {
this.captureLocalMedia(() => {
this.sendMsg('changeViewData', {
key: 'hasLocalStream',
value: true
})
}, (e) => {
this.sendMsg('changeViewData', {
key: 'jsSipTestLocalStream',
value: false
})
this.sendMsg('notify', {
type: 'error',
message: "getUserMedia() error: " + e.name
})
})
} else {
this.stopLocalMedia()
this.sendMsg('changeViewData', {
key: 'jsSipTestLocalStream',
value: false
})
}
}
},
handleSipRegistedChange(nV, oV) {
if(oV !== undefined) {
if(nV) {
this.registerUser()
} else {
this.unregisterUser()
}
}
},
handleCallByAudio(nV, oV) {
if(oV !== undefined) {
if(nV) {
this.startCall(false)
}
}
},
handleCallByVideo(nV, oV) {
if(oV !== undefined) {
if(nV) {
this.startCall(true)
}
}
},
handleJsSipHangupChange(nV, oV) {
if(oV !== undefined) {
if(nV) {
this.hangUpCall()
}
}
},
captureLocalMedia(successCb, errCb) {
console.log("Requesting local video & audio");
navigator.mediaDevices
.getUserMedia(this.constraints)
.then((stream) => {
console.log("Received local media stream");
this.localStream = stream;
// 连接本地麦克风
if ("srcObject" in this.audio) {
this.audio.srcObject = stream;
} else {
this.audio.src = window.URL.createObjectURL(stream);
}
// 如果有视频流,则连接本地摄像头
if (stream.getVideoTracks().length > 0) {
if ("srcObject" in this.meVideo) {
this.meVideo.srcObject = stream;
} else {
this.meVideo.src = window.URL.createObjectURL(stream);
}
}
successCb()
})
.catch((e) => errCb(e));
},
stopLocalMedia() {
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
// 清空音频和视频的 srcObject
this.clearMedia("audio");
this.clearMedia("meVideo");
}
},
clearMedia(mediaNameOrStream) {
let mediaSrcObject = this[mediaNameOrStream].srcObject;
if (mediaSrcObject) {
let tracks = mediaSrcObject.getTracks();
for (let i = 0; i < tracks.length; i++) {
tracks[i].stop();
}
}
this[mediaNameOrStream].srcObject = null;
},
registerUser() {
const configuration = {
sockets: [new JsSIP.WebSocketInterface(this.ws_url)],
uri: `sip:${this.userExtension}@${this.serverIp};transport=ws`,
password: this.password,
contact_uri: `sip:${this.userExtension}@${this.serverIp};transport=ws`,
display_name: this.userExtension,
register: true, //指示启动时JsSIP用户代理是否应自动注册
session_timers: false, //关闭会话计时器(根据RFC 4028)
};
this.userAgent = new JsSIP.UA(configuration);
this.userAgent.on("connecting", () => console.log("WebSocket 连接中"));
this.userAgent.on("connected", () => console.log("WebSocket 连接成功"));
this.userAgent.on("disconnected", () =>
console.log("WebSocket 断开连接")
);
this.userAgent.on("registered", () => {
console.log("用户代理注册成功");
this.sendMsg('changeViewData', { key: 'isRegisted', value: true })
});
this.userAgent.on("unregistered", () => {
console.log("用户代理取消注册");
this.sendMsg('changeViewData', { key: 'isRegisted', value: false })
});
this.userAgent.on("registrationFailed", (e) => {
this.sendMsg('notify', { type: 'error', message: '注册失败' })
});
this.userAgent.on("newRTCSession", (e) => {
console.log("新会话: ", e);
if (e.originator == "remote") {
console.log("接听到来电");
this.incomingSession = e.session;
this.sipEventBind(e);
} else {
console.log("打电话");
this.outgoingSession = e.session;
this.outgoingSession.on("connecting", (data) => {
console.info("onConnecting - ", data.request);
this.currentSession = this.outgoingSession;
this.sendMsg('changeViewData', { key: 'hasCurrentSession', value: true })
this.outgoingSession = null;
});
this.outgoingSession.connection.addEventListener("track", (event) => {
console.info("Received remote track:", event.track);
this.trackHandle(event.track, event.streams[0]);
});
//连接到信令服务器,并恢复以前的状态,如果以前停止。重新开始时,如果UA配置中的参数设置为register:true,则向SIP域注册。
this.userAgent.start();
console.log("用户代理启动");
}
})
//连接到信令服务器,并恢复以前的状态,如果以前停止。重新开始时,如果UA配置中的参数设置为register:true,则向SIP域注册。
this.userAgent.start();
console.log("用户代理启动");
},
startCall(isVideo = false) {
if (this.userAgent) {
try {
const eventHandlers = {
progress: (e) => console.log("call is in progress"),
failed: (e) => {
console.error(e);
this.sendMsg('notify', {
type: 'error',
message: `call failed with cause: ${e.cause}`
})
},
ended: (e) => {
this.endedHandle();
console.log(`call ended with cause: ${e.cause}`);
},
confirmed: (e) => console.log("call confirmed"),
};
console.log("this.userAgent.call");
this.outgoingSession = this.userAgent.call(
`sip:${this.targetExtension}@${this.serverIp}`, // :5060
{
mediaConstraints: { audio: true, video: isVideo },
eventHandlers,
}
);
} catch (error) {
this.sendMsg('notify', {
type: 'error',
message: '呼叫失败'
})
console.error("呼叫失败:", error);
}
} else {
this.sendMsg('notify', {
type: 'error',
message: '用户代理未初始化'
})
}
},
sipEventBind(remotedata, callbacks) {
//接受呼叫时激发
remotedata.session.on("accepted", () => {
console.log("onAccepted - ", remotedata);
if (remotedata.originator == "remote" && this.currentSession == null) {
this.currentSession = this.incomingSession;
this.sendMsg('changeViewData', { key: 'hasCurrentSession', value: true })
this.incomingSession = null;
console.log("setCurrentSession:", this.currentSession);
}
});
//在将远程SDP传递到RTC引擎之前以及在发送本地SDP之前激发。此事件提供了修改传入和传出SDP的机制。
remotedata.session.on("sdp", (data) => {
console.log("onSDP, type - ", data.type, " sdp - ", data.sdp);
});
//接收或生成对邀请请求的1XX SIP类响应(>100)时激发。该事件在SDP处理之前触发(如果存在),以便在需要时对其进行微调,甚至通过删除数据对象中响应参数的主体来删除它
remotedata.session.on("progress", () => {
console.log(remotedata);
console.log("onProgress - ", remotedata.originator);
if (remotedata.originator == "remote") {
console.log("onProgress, response - ", remotedata.response);
//answer设置的自动接听
//RTCSession 的 answer 方法做了自动接听。实际开发中,你需要弹出一个提示框,让用户选择是否接听
const isVideoCall = remotedata.request.body.includes("m=video");
const flag = confirm(`检测到${remotedata.request.from.display_name}的${isVideoCall ? "视频" : "语音"}来电,是否接听?`);
if(!flag) {
this.hangUpCall();
return;
} else {
//如果同一电脑两个浏览器测试则video改为false,这样被呼叫端可以看到视频,两台电脑测试让双方都看到改为true
remotedata.session.answer({
mediaConstraints: { audio: true, video: isVideoCall },
// mediaStream: this.localStream,
});
}
}
});
//创建基础RTCPeerConnection后激发。应用程序有机会通过在peerconnection上添加RTCDataChannel或设置相应的事件侦听器来更改peerconnection。
remotedata.session.on("peerconnection", () => {
console.log("onPeerconnection - ", remotedata.peerconnection);
if (remotedata.originator == "remote" && this.currentSession == null) {
//拿到远程的音频流
/* remotedata.session.connection.addEventListener(
"addstream",
(event) => {
console.info("Received remote stream:", event.stream);
this.streamHandle(event.stream);
}
); */
remotedata.session.connection.addEventListener("track", (event) => {
console.info("Received remote track:", event.track);
this.trackHandle(event.track, event.streams[0]);
});
}
});
//确认呼叫后激发
remotedata.session.on("confirmed", () => {
console.log("onConfirmed - ", remotedata);
if (remotedata.originator == "remote" && this.currentSession == null) {
this.currentSession = this.incomingSession;
this.sendMsg('changeViewData', { key: 'hasCurrentSession', value: true })
this.incomingSession = null;
console.log("setCurrentSession - ", this.currentSession);
}
});
// 挂断处理
remotedata.session.on("ended", () => {
this.endedHandle();
console.log("call ended:", remotedata);
});
remotedata.session.on("failed", (e) => {
this.sendMsg('notify', { type: 'error', message: '会话失败' })
console.error("会话失败:", e);
});
},
unregisterUser() {
console.log("取消注册");
this.userAgent.unregister();
this.sendMsg('changeViewData', { key: 'isRegisted', value: false })
this.sendMsg('changeViewData', { key: 'userExtension', value: '' })
this.sendMsg('changeViewData', { key: 'targetExtension', value: '' })
},
trackHandle(track, stream) {
const showVideo = () => {
navigator.mediaDevices
.getUserMedia({
...this.constraints,
audio: false, // 不播放本地声音
})
.then((stream) => {
this.meVideo.srcObject = stream;
})
.catch((error) => {
this.sendMsg('notify', {
type: 'error',
message: `${error.name}:${error.message}`
})
});
};
// 根据轨道类型选择播放元素
if (track.kind === "video") {
// 使用 video 元素播放视频轨道
this.remoteVideo.srcObject = stream;
showVideo();
} else if (track.kind === "audio") {
// 使用 audio 元素播放音频轨道
this.audio.srcObject = stream;
}
},
endedHandle() {
this.clearMedia("meVideo");
this.clearMedia("remoteVideo");
this.clearMedia("audio");
if (this.myHangup) {
this.sendMsg('notify', { type: 'success', message: '通话结束' })
} else {
this.sendMsg('notify', { type: 'warning', message: '对方已挂断!' })
}
this.myHangup = false;
this.currentSession = null;
this.sendMsg('changeViewData', { key: 'hasCurrentSession', value: false })
this.sendMsg('changeViewData', { key: 'jsSipCallByVideo', value: false })
this.sendMsg('changeViewData', { key: 'jsSipCallByAudio', value: false })
},
hangUpCall() {
this.myHangup = true;
this.outgoingSession = this.userAgent.terminateSessions();
this.currentSession = null;
this.sendMsg('changeViewData', { key: 'hasCurrentSession', value: false })
this.sendMsg('changeViewData', { key: 'jsSipHangup', value: false })
},
// 日志
log(key, nV, oV) {
console.log(`renderjs:${key} 改变`);
console.log(`${key} 新值:`, nV);
console.log(`${key} 旧值:`, oV);
},
// 向视图层发送消息
sendMsg(msg, data) {
// 向页面传参
// console.log('renderjs sendMsg:');
// console.log(msg, data);
this.$ownerInstance.callMethod('reciveMessage', {
msg,
data
})
},
}
}