SkyBoard：SkyWay＋Vue.jsで作るライブ配信アプリ

# はじめに
なにやら[新しくなったSkyWayを使ってみよう！](https://qiita.com/official-events/93564ad363199fa7999c)という企画が開催されるとのことなので、せっかくなので何か作ってみようと思いました！
# 作ったもの
WebRTCを使ったライブ配信アプリとして「[SkyBoard](https://skyboard-9bf56.web.app/)」を作ってみました！
アプリ名はSky(Way) + (White)Boardから名付けています。

左側が配信者用の画面で、読み込んだ画像を好きな位置に配置したり、ペンで映像上に書き込んだりすることができます。
右側は視聴者用の画面で、コメントを送信したりすることができます。
![skyboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259236/890700ac-4b18-9587-b7b3-b8a1c0488450.png)
![abstract.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/259236/c0f1e4d9-0401-3971-d2c2-737b903a1a3e.png)


個人的にはWebRTCといえば映像配信というイメージがあったので、せっかくならなにか面白そうな機能をつけた配信アプリを作りたいなーと思っていました。
以前作ったアプリの機能を参考に「配信者が映像上になにかを書いたり画像とかを貼り付けたりできたら面白いんじゃないか？」と思ったのでそこをベースに考えてみました。
この「映像上に書いたり画像を貼り付ける」という機能がホワイトボードっぽいと思ったのでアプリ名の由来になっています。

# 実装
実装したコードについては、主にSkyWayを使用している箇所を中心に載せています。
また、複数のファイルに分けたりしているところを切り出して載せているのでちょっとおかしくなっているところもあるかもしれませんが、あくまで参考程度に考えていただければ幸いです。
## 使用したライブラリ
- vite
- vue.js
- vuetify
- konva(vue-konva)
    - 画像をドラッグで移動させたりサイズ変更などの機能を実現するために使用しています。
- skyway-sdk

## 使用したサービス
- Firebase
    - Hosing
        - 作成したアプリのホスティングに使用しています。
    - Cloud Functions
        - 公式のドキュメントにて『サーバーを構築せずにフロントエンドで SkyWay Auth Token を生成した場合、シークレットキーをエンドユーザーが取得できるため、権限の制限が機能せず注意する必要があります』という記述があったので、Auth Tokenを生成するための関数を作成しました。

## 1. SkyWay Auth Tokenの生成
SkyWayAuthTokenを生成して返り値として返すCloud Functionsを実装しています。
```javascript:functions/index.js
const functions =  require("firebase-functions");
const { SkyWayAuthToken, uuidV4, nowInSec } = require("@skyway-sdk/token");

const appId = /* SKYWAY_APP_ID */;
const secretKey = /* SKYWAY_SECRET_KEY */;

exports.fetchSkyWayToken = functions.https.onCall(() => {
    const skyWayToken = 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);

    return {
        token: skyWayToken
    }
});
```

作成した関数は`App.vue`内で呼び出し、provideで他のコンポーネントから取得できるようにしておきます。
```javascript:main.js(主要部分のみ)
import { initializeApp } from "firebase/app";
import { getFunctions, connectFunctionsEmulator } from "firebase/functions";

const firebaseConfig = {
    /* Your Firebase Config */
};
const firebaseApp = initializeApp(firebaseConfig);
const functions = getFunctions(firebaseApp);
// connectFunctionsEmulator(functions, "localhost", 8080); /* ローカルで開発する際はエミュレータを使用 */
```
```vuejs:App.vue(主要部分のみ)
<script setup>
import { onMounted, provide, readonly, ref } from "vue";
import { getFunctions, httpsCallable } from "firebase/functions";

const functions = getFunctions();
const fetchSkyWayToken = httpsCallable(functions, "fetchSkyWayToken");
const skyWayToken = ref("");
provide("skyWayToken", readonly(skyWayToken);

onMounted( async () => {
    const res = await fetchSkyWayToken();
    skyWayToken.value = res.data.token;
});
</script>
```
## 2. 配信者側の処理
配信者側の処理は主に以下の様になっています。

```vuejs:Publisher.vue
<script setup>
import { onMounted, inject, ref } from "vue";

let context;
let room;
let me;
let dataStream;
const createRoom = (skyWayToken, roomName, audio, video) => {
    if (roomName === "") {
        return;
    }

    context = await SkyWayContext.Create(skyWayToken);
    room = await SkyWayRoom.FindOrCreate(context, {
        type: "p2p",
        name: roomName,
    });
    me = await room.join();
    dataStream = await SkyWayStreamFactory.createDataStream(context);
    if (audio) {
        await me.publish(audio);
    }
    if (video) {
        await me.publish(video);
    }
    if (dataStream) {
        await me.publish(dataStream);
    }
}

const skyWayToken = inject("skyWayToken");
const roomName = inject("roomName");
const startPublication = async () => {
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
    const audio = await SkyWayStreamFactory.createMicrophoneAudioStream();
    const video = new LocalVideoStream(canvasStreamTrack);

    try {
      await createRoom(skyWayToken.value, roomName.value, audio, video);
    } catch (e) {
      console.log(e);
    }

    room.onStreamPublished.add(async (e) => {
      const { stream } = await me.subscribe(e.publication.id);
      if (stream) {
        if (stream.contentType === "data") {
          stream.onData.add((comment) => {
            emits("update:comment", comment);
            dataStream.write(comment);
          });
        }
      }
    });
  }
};

onMounted(() => {
    startPublication();
});
onUnmounted(async () => {
    if (me) {
        await me.leave();
    }
    if (room) {
        await room.close();
    }
    if (context) {
        await context.dispose();
    }
});
</script>
```

### 2.1 Roomの作成
基本的にはチュートリアル通りですが、違いとして`audio`と`video`以外にコメント用の`dataStream`を作成しています。
この`dataStream`には視聴者側から送信されたコメントをPublishすることで、他の視聴者が送信したコメントも見られるようにしています。
```javascript
let context;
let room;
let me;
let dataStream;
const createRoom = (skyWayToken, roomName, audio, video) => {
    if (roomName === "") {
        return;
    }

    context = await SkyWayContext.Create(skyWayToken);
    room = await SkyWayRoom.FindOrCreate(context, {
        type: "p2p",
        name: roomName,
    });
    me = await room.join();
    dataStream = await SkyWayStreamFactory.createDataStream(context);
    if (audio) {
        await me.publish(audio);
    }
    if (video) {
        await me.publish(video);
    }
    if (dataStream) {
        await me.publish(dataStream);
    }
}
```

### 2.2 配信者用のAudioStreamとVideoStreamの作成
通常は`createMicrophoneAudioAndCameraStream()`で`audio`と`video`を返り値として受け取るかと思いますが、今回はカメラの映像をそのまま使用するわけではないので
- audio: `createMicrophoneAudioStream()`
- video: `LocalVideoStream()`
をそれぞれ使用します。
```javascript
const startPublication = async () => {
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
        const audio = await SkyWayStreamFactory.createMicrophoneAudioStream();
        const video = new LocalVideoStream(canvasStreamTrack);

        await createRoom(skyWayToken.value, roomName.value, audio, video);
    }
};
```
canvasStreamTrackについては，`HTMLCanvasElement`の`captureStream()`でstreamを取得→streamから`getVideoTracks()`でtrackを取得という形で実装しています。
今回は
- カメラの映像表示用のcanvas
- 描画用のcanvas
- konvaを使って画像を表示するcanvas
の3つのcanvasを順番に描画（合成）して、その合成後のcanvasからstreamを生成して配信に使用しています。

```javascript
const canvasStream = canvasElement.captureStream(frameRate);
const canvasStreamTrack = canvasStream.getVideoTracks()[0];
```

### 2.3 コメントの受信
Roomに視聴者が参加した際にコメント用のDataStreamをPublishする（後述）ようにしているので、`onStreamPublishde`がトリガーされます。
そのイベント内で

- 受信したコメントを配列として保持（今回は親コンポーネント側で保持するためemitに渡している）
- 受信したコメントを`DataStream`に書き込む（視聴者側での表示に使用するため）

という処理を行っています。
```javascript
room.onStreamPublished.add(async (e) => {
      const { stream } = await me.subscribe(e.publication.id);
      if (stream) {
        if (stream.contentType === "data") {
          stream.onData.add((comment) => {
            emits("update:comment", comment);
            dataStream.write(comment);
          });
        }
      }
    });
```


## 3. 視聴者側の処理
視聴者側の処理は主に以下の様になっています。
```vuejs:Subscriber.vue
<script setup>
import { inject, onMounted, onUnmounted, ref } from "vue";


let context;
let room;
let me;
let dataStream;
async function joinRoom(skyWayToken, roomName) {
  if (roomName === "") {
    return;
  }

  context = await SkyWayContext.Create(skyWayToken);
  room = await SkyWayRoom.Find(
    context,
    {
      name: roomName,
    },
    "p2p"
  );
  me = await room.join();

  const streams = [];
  for (const publication of room.publications.filter((publication) => {
    return publication.publisher.id !== me.id;
  })) {
    if (publication.state === "closed") continue;
    const { stream } = await me.subscribe(publication.id);
    streams.push(stream);
  }

  return streams;
}
const skyWayToken = inject("skyWayToken");
const roomName = inject("roomName");
const video = ref(null);
const emits = defineEmits(["send"]);
const startSubscription = async () => {
  const streams = await joinRoom(skyWayToken.value, roomName.value);

  for (const stream of streams) {
    if (stream.contentType === "video") {
      stream.attach(video.value);
    } else if (stream.contentType === "audio") {
      stream.attach(video.value);
    } else if (stream.contentType === "data") {
      stream.onData.add((comment) => {
        emits("send", comment);
      });
    }
  }
};

const publishComment = async () => {
    if (!dataStream) {
        dataStream = await SkyWayStreamFactory.createDataStream(context);
        await me.publish(dataStream);
        return;
    }
    if (comment.length === 0) return;
    await dataStream.write(comment);
};

onMounted(async () => {
    await startSubscription();
    await publishComment("");  // DataStreamを作成するためにこの処理を入れたがちょっと書き直せば不要かも？
});
onUnmounted(async () => {
    if (me) {
        await me.leave();
    }
    if (room) {
        await room.leave();
    }
});
</script>
<template>
  <video ref="video" autoplay></video>
</template>
```

### 3.1 Roomへの参加
Roomの参加処理についてもほとんどチュートリアルの内容を参考に実装しています。
Roomに参加後に現在PublishされているものをSubscribeしています。
```javascript
async function joinRoom(skyWayToken, roomName) {
  if (roomName === "") {
    return;
  }

  const context = await SkyWayContext.Create(skyWayToken);
  const room = await SkyWayRoom.Find(
    context,
    {
      name: roomName,
    },
    "p2p"
  );
  const me = await room.join();

  const streams = [];
  for (const publication of room.publications.filter((publication) => {
    return publication.publisher.id !== me.id;
  })) {
    if (publication.state === "closed") continue;
    const { stream } = await me.subscribe(publication.id);
    streams.push(stream);
  }

  return streams;
}
```

### 3.2 映像の表示
Roomに参加した際にSubcribeしたstreamを返すようにしているので、各streamに対してattachやイベントハンドラーの追加を行っています。
`video`についてはVue.jsのrefで要素にアクセスできるようにしてattachしています。
commentについては配信者側の処理と同様に、comment用の配列に追加した上で表示に使用しています。
```javascript
const startSubscription = async () => {
  const streams = await joinRoom(skyWayToken.value, roomName.value);

  for (const stream of streams) {
    if (stream.contentType === "video") {
      stream.attach(video.value);
    } else if (stream.contentType === "audio") {
      stream.attach(video.value);
    } else if (stream.contentType === "data") {
      stream.onData.add((comment) => {
        emits("send", comment);
      });
    }
  }
};
```

### 3.3 コメントの送信
コメントは`DataStream`がなければ作成、`DataStream`を作成済みであればwrite()でコメントを書き込む処理を行っています。
記事を書いている途中で思いましたが`dataStream`が存在しない場合の処理でreturnしていますが別にreturnする必要ない気がしますね…。

```javascript
const publishComment = async (comment) => {
    if (!dataStream) {
        dataStream = await SkyWayStreamFactory.createDataStream(context);
        await me.publish(dataStream);
        return;
    }
    if (comment.length === 0) return;
    await dataStream.write(comment);
};
```

# 終わりに
SkyWayを使ってみるのは今回が初めてでしたが、ほぼチュートリアル通りで簡単に配信機能が組み込めるなという印象を持ちました。
コメント機能についてもAPIリファレンスやサンプルコードを参考に割と簡単に実装できました。

どちらかというとVue.jsやレイアウト周りで色々と詰まったりすることが多くて、もっと勉強しないとなーという感じでした。
また、これまでなにかを作ってもQiitaに記事を書いたりはしてこなかったのですが、今回記事を書いている途中でも「ここ書き直せるんじゃないか？」という気づきもあったりと、見直すきっかけにもなりましたね。
せっかく作ったアプリなので、少しずつ時間を見つけて改良とかもしていきたいですね。