当前位置: 首页 > article >正文

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 = false

App.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,
			// 初始化 ua
			MyCaller: 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的socket
			socket: 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 = true
				this.remoteVideoElement.playsinline = true
				document.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);

			// 将画布内容转换为MediaStream
			const 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 = true
							this.localVideoElement.playsinline = true
							this.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");
		},
		
		//测试pinia
		test(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值,传给renderjs
			const 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.kler.cn/a/460118.html

相关文章:

  • Windows 11 系统中npm-cache优化
  • 出现 Error during query execution: StatementCallback; bad SQL grammar 解决方法
  • 【AI】最近有款毛茸茸AI生成图片圈粉了,博主也尝试使用风格转换生成可爱的小兔子,一起来探索下是如何实现的
  • 前端路由layout布局处理以及菜单交互(三)
  • 慧集通iPaaS集成平台低代码培训-基础篇
  • 数据挖掘——支持向量机分类器
  • Windows11家庭版 Docker Desktop 的安装历程
  • VITUREMEIG | AR眼镜 算力增程
  • 你了解DNS吗?
  • 让每一条数据发光:Grafana 打造的现代化仪表盘
  • 多分类的损失函数
  • 深度学习论文: RemDet: Rethinking Efficient Model Design for UAV Object Detection
  • 数据结构(顺序队列)
  • LCE软机器人登场!热场光控下的多模态运动传奇?
  • 多目标应用(一):多目标麋鹿优化算法(MOEHO)求解10个工程应用,提供完整MATLAB代码
  • Zabbix 监控平台 添加监控目标主机
  • 单元测试中创建多个线程测试 ThreadLocal
  • C++系列之构造函数和析构函数
  • 龙迅#LT9711UX适用于双端口 MIPI DPHY/CPHY 转 DP1.4 产品应用,分辨率高达4K120HZ。
  • c++表达范围勿用数学符号
  • TCP-IP入门
  • 架构与通信机制:深入解析JMediaDataSource的JNI实现
  • 【每日学点鸿蒙知识】placement设置top、组件携带自定义参数、主动隐藏输入框、Web设置字体、对话框设置全屏宽
  • 静默模式下安装Weblogic 14.1.1.0.0
  • 医院大数据平台建设:基于快速流程化工具集的考察
  • Ashy的考研游记