基于 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 天前
2026年实时音视频服务计费模式指南
实时音视频
番茄灭世神4 天前
PN学堂GD32教程第8篇——RTC
实时音视频
runner365.git4 天前
RTC实现VoiceAgent(二)
大模型·webrtc·实时音视频·voiceagent
xuxie994 天前
N18 RTC
单片机·嵌入式硬件·实时音视频
runner365.git5 天前
RTC会议实时翻译系统
实时音视频
runner365.git5 天前
如何使用RTCPilot配置一个集群RTC服务
webrtc·实时音视频·音视频开发
深念Y6 天前
从WebSocket到WebRTC,豆包级实时语音交互背后的技术演进
websocket·网络协议·实时互动·webrtc·语音识别·实时音视频
海水冷却9 天前
2026 主流 RTC SDK 选型参考,7 大维度横向对比
实时音视频·rtc
TEL1892462247710 天前
IT6636/IT66362(3进1出 / 2进1出 HDMI 2.1 48Gbps Retiming Switch,内置 MCU)
音视频·实时音视频·视频编解码
天上路人15 天前
A-59F 多功能语音处理模组在本地会议系统扩音啸叫处理中的技术应用与性能分析
人工智能·神经网络·算法·硬件架构·音视频·语音识别·实时音视频