11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

新SkyWayのチュートリアルを React×TypeScript で

Posted at

SkyWayが新しくなったとのことで。

旧プランと変わらず、無料プランもしっかり用意されています。

JavaScript SDKのチュートリアルも公開されているので、さっそくReactバージョンを作ってみるとしましょう。

使用したライブラリ・フレームワークとバージョン

  • npm: v8.19.2
  • react: v18.2.0
  • typescript: v4.9.5
    • create-react-app で入ってきたバージョン
  • @skyway-sdk/room: 1.4.1

アカウントとアプリケーションの準備

SkyWayのアカウントを作成する。

旧SkyWayのアカウントではログインできなかったので、どうやらアカウント管理が別のようです。

アカウントを作ったらコンソールにログインし、

  • 新規プロジェクトを作成
  • プロジェクト内に、新規アプリケーションを作成
  • アプリケーションIDとシークレットキーをコピーしておく

Reactプロジェクト準備

create-react-appを使います。もちろんTypeScriptです。

$ npx create-react-app skyway-react-sample --template typescript

作成された skyway-react-sample フォルダに移動して npm run start し、http://localhost:3000/ でサイトが起動することを確認しておく。

skywayのライブラリをインストール。

$ npm i @skyway-sdk/room

これでプロジェクト準備は完了。

画面の準備

適当な名前のコンポーネント MainContent.tsx を作成。

MainContent.tsx
export const MainContent = () => {
  return (
    <div>main!</div>
  );
}

App.tsxApp.css の中身をごそっと消して、 App.tsx には MainContent だけ書いておく。

App.tsx
import './App.css';
import { MainContent } from './MainContent';

function App() {
  return (
    <MainContent />
  );
}

export default App;

起動。

image.png

あとは先ほどの SkyWay のアプリケーションIDとシークレットキーを環境変数として読み込めるようにしておきましょう。
プロジェクトルートに .env ファイルを作成。

.env
REACT_APP_SKYWAY_APP_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
REACT_APP_SKYWAY_SECRET_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

MainContent コンポーネント内の先頭で読ませておきましょう。

MainContent.tsx
  const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
  const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);

これで準備OK。
あとはチュートリアルに沿って画面を作っていきましょう。

画面作成

SkyWay Auth Token 生成

MainContent の最初で useMemo でtokenを作っておく。

MainContent.tsx
  const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
  const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);

  const token = useMemo(() => {
    if (appId == null || secretKey == null) return undefined;

    return new SkyWayAuthToken({
      jti: uuidV4(),
      iat: nowInSec(),
      exp: nowInSec() + 60 * 60 * 24,
      scope: {
        app: {
          id: appId,
          turn: true,
          actions: ["read"],
          channels: [
            {
              id: "*",
              name: "*",
              actions: ["write"],
              members: [
                {
                  id: "*",
                  name: "*",
                  actions: ["write"],
                  publication: {
                    actions: ["write"],
                  },
                  subscription: {
                    actions: ["write"],
                  },
                },
              ],
              sfuBots: [
                {
                  actions: ["write"],
                  forwardings: [
                    {
                      actions: ["write"],
                    },
                  ],
                },
              ],
            },
          ],
        },
      },
    }).encode(secretKey);
  }, [appId, secretKey]);

画面レイアウト

MainContent からreturnするDOMを以下に置きかえる。

MainContent.tsx
  return (
    <div>
      <p>ID: </p>
      <div>
        room name: <input type="text" />
        <button>join</button>
      </div>
      <video ref={localVideo} width="400px" muted playsInline></video>
      <div>{/* TODO ここに他の人のメディア */}</div>
    </div>
  );

自分の映像を映す video 要素を参照するために useRef を作っておく。
先ほどの token 作ったとこの下に。

MainContent.tsx
  const localVideo = useRef<HTMLVideoElement>(null);

自分の映像を映す

ページがロードされて、token が生成されたら、自分のカメラ映像&音声を取得して video に映します。

MainContent.tsx
  // ローカスストリームをここに保持する
  const [localStream, setLocalStream] = useState<{
    audio: LocalAudioStream;
    video: LocalVideoStream;
  }>();

  // tokenとvideo要素の参照ができたら実行
  useEffect(() => {
    const initialize = async () => {
      if (token == null || localVideo.current == null) return;

      const stream =
        await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
      stream.video.attach(localVideo.current);

      await localVideo.current.play();
      setLocalStream(stream);
    };

    initialize();
  }, [token, localVideo]);

ここまでで起動すると、画面に自分のカメラ映像が映るはずです。

ルームに参加

ルーム名を入力して参加ボタンをクリックすることで、ルームを作成 or ルームに参加できるようにしましょう。

以下のStateを作っておきます。

MainContent.tsx
  // ルーム名
  const [ roomName, setRoomName ] = useState("");
  // 自分自身の参加者情報
  const [ me, setMe ] = useState<LocalP2PRoomMember>();

続いて、参加ボタンが押せるかどうかの状態。

MainContent.tsx
  const canJoin = useMemo(() => {
    return roomName !== "" && localStream != null && me == null;
  }, [roomName, localStream, me]);

最後に、参加ボタンを押したときの処理です。

MainContent.tsx
  const onJoinClick = useCallback(async () => {
    // canJoinまでにチェックされるので普通は起きない
    // assertionメソッドにしてもいい
    if (localStream == null || token == null) return;

    const context = await SkyWayContext.Create(token);

    // ルームを取得、または新規作成
    const room = await SkyWayRoom.FindOrCreate(context, {
      type: 'p2p',
      name: roomName,
    });

    const me = await room.join();
    setMe(me);

    // 映像と音声を配信
    await me.publish(localStream.video);
    await me.publish(localStream.audio);

    // TODO 他の参加者の購読

  }, [roomName, token, localStream]);

これらをDOMに組み込みます。

MainContent.tsx
  return (
    <div>
      <p>ID: {me?.id ?? ""}</p>
      <div>
        room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
        <button onClick={onJoinClick} disabled={!canJoin}>join</button>
      </div>
      <video ref={localVideo} width="400px" muted playsInline></video>
      <div>{/* TODO ここに他の人のメディア */}</div>
    </div>
  );

ルーム名を入れて参加ボタンを押し、ID欄にIDっぽいものが表示されればOK。

他の参加者の音声・映像を取得

ルームに参加したら、その時点でルームに参加している他のメンバーの情報と、その後に参加してきたメンバーの情報を取得する必要がある。

まずはそれを入れておくためのStateを用意。

MainContent.tsx
  const [ otherUserPublications, setOtherUserPublications ] = useState<RoomPublication<LocalStream>[]>([]);

先ほど「TODO 他の参加者の購読」としておいたところで、この処理を追加。

MainContent.tsx
    // 自分以外の参加者情報を取得
    setOtherUserPublications(room.publications.filter(p => p.publisher.id !== me.id));

    // その後に参加してきた人の情報を取得
    room.onStreamPublished.add((e) => {
      if (e.publication.publisher.id !== me.id) {
        setOtherUserPublications(pre => [ ...pre, e.publication ]);
      }
    });

この参加者情報を使って他ユーザの映像・音声を表示するためのコンポーネントを作っておきましょう。

RemoteMedia.tsx
export const RemoteMedia = (props: {
  me: LocalP2PRoomMember,
  publication: RoomPublication<LocalStream>
}) => {
  // TODO
  return <div>{props.publication.id}</div>;
};

publicationの数だけこれをMainContentに配置。

MainContent.tsx
  return (
    <div>
      <p>ID: {me?.id ?? ""}</p>
      <div>
        room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
        <button onClick={onJoinClick} disabled={!canJoin}>join</button>
      </div>
      <video ref={localVideo} width="400px" muted playsInline></video>
      <div>
        {
          me != null && otherUserPublications.map(p => (
            <RemoteMedia key={p.id} me={me} publication={p} />
          ))
        }
      </div>
    </div>
  );

続いて RemoteMedia の中身です。

まずは購読したstreamを入れるためのStateを作っておきます。

RemoteMedia.tsx
  const [ stream, setStream ] = useState<RemoteVideoStream | RemoteAudioStream>();

購読する前は購読ボタンを表示する。

RemoteMedia.tsx
  if (stream == null) {
    return (
      <div>
        <button onClick={onSubscribeClick}>
          {props.publication.publisher.id}: {props.publication.contentType}
        </button>
      </div>
    )
  }

クリック時のcallbackでstreamを生成。

RemoteMedia.tsx
  const onSubscribeClick = useCallback(async () => {
    const { stream } = await props.me.subscribe(props.publication.id);
    // video または audio であることを確認
    if (!("track" in stream)) return;

    setStream(stream);

  }, [ props.publication, props.me ]);

このとき、subscribe で取得される stream がTypeScriptの型付け的には RemoteVideoStream | RemoteAudioStream | RemoteDataStream になっていて、 RemoteDataStream はこの場合想定しない(と思う)ので、チェックしておきます。
たぶん本来はunsub処理とか入れるべきだと思う。

streamの種類によって、video または audio 要素を表示する。
useRef を作っておいて。

RemoteMedia.tsx
  const refVideo = useRef<HTMLVideoElement>(null);
  const refAudio = useRef<HTMLAudioElement>(null);

DOMで指定。

RemoteMedia.tsx
  // 映像のとき
  if (stream.contentType === "video") {
    return <video width="400px" playsInline={true} autoPlay={true} ref={refVideo} />;
  }

  // 音声のとき
  return <audio controls={true} autoPlay={true} ref={refAudio} />;

stream にこの video/audio をアタッチします。

RemoteMedia.tsx
  useEffect(() => {
    if (stream == null) return;

    if (refVideo.current != null) {
      stream.attach(refVideo.current);
    } else if (refAudio.current != null) {
      stream.attach(refAudio.current);
    }
  }, [stream, refVideo, refAudio]);

複数ブラウザを立ち上げて、同じルーム名で入室すれば、映像と音声のsubscribeボタンが表示されます。
クリックで映像と音声のコントロールが表示されればOK!

ここまででチュートリアルの内容としては終わりです。

コード全体

MainContent.tsx
import {
  LocalAudioStream,
  LocalP2PRoomMember,
  LocalStream,
  LocalVideoStream,
  RoomPublication,
  SkyWayAuthToken,
  SkyWayContext,
  SkyWayRoom,
  SkyWayStreamFactory,
  nowInSec,
  uuidV4,
} from "@skyway-sdk/room";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RemoteMedia } from "./RemoteMedia";

export const MainContent = () => {
  const [localStream, setLocalStream] = useState<{
    audio: LocalAudioStream;
    video: LocalVideoStream;
  }>();

  const [roomName, setRoomName] = useState("");
  const [me, setMe] = useState<LocalP2PRoomMember>();

  const [ otherUserPublications, setOtherUserPublications ] = useState<RoomPublication<LocalStream>[]>([]);

  const appId = useMemo(() => process.env.REACT_APP_SKYWAY_APP_ID, []);
  const secretKey = useMemo(() => process.env.REACT_APP_SKYWAY_SECRET_KEY, []);

  const token = useMemo(() => {
    if (appId == null || secretKey == null) return undefined;

    return new SkyWayAuthToken({
      jti: uuidV4(),
      iat: nowInSec(),
      exp: nowInSec() + 60 * 60 * 24,
      scope: {
        app: {
          id: appId,
          turn: true,
          actions: ["read"],
          channels: [
            {
              id: "*",
              name: "*",
              actions: ["write"],
              members: [
                {
                  id: "*",
                  name: "*",
                  actions: ["write"],
                  publication: {
                    actions: ["write"],
                  },
                  subscription: {
                    actions: ["write"],
                  },
                },
              ],
              sfuBots: [
                {
                  actions: ["write"],
                  forwardings: [
                    {
                      actions: ["write"],
                    },
                  ],
                },
              ],
            },
          ],
        },
      },
    }).encode(secretKey);
  }, [appId, secretKey]);

  const localVideo = useRef<HTMLVideoElement>(null);

  useEffect(() => {
    const initialize = async () => {
      if (token == null || localVideo.current == null) return;

      const stream = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
      stream.video.attach(localVideo.current);

      await localVideo.current.play();
      setLocalStream(stream);
    };

    initialize();
  }, [token, localVideo]);

  const canJoin = useMemo(() => {
    return roomName !== "" && localStream != null && me == null;
  }, [roomName, localStream, me]);

  const onJoinClick = useCallback(async () => {
    if (localStream == null || token == null) return;

    const context = await SkyWayContext.Create(token);

    const room = await SkyWayRoom.FindOrCreate(context, {
      type: "p2p",
      name: roomName,
    });

    const me = await room.join();
    setMe(me);

    await me.publish(localStream.video);
    await me.publish(localStream.audio);

    setOtherUserPublications(room.publications.filter(p => p.publisher.id !== me.id));

    room.onStreamPublished.add((e) => {
      if (e.publication.publisher.id !== me.id) {
        setOtherUserPublications(pre => [ ...pre, e.publication ]);
      }
    });

  }, [roomName, token, localStream]);

  return (
    <div>
      <p>ID: {me?.id ?? ""}</p>
      <div>
        room name: <input type="text" value={roomName} onChange={(e) => setRoomName(e.target.value)} />
        <button onClick={onJoinClick} disabled={!canJoin}>join</button>
      </div>
      <video ref={localVideo} width="400px" muted playsInline></video>
      <div>
        {
          me != null && otherUserPublications.map(p => (
            <RemoteMedia key={p.id} me={me} publication={p} />
          ))
        }
      </div>
    </div>
  );
};
RemoteMedia.tsx
import { LocalP2PRoomMember, LocalStream, RemoteAudioStream, RemoteVideoStream, RoomPublication } from "@skyway-sdk/room";
import { useCallback, useEffect, useRef, useState } from "react";

export const RemoteMedia = (props: {
  me: LocalP2PRoomMember,
  publication: RoomPublication<LocalStream>
}) => {
  const [ stream, setStream ] = useState<RemoteVideoStream | RemoteAudioStream>();

  const refVideo = useRef<HTMLVideoElement>(null);
  const refAudio = useRef<HTMLAudioElement>(null);

  useEffect(() => {
    if (stream == null) return;

    if (refVideo.current != null) {
      stream.attach(refVideo.current);
    } else if (refAudio.current != null) {
      stream.attach(refAudio.current);
    }
  }, [stream, refVideo, refAudio]);

  const onSubscribeClick = useCallback(async () => {
    const { stream } = await props.me.subscribe(props.publication.id);

    // video または audio であることを確認
    if (!("track" in stream)) return;

    setStream(stream);

  }, [ props.publication, props.me ]);

  if (stream == null) {
    return (
      <div>
        <button onClick={onSubscribeClick}>
          {props.publication.publisher.id}: {props.publication.contentType}
        </button>
      </div>
    )
  }

  // 映像のとき
  if (stream.contentType === "video") {
    return <video width="400px" playsInline={true} autoPlay={true} ref={refVideo} />;
  }

  // 音声のとき
  return <audio controls={true} autoPlay={true} ref={refAudio} />;
};

以上

ここまではけっこう直感的で簡単ですね。

実際のアプリケーションに組み込む場合には、退室時の処理とかネットワーク切断時とか、もう少しケアの必要な部分があると思いますが、開発者ドキュメントは充実してそうです。

個人的にももうちょっと遊んでみたいかも!

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?