完整实战示例:
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静态引入。
前置准备
- 安装 SDK(推荐 npm 方式):
javascriptnpm 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>