发布于 2026-01-06 11 阅读
1

使用 100ms 和 Next.js 构建 Discord 舞台频道克隆 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

使用 100ms 和 Next.js 构建一个 Discord 舞台频道克隆。

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

自从 Clubhouse 应用走红以来,即时语音聊天室的受欢迎程度迅速增长,并被 Slack、Twitter 和 Discord 等多个平台所采用。这些聊天室非常适合举办问答环节、小组讨论等等。

本文最初发布于此处 -此处

今年早些时候,Discord 推出了 Stages,这是一个纯音频频道,旨在将发言者和听众分开,从而更好地与 Discord 社区互动。在本篇博客中,我们将学习如何使用 100ms 轻松构建类似的平台。

我们将建造什么

我们将使用 100ms React SDK 构建自定义音频房间应用程序,该应用程序将模仿 Discord 的这些功能。

阶段:

允许用户以发言者、听众或主持人的身份加入。
发言者和主持人可以自行静音或取消静音。

听众只能旁听对话、举手发言或离开房间。
主持人可以静音任何人,也可以更改任何人的发言者或听众角色。

读完这篇博客,您应该能够使用 Next.js(一个 React 框架)和 100ms SDK 构建类似这样的应用程序:

看起来会是这样

构建此项目的唯一前提条件是对 Next.js 和 React Hooks 有基本的了解。Next.js 官方文档是了解 Next.js 工作原理的绝佳起点,但即使您之前只使用过 React,仍然可以继续学习。

熟悉基于 Flux 的架构固然有益,但并非必要条件,也无需事先了解 WebRTC。这真是太棒了!

项目设置

在深入代码之前,请先从 100ms 控制面板创建一个 100ms 帐户以获取您的用户名token_endpoint和密码room_id。我们将在应用程序构建的后续阶段需要这些凭据。

创建账户后,请按照以下步骤创建您的应用程序并将其设置到 100ms 控制面板上:

定义角色

我们的应用程序中将有四个角色:,,listener让我们为每个角色设置权限,首先是监听器角色。speakerhandraisemoderator

对于该listener角色,我们可以关闭所有发布策略,因为我们不希望听众共享他们的音频、视频或屏幕。听众仍然可以收听其他人的音频。

在权限部分,取消选中除以下选项之外的所有选项Can change any participant's role permission

第二张图片

对于handraise角色设置,我们可以再次关闭所有发布策略,仅保留Can change any participant's role权限启用状态。此权限允许我们在用户listener角色和举手角色之间切换,并帮助我们实现举手功能。

第三张图片

当用户listener想要成为管理员时speaker,他们可以点击举手按钮,将其角色更改为管理员handraise。当用户的角色为管理员时handraise,我们会在其头像旁边显示一个小徽章,以通知版主。

现在来说说speaker角色设置,因为我们正在创建一个纯音频房间,所以我们只需要勾选Can share audio发布策略,其他选项保持未勾选状态即可。我们可以将该speaker角色的所有权限都关闭。

第四幅图

最后,对于moderator角色,我们可以检查“可共享音频”发布策略,然后继续设置权限。在权限部分,启用Can change any participant's role相应的权限Can mute any participant

第五幅图

对于所有角色,将订阅策略设置为speakermoderator。这样,我们就可以继续从 100ms 控制面板获取所需的凭据了。

获取 token_enpoint

创建自定义应用程序并设置好角色后,请前往“开发者”选项卡获取令牌端点 URL。请妥善保管此 URL。我们将在后续章节中将其存储在环境变量中。

第六幅图

获得room_id

要获取房间 ID,请前往100ms 控制面板的“房间”选项卡。如果您还没有房间,可以创建一个房间来获取其 ID。否则,请复制现有房间的 ID 并暂时粘贴到某个位置。

第七幅图

理解术语

我知道你很兴奋想要开始编写代码,但让我们花点时间来了解与 100ms SDK 相关的关键术语,以便我们达成共识。

房间 — 房间是 100ms SDK 在连接成功后返回的基本对象。它包含对等节点、音轨以及渲染实时音频/视频应用所需的一切信息的引用。

Peer — Peer 是 100ms SDK 返回的对象,其中包含有关用户的所有信息,例如姓名、角色、视频轨道等。

轨道 — 轨道代表用户正在发布的音频或视频。

角色——角色定义了同伴可以看到/听到谁,他们发布视频的质量,他们是否有权限发布视频/屏幕共享,将某人静音,更改某人的角色。

入门代码概述

为了简化开发过程,您可以使用template以下命令克隆此仓库的分支,从而获取包含预构建组件和样式的初始代码:

git clone -b template https://github.com/itsnitinr/discord-stages-clone-100ms.git 
Enter fullscreen mode Exit fullscreen mode

此初始代码使用 Create Next App CLI 工具和 Tailwind CSS 模板构建。构建此项目所需的所有依赖项,例如SDK @100mslive/hms-video@100mslive/hms-video-react均已添加到package.json文件中。

因此,请务必在继续操作之前,在本地运行npm installyarn install安装这些依赖项。

还记得我们之前保存的令牌端点 URL 和房间 ID 吗?现在需要将它们复制到环境变量文件中。初始代码中包含一个这样的.env.local.example文件。

cp .env.local.example .env.local
Enter fullscreen mode Exit fullscreen mode

运行以下命令复制此示例 env 文件并创建一个实际的文件:
现在,将令牌端点 URL 和房间 ID 添加到此.env.local文件中:

// .env.local
TOKEN_ENDPOINT = <YOUR-TOKEN-ENDPOINT-URL>
ROOM_ID = <YOUR-ROOM-ID>
Enter fullscreen mode Exit fullscreen mode

要启动 Next.js 开发服务器,请dev按如下方式运行脚本:

npm run dev
#or
yarn dev
Enter fullscreen mode Exit fullscreen mode

在浏览器中访问http://localhost:3000,如果一切顺利,您将看到以下屏幕:

第八幅图

太棒了!接下来我们将逐一实现这些功能。

建设项目

在开始使用 100ms React SDK 中的 hooks、selectors 或 store 之前,我们需要用<HMSRoomProvider />组件包裹整个应用程序@100mslive/hms-video-react package

完成此步骤后,您的代码应如下所示:

// pages/index.js

import { HMSRoomProvider } from '@100mslive/hms-video-react';
import Head from 'next/head';

import Join from '../components/Join';
import Room from '../components/Room';

const StagesApp = () => {
  const isConnected = false;
  return isConnected ? <Room /> : <Join />;
};

const App = () => {
  return (
    <HMSRoomProvider>
      <Head>
        <title>Discord Stages Clone</title>
      </Head>
      <StagesApp />
    </HMSRoomProvider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode
加入房间

目前,我们根据 isConnected 变量的值来有条件地渲染<Room />组件或子<Join />组件。但是,目前它的值被硬编码为 false。

要检查用户是否已连接到房间,我们可以使用selectIsConnectedToRoom选择器和useHMSStore钩子,如下所示:

// pages/index.js

import { HMSRoomProvider,
  useHMSStore,
  selectIsConnectedToRoom,
} from '@100mslive/hms-video-react';
import Head from 'next/head';

import Join from '../components/Join';
import Room from '../components/Room';

const StagesApp = () => {
  const isConnected = useHMSStore(selectIsConnectedToRoom);
  return isConnected ? <Room /> : <Join />;
};

const App = () => {
  return (
    <HMSRoomProvider>
      <Head>
        <title>Discord Stages Clone</title>
      </Head>
      <StagesApp />
    </HMSRoomProvider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

默认情况下,用户不会连接到任何房间,因此该<Join />组件会被渲染。让我们在组件内部实现加入房间的功能components/Join.jsx file

要加入房间,我们可以使用hmsActions返回对象的 join() 方法useHMSActions() hook

join(方法接受一个包含用户名、authToken 和一个可选的设置对象的对象作为参数。

我们可以userName从 React hook 创建的本地name状态变量中获取该值useState()。但是,要获取该值authToken,我们需要向我们自定义的 Next.js API 路由发送网络请求,并传入我们想要加入房间的角色。

我们还使用本地角色状态变量跟踪用户选择的角色,类似于name

您可以在文件中找到 API 路由pages/api/token.js。它看起来是这样的:

// pages/api/token.js

import { v4 } from 'uuid';

export default async function getAuthToken(req, res) {
  try {
    const { role } = JSON.parse(req.body);

    const response = await fetch(`${process.env.TOKEN_ENDPOINT}api/token`, {
      method: 'POST',
      body: JSON.stringify({
        user_id: v4(),
        room_id: process.env.ROOM_ID,
        role,
      }),
    });

    const { token } = await response.json();
    res.status(200).json({ token });
  } catch (error) {
    console.log('error', error);
    res.status(500).json({ error });
  }
} 
Enter fullscreen mode Exit fullscreen mode

本质上,此 API 路由POST向我们的 100ms 发出请求token endpoint URL,该 100ms 存储在环境变量中,同时还包含一个唯一的user_idroleroom_id,该 也存储在环境变量中。

如果成功,我们的 Next.js API 路由将返回authToken。使用此路由authToken,我们可以加入room。由于我们不希望用户在加入时开启麦克风,我们可以在可选的设置对象中将其设置isAudioMuted为。true

// components/Join.jsx

import Image from 'next/image';
import { useState } from 'react';
import Avatar from 'boring-avatars';
import { useHMSActions } from '@100mslive/hms-video-react';

import NameInput from './Join/NameInput';
import RoleSelect from './Join/RoleSelect';
import JoinButton from './Join/JoinButton';

const Join = () => {
  const hmsActions = useHMSActions();

  const [name, setName] = useState('');
  const [role, setRole] = useState('listener');

  const joinRoom = async () => {
    try {
      const response = await fetch('/api/token', {
        method: 'POST',
        body: JSON.stringify({ role }),
      });
      const { token } = await response.json();
      hmsActions.join({
        userName: name || 'Anonymous',
        authToken: token,
        settings: {
          isAudioMuted: true,
        },
      });
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <>
      <Image
        src="https://imgur.com/27iLD4R.png"
        alt="Login background"
        className="w-screen h-screen object-cover relative"
        layout="fill"
      />
      <div className="bg-gray-800 rounded-lg w-11/12 md:w-1/2 lg:w-1/3 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-8 text-white shadow-lg space-y-4 flex flex-col items-center max-w-md">
        <Avatar name={name} variant="marble" size="72" />
        <NameInput name={name} setName={setName} />
        <RoleSelect role={role} setRole={setRole} />
        <JoinButton joinRoom={joinRoom} />
      </div>
    </>
  );
};

export default Join;
Enter fullscreen mode Exit fullscreen mode

第九张图片

只需几行代码,我们就实现了加入房间和渲染<Room />组件的功能。现在,让我们继续渲染连接到我们房间的同伴。

渲染同伴

现在,如果您查看文件<Room />中的组件components/Room.jsx,可以看到我们已将 peers 的值硬编码为一个空数组。让我们将其动态化。

为此,我们可以结合selectPeers selector钩子useHMSStore()函数,获取连接到房间的所有对等节点的数组,数组以对象的形式存储。每个对等节点对象都包含诸如其地址nameroleName位置等信息,我们可以利用这些信息来相应地渲染它们的图块。

获取到所有对等节点的数组后,我们可以使用filter()JavaScript 数组方法将它们拆分listenersAndHandraised,并speakersAndModerators使用roleName每个对等节点对象的属性。这将有助于我们根据用户的角色渲染相应的图块。

如果用户角色为“是”listener或“否handraise”,则渲染<ListenerTile />组件。否则,渲染组件<SpeakerTile />。渲染这些图块时,将peer对象作为 prop 传递,以便在图块中显示对等用户的信息。

// components/Room.jsx

import { selectPeers, useHMSStore } from '@100mslive/hms-video-react';

import RoomInfo from './Room/RoomInfo';
import Controls from './Room/Controls';
import ListenerTile from './User/ListenerTile';
import SpeakerTile from './User/SpeakerTile';

const Room = () => {
  const peers = useHMSStore(selectPeers);

  const speakersAndModerators = peers.filter(
    (peer) => peer.roleName === 'speaker' || peer.roleName === 'moderator'
  );
  const listenersAndHandraised = peers.filter(
    (peer) => peer.roleName === 'listener' || peer.roleName === 'handraise'
  );

  return (
    <div className="flex flex-col bg-main text-white min-h-screen p-6">
      <RoomInfo count={peers.length} />
      <div className="flex-1 py-8">
        <h5 className="uppercase text-sm text-gray-300 font-bold mb-8">
          Speakers - {speakersAndModerators.length}
        </h5>
        <div className="flex space-x-6 flex-wrap">
          {speakersAndModerators.map((speaker) => (
            <SpeakerTile key={speaker.id} peer={speaker} />
          ))}
        </div>
        <h5 className="uppercase text-sm text-gray-300 font-bold my-8">
          Listeners - {listenersAndHandraised.length}
        </h5>
        <div className="flex space-x-8 flex-wrap">
          {listenersAndHandraised.map((listener) => (
            <ListenerTile key={listener.id} peer={listener} />
          ))}
        </div>
      </div>
      <Controls />
    </div>
  );
};

export default Room;
Enter fullscreen mode Exit fullscreen mode

第十幅图

<RoomInfo />组件接收一个 count 属性,其值为连接到房间的对等节点总数。对于演讲者和听众标题,我们可以分别访问数组的长度属性来获取它们的数量speakersAndModeratorslistenersAndHandraised

为控件添加功能

我们来看看<Controls />内部组件components/Room/Controls.jsx。基本上,我们将有三个控件:一个用于打开或关闭麦克风,一个用于切换举手功能,最后一个用于离开房间。我们将在本文的后半部分介绍举手功能。

<MicButton />负责切换麦克风功能的组件只会显示给发言人和主持人,而该组件<ExitButton />则会显示给所有角色。

我们需要检查角色状态和麦克风是否开启,以便相应地渲染按钮。为此,我们使用selectIsLocalAudioEnabled选择器获取麦克风状态,并使用selectLocalPeer另一个选择器获取本地对等对象。

// components/Room/Controls.jsx

import {
  useHMSStore,
  selectIsLocalAudioEnabled,
  selectLocalPeer,
} from '@100mslive/hms-video-react';

import MicButton from './MicButton';
import ExitButton from './ExitButton';
import HandRaiseButton from './HandRaiseButton';

const Controls = () => {
  const isMicOn = useHMSStore(selectIsLocalAudioEnabled);
  const peer = useHMSStore(selectLocalPeer);

  const isListenerOrHandraised =
    peer.roleName === 'listener' || peer.roleName === 'handraise';

  return (
    <div className="flex justify-center space-x-4">
      {!isListenerOrHandraised && (
        <MicButton isMicOn={isMicOn} toggleMic={() => {}} />
      )}
      {isListenerOrHandraised && (
        <HandRaiseButton
          isHandRaised={peer.roleName === 'handraise'}
          toggleHandRaise={() => {}}
        />
      )}
      <ExitButton exitRoom={() => {}} />
    </div>
  );
};

export default Controls;
Enter fullscreen mode Exit fullscreen mode

现在,要添加这些功能,首先创建一个新的钩子实例useHMSActions()并将其存储在其中hmsActions

调用组件 prop中对象setLocalAudioEnabled()的方法hmsActionstoggleMic<MicButton />

此方法接受一个布尔值:true用于打开麦克风,false用于关闭麦克风。由于我们需要切换麦克风的开关状态,因此可以使用 `!` 运算符传递与当前状态相反的值。

要退出房间,我们可以直接调用对象leave()上的方法hmsActions

// components/Room/Controls.jsx

import {
  useHMSStore,
  useHMSActions,
  selectIsLocalAudioEnabled,
  selectLocalPeer,
} from '@100mslive/hms-video-react';

import MicButton from './MicButton';
import ExitButton from './ExitButton';
import HandRaiseButton from './HandRaiseButton';

const Controls = () => {
  const hmsActions = useHMSActions();
  const isMicOn = useHMSStore(selectIsLocalAudioEnabled);
  const peer = useHMSStore(selectLocalPeer);

  const isListenerOrHandraised =
    peer.roleName === 'listener' || peer.roleName === 'handraise';

  return (
    <div className="flex justify-center space-x-4">
      {!isListenerOrHandraised && (
        <MicButton
          isMicOn={isMicOn}
          toggleMic={() => hmsActions.setLocalAudioEnabled(!isMicOn)}
        />
      )}
      {isListenerOrHandraised && (
        <HandRaiseButton
          isHandRaised={peer.roleName === 'handraise'}
          toggleHandRaise={() => {}}
        />
      )}
      <ExitButton exitRoom={() => hmsActions.leave()} />
    </div>
  );
};

export default Controls;
Enter fullscreen mode Exit fullscreen mode

第十一幅图

显示音频电平和麦克风状态

当用户说话时,我们希望在用户头像外侧显示一个绿色圆环来提示。这需要我们了解说话者的音量大小,但我们该如何获取呢?使用 100ms React SDK,只需使用选择器即可轻松实现selectPeerAudioByID

此选择器函数以对等方的 ID 作为参数,并返回一个表示音频级别的整数。我们可以将其赋值给一个变量,并检查该变量是否大于 0,以此来判断用户是否正在说话。

类似地,要检查用户的麦克风是否打开,我们可以使用selectIsPeerAudioEnabled选择器,它也接受对等方的 ID 作为参数,并返回一个布尔值来指示麦克风状态。

使用这两个选择器,我们可以通过 Tailwind CSS 类添加一个圆环并显示相应的图标来渲染 UI。转到<SpeakerTile />组件内部components/User/SpeakerTile.jsx并进行以下更改:

// components/User/SpeakerTile.jsx

import Avatar from 'boring-avatars';
import { FiMic, FiMicOff } from 'react-icons/fi';
import {
  useHMSStore,
  selectPeerAudioByID,
  selectIsPeerAudioEnabled,
} from '@100mslive/hms-video-react';

import PermissionsMenu from './PermissionsMenu';

const SpeakerTile = ({ peer }) => {
  const isSpeaking = useHMSStore(selectPeerAudioByID(peer.id)) > 0;
  const isMicOn = useHMSStore(selectIsPeerAudioEnabled(peer.id));

  return (
    <div className="relative bg-secondary px-12 py-6 rounded-lg border border-purple-500">
      <PermissionsMenu id={peer.id} audioTrack={peer.audioTrack} />
      <div className="flex flex-col gap-y-4 justify-center items-center">
        <div
          className={
            isSpeaking
              ? 'ring rounded-full transition ring-3 ring-green-600 p-0.5'
              : 'p-0.5'
          }
        >
          <Avatar name={peer.name} size="60" />
        </div>
        <p className="flex items-center gap-x-2">
          {peer.name}
          {isMicOn ? (
            <FiMic className="h-3 w-3" />
          ) : (
            <FiMicOff className="h-3 w-3" />
          )}
        </p>
      </div>
    </div>
  );
};

export default SpeakerTile;
Enter fullscreen mode Exit fullscreen mode

第十二幅图

权限菜单

<PermissionsMenu />现在该给文件中的组件添加一些功能了components/User/PermissionsMenu.jsx。我们希望只有拥有相应moderator角色时才显示此菜单。

要获取我们的localPeer对象,我们可以使用selectLocalPeer选择器函数。该函数会返回一个包含特定属性的对象roleName,我们可以通过检查该属性来获取我们的角色。

或者,您也可以选择使用selectLocalPeerRole选择器并访问name返回对象的属性。

要检查我们是否是moderator,请使用===相等运算符检查我们的是否roleName等于moderator

因此,我们可以渲染此组件,或者如果我们不是……则渲染 null moderator

权限菜单有三个选项:Mute PeerMake ListenerMake Speaker。要实现这些功能,请创建一个新的钩子实例useHMSActions()以访问所有必需的方法。

要使对等方静音,请setRemoteTrackEnabled()调用该方法,hmsActions并将对等方的音频轨道(我们从 props 中获取)false作为参数。

要更改对等节点的角色,请调用该changeRole()方法,hmsActions并提供对等节点的 ID、新角色以及强制布尔值,以便在不询问或给予其接受/拒绝机会的情况下更改其角色。

// components/User/PermissionsMenu.jsx

import { useState } from 'react';
import { AiOutlineMenu } from 'react-icons/ai';
import {
  useHMSStore,
  useHMSActions,
  selectLocalPeer,
} from '@100mslive/hms-video-react';

const PermissionsMenu = ({ audioTrack, id }) => {
  const hmsActions = useHMSActions();

  const mutePeer = () => {
    hmsActions.setRemoteTrackEnabled(audioTrack, false);
  };

  const changeRole = (role) => {
    hmsActions.changeRole(id, role, true);
  };

  const localPeer = useHMSStore(selectLocalPeer);

  const [showMenu, setShowMenu] = useState(false);

  const btnClass = 'w-full text-sm font-semibold hover:text-purple-800 p-1.5';

  const isModerator = localPeer.roleName === 'moderator';

  if (isModerator) {
    return (
      <div className="absolute right-1 top-1 z-50">
        <AiOutlineMenu
          className="ml-auto"
          onClick={() => setShowMenu(!showMenu)}
        />
        {showMenu && (
          <div className="mt-2 bg-white text-black py-2 rounded-md">
            <button className={btnClass} onClick={() => mutePeer()}>
              Mute
            </button>
            <button className={btnClass} onClick={() => changeRole('listener')}>
              Make Listener
            </button>
            <button className={btnClass} onClick={() => changeRole('speaker')}>
              Make Speaker
            </button>
          </div>
        )}
      </div>
    );
  } else {
    return null;
  }
};

export default PermissionsMenu;
Enter fullscreen mode Exit fullscreen mode

第十三幅图

增加举手功能

最后,让我们为应用程序添加最后一个功能:hand-raise作为用户listener,您可能也希望有机会发言。为了通知用户moderator,我们可以创建一个简单的举手按钮,该按钮会在您的头像旁边显示一个徽章,表明您有兴趣发言。

因此,首先要构建点击组件listener将我们的角色从 更改为 的功能handraise<HandRaiseButton />

为此,请返回到<Controls />内部组件components/Room/Controls.jsx

这里你会看到一个<HandRaiseButton />带有两个属性的组件:一个isHandRaised布尔属性,用于检查你当前是否举手;以及一个toggleHandRaise用于切换举手状态的函数。此外,只有当你是管理员listener或拥有handraise特定角色时,我们才会显示此按钮。

对于isHandRaised属性,我们只需要通过访问roleName本地对等节点的属性来获取我们的角色,并检查它是否等于该handraise角色即可。

对于切换功能,我们可以像对组件那样,使用对象changeRole()上可用的方法hmsActions<PermissionsMenu />

changeRole()方法接受本地对等节点的 ID、要设置的新角色以及一个强制布尔属性。对于新角色,如果我们listener当前是监听器,则需要将handraise参数传递给它。如果我们已经拥有监听器角色handraise,则需要将其设置回监听器。

你的代码应该如下所示:

// components/Room/Controls.jsx

import {
  useHMSStore,
  useHMSActions,
  selectIsLocalAudioEnabled,
  selectLocalPeer,
} from '@100mslive/hms-video-react';

import MicButton from './MicButton';
import ExitButton from './ExitButton';
import HandRaiseButton from './HandRaiseButton';

const Controls = () => {
  const hmsActions = useHMSActions();
  const isMicOn = useHMSStore(selectIsLocalAudioEnabled);
  const peer = useHMSStore(selectLocalPeer);

  const isListenerOrHandraised =
    peer.roleName === 'listener' || peer.roleName === 'handraise';

  return (
    <div className="flex justify-center space-x-4">
      {!isListenerOrHandraised && (
        <MicButton
          isMicOn={isMicOn}
          toggleMic={() => hmsActions.setLocalAudoEnabled(!isMicOn)}
        />
      )}
      {isListenerOrHandraised && (
        <HandRaiseButton
          isHandRaised={peer.roleName === 'handraise'}
          toggleHandRaise={() =>
            hmsActions.changeRole(
              peer.id,
              peer.roleName === 'listener' ? 'handraise' : 'listener',
              true
            )
          }
        />
      )}
      <ExitButton exitRoom={() => hmsActions.leave()} />
    </div>
  );
};

export default Controls;
Enter fullscreen mode Exit fullscreen mode

第十四幅图

初始代码中已经包含了在组件中显示举手徽章的代码<ListenerTile />。在这个组件内部,我们只需要检查同伴的角色是否已设置handraise,然后根据条件渲染<HandRaiseBadge />相应的徽章即可。

节目到此结束!

使用 100ms SDK 构建实时音频应用程序就是这么简单。希望您喜欢这个应用程序的开发过程,如有任何疑问,欢迎访问 100ms Discord 服务器。

我们迫不及待地想看到你用 100ms 构建的所有精彩项目。在此之前,祝你编程愉快!

立即查看 100ms -> https://www.100ms.live/blog/build-discord-stage-channel-clone-hms

文章来源:https://dev.to/100mslive/build-a-discord-stage-channel-clone-with-100ms-and-nextjs-5heb