上次我们已经用vue ts实现了多人会议室的搭建,这次我们继续在上次项目的基础上,实现互动直播功能。
这次的互动直播功能包含了trtc里面的直播模式、实时屏幕分享和观众上下麦的功能。
效果图
项目代码结构
代码结构介绍:
- LiveClient类,继承上次文章中的Client类进行改动,添加一些直播场景所需要的方法和变量;
- ShareClient类,集成Client类,实现屏幕分享功能;
- live-room/index,观众页面vue文件,主要处理观众界面的业务逻辑,可以实现上下麦;
- anchor-room,主播页面vue文件,处理主播功能的业务逻辑,;
- LiveStreamMap类,用于存储和管理远端流的map;
- video-list,用于管理所有的直播画面整体布局和样式;
- live-video,用于管理单个直播画面的布局和样式;
- im-list,聊天室,目前还没有实现,之后会结合IM即时通信技术实现聊天室功能。
主要代码逻辑展示
LiveClient
代码语言:javascript复制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
代码语言:javascript复制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
代码语言:javascript复制<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
代码语言:javascript复制<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,将即时通信技术更新上去,实现一个完整的直播间互动模式。