feat: 添加会议功能

This commit is contained in:
kuaifan 2022-06-01 01:48:19 +08:00
parent 24ed87cc82
commit 0f8cfa72b6
13 changed files with 773 additions and 4 deletions

View File

@ -8,6 +8,7 @@ use App\Models\User;
use App\Models\UserEmailVerification;
use App\Models\UserTransfer;
use App\Models\WebSocket;
use App\Module\AgoraIO\AgoraTokenGenerator;
use App\Module\Base;
use Arr;
use Cache;
@ -760,4 +761,43 @@ class UsersController extends AbstractController
return Base::retError('not exist');
}
}
/**
* @api {get} api/users/agoraio/token 16. 【agoraio】获取 token
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup users
* @apiName agoraio__token
*
* @apiParam {Number} dialog_id 会话ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function agoraio__token()
{
$user = User::auth();
//
$appid = '342c604542484b0d9659527f79aefcdb';
$app_certificate = '920eb911c1f549948366e44d6dcabcbe';
$channel = "DooTask:" . md5(env("APP_KEY"));
$uid = $user->userid;
try {
$service = new AgoraTokenGenerator($appid, $app_certificate, $channel, $uid);
} catch (\Exception $e) {
return Base::retError($e->getMessage());
}
$token = $service->buildToken();
if (empty($token)) {
return Base::retError('Generated token failed');
}
return Base::retSuccess('success', [
'appid' => $appid,
'channel' => $channel,
'uid' => $uid,
'token' => $token
]);
}
}

View File

@ -0,0 +1,193 @@
<?php
/**
* @Description :
*
* @Date : 2019-03-14 13:22
* @Author : hmy940118@gmail.com
*/
namespace App\Module\AgoraIO;
class AccessToken
{
const Privileges = array(
"kJoinChannel" => 1,
"kPublishAudioStream" => 2,
"kPublishVideoStream" => 3,
"kPublishDataStream" => 4,
"kPublishAudioCdn" => 5,
"kPublishVideoCdn" => 6,
"kRequestPublishAudioStream" => 7,
"kRequestPublishVideoStream" => 8,
"kRequestPublishDataStream" => 9,
"kInvitePublishAudioStream" => 10,
"kInvitePublishVideoStream" => 11,
"kInvitePublishDataStream" => 12,
"kAdministrateChannel" => 101
);
public $appID, $appCertificate, $channelName, $uid;
public $message;
/**
* AccessToken constructor.
* @throws \Exception
*/
public function __construct()
{
$this->message = new Message();
}
/**
* @param $uid
*/
public function setUid($uid)
{
if ($uid === 0) {
$this->uid = "";
} else {
$this->uid = $uid . '';
}
}
/**
* @param $name
* @param $str
* @return bool
*/
public function is_nonempty_string($name, $str)
{
if (is_string($str) && $str !== "") {
return true;
}
echo $name . " check failed, should be a non-empty string";
return false;
}
/**
* @param $appID
* @param $appCertificate
* @param $channelName
* @param $uid
* @return AccessToken|null
* @throws \Exception
*/
public static function init($appID, $appCertificate, $channelName, $uid)
{
$accessToken = new AccessToken();
if (!$accessToken->is_nonempty_string("appID", $appID) ||
!$accessToken->is_nonempty_string("appCertificate", $appCertificate) ||
!$accessToken->is_nonempty_string("channelName", $channelName)) {
return null;
}
$accessToken->appID = $appID;
$accessToken->appCertificate = $appCertificate;
$accessToken->channelName = $channelName;
$accessToken->setUid($uid);
$accessToken->message = new Message();
return $accessToken;
}
/**
* @param $token
* @param $appCertificate
* @param $channel
* @param $uid
* @return AccessToken|null
* @throws \Exception
*/
public static function initWithToken($token, $appCertificate, $channel, $uid)
{
$accessToken = new AccessToken();
if (!$accessToken->extract($token, $appCertificate, $channel, $uid)) {
return null;
}
return $accessToken;
}
/**
* @param $key
* @param $expireTimestamp
* @return $this
*/
public function addPrivilege($key, $expireTimestamp)
{
$this->message->privileges[$key] = $expireTimestamp;
return $this;
}
/**
* @param $token
* @param $appCertificate
* @param $channelName
* @param $uid
* @return bool
* @throws \Exception
*/
protected function extract($token, $appCertificate, $channelName, $uid)
{
$ver_len = 3;
$appid_len = 32;
$version = substr($token, 0, $ver_len);
if ($version !== "006") {
echo 'invalid version ' . $version;
return false;
}
if (!$this->is_nonempty_string("token", $token) ||
!$this->is_nonempty_string("appCertificate", $appCertificate) ||
!$this->is_nonempty_string("channelName", $channelName)) {
return false;
}
$appid = substr($token, $ver_len, $appid_len);
$content = (base64_decode(substr($token, $ver_len + $appid_len, strlen($token) - ($ver_len + $appid_len))));
$pos = 0;
$len = unpack("v", $content . substr($pos, 2))[1];
$pos += 2;
$sig = substr($content, $pos, $len);
$pos += $len;
$crc_channel = unpack("V", substr($content, $pos, 4))[1];
$pos += 4;
$crc_uid = unpack("V", substr($content, $pos, 4))[1];
$pos += 4;
$msgLen = unpack("v", substr($content, $pos, 2))[1];
$pos += 2;
$msg = substr($content, $pos, $msgLen);
$this->appID = $appid;
$message = new Message();
$message->unpackContent($msg);
$this->message = $message;
//non reversable values
$this->appCertificate = $appCertificate;
$this->channelName = $channelName;
$this->setUid($uid);
return true;
}
/**
* @return string
*/
public function build()
{
$msg = $this->message->packContent();
$val = array_merge(unpack("C*", $this->appID), unpack("C*", $this->channelName), unpack("C*", $this->uid), $msg);
$sig = hash_hmac('sha256', implode(array_map("chr", $val)), $this->appCertificate, true);
$crc_channel_name = crc32($this->channelName) & 0xffffffff;
$crc_uid = crc32($this->uid) & 0xffffffff;
$content = array_merge(unpack("C*", $this->packString($sig)), unpack("C*", pack("V", $crc_channel_name)), unpack("C*", pack("V", $crc_uid)), unpack("C*", pack("v", count($msg))), $msg);
$version = "006";
$ret = $version . $this->appID . base64_encode(implode(array_map("chr", $content)));
return $ret;
}
/**
* @param $value
* @return string
*/
public function packString($value)
{
return pack("v", strlen($value)) . $value;
}
}

View File

@ -0,0 +1,122 @@
<?php
/**
* @Description :
*
* @Date : 2019-03-14 13:20
* @Author : hmy940118@gmail.com
*/
namespace App\Module\AgoraIO;
class AgoraTokenGenerator
{
const AttendeePrivileges = array(
AccessToken::Privileges["kJoinChannel"] => 0,
AccessToken::Privileges["kPublishAudioStream"] => 0,
AccessToken::Privileges["kPublishVideoStream"] => 0,
AccessToken::Privileges["kPublishDataStream"] => 0
);
const PublisherPrivileges = array(
AccessToken::Privileges["kJoinChannel"] => 0,
AccessToken::Privileges["kPublishAudioStream"] => 0,
AccessToken::Privileges["kPublishVideoStream"] => 0,
AccessToken::Privileges["kPublishDataStream"] => 0,
AccessToken::Privileges["kPublishAudioCdn"] => 0,
AccessToken::Privileges["kPublishVideoCdn"] => 0,
AccessToken::Privileges["kInvitePublishAudioStream"] => 0,
AccessToken::Privileges["kInvitePublishVideoStream"] => 0,
AccessToken::Privileges["kInvitePublishDataStream"] => 0
);
const SubscriberPrivileges = array(
AccessToken::Privileges["kJoinChannel"] => 0,
AccessToken::Privileges["kRequestPublishAudioStream"] => 0,
AccessToken::Privileges["kRequestPublishVideoStream"] => 0,
AccessToken::Privileges["kRequestPublishDataStream"] => 0
);
const AdminPrivileges = array(
AccessToken::Privileges["kJoinChannel"] => 0,
AccessToken::Privileges["kPublishAudioStream"] => 0,
AccessToken::Privileges["kPublishVideoStream"] => 0,
AccessToken::Privileges["kPublishDataStream"] => 0,
AccessToken::Privileges["kAdministrateChannel"] => 0
);
const Role = array(
"kRoleAttendee" => 0, // for communication
"kRolePublisher" => 1, // for live broadcast
"kRoleSubscriber" => 2, // for live broadcast
"kRoleAdmin" => 101
);
const RolePrivileges = array(
self::Role["kRoleAttendee"] => self::AttendeePrivileges,
self::Role["kRolePublisher"] => self::PublisherPrivileges,
self::Role["kRoleSubscriber"] => self::SubscriberPrivileges,
self::Role["kRoleAdmin"] => self::AdminPrivileges
);
public $token;
/**
* AgoraTokenGenerator constructor.
* @param $appID
* @param $appCertificate
* @param $channelName
* @param $uid
* @throws \Exception
*/
public function __construct($appID, $appCertificate, $channelName, $uid){
$this->token = new AccessToken();
$this->token->appID = $appID;
$this->token->appCertificate = $appCertificate;
$this->token->channelName = $channelName;
$this->token->setUid($uid);
}
/**
* @param $token
* @param $appCertificate
* @param $channel
* @param $uid
* @throws \Exception
*/
public function initWithToken($token, $appCertificate, $channel, $uid){
$this->token = AccessToken::initWithToken($token, $appCertificate, $channel, $uid);
}
/**
* @param $role
*/
public function initPrivilege($role){
$p = self::RolePrivileges[$role];
foreach($p as $key => $value){
$this->setPrivilege($key, $value);
}
}
/**
* @param $privilege
* @param $expireTimestamp
*/
public function setPrivilege($privilege, $expireTimestamp){
$this->token->addPrivilege($privilege, $expireTimestamp);
}
/**
* @param $privilege
*/
public function removePrivilege($privilege){
unset($this->token->message->privileges[$privilege]);
}
/**
* @return string
*/
public function buildToken(){
return $this->token->build();
}
}

View File

@ -0,0 +1,70 @@
<?php
/**
* @Description :
*
* @Date : 2019-03-14 13:27
* @Author : hmy940118@gmail.com
*/
namespace App\Module\AgoraIO;
class Message
{
public $salt;
public $ts;
public $privileges;
/**
* Message constructor.
* @throws \Exception
*/
public function __construct()
{
$this->salt = rand(0, 100000);
$date = new \DateTime("now", new \DateTimeZone('UTC'));
$this->ts = $date->getTimestamp() + 168 * 3600; // 7天时间
$this->privileges = array();
}
/**
* @return array
*/
public function packContent()
{
$buffer = unpack("C*", pack("V", $this->salt));
$buffer = array_merge($buffer, unpack("C*", pack("V", $this->ts)));
$buffer = array_merge($buffer, unpack("C*", pack("v", sizeof($this->privileges))));
foreach ($this->privileges as $key => $value) {
$buffer = array_merge($buffer, unpack("C*", pack("v", $key)));
$buffer = array_merge($buffer, unpack("C*", pack("V", $value)));
}
return $buffer;
}
/**
* @param $msg
*/
public function unpackContent($msg)
{
$pos = 0;
$salt = unpack("V", substr($msg, $pos, 4))[1];
$pos += 4;
$ts = unpack("V", substr($msg, $pos, 4))[1];
$pos += 4;
$size = unpack("v", substr($msg, $pos, 2))[1];
$pos += 2;
$privileges = array();
for ($i = 0; $i < $size; $i++) {
$key = unpack("v", substr($msg, $pos, 2));
$pos += 2;
$value = unpack("V", substr($msg, $pos, 4));
$pos += 4;
$privileges[$key[1]] = $value[1];
}
$this->salt = $salt;
$this->ts = $ts;
$this->privileges = $privileges;
}
}

View File

@ -350,6 +350,9 @@
<ProjectArchived v-if="archivedProjectShow"/>
</DrawerOverlay>
<!--会议管理-->
<MeetingManager/>
<!--移动端选项卡-->
<transition name="mobile-slide">
<MobileTabbar v-if="showMobileTabbar" @on-click="onTabbarClick"/>
@ -376,9 +379,11 @@ import {Store} from "le5le-store";
import MobileBack from "../components/Mobile/Back";
import TaskMenu from "./manage/components/TaskMenu";
import MobileNotification from "../components/Mobile/Notification";
import MeetingManager from "./manage/components/MeetingManager";
export default {
components: {
MeetingManager,
MobileNotification,
TaskMenu,
MobileBack,

View File

@ -51,13 +51,17 @@
<ETooltip slot="reference" ref="moreTip" :disabled="!$isDesktop || showMore" placement="top" :content="$L('展开')">
<i class="taskfont">&#xe790;</i>
</ETooltip>
<div v-if="recordReady" class="chat-input-popover-item" @click="onToolbar('meeting')">
<i class="taskfont">&#xe7c1;</i>
{{$L('新会议')}}
</div>
<div class="chat-input-popover-item" @click="onToolbar('image')">
<i class="taskfont">&#xe64a;</i>
{{$L('图片')}}
<i class="taskfont">&#xe7bc;</i>
{{$L('发送图片')}}
</div>
<div class="chat-input-popover-item" @click="onToolbar('file')">
<i class="taskfont">&#xe786;</i>
{{$L('文件')}}
<i class="taskfont">&#xe7c0;</i>
{{$L('上传文件')}}
</div>
</EPopover>
</li>
@ -112,6 +116,7 @@ import ChatEmoji from "./emoji";
import touchmouse from "../../../../directives/touchmouse";
import TransferDom from "../../../../directives/transfer-dom";
import clickoutside from "../../../../directives/clickoutside";
import {Store} from "le5le-store";
export default {
name: 'ChatInput',
@ -781,6 +786,12 @@ export default {
this.openMenu("#");
break;
case 'meeting':
Store.set('addMeeting', {
userids: [this.userId]
}); // todo
break;
case 'image':
case 'file':
this.$emit('on-more', action)

View File

@ -0,0 +1,238 @@
<template>
<div v-show="false">
<Modal
v-model="addShow"
:title="$L('新会议')"
:mask-closable="false">
<Form ref="addForm" :model="addData" :rules="addRule" label-width="auto" @submit.native.prevent>
<FormItem prop="userids" :label="$L('会议成员')">
<UserInput v-model="addData.userids" :multiple-max="10" :placeholder="$L('选择会议成员')"/>
</FormItem>
<FormItem prop="video" :label="$L('开启视频')">
<RadioGroup v-model="addData.video">
<Radio label="open">{{$L('开启')}}</Radio>
<Radio label="close">{{$L('关闭')}}</Radio>
</RadioGroup>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="addShow=false">{{$L('取消')}}</Button>
<Button type="primary" :loading="loadIng > 0" @click="onSubmit">{{$L('开始会议')}}</Button>
</div>
</Modal>
<Modal
v-model="meetingShow"
:title="$L('会议中')"
:mask="false"
:mask-closable="false"
:closable="false"
:transition-names="['', '']"
class-name="meeting-manager"
fullscreen>
<ul>
<li v-if="localTracks.uid">
<MeetingPlayer :player="localTracks"/>
</li>
<li v-for="item in remoteUsers">
<MeetingPlayer :player="item"/>
</li>
</ul>
<div slot="footer" class="adaption">
<Button type="warning" :loading="loadIng > 0" @click="onClose">{{$L('退出会议')}}</Button>
</div>
</Modal>
</div>
</template>
<script>
import UserInput from "../../../components/UserInput";
import {Store} from "le5le-store";
import {mapState} from "vuex";
import MeetingPlayer from "./MeetingPlayer";
export default {
name: "MeetingManager",
components: {MeetingPlayer, UserInput},
data() {
return {
loadIng: 0,
subscribe: null,
addShow: false,
addData: {
userids: [],
video: 'close'
},
addRule: {},
meetingShow: false,
agoraClient: null,
remoteUsers: [],
localTracks: {
uid: null,
mediaType: null,
audioTrack: null,
videoTrack: null,
},
}
},
mounted() {
this.subscribe = Store.subscribe('addMeeting', this.onAdd);
},
beforeDestroy() {
if (this.subscribe) {
this.subscribe.unsubscribe();
this.subscribe = null;
}
},
computed: {
...mapState(['userId'])
},
methods: {
onAdd(data) {
this.addData = Object.assign({}, this.addData, $A.isJson(data) ? data : {
'userids': [this.userId],
});
this.addShow = true;
},
onSubmit() {
this.$refs.addForm.validate((valid) => {
if (valid) {
this.loadIng++;
this.$store.dispatch("call", {
url: 'users/agoraio/token',
}).then(({data}) => {
$A.loadScript('//download.agora.io/sdk/release/AgoraRTC_N.js', e => {
if (e !== null || typeof AgoraRTC !== 'object') {
this.loadIng--;
$A.modalError("会议组件加载失败!");
return;
}
this.join(data).then(_ => {
this.loadIng--;
this.addShow = false;
this.meetingShow = true;
})
});
}).catch(({msg}) => {
this.loadIng--;
$A.modalError(msg);
});
}
});
},
onClose() {
$A.modalConfirm({
content: '确定要退出会议吗?',
cancelText: '继续',
okText: '退出',
onOk: () => {
this.loadIng++;
this.leave().then(_ => {
this.loadIng--;
this.meetingShow = false;
})
}
});
},
join(options) {
return new Promise(async resolve => {
AgoraRTC.onAutoplayFailed = () => {
// alert("click to start autoplay!")
}
AgoraRTC.onMicrophoneChanged = async (changedDevice) => {
// When plugging in a device, switch to a device that is newly plugged in.
if (changedDevice.state === "ACTIVE") {
this.localTracks.audioTrack.setDevice(changedDevice.device.deviceId);
// Switch to an existing device when the current device is unplugged.
} else if (changedDevice.device.label === this.localTracks.audioTrack.getTrackLabel()) {
const oldMicrophones = await AgoraRTC.getMicrophones();
oldMicrophones[0] && this.localTracks.audioTrack.setDevice(oldMicrophones[0].deviceId);
}
}
AgoraRTC.onCameraChanged = async (changedDevice) => {
// When plugging in a device, switch to a device that is newly plugged in.
if (changedDevice.state === "ACTIVE") {
this.localTracks.videoTrack.setDevice(changedDevice.device.deviceId);
// Switch to an existing device when the current device is unplugged.
} else if (changedDevice.device.label === this.localTracks.videoTrack.getTrackLabel()) {
const oldCameras = await AgoraRTC.getCameras();
oldCameras[0] && this.localTracks.videoTrack.setDevice(oldCameras[0].deviceId);
}
}
//
this.agoraClient = AgoraRTC.createClient({
mode: "rtc",
codec: "vp8"
});
// Add an event listener to play remote tracks when remote user publishes.
this.agoraClient.on("user-published", this.handleUserPublished);
this.agoraClient.on("user-unpublished", this.handleUserUnpublished);
// Join a channel and create local tracks. Best practice is to use Promise.all and run them concurrently.
[options.uid, this.localTracks.audioTrack, this.localTracks.videoTrack] = await Promise.all([
// Join the channel.
this.agoraClient.join(options.appid, options.channel, options.token || null, options.uid || null),
// Create tracks to the local microphone and camera.
AgoraRTC.createMicrophoneAudioTrack(),
AgoraRTC.createCameraVideoTrack()
]);
// Play the local video track to the local browser and update the UI with the user ID.
this.localTracks.uid = options.uid;
this.localTracks.mediaType = 'video';
// Publish the local video and audio tracks to the channel.
await this.agoraClient.publish([this.localTracks.audioTrack, this.localTracks.videoTrack]);
resolve()
})
},
leave() {
return new Promise(async resolve => {
for (let trackName in this.localTracks) {
const track = this.localTracks[trackName];
if (track) {
if (['audioTrack', 'videoTrack'].includes(trackName)) {
track.stop();
track.close();
}
this.localTracks[trackName] = null;
}
}
// Remove remote users and player views.
this.remoteUsers = [];
// leave the channel
await this.agoraClient.leave();
resolve();
})
},
async handleUserPublished(user, mediaType) {
// subscribe to a remote user
await this.agoraClient.subscribe(user, mediaType);
// add remote
user.mediaType = mediaType
const index = this.remoteUsers.findIndex(item => item.uid == user.uid)
if (index > -1) {
this.remoteUsers.splice(index, 1, user)
} else {
this.remoteUsers.push(user)
}
},
handleUserUnpublished(user) {
const index = this.remoteUsers.findIndex(item => item.uid == user.uid)
if (index > -1) {
this.remoteUsers.splice(index, 1)
}
}
}
}
</script>

View File

@ -0,0 +1,46 @@
<template>
<div class="meeting-player">
<div :id="id" class="player"></div>
<UserAvatar :userid="player.uid" show-name/>
</div>
</template>
<script>
export default {
name: "MeetingPlayer",
props: {
id: {
type: String,
default: () => {
return "meeting-player-" + Math.round(Math.random() * 10000);
}
},
player: {
type: Object,
},
},
data() {
return {
}
},
watch: {
player: {
handler(e) {
this.$nextTick(_ => {
switch (e.mediaType) {
case 'video':
e.videoTrack.play(this.id);
break;
case 'audio':
e.audioTrack.play();
break;
}
})
},
immediate: true
}
}
}
</script>

View File

@ -2,6 +2,7 @@
@import "dialog-group-info";
@import "dialog-wrapper";
@import "file-content";
@import "meeting-manager";
@import "project-archived";
@import "project-dialog";
@import "project-gantt";

View File

@ -0,0 +1,43 @@
body {
.ivu-modal-wrap {
&.meeting-manager {
.ivu-modal {
.ivu-modal-content {
.ivu-modal-body {
padding: 16px 12px 0;
> ul {
display: grid;
justify-content: center;
grid-template-columns: repeat(auto-fill, 180px);
grid-gap: 16px;
> li {
list-style: none;
width: 180px;
height: 230px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
.meeting-player {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.player {
flex: 1;
width: 100%;
background-color: #f1f1f1;
}
.common-avatar {
margin-top: 12px;
}
}
}
}
}
}
}
}
}
}