基于 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>
相关推荐
hazy1k18 小时前
ESP32 ESP32基础-WIFI_手机控制LED
c语言·stm32·单片机·嵌入式硬件·51单片机·esp32·实时音视频
Cxiaomu2 天前
React Native 集成 TRTC实时音视频实战指南
react native·react.js·实时音视频
python百炼成钢11 天前
13.RTC实时时钟
linux·stm32·单片机·嵌入式硬件·实时音视频
二进制coder11 天前
Linux RTC 驱动子系统详细实现方案
linux·运维·实时音视频
huangql52016 天前
WebRTC技术详解:构建实时音视频应用实践
webrtc·实时音视频
ZEGO即构开发者20 天前
【ZEGO即构开发者日报】Soul AI Lab开源播客语音合成模型;腾讯混元推出国内首个交互式AI播客;ChatGPT Go向用户免费开放一年......
人工智能·aigc·语音识别·实时音视频
xqlily21 天前
技术文章大纲:设备如何“开口说话”?
实时音视频
zymill22 天前
hysAnalyser --- 支持UDP实时流分析和录制功能
udp·音视频·实时音视频·ts流分析·mpegts录制
二进制coder25 天前
BMC RTC:服务器硬件管理的“时间心脏”与系统协同核心
服务器·单片机·实时音视频