FreeSWITCH 简单图形化界面38 - 使用uniapp中使用JsSIP进行音视频呼叫

FreeSWITCH 简单图形化界面38 - 在uniapp中使用JsSIP进行音视频呼叫

  • 0、测试环境
  • 1、学习uniapp
  • 2、测试代码
    • main.js
    • utils/render.js
    • store/data.js
    • pages/index/index.vue
    • pages.json
  • 3、效果
  • 4、难点


0、测试环境

http://myfs.f3322.net:8020/
用户名:admin,密码:admin

FreeSWITCH界面安装参考:https://blog.csdn.net/jia198810/article/details/137820796

1、学习uniapp

在学习JsSIP的时候,之前写过几个 demo ,并试图将其拓展到手机端应用。采用纯 Web 页面在手机浏览器环境下,借助 WSS 协议能够顺利达成通信效果。但考虑到实际的使用场景,需要将其封装为独立的 APP 更好。

JsSIP 的音视频功能必须依赖 WSS 协议才能实现,否则浏览器会限制音频或视频设备的调用,并且需要有效的证书支持。即便证书不受浏览器信任,用户还可以手动选择 “信任该证书,继续访问” 来维持功能的正常使用。
鉴于 Uniapp 本质上也是基于网页技术,之前认为在 Uniapp 中同样需要可信任的证书,尤其是对于自签名证书而言,由于 APP 中不存在 “信任该证书,继续访问” 这样的手动操作选项,就放弃了在 Uniapp 上的测试。

后来,看下了Uniapp 框架的教程,经过测试,发现 Uniapp 不仅能够成功运行 JsSIP 库,而且在使用 WSS 协议时,证书似乎被默认信任了(我不知道为什么,但实际效果是可以正常使用)。

值得注意的是,Uniapp 本身并不直接具备调用 JsSIP 的能力,但通过其提供的 renderjs ,可以实现调用JsSIP库。调用JsSIP后,基本就是复制之前的demo代码。

2、测试代码

基本流程就是index.vue界面变量发生变化后–>触发render.js里的jssip逻辑。
就写了一个页面,代码结构:
在这里插入图片描述

main.js

// #ifndef VUE3
import Vue from 'vue'
import { reactive } from 'vue'
import App from './App'
Vue.config.productionTip = falseApp.mpType = 'app'const app = new Vue({...App,
})
app.$mount()
// #endif//用了个pinia
//#ifdef VUE3
import { createSSRApp } from 'vue'
import App from './App.vue'
import { reactive } from 'vue'
import * as Pinia from 'pinia';export function createApp() {const app = createSSRApp(App)app.use(Pinia.createPinia());return {app,Pinia}
}
// #endif

utils/render.js

import JsSIP from "jssip";
import {toRaw,inject
} from "vue";export default {data() {return {// 是否有音频设备hasAudioDevice: false,// 初始化 uaMyCaller: null,// 当前会话currentCall: null,//当前呼叫类型currentCallMediaType: null,// 连接服务器失败次数connectCount: 5,ua: null,//分机设置setting: {username: "1020",password: "1020",wssServer: "210.51.10.231",wssPort: 7443,userAgent: "MyWssPhone",},//当前分机状态status: {isRegistered: false,isUnregistered: false,isConnecting: false,isDisconnected: false,isNewMessage: false,isIncomingCall: false,isOutgoingCall: false,isIceCandidate: false,isProgress: false,isAccepted: false,},//jssip的socketsocket: null,//被叫号码callee: "",//呼叫放行originator: "",//呼叫媒体mediaType: "audio",//音频控件ringtone: null,ringtoneSound: "./static/sounds/ringin.wav",//本地视频控件localVideoElement: null,//本地视频父控件localVideoElementId: "local-video",//本地视频控件媒体流localMediaStream: null,//播放状态localVideoIsPlaying: false,//远程视频控件remoteVideoElement: null,//远程视频父控件remoteVideoElementId: "remote-video",//远端媒体流remoteMediaStream: null,//日志enableLog: true,log: ""};},mounted() {},methods: {// 日志showLog(...data) {this.log = data.join("");if (this.enableLog) {//JsSIP.debug.enable('JsSIP:*');console.log(this.log);}},//接收vue页面username值updateUsername(newValue, oldValue) {this.showLog("用户名变化:", newValue, oldValue);this.setting.username = newValue;},// 接收vue页面password值updatePassword(newValue, oldValue) {this.showLog("密码变化:", newValue, oldValue);this.setting.password = newValue;},// 接收vue页面wssServer值updateWssServer(newValue, oldValue) {this.showLog("服务器地址变化:", newValue, oldValue);this.setting.wssServer = newValue;},// 接收vue页面wssPort值updateWssPort(newValue, oldValue) {this.showLog("Wss端口变化:", newValue, oldValue);this.setting.wssPort = newValue;},// 接收vue页面callee值updateCallee(newValue, oldValue) {this.showLog("被叫号码变化:", newValue, oldValue);this.callee = newValue;},// 停止一切handleStop(newValue, oldValue) {if (!newValue) {return}//停止if (this.ua) {this.showLog("注销并停止ua");this.ua.unregister()this.ua.stop();}if (this.socket) {this.socket.disconnect()this.showLog("断开连接");}},//监听vue页面,login 数据变化,处理注册handleRegister(newValue, oldValue) {if (!newValue) {this.showLog("注销或者不注册")this.handleStop()return}if (!this.setting.username || !this.setting.password || !this.setting.wssServer || !this.setting.wssPort) {this.showLog("数据为空");return;}const jssip_uri = new JsSIP.URI("sip",this.setting.username,this.setting.wssServer,this.setting.wssPort);this.showLog("uri", jssip_uri);const wss_uri = `wss://${this.setting.wssServer}:${this.setting.wssPort}`;this.socket = new JsSIP.WebSocketInterface(wss_uri);const configuration = {sockets: [this.socket],uri: jssip_uri.toAor(),authorization_user: this.setting.username,display_name: this.setting.username,register: true,password: this.setting.password,realm: this.setting.wssServer,register_expires: 300,user_agent: this.setting.userAgent,contact_uri: `sip:${this.setting.username}@${this.setting.wssServer};transport=wss`};JsSIP.C.SESSION_EXPIRES = 180, JsSIP.C.MIN_SESSION_EXPIRES = 180;this.showLog("配置参数", configuration);if (this.ua) {this.ua.stop();}this.ua = new JsSIP.UA(configuration);this.ua.on("registrationFailed", (e) => {this.onRegistrationFailed(e);});this.ua.on('registered', (e) => {this.onRegistered(e);});this.ua.on('unregistered', (e) => {this.onUnregistered(e);});this.ua.on("connecting", (e) => {this.onConnecting(e);});this.ua.on("disconnected", (response, cause) => {this.onDisconnected(response, cause);});this.ua.on("newMessage", (e) => {this.onNewMessage(e);});this.ua.on('newRTCSession', (e) => {this.showLog("新呼叫:", e)this.showLog("当前呼叫:主叫号码", e.request.from.uri.user);this.showLog("当前呼叫:被叫号码", e.request.to.uri.user);this.currentCall = e.session;this.originator = e.originator;if (this.originator === "remote") {//来电this.onInComingCall(e);} else {//去电this.onOutGoingCall(e);}this.currentCall.on("connecting", (e) => {this.showLog("呼叫连接中")});this.currentCall.on("icecandidate", (e) => {this.onIceCandidate(e);});this.currentCall.on("progress", (e) => {this.onProgress(e);});this.currentCall.on("accepted", (e) => {this.onAccepted(e);});this.currentCall.on("peerconnection", (e) => {this.onPeerConnection(e);});this.currentCall.on("confirmed", (e) => {this.onConfirmed(e);});this.currentCall.on("sdp", (e) => {this.onSDP(e);});this.currentCall.on("getusermediafailed", (e) => {this.onGetUserMediaFailed(e);});this.currentCall.on("ended", (e) => {this.onEnded(e);});this.currentCall.on("failed", (e) => {this.onFailed(e);});});this.ua.start();},// 呼叫async handleCall(newValue, oldValue) {console.log(newValue, oldValue)if (String(newValue).indexOf('audio') !== -1) {this.mediaType = "audio";} else {this.mediaType = "video";}if (this.ua == null) {this.showLog("发起呼叫:没有ua")return false}if (this.ua.isRegistered() == false) {this.showLog("发起呼叫:未注册")return false}if (this.callee == "") {this.showLog("发起呼叫:被叫号码不能为空")return false;}if (this.callee === this.setting.username) {this.showLog("发起呼叫:不能呼叫自己")return false;}if (this.currentCall) {this.showLog("发起呼叫:已经在通话中")return false;}let options = {eventHandlers: {progress: (e) => {},failed: (e) => {},ended: (e) => {},confirmed: (e) => {},},mediaConstraints: this.mediaType === "video" ? {audio: true,video: true} : {audio: true,video: false},mediaStream: await this.getLocalMediaStream(this.mediaType),pcConfig: {}};console.log("呼出OPTION:", options)try {this.currentCall = toRaw(this.ua).call(`sip:${this.callee}@${this.setting.wssServer}`, options);} catch (error) {console.debug(error)}},//接听电话async handleAnswerCall(newValue, oldValue) {if (!newValue) {return}if (this.currentCall == null) {this.showLog("应答通话,没有通话");}//停止播放来电铃声if (this.ringtone) {this.showLog("应答通话,停止播放来电铃声")this.ringtone.pause();}//开始应答呼let options = {mediaConstraints: this.currentCallMediaType === "video" ? {audio: true,video: true} : {audio: true,video: false},mediaStream: await this.getLocalMediaStream(this.currentCallMediaType),pcConfig: {},};this.showLog("应答通话,options,", options);//应答来电this.currentCall.answer(options);},// 挂断通话handleHangupCall(newValue, oldValue) {console.log(this.currentCall)if (this.currentCall == null) {this.showLog("挂断呼叫:没有通话");}if (this.currentCall) {this.currentCall.terminate();}},// 注册失败时,回调函数onRegistrationFailed(e) {this.showLog("注册失败:", e);this.status.isRegistered = false;},// 注册成功时,回调函数onRegistered(e) {this.showLog("注册成功:", e);this.status.isRegistered = true;//注册成功,跳转到vue home页面this.handleToHome();},// 注销成功时,回调函数onUnregistered(e) {this.showLog("注销成功:", e);this.status.isRegistered = false;},// 连接中时,回调函数onConnecting(e) {this.showLog("正在连接:", e);if (e.attempts >= this.connectCount) {this.showLog("连接失败次数超过5次,停止连接", e);this.handleStop()}},// 服务器断开时,回调函数onDisconnected(e) {this.showLog("断开连接", e);this.status.isRegistered = false;},// 新短信时,回调函数onNewMessage(e) {this.showLog("新短信:", e.originator, e.message, e.request);},// 来电时,回调函数onInComingCall(e) {this.showLog("来电:", e);//获取主叫号码this.ringtone = new Audio(this.ringtoneSound);this.ringtone.loop = true;let play = this.ringtone.play();if (play) {play.then(() => {// 视频频加载成功// 视频频的播放需要耗时setTimeout(() => {// 后续操作this.showLog("来电呼叫,播放来电铃声", this.ringtoneSound);}, this.ringtone.duration * 1000);}).catch((e) => {this.showLog("来电呼叫,呼叫:播放来电铃声失败,", e);})}//判断媒体里是否有视频编码this.currentCallMediaType = this.parseSdp(e.request.body);},// 去电时,回调函数onOutGoingCall(e) {this.showLog("去电:", e);},// ice候选时,回调函数onIceCandidate(e) {this.showLog("ice候选:", e);},// 呼叫中时,回调函数onProgress(e) {this.showLog("呼叫中:", e);this.showLog("当前呼叫:progress-1,", e);this.showLog("当前呼叫:pregress-2:", this.currentCall.connection);if (this.originator === "local") {//去电this.showLog("当前呼叫:progress-3,去电播放被叫回铃音......")//播放180回铃音或者183sdp彩铃(音频彩铃)if (this.currentCall.connection) {let receivers = this.currentCall.connection.getReceivers();this.showLog("当前呼叫:progress-4", receivers)let stream = new MediaStream();stream.addTrack(receivers[0].track);let ringback = new Audio();ringback.srcObject = stream;ringback.play();}} else {//来电this.showLog("当前呼叫:progress-5,来电等待接听......")}},// 呼叫接受时,回调函数onAccepted(e) {this.showLog("呼叫接受:", e);},// PeerConnection时,回调函数onPeerConnection(e) {this.showLog("PeerConnection:", e);},// 呼叫确认时,回调函数onConfirmed(e) {this.showLog("呼叫确认:", e);this.showLog("当前呼叫:confirmed,", this.originator);this.showLog("当前呼叫:confirmed,", this.currentCall.connection);let receivers = this.currentCall.connection.getReceivers();let audioReceiver = null;let videoReceiver = null;// 区分音频和视频接收器receivers.forEach((receiver) => {if (receiver.track.kind === "audio") {audioReceiver = receiver;} else if (receiver.track.kind === "video") {videoReceiver = receiver;}});// 播放音频if (audioReceiver) {this.showLog("播放远端音频")let audioElement = new Audio();let stream = new MediaStream();stream.addTrack(audioReceiver.track);this.audioStream = stream;// 延时播放音频setTimeout(() => {audioElement.srcObject = stream;audioElement.play();}, 500); // 设置音频延时播放 1 秒}// 播放视频if (videoReceiver) {this.showLog("播放远端视频")this.remoteVideoElement = document.createElement('video')// 直接设置内联样式this.remoteVideoElement.style.width = '60%';this.remoteVideoElement.style.height = '60%';this.remoteVideoElement.style.objectFit = 'fill'; // 视频将拉伸以填满容器this.remoteVideoElement.autoplay = truethis.remoteVideoElement.playsinline = truedocument.getElementById(this.remoteVideoElementId).appendChild(this.remoteVideoElement)this.remoteMediaStream = new MediaStream();this.remoteMediaStream.addTrack(videoReceiver.track);// 延时播放视频setTimeout(() => {this.remoteVideoElement.srcObject = this.remoteMediaStream;this.remoteVideoElement.play();}, 500); // 设置视频延时播放 1 秒} else {this.showLog("没有视频流,可能是音频呼叫")}},// 获取sdp时,回调函数onSDP(e) {this.showLog("获取sdp:", e);},// 获取媒体失败时,回调函数onGetUserMediaFailed(e) {this.showLog("获取媒体失败:", e);},// 呼叫结束时,回调函数onEnded(e) {this.releaseMediaStreams();this.showLog("呼叫结束:", e);},// 呼叫失败时,回调函数onFailed(e) {this.releaseMediaStreams();this.showLog("呼叫失败:", e);},// 释放媒体流的方法releaseMediaStreams() {//释放本地视频if (this.localMediaStream) {this.localMediaStream.getTracks().forEach(track => {this.showLog("停止本地媒体流:", track.kind); // 显示是音频还是视频轨道track.stop(); // 停止轨道});this.localMediaStream = null;}// 移除远程video元素if (this.remoteVideoElement) {this.showLog("移除远程video元素");this.remoteVideoElement.remove();this.remoteVideoElement = null;}// 清理本地视频元素(如果有)if (this.localVideoElement) {this.showLog("移除本地video元素");this.localVideoElement.srcObject = null; // 确保不再引用本地流this.localVideoElement = null;}//停止播放铃声if (this.ringtone) {this.showLog("停止播放来电铃声")this.ringtone.pause();}this.currentCall = null;},//解析sdp,获取媒体类型,呼入时使用parseSdp(sdp) {this.showLog("解析SDP:sdp是", sdp)let sb = {};let bs = sdp.split("\r\n");bs.forEach((value, index) => {let a = value.split("=");sb[a[0]] = a[1];});let mediaType = sb.m.split(" ")[0]// mediaType = audio or mediaType = video// 根据不通的类型弹窗this.showLog("解析SDP:媒体类型是", mediaType)return mediaType;},//获取本地媒体 //stream.getTracks() [0]音频 [1]视频.//stream.getAudioTracks() stream.getVideoTracks()// 获取本地流async getLocalMediaStream(mediaType) {try {this.showLog("尝试获取本地媒体流:", mediaType);let constraints = mediaType === "video" ? {audio: true,video: true} : {audio: true,video: false};let stream = await navigator.mediaDevices.getUserMedia(constraints);this.localMediaStream = stream; // 保存本地媒体流,关闭时使用它。this.showLog("获取本地媒体流成功", stream);return stream;} catch (error) {this.showLog('获取本地媒体流失败, 设置虚拟摄像头:', error.name, error.message);// 获取音频流let audioConstraints = {audio: true};let audioStream;try {audioStream = await navigator.mediaDevices.getUserMedia(audioConstraints);} catch (audioError) {this.showLog('获取音频流也失败,仅使用虚拟摄像头:', audioError.name, audioError.message);return this.createVirtualStream(); // 如果音频也获取失败,直接返回虚拟摄像头流}// 获取虚拟摄像头视频流let videoStream = this.createVirtualStream();// 合并音频和视频流let combinedStream = new MediaStream([...audioStream.getTracks(), ...videoStream.getTracks()]);return combinedStream;}},// 创建虚拟摄像头createVirtualStream() {const text = "未找到摄像头设备";const canvas = document.createElement('canvas');canvas.width = 800;canvas.height = 600;const ctx = canvas.getContext('2d');ctx.fillStyle = 'black';ctx.fillRect(0, 0, canvas.width, canvas.height);ctx.fillStyle = 'white';ctx.font = '64px Arial';ctx.textAlign = 'center';ctx.fillText(text, canvas.width / 2, canvas.height / 2);// 将画布内容转换为MediaStreamconst stream = canvas.captureStream();return stream;},// 播放本地媒体流async handleOpenLocalVideo(newValue, oldValue) {if (newValue == false) {this.showLog("初始化数据,跳过");return}this.showLog("打开本地媒体");// 首先获取本地视频if (this.localVideoElementId) {this.showLog("播放视频的控件id:", this.localVideoElementId);if (this.localVideoIsPlaying) {// 已经打开了摄像头,就无需再次打开了this.showLog("本地音频/视频已经打开,无需再次打开");return;} else {try {const stream = await this.getLocalMediaStream("video");this.showLog("视频流为:", stream)if (stream) {this.localVideoElement = document.createElement('video')// 直接设置内联样式this.localVideoElement.style.width = '60%';this.localVideoElement.style.height = '60%';this.localVideoElement.style.objectFit = 'fill'; // 视频将拉伸以填满容器this.localVideoElement.autoplay = truethis.localVideoElement.playsinline = truethis.localVideoElement.srcObject = stream;document.getElementById(this.localVideoElementId).appendChild(this.localVideoElement)this.localVideoIsPlaying = true;} else {this.showLog('无法获取本地视频流:获取到的流为空');}} catch (error) {this.showLog('获取本地视频流失败:', error);if (error.name === 'NotAllowedError') {this.showLog('用户拒绝授予摄像头权限');} else if (error.name === 'NotFoundError') {this.showLog('未找到摄像头设备');} else {this.showLog('其他错误:', error.name, error.message);}}}} else {this.showLog('没有本地视频控件id');}},//关闭本地摄像头handleCloseLocalVideo(newValue, oldValue) {if (newValue == false) {this.showLog("初始化数据,跳过");return}this.showLog("关闭本地视频流");if (this.localMediaStream) {this.localMediaStream.getTracks().forEach((track) => {track.stop();});this.localMediaStream = null;this.localVideoIsPlaying = false; // 更新本地视频播放状态//移除本地video元素if (this.localVideoElement) {this.localVideoElement.remove();this.localVideoElement = null;}} else {this.showLog("没有本地流可以关闭");}},/*** Vue界面相关的操作*///跳转vue界面到home页面,未用到handleToHome() {this.$ownerInstance.callMethod("handleToHome");},//测试piniatest(newValue,oldValue){console.log("我在测试pinia,数据是:",newValue,oldValue);console.log("当前ua的状态:",this.ua?.isRegistered());}},// //监听status,如果发送变化,则更新状态,向vue页面发送状态数据watch: {status: {handler(newValue, oldValue) {console.log("监听status变化,向vue页面发送:", newValue, oldValue);this.$ownerInstance?.callMethod("handleUpdateStatus", newValue);},deep: true},log: {handler(newValue, oldValue) {//console.log("监听log变化,向vue页面发送", newValue, oldValue);this.$ownerInstance?.callMethod("handleUpdateLog", newValue);},deep: true}}
}

store/data.js

import {defineStore
} from 'pinia';export const useSettingStore = defineStore('data', {state: () => {return {formData: {username: "1020",password: "1020",wssServer: "210.51.10.231",wssPort: 7443,mediaType: "audio",callee: "",},action: {register: false,openRemoteVideo: false,closeRemoteVideo: false,openLocalVideo: false,closeLocalVideo: false,audioCall: false,videoCall: false,answerCall: false,hangUpCall: false,stop: false,},status: {data:""}};},
});

pages/index/index.vue

<template><view class="login-container" :register="action.register" :change:register="WebPhone.handleRegister":username="formData.username" :change:username="WebPhone.updateUsername" :password="formData.password":change:password="WebPhone.updatePassword" :wssServer="formData.wssServer":change:wssServer="WebPhone.updateWssServer" :wssPort="formData.wssPort":change:wssPort="WebPhone.updateWssPort" :callee="formData.callee" :change:callee="WebPhone.updateCallee":audioCall="action.audioCall" :change:audioCall="WebPhone.handleCall" :videoCall="action.videoCall":change:videoCall="WebPhone.handleCall" :answerCall="action.answerCall":change:answerCall="WebPhone.handleAnswerCall" :hangupCall="action.hangupCall":change:hangupCall="WebPhone.handleHangupCall" :openLocalVideo="action.openLocalVideo":change:openLocalVideo="WebPhone.handleOpenLocalVideo" :closeLocalVideo="action.closeLocalVideo":change:closeLocalVideo="WebPhone.handleCloseLocalVideo" :stop="action.stop" :change:stop="WebPhone.handleStop"><!-- 厂商Logo或者软件名称 --><text class="software-name">{{ softwareName }}</text><!-- 登录表单 --><uni-forms ref="form" :model="formData"><!-- 分机号 --><uni-forms-item><uni-easyinput v-model="formData.username" placeholder="请输入分机号" /></uni-forms-item><!-- 分机密码 --><uni-forms-item><uni-easyinput v-model="formData.password" placeholder="请输入分机密码" /></uni-forms-item><!-- 服务器地址 --><uni-forms-item><uni-easyinput v-model="formData.wssServer" placeholder="请输入服务器地址" /></uni-forms-item><uni-forms-item><uni-easyinput v-model.number="formData.wssPort" placeholder="请输入服务器端口" /></uni-forms-item><!-- 提交按钮 --><button form-type="submit" class="submit-btn" :disabled="status.data?.isRegistered" @click="handleRegister">登录</button><view>{{ status.data?.isRegistered }}</view><view>{{ log.data }}</view></uni-forms><view class="container"><!-- 远端视频 --><view class="remote-video-container"><view><text class="description">远端视频</text></view><view id="remote-video"></view></view><!-- 本地视频 --><view class="local-video-container"><view><text class="description">本地视频</text></view><view id="local-video"></view></view><!-- 控制按钮:开启、关闭、切换前后摄像头 --><view class="control-buttons"><uni-row><uni-col :span="12"><button @click="openLocalVideo">开启本地视频</button></uni-col><uni-col :span="12"><button @click="closeLocalVideo">关闭本地视频</button></uni-col></uni-row></view><!-- 号码输入框 --><view class="input-container"><uni-easyinput v-model.number="formData.callee" placeholder="请输入被叫号码" /></view><!-- 通话控制按钮:音频、视频通话、挂断 --><view class="call-control-buttons"><uni-row><uni-col :span="6"><button @click="handleAudioCall">音频</button></uni-col><uni-col :span="6"><button @click="handleVideoCall">视频</button></uni-col><uni-col :span="6"><button @click="handleAnswerCall">接听</button></uni-col><uni-col :span="6"><button @click="handleHangupCall">挂断</button></uni-col></uni-row></view><!-- 退出按钮 --><view class="exit-button"><button @click="handleStop">注销</button></view></view></view>
</template><script module="WebPhone" lang="renderjs">import render from "../../utils/render.js";export default render;
</script><script>import {ref,reactive,} from "vue";import {useSettingStore} from "../../store/data";import { storeToRefs } from 'pinia';export default {setup() {const setting = useSettingStore();const formData = setting.formData;const action = setting.action;const status = setting.status;const form = ref(null);//日志const log = reactive({data: ""})//软件名称const softwareName = "MyWssPhone";//修改register值,传给renderjsconst handleRegister = () => {console.log("提交的数据:", formData);action.register = new Date().getTime();};const openLocalVideo = () => {//不变化,不触发renderjs里的方法,所以用date作为每次变化的值console.log("开启本地视频");action.openLocalVideo = new Date().getTime();};const closeLocalVideo = () => {console.log("关闭本地视频");action.closeLocalVideo = new Date().getTime();};const handleAudioCall = () => {console.log("开始音频通话");action.audioCall = "audio" + new Date().getTime();};const handleVideoCall = () => {console.log("开始视频通话");action.videoCall = "video" + new Date().getTime();};const handleAnswerCall = () => {console.log("接听电话")action.answerCall = "answer" + new Date().getTime();}const handleHangupCall = () => {console.log("挂断通话");action.hangupCall = new Date().getTime();};const handleStop = () => {console.log("注销");action.stop = new Date().getTime();};//跳转到首页const handleToHome = () => {console.log("vue页面跳转到home");// uni.navigateTo({// 	url: "/pages/home/index",// });};//监听renderjs传回的状态const handleUpdateStatus = (data) => {//console.log("vue页面接收到的状态:", data);status.data = data;};//监听renderjs传回的日志const handleUpdateLog = (data) => {//console.log("vue页面接收到的日志:", data);log.data = data;};return {formData,action,status,log,form,softwareName,handleRegister,handleUpdateStatus,handleUpdateLog,handleToHome,openLocalVideo,closeLocalVideo,handleAudioCall,handleVideoCall,handleAnswerCall,handleHangupCall,handleStop,};},};
</script><style scoped>.login-container {padding: 20px;display: flex;flex-direction: column;align-items: center;}.logo,.software-name {margin-bottom: 20px;text-align: center;}.submit-btn {width: 100%;margin-top: 20px;background-color: royalblue;color: white;}.container {display: flex;flex-direction: column;align-items: center;padding: 20px;}.remote-video-container,.local-video-container {text-align: center;}.local-video {width: 350px;/* 容器宽度 */height: 270px;/* 容器高度 */display: flex;justify-content: center;align-items: center;}.remote-video {width: 350px;/* 容器宽度 */height: 270px;/* 容器高度 */display: flex;justify-content: center;align-items: center;}.description {font-size: 12px;}.control-buttons,.call-control-buttons {flex: auto;}.input-container {width: 50%;max-width: 300px;}button {margin: 10px;font-size: 12px;border: none;border-radius: 4px;background-color: #007bff;color: white;cursor: pointer;width: 98%;}button:hover {background-color: #0056b3;}.exit-button button {font-size: 20px;background-color: red;}.exit-button button:hover {background-color: darkred;}
</style>

pages.json

{"pages": [{"path": "pages/index/index","style": {"navigationBarTitleText": "登录"}},{"path": "pages/home/index","style": {"navigationBarTitleText": "首页"}}],"globalStyle": {"navigationBarTextStyle": "black","navigationBarTitleText": "uni-app","navigationBarBackgroundColor": "#F8F8F8","backgroundColor": "#F8F8F8","app-plus": {"background": "#efeff4"}},"condition": {"current": 1,"list": [{"name": "登录","path": "pages/index/index","query": "" }]}
}

3、效果

配置下测试环境,在手机打开app的所有权限,网络、使用音频设备等,看下效果:

Screenrecorder-2024-12-

4、难点

在uniapp上使用JsSIP可以正常进行音视频通信,但是:

APP保活是问题,本人并不了解安卓底层开发,APP运行一段时间后,程序被自动杀掉了,我也沙雕了。

兴趣使然, 仅用于参考,祝君好运

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/890869.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【蓝桥杯——物联网设计与开发】拓展模块4 - 脉冲模块

目录 一、脉冲模块 &#xff08;1&#xff09;资源介绍 &#x1f505;原理图 &#x1f505;采集原理 &#xff08;2&#xff09;STM32CubeMX 软件配置 &#xff08;3&#xff09;代码编写 &#xff08;4&#xff09;实验现象 二、脉冲模块接口函数封装 三、踩坑日记 &a…

嵌入式硬件杂谈(八)电源的“纹波”到底是什么?

纹波的引入&#xff1a;在我们嵌入式设备中&#xff0c;很多时候电路电源的纹波很敏感&#xff0c;纹波太大会导致系统不工作&#xff0c;因此设计一个纹波很小的电路就是我们的需求了。 电路的纹波是什么&#xff1f; 纹波&#xff08;Ripple&#xff09;是指电源输出中叠加在…

Linux系统之stat命令的基本使用

Linux系统之stat命令的基本使用 一、stat命令 介绍二、stat命令帮助2.1 查询帮助信息2.2 stat命令的帮助解释 三、stat命令的基本使用3.1 查询文件信息3.2 查看文件系统状态3.3 使用格式化输出3.4 以简洁形式打印信息 四、注意事项 一、stat命令 介绍 stat 命令用于显示文件或文…

uniapp开发微信小程序实现获取“我的位置”

1. 创建GetLocation项目 使用HBuilder X创建一个项目GetLocation,使用Vue3。 2. 在腾讯地图开放平台中创建应用 要获取位置,在小程序中需要使用腾讯地图或是高德地图。下面以腾讯地图为例。 (1)打开腾讯地图开放平台官方网址:腾讯位置服务 - 立足生态,连接未来 (2)注册…

基于NodeMCU的物联网空调控制系统设计

最终效果 基于NodeMCU的物联网空调控制系统设计 项目介绍 该项目是“物联网实验室监测控制系统设计&#xff08;仿智能家居&#xff09;”项目中的“家电控制设计”中的“空调控制”子项目&#xff0c;最前者还包括“物联网设计”、“环境监测设计”、“门禁系统设计计”和“小…

easegen将教材批量生成可控ppt课件方案设计

之前客户提出过一个需求&#xff0c;就是希望可以将一本教材&#xff0c;快速的转换为教学ppt&#xff0c;虽然通过人工程序脚本的方式&#xff0c;已经实现了该功能&#xff0c;但是因为没有做到通用&#xff0c;每次都需要修改脚本&#xff0c;无法让客户自行完成所有流程&am…

从安全角度看 SEH 和 VEH

从安全角度看 SEH 和 VEH 异常处理程序是处理程序中不可预见的错误的基本方法之一 https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/exceptions/ SEH——结构化异常处理程序 就其工作方式而言&#xff0c;异常处理程序与其他处理程序相比相当基础&#xff0…

nexus docker安装

#nexus docker 安装 docker pull sonatype/nexus3 mkdir -p /data/nexus-data docker run -itd -p 8081:8081 --privilegedtrue --name nexus3 \ -v /data/nexus-data:/var/nexus-data --restartalways docker.io/sonatype/nexus3 #访问 http://192.168.31.109:8081/ 用户名&am…

Spark生态圈

Spark 主要用于替代Hadoop中的 MapReduce 计算模型。存储依然可以使用 HDFS&#xff0c;但是中间结果可以存放在内存中&#xff1b;调度可以使用 Spark 内置的&#xff0c;也可以使用更成熟的调度系统 YARN 等。 Spark有完善的生态圈&#xff1a; Spark Core&#xff1a;实现了…

CSS---实现盒元素div内input/textarea的focus状态时给父元素加属性!

注意兼容性&#xff0c;低版本浏览器无效 要实现当 textarea 文本框获得焦点时&#xff0c;自动给其父元素添加类名或样式&#xff0c;您可以使用 CSS 的 :focus-within 伪类选择器。这个选择器会在元素本身或其任何子元素获得焦点时应用样式。 示例代码 假设您有以下 HTML 结…

2011-2020年各省城镇职工基本医疗保险年末参保人数数据

2011-2020年各省城镇职工基本医疗保险年末参保人数数据 1、时间&#xff1a;2011-2020年 2、来源&#xff1a;国家统计局 3、指标&#xff1a;省份、时间、城镇职工基本医疗保险年末参保人数 4、范围&#xff1a;31省 5、指标解释&#xff1a;参保人数指报告期末按国家有关…

Bert中文文本分类

这是一个经典的文本分类问题&#xff0c;使用google的预训练模型BERT中文版bert-base-chinese来做中文文本分类。可以先在Huggingface上下载预训练模型备用。https://huggingface.co/google-bert/bert-base-chinese/tree/main 我使用的训练环境是 pip install torch2.0.0; pi…

【无标题】学生信息管理系统界面

网页是vue框架&#xff0c;后端直接python写的没使用框架

macos安装maven以及.bash_profile文件优化

文章目录 下载和安装maven本地仓库配置国内镜像仓库配置.bash_profile文件优化 下载和安装maven maven下载地址 存放在/Library/Java/env/maven目录 本地仓库配置 在maven-3.9.9目录下创建maven-repo目录作为本地文件仓库打开setting配置文件 在setting标签下&#xff0c;添…

用Excel表格在线发布期末考试成绩单

每到期末&#xff0c;发布学生的期末考试成绩单便是老师们的一项重要任务。以往&#xff0c;传统的纸质成绩单分发效率低还易出错&#xff0c;而借助 Excel 表格在线发布&#xff0c;则开启了全新高效模式。 老师们先是精心整理各科成绩&#xff0c;录入精准无误的分数到 Excel…

WPF 绘制过顶点的圆滑曲线(样条,贝塞尔)

项目中要用到样条曲线&#xff0c;必须过顶点&#xff0c;圆滑后还不能太走样&#xff0c;捣鼓一番&#xff0c;发现里面颇有玄机&#xff0c;于是把我多方抄来改造的方法发出来&#xff0c;方便新手&#xff1a; 如上图&#xff0c;看代码吧&#xff1a; -------------------…

python监控数据处理应用服务Socket心跳解决方案

1. 概述 从网页、手机App上抓取数据应用服务&#xff0c;涉及到多个系统集成协同工作&#xff0c;依赖工具较多。例如&#xff0c;使用Frida进行代码注入和动态分析&#xff0c;以实现对网络通信的监控和数据捕获。在这样的集成环境中&#xff0c;手机模拟器、手机中应用、消息…

商品线上个性定制,并实时预览3D定制效果,是如何实现的?

商品线上3D个性化定制的实现涉及多个环节和技术&#xff0c;以下是详细的解释&#xff1a; 一、实现流程 产品3D建模&#xff1a; 是实现3D可视化定制的前提&#xff0c;需要对产品进行三维建模。可通过三维扫描仪或建模师进行建模&#xff0c;将产品的外观、结构、材质等细…

Python PyMupdf 去除PDF文档中Watermark标识水印

通过PDF阅读或编辑工具&#xff0c;可在PDF中加入Watermark标识的PDF水印&#xff0c;如下图&#xff1a; 该类水印特点 这类型的水印&#xff0c;会在文件的字节流中出现/Watermark、EMC等标识&#xff0c;那么&#xff0c;我们可以通过改变文件字节内容&#xff0c;清理掉…

旧衣回收小程序开发,绿色生活,便捷回收

随着绿色生活、资源回收利用理念的影响&#xff0c;人们逐渐开始关注旧衣回收&#xff0c;选择将断舍离等闲置衣物进行回收&#xff0c;在资源回收的同时也能够减少资金浪费。目前&#xff0c;旧衣回收的方式也迎来了数字化发展&#xff0c;相比传统的回收方式更加便捷&#xf…