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

[SkyWay](https://skyway.ntt.com/ja/)が新しくなったとのことで。

旧プランと変わらず、無料プランもしっかり用意されています。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/614e89be-cf97-21cb-0bdb-ebb169b46c32.png)

[JavaScript SDKのチュートリアル](https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/)も公開されているので、さっそくReactバージョンを作ってみるとしましょう。

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

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

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

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

https://console.skyway.ntt.com/signup

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

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

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

## Reactプロジェクト準備

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

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

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

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/412fe228-bd4e-55db-ef90-7aa820053c2a.png)

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

```bash
$ npm i @skyway-sdk/room
```

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

## 画面の準備

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

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

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

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

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

export default App;
```

起動。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/041fd01f-0c42-0924-c6bf-4d9a81716443.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]);
```

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

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/6e3d8eec-63bb-cbba-1785-342a26b467d4.png)

### ルームに参加

ルーム名を入力して参加ボタンをクリックすることで、ルームを作成 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！

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2903443/c24a9a9e-4e51-054d-d4a7-c9b39720e22e.png)

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

## コード全体

```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} />;
};
```

## 以上

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

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

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