ある中卒がWeb系エンジニアになるまでと、それからのこと

うつ病で高校を中退したり、たこ焼き屋のオヤジにホームページとたこ焼きを作らされたり、そのホームページが訴えられそうになったり、弁護士を目指したりした後にエンジニアになった人が書くブログ

絶対に口を挟ませない通話アプリを作る

2020/07/08の日報

Hanasot開発の日報、第12日目です。
やったことは

  • 発言中、絶対に口を挟ませないようにする機能を実装

です。
時間は、

  • Hanasot開発 1h32min
  • 日報ブログ 58min

です。
運動は

  • ハンドスタンド・プッシュアップ 10reps → 7reps → 6reps → 3reps

です。

vue + skywayアプリでDBをリアルタイムに反映させる

これ何度も言っていた「発言者を一人に限定する(横から口を挟ませない)」機能のことです。DBに現在の発言者を登録しておいて、発言者以外は強制ミュートにするわけですね。
ソースコードいきましょう。

<template>
      <div
        class="c-video__mic"
        :class="{'c-video__mic--disabled': isStarted && !isRight}"
        @click="isMuted = !isMuted">
        <img :src="require(isMuted ? '../assets/mic_off.svg' : '../assets/mic.svg')">
      </div>
      <div
        class="c-video__camera"
        @click="isCameraOff = !isCameraOff">
        <img :src="require(isCameraOff ? '../assets/camera_off.svg' : '../assets/camera.svg')">
      </div>
    <button id="make-call" @click="makeRoom">発信</button>
    <button v-if="!isStarted" @click="startDialogue">哲学対話を開始する</button>
</template>
    data() {
      return {
~~~~略~~~~
        isMuted: true,
        isCameraOff: true,
        isStarted: false,
        isRight: true,
      }
    },
    watch: {
      async isCameraOff(newValue) {
        const stream = this.localStream;
        stream.getVideoTracks()[0].enabled = !newValue;
        this.localStream = stream;
        await firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).update({'isCameraOff': newValue});
      },
      async isMuted(newValue) {
        const stream = this.localStream;
        stream.getAudioTracks()[0].enabled = !newValue;
        this.localStream = stream;
        await firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).update({'isMuted': newValue});
      },
      async isStarted(newValue) {
        if (newValue) {
          const snapshot = await firebase.firestore().collection('rooms').doc(this.roomID).get();
          const right = snapshot.data().right;

          if (right !== this.peer.id) {
            this.isMuted = true;
            this.isRight = false;
          }
        }
      }
    },
    methods: {
      async startDialogue() {
        // isStartedをtrueにする
        await firebase.firestore().collection('rooms').doc(this.roomID).update({isStarted: true});
      },
      async makeRoom() {
        // ルームを作成
        firebase.firestore().collection('rooms').doc(this.roomID).set({
          createdAt: firebase.firestore.FieldValue.serverTimestamp(),
          isStarted: false,
          right: this.peer.id,
        });
        firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).set({
            isMuted: true,
            isCameraOff: true,
            joinedAt: firebase.firestore.FieldValue.serverTimestamp(),
          });

        this.setEventListener(mediaConnection);
      },
      async setEventListener(mediaConnection) {
        ~~~ 略~~~
        await firebase.firestore().collection('rooms').doc(this.roomID).onSnapshot(snapshot => {
          const room = snapshot.data();
          this.isStarted = room.isStarted;
        });

      }

長いですね。さっくり解説していきます。

発信ボタンを押したときにDBでもルーム生成する

まずはチャットルームIDを入力して発信ですね。その際にDBの方でもルームを作成します。

      async makeRoom() {
        // ルームを作成
        firebase.firestore().collection('rooms').doc(this.roomID).set({
          createdAt: firebase.firestore.FieldValue.serverTimestamp(),
          isStarted: false,
          right: this.peer.id,
        });
        firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).set({
            isMuted: true,
            isCameraOff: true,
            joinedAt: firebase.firestore.FieldValue.serverTimestamp(),
          });

        this.setEventListener(mediaConnection);
      },

まずチャットルームIDをIDに持つドキュメントを生成。その時rightは自分のskywayID、isStartedはfalseにしておきます。

その後、上で作成したドキュメントの中にサブコレクションを作成します。さらにサブコレクション内のドキュメントには

  • 自分のskywayID(ドキュメントIDにする)
  • マイクとカメラはオフ
  • 参加日時

という情報を持たせます。

ちなみに後から参加者が入ってくるたびにrightとcreatedAtは上書きされていきます。本来上書きされるべきではないので、明日if文の処理を追加します。

マイク・カメラのオンオフが切り替わったらDBにも反映する
    watch: {
      async isCameraOff(newValue) {
        const stream = this.localStream;
        stream.getVideoTracks()[0].enabled = !newValue;
        this.localStream = stream;
        await firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).update({'isCameraOff': newValue});
      },
      async isMuted(newValue) {
        const stream = this.localStream;
        stream.getAudioTracks()[0].enabled = !newValue;
        this.localStream = stream;
        await firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).update({'isMuted': newValue});
      },

前回の記事でもwatchで見張って処理をしていましたが、更にDBへの書き込み処理を追加しました。updateメソッドに連想配列の形でデータを渡すとDBを更新してくれます。

対話を開始ボタンが押されたらDBのisStartedをtrueにする
    methods: {
      async startDialogue() {
        // isStartedをtrueにする
        await firebase.firestore().collection('rooms').doc(this.roomID).update({isStarted: true});
      },

実質1行だけの処理ですね。isStartedをtrueに書き換えています。

DBの変更を常に監視する

今日1番重要です。

      async setEventListener(mediaConnection) {
~~~略~~~
        await firebase.firestore().collection('rooms').doc(this.roomID).onSnapshot(snapshot => {
          const room = snapshot.data();
          this.isStarted = room.isStarted;
        });
      }

updateなんかと最初は同じですが、onSnapshotというメソッドを使っています。これがDBを監視するメソッドです。今回はroomsコレクション直下のドキュメントを監視させています。変更がある度にonSnapshot内の関数が実行されるため、どの参加者が対話を開始ボタンを押したとしても、即座に参加者全員に反映させることができます。

誰かが開始ボタンを押したのを合図に、参加者全員のisStartedが切り替わったら次の処理に移ります。

発言者以外のマイクを強制的にオフにする

対話が開始された(isStartedが変更された)時、この処理が走ります。

    watch: {
~~~略~~~
      async isStarted(newValue) {
        if (newValue) {
          const snapshot = await firebase.firestore().collection('rooms').doc(this.roomID).get();
          const right = snapshot.data().right;

          if (right !== this.peer.id) {
            this.isMuted = true;
            this.isRight = false;
          }
        }
      }
    },

newValueに入ってくるのがisStartedの変更後の値ですね。falseにする処理は書いていないので間違いなくtrueしか入らないのですが、一応if文でtrueかどうか確認しています。

その後、改めて参加しているルームのドキュメントを取得。そこに保存されているright(発言者)を取ります。ルームに入室するたびに上書きされるので、最後の参加者がrightに入っているはずです。

rightと自分のskywayIDを見比べて、同じでなければマイクがオフになり、isRight(発言権)もfalseになります。

ボタンをクリックできないようにする

マイクをオフにすることはできましたが、今のままでは再度マイクボタンを押せば喋れてしまいます。上で書き換えたisRightを参照してボタンをクリックできないようにしましょう。

      <div
        class="c-video__mic"
        :class="{'c-video__mic--disabled': isStarted && !isRight}"
        @click="isMuted = !isMuted">
        <img :src="require(isMuted ? '../assets/mic_off.svg' : '../assets/mic.svg')">
      </div>
~~~略~~~
  .c-video {
    &__mic {
      &--disabled {
        pointer-events: none;
      }
    }
 
~~~略~~~

重要なのは`:class="{'c-videomic--disabled': isStarted && !isRight}"ですね。昨日も使ったv-bindです。この書き方だと「isStartedがtrueで、isRightがfalseの時は『c-videomic--disabled』というクラス名をつけてください」という意味になります。そのクラス名がついた要素にはpointer-events: none;をあてます。するとクリックやホバーなどマウスによるイベントが全て使用できなくなるんです。マイクのオンオフ切り替えをするにはボタンをクリックする必要があるので、結果的に強制マイクオフを実現できます。

所感

絶対に哲学対話でしか使わないであろう機能の実装を行いました。結構トントン拍子でここまで実装できたので嬉しいです。あとは

  • 発言権の譲渡
  • 挙手機能

あたりが哲学対話っぽい機能ですね。ガンガン実装していこうと思います。

今日はそんな感じです。また明日。