很明显,未来的工作将是完全偏远的或混合的。许多公司将不得不使用或开发工具来增强他们的沟通并远程为他们的客户提供服务。
此内容最初发布 -HERE
在本文中,我们将演示在 VueJs3(使用 TypeScript)和 Golang 中的 Netlify 函数中使用 100ms SDK 构建视频聊天应用程序是多么容易。 Tailwindcss 将用于样式。
在教程结束时,我们的应用程序将如下所示:
[](https://res.cloudinary.com/practicaldev/image/fetch/s--xFwkukGq--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_66%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/6p58xc9ptrxr70fch3mz.gif)
特点
1.创建一个可以进行对话的新房间
2.生成认证令牌后加入房间
- 本地和远程对等方的音频和视频静音和取消静音。
4.为音频和视频的开启和关闭状态显示适当的用户界面。
先决条件
APP_ACCESS_KEYAPP_SECRET
- 熟悉我们将用来创建新房间和生成身份验证令牌的 Golang。
3.对VueJs3及其组成API有一个比较了解。
4.无服务器功能。我们将在这篇博客中使用Netlify 函数来托管我们的 Golang 后端。确保安装Netlify CLI。
项目设置
1.新建一个VueJs3应用
npm init vite@latest vue-video-chat --template vue-ts
cd vue-video-chat
npm install
进入全屏模式 退出全屏模式
- 在应用程序中初始化一个新的 Netlify 应用程序。运行以下命令后按照提示进行操作:
ntl init
进入全屏模式 退出全屏模式
- 安装 100ms JavaScript SDK 和项目依赖。对于 Tailwindcss,请遵循此安装指南。
# 100ms SDKs for conferencing
npm install @100mslive/hms-video-store
# Axios for making API calls
npm install axios
# Setup tailwindcss for styling.(https://tailwindcss.com/docs/guides/vite)
# A tailwind plugin for forms
npm install @tailwindcss/forms
进入全屏模式 退出全屏模式
netlify.toml
# Let's tell Netlify about the directory where we'll
# keep the serverless functions
[functions]
directory = "hms-functions/"
进入全屏模式 退出全屏模式
hms-functionscreateRoomgenerateAppToken
在项目的根目录中,即 vue-video-chat
mkdir hms-functions
cd hms-functions
ntl functions:create --name="createRoom"
ntl functions:create --name="generateAppToken"
进入全屏模式 退出全屏模式
用于房间和令牌的休息 APIS
我们希望为两件事提供 API。第一个是创建房间,当用户想要创建一个新房间时会调用它。第二个是身份验证令牌,当用户想要加入房间时将调用它。授权令牌是让 100 毫秒允许加入所必需的。
让我们从房间创建端点开始
导航到 createRoom 目录并安装以下库。
cd hms-functions/createRoom
go get github.com/golang-jwt/jwt/v4 v4.2.0
go get github.com/google/uuid v1.3.0
go mod tidy
进入全屏模式 退出全屏模式
room name
端点执行以下操作:
generateManagementToken
2.使用管理令牌和传入的房间名称创建房间。
hms-functions/createRoom/main.go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strings"
"time"
"os"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type RequestBody struct {
Room string `json:"room"`
}
// https://docs.100ms.live/server-side/v2/foundation/authentication-and-tokens#management-token
func generateManagementToken() string {
appAccessKey := os.Getenv("APP_ACCESS_KEY")
appSecret := os.Getenv("APP_SECRET")
mySigningKey := []byte(appSecret)
expiresIn := uint32(24 * 3600)
now := uint32(time.Now().UTC().Unix())
exp := now + expiresIn
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"access_key": appAccessKey,
"type": "management",
"version": 2,
"jti": uuid.New().String(),
"iat": now,
"exp": exp,
"nbf": now,
})
// Sign and get the complete encoded token as a string using the secret
signedToken, _ := token.SignedString(mySigningKey)
return signedToken
}
func handleInternalServerError(errMessage string) (*events.APIGatewayProxyResponse, error) {
err := errors.New(errMessage)
return &events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Headers: map[string]string{"Content-Type": "application/json"},
Body: "Internal server error",
}, err
}
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) {
var f RequestBody
managementToken := generateManagementToken()
b := []byte(request.Body)
err1 := json.Unmarshal(b, &f)
if err1 != nil {
return &events.APIGatewayProxyResponse{
StatusCode: http.StatusUnprocessableEntity,
}, errors.New("Provide room name in the request body")
}
postBody, _ := json.Marshal(map[string]interface{}{
"name": strings.ToLower(f.Room),
"active": true,
})
payload := bytes.NewBuffer(postBody)
roomUrl := os.Getenv("ROOM_URL")
method := "POST"
client := &http.Client{}
req, err := http.NewRequest(method, roomUrl, payload)
if err != nil {
return handleInternalServerError(err.Error())
}
// Add Authorization header
req.Header.Add("Authorization", "Bearer "+managementToken)
req.Header.Add("Content-Type", "application/json")
// Send HTTP request
res, err := client.Do(req)
if err != nil {
return handleInternalServerError(err.Error())
}
defer res.Body.Close()
resp, err := ioutil.ReadAll(res.Body)
if err != nil {
return handleInternalServerError(err.Error())
}
return &events.APIGatewayProxyResponse{
StatusCode: res.StatusCode,
Headers: map[string]string{"Content-Type": "application/json"},
Body: string(resp),
IsBase64Encoded: false,
}, nil
}
func main() {
// start the serverless lambda function for the API calls
lambda.Start(handler)
}
进入全屏模式 退出全屏模式
令牌生成端点
现在我们有了创建房间的 API,我们还需要允许用户加入他们。 100ms 需要应用程序令牌来授权有效加入。导航到 generateAppToken 目录并安装以下库。
cd hms-functions/generateAppToken
go get github.com/golang-jwt/jwt/v4 v4.2.0
go get github.com/google/uuid v1.3.0
go mod tidy
进入全屏模式 退出全屏模式
此端点接受以下参数:
user_id
room_id
role
以下代码接受上面列出的参数,并返回一个 JWT 令牌,有效期为 1 天,将在加入视频通话时使用。
hms-functions/generateAppToken/main.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
type RequestBody struct {
UserId string `json:"user_id"`
RoomId string `json:"room_id"`
Role string `json:"role"`
}
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (*events.APIGatewayProxyResponse, error) {
var f RequestBody
b := []byte(request.Body)
err1 := json.Unmarshal(b, &f)
if err1 != nil {
return &events.APIGatewayProxyResponse{
StatusCode: http.StatusUnprocessableEntity,
}, errors.New("Provide user_id, room_id and room in the request body")
}
appAccessKey := os.Getenv("APP_ACCESS_KEY")
appSecret := os.Getenv("APP_SECRET")
mySigningKey := []byte(appSecret)
expiresIn := uint32(24 * 3600)
now := uint32(time.Now().UTC().Unix())
exp := now + expiresIn
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"access_key": appAccessKey,
"type": "app",
"version": 2,
"room_id": f.RoomId,
"user_id": f.UserId,
"role": f.Role,
"jti": uuid.New().String(),
"iat": now,
"exp": exp,
"nbf": now,
})
// Sign and get the complete encoded token as a string using the secret
signedToken, err := token.SignedString(mySigningKey)
if err != nil {
return &events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Headers: map[string]string{"Content-Type": "application/json"},
Body: "Internal server error",
}, err
}
// return the app token so the UI can join
return &events.APIGatewayProxyResponse{
StatusCode: http.StatusOK,
Headers: map[string]string{"Content-Type": "application/json"},
Body: signedToken,
IsBase64Encoded: false,
}, nil
}
func main() {
lambda.Start(handler)
}
进入全屏模式 退出全屏模式
用户界面
UI 由一个表单组成,用户将在其中输入一些详细信息以加入房间,并且当他们成功加入同一个房间进行视频聊天时,将显示他们的视频和音频流。
发出 API 请求的实用程序函数。
types.ts
// Inside the project's root directory
touch src/types.ts
// Add the following code to types.ts
export type HmsTokenResponse = {
user_id?: String;
room_id?: String;
token: String;
};
进入全屏模式 退出全屏模式
hms.ts
HMSReactiveStore
hmsStore
hmsActions
FUNCTION_BASE_URL
fetchToken
// this code will be in src/hms.ts
import axios from "axios";
import { HMSReactiveStore } from "@100mslive/hms-video-store";
import { HmsTokenResponse } from "./types";
const FUNCTION_BASE_URL = "/.netlify/functions";
const hmsManager = new HMSReactiveStore();
// store will be used to get any state of the room
// actions will be used to perform an action in the room
export const hmsStore = hmsManager.getStore();
export const hmsActions = hmsManager.getActions();
export const fetchToken = async (
userName: string,
roomName: string
): Promise<HmsTokenResponse | any> => {
try {
// create or fetch the room_id for the passed in room
const { data: room } = await axios.post(
`${FUNCTION_BASE_URL}/createRoom`,
{ room: roomName },
{
headers: {
"Content-Type": "application/json",
},
}
);
// Generate the app/authToken
const { data:token } = await axios.post(
`${FUNCTION_BASE_URL}/generateAppToken`,
{
user_id: userName,
room_id: room.id,
role: "host",
},
{
headers: {
"Content-Type": "application/json",
},
}
);
return token;
} catch (error: any) {
throw error;
}
};
进入全屏模式 退出全屏模式
join.vue
[](https://res.cloudinary.com/practicaldev/image/fetch/s--A8TBYUOo--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/l394jwatao5075euc6pq.png)
这是一个简单的表单,用户可以在其中输入他们的用户名和他们想要加入视频通话的房间。
joinHmsRoomfetchTokenhmsActions.joinisAudioMuted: true
// Add the following to src/components/Join.vue
<script setup lang="ts">
import { reactive, ref } from "vue";
import { fetchTokens, hmsActions } from "../hms";
const defaultRoomName = import.meta.env.VITE_APP_DEFAULT_ROOM;
const isLoading = ref(false);
const formData = reactive({
name: "",
room: `${defaultRoomName}`,
});
const joinHmsRoom = async () => {
try {
isLoading.value = true;
const authToken = await fetchToken(formData.name, formData.room);
hmsActions.join({
userName: formData.name,
authToken: authToken,
settings: {
isAudioMuted: true, // Join with audio muted
},
});
} catch (error) {
alert(error);
}
isLoading.value = false;
};
</script>
<template>
<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-10 px-5 shadow sm:rounded-lg sm:px-10">
<form class="space-y-6" @submit.prevent="joinHmsRoom">
<div>
<label for="name" class="block text-sm font-2xl text-gray-700">
Name
</label>
<div class="mt-1">
<input
id="name"
name="name"
type="text"
autocomplete="username"
required
v-model="formData.name"
class="
appearance-none
block
w-full
px-3
py-2
border border-gray-300
rounded-md
shadow-sm
placeholder-gray-400
focus:outline-none focus:ring-indigo-500 focus:border-indigo-500
sm:text-sm
"
/>
</div>
</div>
<div>
<label for="room" class="block text-sm font-medium text-gray-700">
Room
</label>
<div class="mt-1">
<input
id="room"
name="room"
type="text"
required
disabled
v-model="formData.room"
class="
appearance-none
block
w-full
px-3
py-2
border border-gray-300
rounded-md
shadow-sm
placeholder-gray-400
focus:outline-none focus:ring-indigo-500 focus:border-indigo-500
sm:text-sm
disabled:cursor-not-allowed
"
/>
</div>
</div>
<div>
<button
type="submit"
:disabled="formData.name === '' || isLoading"
:class="{ 'cursor-not-allowed': isLoading }"
class="
w-full
flex
justify-center
py-2
px-4
border border-transparent
rounded-md
shadow-sm
text-sm
font-medium
text-white
bg-indigo-600
hover:bg-indigo-700
focus:outline-none
focus:ring-2
focus:ring-offset-2
focus:ring-indigo-500
"
>
<svg
class="animate-spin mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
v-if="isLoading"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ isLoading ? "Joining..." : "Join" }}
</button>
</div>
</form>
</div>
</div>
</template>
进入全屏模式 退出全屏模式
conference.vue
[](https://res.cloudinary.com/practicaldev/image/fetch/s--jAkw__F6--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/t8ltpmewwr0hf31o91j9.png)
hmsStore
subscribe
hmsStore.getState
我们使用选择器来确定本地和远程对等点的音频和视频状态。
使用的各种方法的说明:
-
onAudioChange:本地对等方静音/取消静音时的处理程序
-
onVideoChange:本地对等方静音/取消静音视频的处理程序
-
onPeerAudioChange:远程对等方静音/取消静音时的处理程序
-
onPeerVideoChange:远程对等端静音/取消静音视频的处理程序
-
toggleAudio & toggleVideo:本地音视频静音/取消静音功能
hmsActions.attachVideo
selectIsPeerAudioEnabledselectIsPeerVideoEnabled
// Add the following to src/components/Conference.vue
<script setup lang="ts">
import { ref, reactive, onUnmounted } from "vue";
import {
selectPeers,
HMSPeer,
HMSTrackID,
selectIsLocalAudioEnabled,
selectIsLocalVideoEnabled,
selectIsPeerAudioEnabled,
selectIsPeerVideoEnabled,
} from "@100mslive/hms-video-store";
import { hmsStore, hmsActions } from "../hms";
const videoRefs: any = reactive({});
const remotePeerProps: any = reactive({});
const allPeers = ref<HMSPeer[]>([]);
const isAudioEnabled = ref(hmsStore.getState(selectIsLocalAudioEnabled));
const isVideoEnabled = ref(hmsStore.getState(selectIsLocalVideoEnabled));
enum MediaState {
isAudioEnabled = "isAudioEnabled",
isVideoEnabled = "isVideoEnabled",
}
onUnmounted(() => {
if (allPeers.value.length) leaveMeeting();
});
const leaveMeeting = () => {
hmsActions.leave();
};
const onAudioChange = (newAudioState: boolean) => {
isAudioEnabled.value = newAudioState;
};
const onVideoChange = (newVideoState: boolean) => {
isVideoEnabled.value = newVideoState;
};
const onPeerAudioChange = (isEnabled: boolean, peerId: string) => {
if (videoRefs[peerId]) {
remotePeerProps[peerId][MediaState.isAudioEnabled] = isEnabled;
}
};
const onPeerVideoChange = (isEnabled: boolean, peerId: string) => {
if (videoRefs[peerId]) {
remotePeerProps[peerId][MediaState.isVideoEnabled] = isEnabled;
}
};
const renderPeers = (peers: HMSPeer[]) => {
allPeers.value = peers;
peers.forEach((peer: HMSPeer) => {
if (videoRefs[peer.id]) {
hmsActions.attachVideo(peer.videoTrack as HMSTrackID, videoRefs[peer.id]);
// If the peer is a remote peer, attach a listener to get video and audio states
if (!peer.isLocal) {
// Set up a property to track the audio and video states of remote peer so that
if (!remotePeerProps[peer.id]) {
remotePeerProps[peer.id] = {};
}
remotePeerProps[peer.id][MediaState.isAudioEnabled] = hmsStore.getState(
selectIsPeerAudioEnabled(peer.id)
);
remotePeerProps[peer.id][MediaState.isVideoEnabled] = hmsStore.getState(
selectIsPeerVideoEnabled(peer.id)
);
// Subscribe to the audio and video changes of the remote peer
hmsStore.subscribe(
(isEnabled) => onPeerAudioChange(isEnabled, peer.id),
selectIsPeerAudioEnabled(peer.id)
);
hmsStore.subscribe(
(isEnabled) => onPeerVideoChange(isEnabled, peer.id),
selectIsPeerVideoEnabled(peer.id)
);
}
}
});
};
const toggleAudio = async () => {
const enabled = hmsStore.getState(selectIsLocalAudioEnabled);
await hmsActions.setLocalAudioEnabled(!enabled);
};
const toggleVideo = async () => {
const enabled = hmsStore.getState(selectIsLocalVideoEnabled);
await hmsActions.setLocalVideoEnabled(!enabled);
// rendering again is required for the local video to show after turning off
renderPeers(hmsStore.getState(selectPeers));
};
// HMS Listeners
hmsStore.subscribe(renderPeers, selectPeers);
hmsStore.subscribe(onAudioChange, selectIsLocalAudioEnabled);
hmsStore.subscribe(onVideoChange, selectIsLocalVideoEnabled);
</script>
<template>
<main class="mx-10 min-h-[80vh]">
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-3 my-6">
<div v-for="peer in allPeers" :key="peer.id" class="relative">
<video
autoplay
:muted="peer.isLocal"
playsinline
class="h-full w-full object-cover"
:ref="
(el) => {
if (el) videoRefs[peer.id] = el;
}
"
></video>
<p
class="
flex
justify-center
items-center
py-1
px-2
text-sm
font-medium
bg-black bg-opacity-80
text-white
pointer-events-none
absolute
bottom-0
left-0
"
>
<span
class="inline-block w-6"
v-show="
(peer.isLocal && isAudioEnabled) ||
(!peer.isLocal &&
remotePeerProps?.[peer.id]?.[MediaState.isAudioEnabled])
"
>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
stroke="#FFF"
fill="#FFF"
d="m23 14v3a7 7 0 0 1 -14 0v-3h-2v3a9 9 0 0 0 8 8.94v2.06h-4v2h10v-2h-4v-2.06a9 9 0 0 0 8-8.94v-3z"
/>
<path
stroke="#FFF"
fill="#FFF"
d="m16 22a5 5 0 0 0 5-5v-10a5 5 0 0 0 -10 0v10a5 5 0 0 0 5 5z"
/>
<path d="m0 0h32v32h-32z" fill="none" />
</svg>
</span>
<span
class="inline-block w-6"
v-show="
(peer.isLocal && !isAudioEnabled) ||
(!peer.isLocal &&
!remotePeerProps?.[peer.id]?.[MediaState.isAudioEnabled])
"
>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
fill="#FFF"
d="m23 17a7 7 0 0 1 -11.73 5.14l1.42-1.41a5 5 0 0 0 8.31-3.73v-4.58l9-9-1.41-1.42-26.59 26.59 1.41 1.41 6.44-6.44a8.91 8.91 0 0 0 5.15 2.38v2.06h-4v2h10v-2h-4v-2.06a9 9 0 0 0 8-8.94v-3h-2z"
/>
<path
fill="#FFF"
d="m9 17.32c0-.11 0-.21 0-.32v-3h-2v3a9 9 0 0 0 .25 2.09z"
/>
<path fill="#FFF" d="m20.76 5.58a5 5 0 0 0 -9.76 1.42v8.34z" />
<path d="m0 0h32v32h-32z" fill="none" />
</svg>
</span>
<span class="inline-block">
{{ peer.isLocal ? `You (${peer.name})` : peer.name }}</span
>
</p>
<p
class="text-white text-center absolute top-1/2 right-0 left-0"
v-show="
(peer.isLocal && !isVideoEnabled) ||
(!peer.isLocal &&
!remotePeerProps?.[peer.id]?.[MediaState.isVideoEnabled])
"
>
Camera Off
</p>
</div>
</div>
<div
class="mx-auto mt-10 flex items-center justify-center"
v-if="allPeers.length"
>
<button
class="bg-teal-800 text-white rounded-md p-3 block"
@click="toggleAudio"
>
{{ isAudioEnabled ? "Mute" : "Unmute" }} Microphone
</button>
<button
class="bg-indigo-400 text-white rounded-md p-3 block mx-5"
@click="toggleVideo"
>
{{ isVideoEnabled ? "Mute" : "Unmute" }} Camera
</button>
<button
class="bg-rose-800 text-white rounded-md p-3 block"
@click="leaveMeeting"
>
Leave Meeting
</button>
</div>
<div v-else>
<p class="text-white text-center font-bold text-2xl">
Hold On!, Loading Video Tiles...
</p>
</div>
</main>
</template>
进入全屏模式 退出全屏模式
将组件添加到 App.vue
selectRoomStartedConferenceJoin
<script setup lang="ts">
import { ref } from "vue";
import { selectRoomStarted } from "@100mslive/hms-video-store";
import { hmsStore } from "./hms";
import Join from "./components/Join.vue";
import Conference from "./components/Conference.vue";
const isConnected = ref(false);
const onConnection = (connectionState: boolean | undefined) => {
isConnected.value = Boolean(connectionState);
};
hmsStore.subscribe(onConnection, selectRoomStarted);
</script>
<template>
<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<img
class="mx-auto block h-20 w-auto"
src="https://www.100ms.live/assets/logo.svg"
alt="100ms"
/>
<h2 class="mt-6 text-center text-3xl font-extrabold text-white">
Kofi Mupati Video Call Meeting
</h2>
</div>
<Conference v-if="isConnected" />
<Join v-else />
</div>
</template>
进入全屏模式 退出全屏模式
添加环境变量
.env
请注意,我设置了默认房间名称,以防止每次我们尝试加入视频聊天时创建房间。
要让其他人加入视频聊天,他们必须使用相同的房间名称。
ROOM_URL=https://prod-in2.100ms.live/api/v2/rooms
APP_ACCESS_KEY=your_hms_app_access_key_from_dashboard
APP_SECRET=your_hms_app_secret_from_dashboard
VITE_APP_DEFAULT_ROOM=kofi_mupati_secret_room
进入全屏模式 退出全屏模式
测试应用程序
- 使用 Netlify-cli 在本地运行应用程序。该应用程序将在以下端口上打开:http://localhost:8888/
ntl dev
进入全屏模式 退出全屏模式
1.打开两个浏览器。一个应该处于常规模式,另一个应处于隐身模式并打开应用程序将运行的链接。
- 输入您的用户名并加入视频聊天。
视觉学习者可以在YouTube上观看应用演示
结论
您可以在此处找到完整项目存储库。
对我来说,简单地订阅特定状态的能力使得 100ms SDK 非常易于使用。类型定义很棒,文档很简单,并提供了非常好的开发人员体验。
我希望本教程是对 100ms.live 平台的一个非常受欢迎的介绍,我期待您将构建的令人惊叹的应用程序。