基于 TRTC 打造uniapp微信小程序实时音视频对讲

完整实战示例:uni-app(编译到微信小程序)+ TRTC(trtc-wx-sdk)(含大窗/小窗、拖拽、自动挂断、远端流管理)

文章要点

  • 技术栈:uni-app(编译到 mp-weixin) + trtc-wx-sdk(小程序版 TRTC) + live-pusher / live-player 组件

  • 功能实现:TRTC 房间进出、pusher 创建/启动、远端流动态渲染、主/次窗口切换、可拖拽小窗、自动 15s 超时挂断、接口联动

  • 注意:必须在真机测试;确保在 HBuilderX 中开启"构建 npm",或直接把 trtc-wx SDK 放到 static 静态引入。

前置准备

  1. 安装 SDK(推荐 npm 方式):
javascript 复制代码
npm install trtc-wx-sdk --save

后端需提供 TRTC 房间参数(sdkAppID, roomID, userID, userSig)。示例中用 getRoomInfo() 占位,替换为你的接口即可。

完整可跑页面 videoCall.vue

集成的是腾讯云无ui版本,完成的是类似微信大小窗口切换以及mqtt控制何时推拉流,仅限参考逻辑,自己记录方便下次使用

javascript 复制代码
<template>
	<view class="container">
		<!-- 大窗组件 -->
		<live-player id="live-player-big" v-if="isSwapped" class="big-box"
			:src="playerList.length === 0 ? '' : (playerList.length === 1 ? playerList[0].src : playerList[1].src)"
			autoplay="true" object-fit="fillCrop" @statechange="handleStateChange" @error="handleError"
			@click="toggleStreamSize" />
		<live-pusher id="live-pusher-big" v-else class="big-box" :url="pusher.url" mode="SD" :muted="true"
			:enable-camera="true" :auto-focus="true" :beauty="1" whiteness="2" aspect="9:16"
			@statechange="_pusherStateChangeHandler" @netstatus="_pusherNetStatusHandler" @error="_pusherErrorHandler"
			@bgmstart="_pusherBGMStartHandler" @bgmprogress="_pusherBGMProgressHandler"
			@bgmcomplete="_pusherBGMCompleteHandler" @audiovolumenotify="_pusherAudioVolumeNotify"
			@click="toggleStreamSize" />

		<!-- 小窗包装层,用于处理拖动 -->
		<movable-area class="small-wrapper">
			<movable-view class="small-box" direction="all" :style="dragStyle" x="500" y="0" drag>
				<!-- 小窗组件 -->
				<live-pusher id="live-pusher-small" v-if="isSwapped" class="small-box" :url="pusher.url" mode="SD"
					:muted="true" :enable-camera="true" :auto-focus="true" :beauty="1" whiteness="2" aspect="9:16"
					@statechange="_pusherStateChangeHandler" @netstatus="_pusherNetStatusHandler"
					@error="_pusherErrorHandler" @bgmstart="_pusherBGMStartHandler"
					@bgmprogress="_pusherBGMProgressHandler" @bgmcomplete="_pusherBGMCompleteHandler"
					@audiovolumenotify="_pusherAudioVolumeNotify" @click="toggleStreamSize" />
				<live-player id="live-player-small" v-if="!isSwapped&&smallPlayerVisible"class="small-box" :src="playerSrcSmall" autoplay="true"
					object-fit="fillCrop" @statechange="handleStateChange" @error="handleError"
					@click="toggleStreamSize" />
			</movable-view>
		</movable-area>

		<view class="bottomimg">
			<image class="liveshimg" src="@/static/livepush/camera.png" @click="stopPreview" />
			<image class="liveshimg" src="@/static/livepush/over.png" @click="stop" />
			<image class="liveshimg" src="@/static/livepush/reversal.png" @click="switchCamera" />
		</view>


	</view>
</template>

<script>
	import {
	接口名称
	} from "@/pagesMy/utils/api.js";
	import {
		mapState
	} from "vuex";
	import TRTC from 'trtc-wx-sdk';
	import MQTT from '@/pagesMy/utils/mqtt.js';
	export default {
		data() {
			return {
				// 当 isSwapped 为 true 时,live-pusher 显示为小窗;反之 live-player 为小窗
				isSwapped: false,
				pusherUrl: "",
				pullUrl: "https://domain/pull_stream",
				// bookoldid: "",
				callid: "",
				// 拖动相关数据(针对小窗包装层)
				offsetX: 10,
				offsetY: 10,
				startX: 0,
				startY: 0,
				dragging: false,
				// 固定的小窗尺寸(需与样式保持一致)
				boxWidth: 150,
				boxHeight: 200,
				hangUpTimer: null, // 存储定时器 ID,
				pusher: {}, // TRTC 返回的 pusher 配置
				playerList: [], // 远端流列表
				sdkappid: "",
				usersig: "",
				playerSrcSmall: '',
				smallPlayerVisible: true,
				roomid: "",
				mqttClient: null,
			};
		},
		computed: {
			...mapState(['openid', "res"]),
			// 使用 transform: translate 设置小窗包装层位置
			dragStyle() {
				return `transform: translate(${this.offsetX}px, ${this.offsetY}px); position: absolute;`;
			},
		},
		async onLoad(options) {
			  setTimeout(() => {
			    this.reconnctmqtt()
			  }, 1000)
			console.log(options);
			// this.bookoldid = options.bookid;
			this.callid = options.callid;
			this.roomid = options.order
			console.log("this.roomid", this.roomid);
			await this.getsdkappid();
			await this.getusersig();

			//  初始化 TRTC
			this.TRTC = new TRTC(this)
			await this.initTRTCPusher()
			await this.bindTRTCRoomEvent()


			const optionsparams = {
				roomID: "",
				strRoomID: String(this.roomid),
				sdkAppID: this.sdkappid,
				userID: this.openid,
				userSig: this.usersig
			};
			this.enterTRTCRoom(optionsparams)
			
		},
		onReady() {
			this.context = uni.createLivePusherContext("livePusher", this);
		},
	onUnload() {
		console.log("onUnloadonUnloadonUnloadonUnload");
	    if (this.hangUpTimer) {
	        clearTimeout(this.hangUpTimer);
	        this.hangUpTimer = null;
	    }
	    this.unsubscribeMqtt();
	    if (this.mqttClient) {
	        this.mqttClient.over();
	        this.mqttClient = null; // 可选,防止再次调用
	    }
	},
	onHide() {
		console.log("onHideonHideonHideonHideonHide");
	    this.unsubscribeMqtt();
	    if (this.mqttClient) {
	        this.mqttClient.over();
	        this.mqttClient = null;
	    }
	},

		methods: {
			reconnctmqtt() {
				console.log("连接了");
				this.mqttClient = new MQTT({
					clientId: "zhyl_" + this.res.Ext.privatekey,
				});
				console.log("连接了1");
				this.mqttClient.init();
					console.log("连接了2");
				const topic = `wechat/${this.openid}`;
				this.mqttClient.subscribe(topic);
					console.log("连接了3");
				// 接收消息,使用回调函数处理
				this.mqttClient.get((topic, message) => {
					console.log(`收到来自 ${topic} 的消息:`, JSON.parse(message.toString()));
					const data = JSON.parse(message.toString())
					if (data.type == "answer") {

					} else if (data.type == "hang_up") {
						wx.showToast({
							title: '通话时间已到',
							icon: 'none',
							duration: 2000,
							mask: true
						});
						// 稍微延迟,确保提示能看到,再挂断并跳转
						setTimeout(() => {
							this.hangUp();
						}, 2500);
					}
				});


			},
			unsubscribeMqtt() {
				if (this.mqttClient) {
					const topic = `订阅`
					this.mqttClient.unsubscribe(topic);
				}
			},
			async getsdkappid() {
				try {
					const res = await ({
						action: ""
					});

					if (res && res.Status) {
						this.sdkappid = res.Result.SDKAppID
					} else {

					}
				} catch (err) {
					console.error("getsdkappid error:", err);
					throw err;
				}
			},

			async getusersig() {
				try {
					const res = await ({
						action: "",
						code: this.openid
					});

					if (res && res.Status) {
						this.usersig = res.Result
						console.log("this.usersig", this.usersig);
					} else {

					}
				} catch (err) {
					console.error("getusersig error:", err);
					throw err;
				}
			},
			initTRTCPusher() {
				const pusherConfig = {
					beautyLevel: 9,
					enableCamera: true,
					enableMic: true,
				}

				this.pusher = this.TRTC.createPusher(pusherConfig)
			},
			enterTRTCRoom({
				roomID,
				sdkAppID,
				strRoomID,
				userID,
				userSig
			}) {
				this.pusher = this.TRTC.enterRoom({
					roomID,
					sdkAppID,
					strRoomID,
					userID,
					userSig,
				})

				console.log("this.pusher ", this.pusher, this.TRTC.getPusherInstance());
				this.TRTC.getPusherInstance().start()
			},
			bindTRTCRoomEvent() {
				console.log(1111111111111);
				const TRTC_EVENT = this.TRTC.EVENT;
				console.log('binding events, TRTC=', this.TRTC)
				this.TRTC.on(TRTC_EVENT.LOCAL_JOIN, (event) => {
					console.log('LOCAL_JOIN', event);

				});

				this.TRTC.on(TRTC_EVENT.LOCAL_LEAVE, (event) => {
					console.log('LOCAL_LEAVE', event);

				});

				this.TRTC.on(TRTC_EVENT.ERROR, (event) => {
					console.error('TRTC ERROR', event);
				});

				this.TRTC.on(TRTC_EVENT.REMOTE_USER_JOIN, (event) => {
					console.log('REMOTE_USER_JOIN111111111111111', event);
				});

				this.TRTC.on(this.TRTC.EVENT.REMOTE_USER_LEAVE, (event) => {
					console.log('有人离开了', event)
					console.log('REMOTE_USER_LEAVE', event);
				});

				this.TRTC.on(this.TRTC.EVENT.REMOTE_VIDEO_ADD, (event) => {
					console.log('✅ 远端开始推视频', event.data, )
					const {
						player
					} = event.data || {};
					if (player) this.setPlayerAttributesHandler(player, {
						muteVideo: false
					});
				});

				this.TRTC.on(TRTC_EVENT.REMOTE_VIDEO_REMOVE, (event) => {
					console.log('REMOTE_VIDEO_REMOVE', event);
					const {
						player
					} = event.data || {};
					if (player) this.setPlayerAttributesHandler(player, {
						muteVideo: true
					});
				});

				this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_ADD, (event) => {
					console.log('REMOTE_AUDIO_ADD', event);
					const {
						player
					} = event.data || {};
					if (player) this.setPlayerAttributesHandler(player, {
						muteAudio: false
					});
				});

				this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_REMOVE, (event) => {
					console.log('REMOTE_AUDIO_REMOVE', event);
					const {
						player
					} = event.data || {};
					if (player) this.setPlayerAttributesHandler(player, {
						muteAudio: true
					});
				});

				this.TRTC.on(TRTC_EVENT.REMOTE_AUDIO_VOLUME_UPDATE, (event) => {
					// event.data: [{ userID, streamID, volume }, ...]
					// 可用于显示远端音量指示
					// console.log('REMOTE_AUDIO_VOLUME_UPDATE', event);
				});

				this.TRTC.on(TRTC_EVENT.LOCAL_AUDIO_VOLUME_UPDATE, (event) => {
					// 本地麦克风音量变化
					// console.log('LOCAL_AUDIO_VOLUME_UPDATE', event);
				});

				// 被踢出或房间被解散
				this.TRTC.on(this.TRTC.EVENT.KICKED_OUT, (ev) => {
					console.warn('KICKED_OUT', ev);
					wx.showToast({
						title: '已被踢出房间',
						icon: 'none'
					});
					this.onExitRoom();
				});
			},
			setPlayerAttributesHandler(player, options) {
				const list = this.TRTC.setPlayerAttributes(player.streamID, options);
				console.log('list', list);

				// 1. 安全化 list(保证 src 为字符串)
				const safeList = (Array.isArray(list) ? list : []).map(item => ({
					...item,
					src: String(item.src || '')
				}));

				// 2. 计算小窗应该使用哪个 src
				let newSmallSrc = '';
				if (safeList.length === 0) newSmallSrc = '';
				else if (safeList.length === 1) newSmallSrc = safeList[0].src;
				else newSmallSrc = safeList[1].src || safeList[0].src || '';

				// 3. 写入 playerList,然后再更新小窗
				this.playerList = safeList;

				// 小窗是否是远端?
				// ------你原来的业务逻辑:小窗 = 远端 = !isSwapped
				const smallIsRemote = !this.isSwapped;
				if (!smallIsRemote) {
					// 小窗是本地推流,不做任何切换
					return;
				}

				// 4. 如果 src 没变,直接 play,避免闪烁
				if (this.playerSrcSmall === newSmallSrc) {
					this.$nextTick(() => {
						const ctx = uni.createLivePlayerContext('live-player-small', this);
						if (ctx && ctx.play) {
							try {
								ctx.play();
							} catch (e) {}
						}
					});
					return;
				}

				// 5. 小窗 src 变化:执行"短暂重建"
				this.smallPlayerVisible = false;

				this.$nextTick(() => {
					// 更新新的 small src
					this.playerSrcSmall = newSmallSrc;

					this.$nextTick(() => {
						setTimeout(() => {
							// 显示小窗
							this.smallPlayerVisible = true;

							this.$nextTick(() => {
								// 双保险:再 play 一次
								try {
									const ctx = uni.createLivePlayerContext(
										'live-player-small', this);
									ctx && ctx.play && setTimeout(() => {
										try {
											ctx.play();
										} catch (e) {}
									}, 80);
								} catch (e) {}
							});
						}, 120); // 可调
					});
				});
			},
			stop() {
				this.hangUp();

				const result = this.TRTC.exitRoom()

				this.pusher = result.pusher
				this.playerList = result.playerList

				this.context.stop({
					success: (a) => {
						uni.switchTab({
							url: "/pages/ShoppingCart/ShoppingCart"
						})
					},
				});
			},


			async toggleStreamSize() {
				const isSwapped = this.isSwapped;
				const next = !isSwapped;

				// 当前使用的 id(基于当前 isSwapped)
				const curPusherId = !isSwapped ? 'live-pusher-big' : 'live-pusher-small';
				const curPlayerId = !isSwapped ? 'live-player-small' : 'live-player-big';

				// 切换后将要使用的 id
				const newPusherId = next ? 'live-pusher-small' : 'live-pusher-big';
				const newPlayerId = next ? 'live-player-big' : 'live-player-small';

				// 1) 停掉当前拉流和推流(尽量先停干净)
				try {
					const playerCtx = uni.createLivePlayerContext(curPlayerId, this);
					if (playerCtx && typeof playerCtx.stop === 'function') playerCtx.stop();
				} catch (e) {
					console.warn('stop cur player failed', curPlayerId, e);
				}

				try {
					const pusherCtx = uni.createLivePusherContext(curPusherId, this);
					if (pusherCtx && typeof pusherCtx.stop === 'function') pusherCtx.stop();
				} catch (e) {
					console.warn('stop cur pusher failed', curPusherId, e);
				}

				// 如果你用 TRTC SDK 管理推流实例,优先 stop SDK 实例(如果有)
				try {
					if (this.TRTC && typeof this.TRTC.getPusherInstance === 'function') {
						const inst = this.TRTC.getPusherInstance();
						if (inst && typeof inst.stop === 'function') inst.stop();
					}
				} catch (e) {
					console.warn('stop TRTC pusher instance failed', e);
				}

				// 2) 切换状态(触发 DOM 由 v-if 切换)
				this.isSwapped = next;

				// 3) 等待 DOM 渲染稳定
				await this.$nextTick();
				await new Promise(res => setTimeout(res, 180)); // 若设备差异,适当延长到 220~300ms

				// 4) 计算要显示的远端索引并更新(保证 player :src 可用)
				let displayedIndex = -1;
				if (Array.isArray(this.playerList)) {
					if (this.playerList.length === 1) displayedIndex = 0;
					else if (this.playerList.length >= 2) displayedIndex = 1;
				}
				this.displayedPlayerIndex = displayedIndex;

				// 等 src 生效
				await new Promise(res => setTimeout(res, 160));

				// 5) 启动新的 player(先拉流)
				try {
					const newPlayerCtx = uni.createLivePlayerContext(newPlayerId, this);
					if (displayedIndex !== -1) {
						if (newPlayerCtx && typeof newPlayerCtx.play === 'function') newPlayerCtx.play();
						// 使用 TRTC 解静音显示流(如有必要)
						try {
							const player = this.playerList && this.playerList[displayedIndex];
							if (player && this.TRTC && typeof this.TRTC.setPlayerAttributes === 'function') {
								this.TRTC.setPlayerAttributes(player.streamID, {
									muteAudio: false,
									muteVideo: false
								});
							}
						} catch (e) {
							/* 忽略 */
						}
					} else {
						if (newPlayerCtx && typeof newPlayerCtx.stop === 'function') newPlayerCtx.stop();
					}
				} catch (e) {
					console.warn('start new player failed', newPlayerId, e);
				}

				// 6) 启动新的本地 pusher(后推流)
				try {
					// 优先通过 TRTC SDK 启动(若 SDK 管理)
					if (this.TRTC && typeof this.TRTC.getPusherInstance === 'function') {
						const inst = this.TRTC.getPusherInstance();
						if (inst && typeof inst.start === 'function') inst.start();
					}
				} catch (e) {
					console.warn('start TRTC pusher instance failed', e);
				}

				try {
					const newPusherCtx = uni.createLivePusherContext(newPusherId, this);
					if (newPusherCtx && typeof newPusherCtx.start === 'function') newPusherCtx.start();
				} catch (e) {
					console.warn('start new pusher ctx failed', newPusherId, e);
				}

				console.log('toggleStreamSize done ->', next, 'displayedIndex ->', this.displayedPlayerIndex);
			},


			handleStateChange(e) {
				// console.log("statechange:" + JSON.stringify(e));
			},
			handleError(e) {
				// console.log("error:" + JSON.stringify(e));
			},
			async hangUp() {
				try {
					const res = await SetDownCallVideo({
						action: "SetDownCallVideo",
						openId: this.openid,
						call_id: this.callid,
					});
					if (res.Status) {
						uni.showToast({
							title: res.Message
						});
						console.log("this.mqttClient",this.mqttClient);
						uni.switchTab({
							url: "/pages/ShoppingCart/ShoppingCart"
						});
					}
				} catch (error) {
					console.error("Error during login:", error);
				}
			},
			start() {
				this.context.start({
					success: (a) => {
						console.log("livePusher.start:" + JSON.stringify(a));
					},
				});
			},
			close() {
				this.context.close({
					success: (a) => {
						console.log("livePusher.close:" + JSON.stringify(a));
					},
				});
			},
			snapshot() {
				this.context.snapshot({
					success: (e) => {
						console.log(JSON.stringify(e));
					},
				});
			},
			resume() {
				this.context.resume({
					success: (a) => {
						console.log("livePusher.resume:" + JSON.stringify(a));
					},
				});
			},
			pause() {
				this.context.pause({
					success: (a) => {
						console.log("livePusher.pause:" + JSON.stringify(a));
					},
				});
			},
			stop() {
				this.hangUp();
				this.context.stop({
					success: (a) => {
						console.log(JSON.stringify(a));
						uni.switchTab({
							url: "/pages/ShoppingCart/ShoppingCart"
						});
					},
				});
			},
			switchCamera() {
				this.context.switchCamera({
					success: (a) => {
						console.log("livePusher.switchCamera:" + JSON.stringify(a));
					},
				});
			},
			startPreview() {
				this.context.startPreview({
					success: (a) => {
						console.log("livePusher.startPreview:" + JSON.stringify(a));
					},
				});
			},
			stopPreview() {
				this.context.stopPreview({
					success: (a) => {
						console.log("livePusher.stopPreview:" + JSON.stringify(a));
					},
				});
			},
			_pusherStateChangeHandler(event) {
				this.TRTC.pusherEventHandler(event)
			},
			_pusherNetStatusHandler(event) {
				this.TRTC.pusherNetStatusHandler(event)
			},
			_pusherErrorHandler(event) {
				this.TRTC.pusherErrorHandler(event)
			},
			_pusherBGMStartHandler(event) {
				this.TRTC.pusherBGMStartHandler(event)
			},
			_pusherBGMProgressHandler(event) {
				this.TRTC.pusherBGMProgressHandler(event)
			},
			_pusherBGMCompleteHandler(event) {
				this.TRTC.pusherBGMCompleteHandler(event)
			},
			_pusherAudioVolumeNotify(event) {
				this.TRTC.pusherAudioVolumeNotify(event)
			},
			_playerStateChange(event) {
				this.TRTC.playerEventHandler(event)
			},
			_playerFullscreenChange(event) {
				this.TRTC.playerFullscreenChange(event)
			},
			_playerNetStatus(event) {
				this.TRTC.playerNetStatus(event)
			},
			_playerAudioVolumeNotify(event) {
				this.TRTC.playerAudioVolumeNotify(event)
			},

		},
	};
</script>

<style>
	.container {
		width: 100%;
		height: 100vh;
		position: relative;
		/* overflow: hidden; */
	}

	.big-box {
		width: 100%;
		height: 100%;
		position: absolute;
		z-index: 1;
	}

	.small-box {
		width: 150px;
		height: 205px;
		/* border: 2px solid #fff; */
		border-radius: 10px;
		cursor: pointer;
	}

	.liveshimg {
		width: 100rpx;
		height: 100rpx;
	}

	.small-wrapper {
		z-index: 2;
		width: 100vw;
		height: 100vh;

	}

	.bottomimg {
		display: flex;
		justify-content: space-around;
		position: absolute;
		bottom: 10%;
		left: 0;
		width: 100%;
		z-index: 3;
	}

	.player {
		width: 100%;
		height: 400px;
		/* 或者全屏 100vh */
		/* background: #000; */
	}
</style>
相关推荐
小白|2 天前
CANN与实时音视频AI:构建低延迟智能通信系统的全栈实践
人工智能·实时音视频
我真会写代码3 天前
WebSocket:告别轮询,实现Web实时通信 WebRTC:无需插件,实现浏览器端实时音视频通信
网络·websocket·网络协议·webrtc·实时音视频
TSINGSEE4 天前
国标GB28181视频质量诊断:EasyGBS服务插件EasyVQD快速识别花屏、蓝屏、画面冻结抖动
人工智能·音视频·实时音视频·视频编解码·视频质量诊断·花屏检测·画面抖动
柒.梧.4 天前
理解WebRTC:浏览器原生实时音视频通信
webrtc·实时音视频
REDcker5 天前
RTSP 直播技术详解
linux·服务器·网络·音视频·实时音视频·直播·rtsp
shansz20206 天前
暂时无法解决的关于STM32F103的RTC日期更新问题
stm32·嵌入式硬件·实时音视频
ZEGO即构开发者8 天前
如何用一句话让AI集成 ZEGO 产品
ai·实时互动·实时音视频·rtc
视频技术分享11 天前
2026年实时音视频服务选型深度解析
音视频·实时音视频·视频
摸摸电11 天前
RTC电路电池寿命计算?
实时音视频
深圳市友昊天创科技有限公司15 天前
友昊天创推出8K ,4K 120Hz 100米延长器方案
音视频·实时音视频·视频编解码