专栏首页跟铭哥学音视频技术TRTC学习之旅(三)-- 使用vue+ts集成互动直播
原创

TRTC学习之旅(三)-- 使用vue+ts集成互动直播

上次我们已经用vue+ts实现了多人会议室的搭建,这次我们继续在上次项目的基础上,实现互动直播功能。

这次的互动直播功能包含了trtc里面的直播模式实时屏幕分享观众上下麦的功能。

效果图

主播界面
观众界面

项目代码结构

页面代码
新增了一个LiveClient类和ShareClient类

代码结构介绍:

  1. LiveClient类,继承上次文章中的Client类进行改动,添加一些直播场景所需要的方法和变量;
  2. ShareClient类,集成Client类,实现屏幕分享功能;
  3. live-room/index,观众页面vue文件,主要处理观众界面的业务逻辑,可以实现上下麦;
  4. anchor-room,主播页面vue文件,处理主播功能的业务逻辑,;
  5. LiveStreamMap类,用于存储和管理远端流的map;
  6. video-list,用于管理所有的直播画面整体布局和样式;
  7. live-video,用于管理单个直播画面的布局和样式;
  8. im-list,聊天室,目前还没有实现,之后会结合IM即时通信技术实现聊天室功能。

主要代码逻辑展示

LiveClient

import Client from './Client'
import { TRTCOptions, JoinRoomOptions } from './../model/trtc.model.defs'
import TRTC from 'trtc-js-sdk';
import { TRTCMode, AppConfig, Role } from '../enum/mode';

/**
 * 实现直播功能
 * @export
 * @class LiveClient 直播客户端
 * @extends {Client} trtc客户端
 */
export default class LiveClient extends Client {
    role: string = Role.ANCHOR
    constructor(options: TRTCOptions) {
        super(options);
    }

    createClient() {
        this.client = TRTC.createClient({
            userId: this.userId,
            userSig: this.userSig,
            mode: TRTCMode.LIVE,  //采用互动直播模式
            sdkAppId: AppConfig.SDKAPPID
        })
    }
    /**
     * 进入房间
     * 完成房间初始化操作,包括本地流的发布
     * @param {JoinRoomOptions} options
     * @returns
     * @memberof LiveClient
     */
    async initRoom(options: JoinRoomOptions) {
        if (this.isJoined) {
            console.log('客户端已经加入过房间');
            return;
        }

        try {
            await this.client.join(options);
            this.role = <string>options.role;
            console.log('成功加入聊天室');
            this.isJoined = true;
            
            await this.initStream(); 
        } catch(err) {
            console.log('加入房间失败', err)
        }
    }
    async initStream() {
        //非主播角色不进行初始化流的操作
        if (this.role !== Role.ANCHOR) return;

        try {
            this.localStream = TRTC.createStream({
                audio: true,
                video: true,
                mirror: true
            })
            
            await this.localStream.initialize();
            
            this.localStream.on('player-state-change', (e: any) => {
                console.log(`本地流${e.type},状态改变=>${e.state},原因=>${e.reason}`);
            })

            await this.publishStream();
        } catch (err) {
            console.log(err)
        }
    }

    /**
     * 切换角色
     * 在调用了切换角色到观众之后,会自动把本地流取消发布
     * @param {Role} role 角色类型
     * @memberof LiveClient
     */
    async switchRole(role: Role) {
        try {
            await this.client.switchRole(role);
            this.role = role;

            //主播角色则进行初始化流操作
            //切换到非主播角色则取消发布流
            if (this.role === Role.ANCHOR) {
                await this.initStream();
            } else {
                //记得切换标记
                //如果不切换的话,因为之前代码的逻辑,会导致本地流无法被发布
                this.isPublished = false;
            }
        } catch (error) {
            console.log('切换角色失败', error);
        }
    }
}

ShareClient

import Client from './Client';
import TRTC from 'trtc-js-sdk';
import { TRTCMode, AppConfig } from './../enum/mode';
import { TRTCOptions } from './../model/trtc.model.defs'

export default class ShareClient extends Client {
    constructor(options: TRTCOptions) {
        super(options);
    }

    createClient() {
        this.client = TRTC.createClient({
            mode: TRTCMode.VIDEOCALL,
            sdkAppId: AppConfig.SDKAPPID,
            userId: this.userId,
            userSig: this.userSig
        })

        //共享屏幕的客户端默认不接收远端流
        this.client.setDefaultMuteRemoteStreams(true);
    }

    /**
     * 共享屏幕的流也需要重新初始化流的方法
     */
    async initStream() {
        //开始屏幕共享
        //屏幕共享流不需要开放摄像头和音频
        this.localStream = TRTC.createStream({
            audio: false,
            screen: true,
            video: false,
        })

        try {
            await this.localStream.initialize();
            console.log('本地流初始化成功');

            this.localStream.on('player-state-change', (e: any)  => {
                console.log(`本地流${e.type},状态改变=>${e.state},原因=>${e.reason}`);
            })

            await this.publishStream();
        } catch (err) {
            console.error('初始化本地流错误', err);
        }
    }
}

live-room/index

<template>
    <el-container>
        <el-aside width="240px" class="anchor-info">
            <el-avatar shape="square" :size="150" fit="cover" :src="url"></el-avatar>
            <p>{{userId}}</p>
            <el-row>
                <el-col :span="24" class="mt-20">
                    <el-button @click="changeVideo">送礼物</el-button>
                    <el-button @click="switchRole">{{isAnchor ? '下麦' : '上麦'}}</el-button>
                </el-col>
            </el-row>
        </el-aside>
        <el-main class="main-body">
            <video-list
                :anchor-stream="{
                        main: anchorStream.main,
                        share: anchorStream.share
                    }"
                :audience-streams="audienceStreams">
            </video-list>
        </el-main>
        <el-aside width="240px">
            <im-list></im-list>
        </el-aside>
    </el-container>
</template>

<script>
    import { Vue, Component } from 'vue-property-decorator'
    import LiveClient from '../../js/client/class/LiveClient'
    import { Role } from '../../js/client/enum/mode';
    import VideoList from './components/video-list'
    import ImList from './components/im-list'
    import Icon from '@/components/icon'
    import LiveStreamMap from './LiveStreamMap'

    @Component({
        components: {
            VideoList,
            ImList,
            Icon
        }
    })
    export default class LiveRoom extends Vue {
        mainClient = null;  //本地客户端
        userId = '';
        roomId = '';
        
        currentRole = Role.AUDIENCE;  //当前角色
        url = 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg';

        anchorStream = {  //主播流
            main: '',
            share: ''
        }

        audienceStreamMap = new LiveStreamMap();  //上麦的观众流Map
        audienceStreams = []; //上麦的观众流数组

        //当前状态是否切换成主播
        get isAnchor() {
            return this.currentRole === Role.ANCHOR;
        }

        created() {
            
            //绑定change事件
            this.audienceStreamMap.on('change', (map) => {
                //获取当前观众流
                this.audienceStreams = map.getValues();
            })

            this.userId = sessionStorage.getItem('userId');
            this.roomId = this.$route.params.roomId;

            this.mainClient = new LiveClient({userId: this.userId});

            //事件绑定
            //本来想用peerJoin和peerLeave方法实现右侧显示 ***观众加入房间  的字样
            //但是在官网中发现,这两个方法只能监听到远端发布了流的客户端
            //要实现有哪些观众加入房间的功能,估计还是只能用IM即时通信实现了
            this.mainClient.handleEvents({
                streamAdded: this.streamAdded,
                streamSubscribed: this.streamSubscribed,
                streamRemoved: this.streamRemoved,
                peerJoin: this.peerJoin,
                peerLeave: this.peerLeave
            })
        }

        async mounted() {
            //初始化房间数据
            await Promise.all([
                this.mainClient.initRoom({ roomId: this.roomId, role: Role.AUDIENCE }),
            ])
        }

        /** 
         * 切换角色
         */
        switchRole() {
            this.currentRole = 
                this.currentRole === Role.AUDIENCE 
                ? Role.ANCHOR
                : Role.AUDIENCE
            
            this.mainClient.switchRole(this.currentRole).then(() => {

                //目前有个bug,本地流获取userId是undefined
                //如果要用本地流对象上的userId最好是自己设置一次
                this.mainClient.localStream.userId_ = this.userId;
                
                //切换成主播,添加本地流到观众流mao中
                //切换成观众,在map中删除本地流
                switch (this.currentRole) {
                    case Role.AUDIENCE:
                        this.audienceStreamMap.deleteStream(this.mainClient.localStream)
                        break;
                    case Role.ANCHOR:
                        this.audienceStreamMap.addStream(this.mainClient.localStream)
                        break;
                }
            });

        }

        peerJoin(e) {
            console.log('有用户进入了房间')
            console.log(e)
        }

        peerLeave(e) {
            console.log('有用户离开了房间')
            console.log(e)
        }

        streamAdded(e) {
            console.log('接收到了远端流', e)
            return true;
        }

        streamSubscribed(e) {
            const stream = e.stream;
            const userId = stream.getUserId();
            let isAnchor = userId.includes('anchor');
            let isShare = userId.includes('share');
            
            //目前由于没有后台接口的支持,无法判断用户是否是原始房间的主播,所以可以在userId中加入标记进行判断
            //流的对象数据里面无法区分当前流是摄像头还是屏幕分享,所以我还是在userId中加入标记
            //主播流添加到主播对象
            //观众的远端流添加到map
            if (isAnchor) {
                isShare 
                ? this.anchorStream.share = stream
                : this.anchorStream.main = stream;
            } else {
                this.audienceStreamMap.addStream(stream);
            }
        }
        streamRemoved(e) {
            this.audienceStreamMap.deleteStream(e.stream);

            console.log('远端流被移除了')
            console.log(e)
        }

        changeShare() {
            const actionFunc = () => {
                return this.isMuteShare 
                    ? this.shareClient.publishStream()
                    : this.shareClient.unpublishStream();
            }
                
            actionFunc()
                ? this.isMuteShare = !this.isMuteShare
                : this.$message({
                    type: 'warning',
                    message: '没有视频设备'
                })
        }
    }
</script>

<style lang="less" scoped>
.device-control {
    display: flex;
    .icon-container {
        flex: 1 1 50%;
        text-align: center;
        padding: 15px;
        cursor: pointer;
        transition: all .3s ease 0s;
        &:hover {
            background-color: rgba(221, 221, 221, .3);
        }
        &:first-child {
            border-right: 1px solid #ddd;
        }
    }
}
.main-body {
    height: 100vh;
    background-color: green;
}
.anchor-info {
    text-align: center;
    padding: 10px;
}

.video-container {
    height: 160px;
}


</style>

anchor-room

<template>
    <el-container>
        <el-aside width="240px" class="anchor-info">
            <el-avatar shape="square" :size="150" fit="cover" :src="url"></el-avatar>
            <p>{{userId}}</p>
            <el-row>
                <el-col :span="24" class="mt-20">
                    <el-button @click="changeVideo">{{ !isMuteVideo ? '关闭摄像头' : '打开摄像头' }}</el-button>
                </el-col>
                <el-col :span="24" class="mt-20">
                    <el-button @click="changeAudio">{{ !isMuteAudio ? '关闭音频' : '打开音频' }}</el-button>
                </el-col>
                <el-col :span="24" class="mt-20">
                    <el-button @click="changeShare">{{ !isMuteShare ? '关闭屏幕共享' : '打开屏幕共享' }}</el-button>
                </el-col>
                <!-- <el-col :span="24" class="mt-20">
                    <el-button @click="initRoom">initRoom</el-button>
                </el-col> -->
            </el-row>
        </el-aside>
        <el-main class="main-body">
            <video-list
                :anchor-stream="anchorStream"
                :audience-streams="audienceStreams">
            </video-list>
        </el-main>
        <el-aside width="240px">
            <im-list></im-list>
        </el-aside>
    </el-container>
</template>

<script>
    import { Vue, Component } from 'vue-property-decorator'
    import LiveClient from '../../js/client/class/LiveClient'
    import ShareClient from '../../js/client/class/ShareClient';
    import { Role } from '../../js/client/enum/mode';
    import ImList from './components/im-list'
    import Icon from '@/components/icon'
    import VideoList from './components/video-list';
    import LiveStreamMap from './LiveStreamMap'

    @Component({
        components: {
            ImList,
            Icon,
            VideoList
        }
    })
    export default class AnchorRoom extends Vue {
        mainClient = null;  //摄像头客户端
        shareClient = null;  //屏幕共享客户端
        mainStream = null;  //摄像头流
        shareStream = null;  //屏幕共享流

        userId = '';
        roomId = '';
        url = 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg';
        isMuteAudio = false; 
        isMuteVideo = false;
        isMuteShare = false; //是否禁用了屏幕分享
        
        audienceStreamMap = new LiveStreamMap();
        audienceStreams = [];

        get anchorStream() {
            let stream = {
                main: this.mainStream,
                share: this.shareStream
            }
            return stream;
        }

        created() {

            this.userId = sessionStorage.getItem('userId');
            this.roomId = this.$route.params.roomId;

            this.audienceStreamMap.on('change', (map) => {
                this.audienceStreams = map.getValues();
            })

            /** 
             * 目前web端无法通过客户端对象属性进行区分该流是屏幕分享流还是摄像头的视频流
             * 所以在web端开发过程中暂时用userId的自己命名来进行区分
             * 目前web端的一个客户端上只能有一个流,所以要实现摄像头和屏幕共享的话,我们需要两个客户端
             */
            this.mainClient = new LiveClient({userId: 'anchor_' + this.userId});
            this.shareClient = new ShareClient({userId: 'anchor_share_' + this.userId});

            this.mainClient.handleEvents({
                streamSubscribed: this.streamSubscribed,
                streamRemoved: this.streamRemoved,
                peerJoin: this.peerJoin,
                peerLeave: this.peerLeave
            })
        }

        async mounted() {
            await this.initRoom();
        }

        async initRoom() {
            //将两个客户端进行房间初始化
            await Promise.all([
                this.mainClient.initRoom({ roomId: this.roomId, role: Role.ANCHOR }),
                this.shareClient.initRoom({ roomId: this.roomId })
            ])

            this.mainStream = this.mainClient.localStream;
            this.shareStream = this.shareClient.localStream;
        }

        peerJoin(e) {
            console.log('有用户进入了房间')
            console.log(e)
        }

        peerLeave(e) {
            console.log('有用户离开了房间')
            console.log(e)
        }

        changeShare() {
            const actionFunc = () => {
                return this.isMuteShare 
                    ? this.shareClient.publishStream()
                    : this.shareClient.unpublishStream();
            }
                
            actionFunc()
                ? this.isMuteShare = !this.isMuteShare
                : this.$message({
                    type: 'warning',
                    message: '没有视频设备'
                })
        }

        /**
         * 切换音频状态
         */
        changeAudio() {
            const actionFunc = () => {
                return this.isMuteAudio 
                    ? this.mainClient.unmuteLocalAudio()
                    : this.mainClient.muteLocalAudio();
            }
                
            actionFunc()
                ? this.isMuteAudio = !this.isMuteAudio
                : this.$message({
                    type: 'warning',
                    message: '没有视频设备'
                })
        }

        /**
         * 切换视频状态
         */
        changeVideo() {
            console.log('this.mainStream', this.mainStream.getUserId());
            const actionFunc = () => {
                return this.isMuteVideo 
                    ? this.mainClient.unmuteLocalVideo()
                    : this.mainClient.muteLocalVideo();
            }
                
            actionFunc()
                ? this.isMuteVideo = !this.isMuteVideo
                : this.$message({
                    type: 'warning',
                    message: '没有视频设备'
                })
        }

        streamSubscribed(e) {
            const stream = e.stream;
            const userId = stream.getUserId();
            let isAnchor = userId.includes('anchor');

            //不是房间原始主播的话,就放入map中
            if (!isAnchor) {
                this.audienceStreamMap.addStream(stream);
            }
        }
        streamRemoved(e) {
            this.audienceStreamMap.deleteStream(e.stream);
            console.log('远端流被移除了')
            console.log(e)
        }
        
    }
</script>

<style lang="less" scoped>
.device-control {
    display: flex;
    .icon-container {
        flex: 1 1 50%;
        text-align: center;
        padding: 15px;
        cursor: pointer;
        transition: all .3s ease 0s;
        &:hover {
            background-color: rgba(221, 221, 221, .3);
        }
        &:first-child {
            border-right: 1px solid #ddd;
        }
    }
}
.main-body {
    height: 100vh;
    background-color: green;
}
.anchor-info {
    text-align: center;
    padding: 10px;
}
.anchor-area {
    height: 90vh;
}
.audience-area {
    height: 160px;
}
</style>

主要的几个代码就是这些了,其余的都是一些控制布局的组件和数据储存结构,我就不贴出来了。

总结

目前为止,如果只使用trtc的话,已经可以实现多人会议和基本实现互动直播功能了,如果需要加上聊天室的互动,我们还需要学习即时通信IM,后期我会继续使用这个demo,将即时通信技术更新上去,实现一个完整的直播间互动模式。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • TRTC学习之旅(二)-- 使用vue+ts集成TRTC实现多人会议室

    根据上回学习了官方TRTC demo之后,已经了解了一个基础的多人会议室创建的流程,接下来我需要将自己学到的转换为自己能够运用的。

    黑眼圈云豆
  • TRTC学习之旅(一)--多人聊天室web篇(官方demo)

    大家好,我是刚入坑TRTC的小菜鸡,黑圆圈云豆。因为我的主要技术方向是web,所以我就从基于web开发的TRTC demo进行学习和知识分享。

    黑眼圈云豆
  • IM即时通信探索(三)-- 实现一个简单的直播聊天室

    今天我们用IM来简单的实现一个直播聊天室场景。不过在这之前呢,我们还需要先来熟悉一下IM直播功能的一些特性。本文以web端进行代码讲解,与其它端可能会有些差异,...

    黑眼圈云豆
  • 【干货】Cocos Creator制作一个微信小游戏(下)

    | 导语 微信小游戏都火成这样了,为什么不尝试一下? 我们的目标是使用Cocos Creator从零开始制作一个小游戏,并放到微信上玩。 上文链接:Cocos...

    腾讯NEXT学位
  • 确认过眼神,你是喜欢Stream的人

    摘要:在学习Node的过程中,Stream流是常用的东东,在了解怎么使用它的同时,我们应该要深入了解它的具体实现。今天的主要带大家来写一写可读流的具体实现,就过...

    用户2145235
  • 寿司开卖:实现寿司制作特效和音响特效

    本节我们将继续上一节完成若干个小功能。首先要完成的是,当客户动画在主页面出现时,它左上角会冒泡,显示它想购买何种寿司,此时玩家可以点击左下角面板中各种元素,组合...

    望月从良
  • Java基础:五、this关键字、static含义(4)

    如果只有一个peel()方法,如何知道是被a还是b所调用的呢?因为编译器会把“所操作对象的引用”作为第一次参数传递给peel()。所以上述两个方法的调用就变成了...

    桑鱼
  • VUE+WebPack游戏设计:欲望都市城市图层的设计

    望月从良
  • 小游戏入门

    极乐君

扫码关注云+社区

领取腾讯云代金券