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

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

vueファイルが肥大化しちゃうのを食い止めよう作戦その1

2020/07/14の日報

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

  • vueのmethodsを外部ファイルに書き出してvueファイルを軽量化する

です。
時間は、

  • Hanasot開発 1h12min
  • 日報ブログ 40min

です。
運動は

  • エアロバイク 15min

です。

vueファイルが肥大化しちゃうのを食い止めよう作戦その1

今日は「vueファイルが肥大化しちゃうのを食い止めよう作戦」のpart1で、別ファイルに書き出せそうな処理は全部書き出してvueファイルを少しでも軽くしてみます。

今日別ファイルに移したのは下記の3つ。

    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});
      },

    },
    methods: {

      timer(resolve, time) {
        this.time = time;
        const self = this;
        const interval = setInterval(function() {
          self.time--;
          if (!self.time) {
            clearInterval(interval);
            return resolve();
          }
        }, 1000);
      },

    },

watchプロパティの「isMuted」「isCameraOff」, methodsの「timer」ですね。

isMutedとisCameraOffは、dataプロパティの同名変数?フィールド?が変更された時に走ります。カメラ(あるいはマイク)の使用許可を切り替えるのと、dbを変更後の値で更新する感じです。そもそもこれwatchじゃなくても良かったなーと思って書き出しました。

次はtimerですが、これは普通のタイマーですね。time引数に取った数字で設定して、1秒ごとに引いていきます。で、0になったら止めると。単純ですのでこちらも書き出すのにピッタリ。

関数を別ファイルに書き出す

/src配下にapiディレクトリを作って、api.jsを作成します。名前は何でもいいし、なんならルートにapi.jsを置いたっていいんですが分かりやすいようにこうします。 で、api.jsの中身はこんな感じです。

import firebase from 'firebase';

export function timer (resolve, time, self) {
  self.time = time;
  const interval = setInterval(function () {
    self.time--;
    if (!self.time) {
      clearInterval(interval);
      return resolve();
    }
  }, 1000);
}

export async function offCamera(value, self) {
  self.isCameraOff = value;
  const stream = self.localStream;
  stream.getVideoTracks()[0].enabled = !value;
  self.localStream = stream;
  await firebase.firestore().collection('rooms').doc(self.roomID).collection('participants')
    .doc(self.peer.id).update({'isCameraOff': value});
}

export async function muteMic(value, self) {
  self.isMuted = value;
  const stream = self.localStream;
  stream.getAudioTracks()[0].enabled = !value;
  self.localStream = stream;
  await firebase.firestore().collection('rooms').doc(self.roomID).collection('participants')
    .doc(self.peer.id).update({'isMuted': value});
}

必要なライブラリをインポートしたあとにexport function 関数名 (){}とするだけです。

export 関数名() => {}とarrow関数で書いてもいいですが、シンプルにしました。

vueファイルで別ファイルの関数を読み込む

読み込み側は

import { timer, offCamera, muteMic } from '../api/api';

こうして、例えばtimerとかは

const self = this;
await new Promise(resolve => timer(resolve, 60, self));

こうします。

muteMicやoffCameraはtemplate内からも呼ぶ必要がありますが、importしたものを直接呼ぶことはできないのでcallMuteMic, callOffCameraを作成します。

    methods: {
      callOffCamera(value) {
        offCamera(value, this);
      },
      callMuteMic(value) {
        muteMic(value, this);
      },
    }

こんな感じですね。普通にmethodsとかで呼びたいなら

muteMic(true, this);

これだけです。

ちょっとハマったところ

thisの扱いです。3つすべてdataプロパティを更新する関数なので、thisをどうにかして渡す必要がありました。あんまりやらないほうがいいのかもですが、普通に引数にとってやると使えちゃったので今回はthisを引数で渡してます。

export function timer (resolve, time, self) {
  self.time = time;
  const interval = setInterval(function () {
    self.time--;
    if (!self.time) {
      clearInterval(interval);
      return resolve();
    }
  }, 1000);
}

selfという引数ですね。うっすら不安になるけどまあいいんじゃないでしょうか。

所感

前回の案件を参考にやりました。apiって呼んでるけどこれapiなのかな。広義のapiには含まれるんでしょうか。
あ、DBの更新処理なんかはfirebaseのcloud functionsを使うともっといい感じにできそうですね。ぼちぼちやろうかな。

関係ないんですが、仕事で最近shopifyを触ってます。liquidファイルは全然なじめないですけど、アプリ制作ではreactを使うんですよ。Reactやらなきゃなーと思ってたところだったので、仕事しながら勉強にもなって嬉しいです。

Vueファイルが肥大化していくのをなんとか食い止めたい話

2020/07/13の日報

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

  • このまま対話セクションを同じvueファイルで作成するとやたらでっかくなっちゃうので、なんとか食い止めるべく色々調べた

です。
時間は、

  • Hanasot開発 16min
  • 日報ブログ 20min

です。
運動は

  • エアロバイク 15min

です。

アイスブレイクが大体完成してちょっと中だるみしてきましたね。
あと当たり前だけど全然反応ないのが悲しいですね。
あと明日か明後日くらいまでかなり時間がないので開発もブログも短めになります。

このままだとvueファイルが肥大化していく

とりあえず現状アイスブレイクは完成しました。この次は対話セクションを開発していきます。ただその前に、対話セクションはどのように開発するのかを考えないといけません。というのもアイスブレイクセクションのvueファイルでコンポーネントとかapiを全然使ってないんです。template, methods, style使うもの全てまるごと書いています。

1つのファイルに必要な要素を全て書けるのがvueの利点でもありますが、すでに800行近くあって非常に保守性が悪いです。見通しが悪いために開発しづらいしなんとかしたい。

なんとかする方法はいくつかあります。

とりあえずセクションごとにvueファイルを分ける

vue-routerを導入しているのでアイスブレイクセクションと対話セクションを分けるのは簡単ですね。ただskywayの接続が切れちゃうきがするんですよねー。再接続するロジックを作ることで対策できそうな気もしますが...

コンポーネントに切り出して共通化する

本命。ヘッダー部分は既に切り出して使ってるけど、メイン機能のところもなんとかできないかな。どうにかなりませんかこれ。

メソッドをapiにする

前回の案件ではこれだった。特に共用するものは全て切り出してた。これもいいけどなあ。

まとめ

短いですね。ごめんなさい。本当に検討したのかお前と言われそうですね。すみません。

メソッドをapiに切り出してセクションごとにvue-routerで画面遷移、skywayの接続が切れるようなら再接続のロジックを作る。という方向でいきます。

今日はそんな感じです。

Hanasotをテストしている動画を公開したりする

2020/07/12の日報

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

  • 対話開始する直前に参加者に確認を取るようにする

です。
時間は、

  • Hanasot開発 2h16min
  • 日報ブログ 20min

です。
運動は

  • アン・イーブン・プルアップ 8reps * 2
  • フル・プルアップ 9reps

です。

Hanasotのテストをしているところの動画

動画編集が終わりましたので公開します。

ルームを作成したホスト側の視点だとこんな感じ。 youtu.be

逆にルームに参加したゲスト側だとこんな感じ。 youtu.be

で、急に対話が始まるのは良くないという話になったので処理を追加しました。 youtu.be

ちょっと時間が遅くなっちゃったので今日は取り急ぎ動画だけ公開します。

2週間くらい個人開発とブログを毎日やって思うこと

2020/07/11の日報

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

  • UIを整えてアイスブレイクを行えるようにする

です。
時間は、

  • Hanasot開発 10h40min
  • 日報ブログ 44min

です。
運動は

  • ウォール・ウォーキング・ブリッジ 8reps * 2

です。

思うこと

昨日から今日にかけての分が日付をまたいだため混ざっちゃいましたが、ブログと開発を2週間ちょっと毎日やれました。ソースコードの解説もしなきゃなんですけど、量が多いのとリファクタしてないので後日やります。とりあえず今日は振り返り。

進捗でいうと、アイスブレイクというセクションを成立させることができたところです。協力者の2人と僕でテストプレイしてみて結構問題なく動いたのでよかったです。その様子の録画は編集中なので明日にでもお見せしますね。

で、思ったのは自分はまだまだ弱いってことでした。エンジニアとしても人としても。少しずつタフになってきてはいるけど、やっぱり雑魚です。今は。

少しでも強くなりたくてビジネス本なんかも最近ぼちぼち読んでます。そこで共通して言ってるのは専門性を掛け合わせろってことと、ビジネスセンスを持てということなのかなと。僕のエンジニアとしての専門性はどの程度でしょうか。例えば日本国民の中から無作為に100人選んだとして、その中に僕が入っていれば、大抵の場合エンジニアリングのスキルは1番だろうと思います。うん。でもその程度です。一生の武器としては心もとない。

そう考えたときに、僕が取るべき行動は2つでしょうか。ひとつはエンジニアとしてのスキルをより高めること。正攻法ですし、僕自身もこれは必須だろうと思っています。より難易度と需要の高いスキルを身につければ、市場価値が上がります。単純に時間を切り売りするなら高い方が良いに決まってますからね。

そしてもう一つは、別の専門性を得ること。例えばビジネススキル。市場で何が求められているのか、何を供給すれば儲かるのかを見極める。あるいは市場にない新たな価値を見つけ出す力。時代が移り変わってもずっと使える力ですよね。これはぜひとも身につけたい。そのためにはとにかく市場にモノを出すことが大事でしょう。Hanasotは収益化も考慮したアプリですし、Hanasotが落ち着いたらまた新しいものを作っていきます。ほぼ原価なしで商品を作れるのはエンジニアの良いところです。このまま開発を続けてエンジニアリングとビジネススキルを伸ばしていけたらベストかなと思います。

今「僕は君たちに武器を配りたい」という本を読んでいます。まだ最初の方しか読めていませんが、自分がコモディティ化することへの危機感を持つようにと書かれていました。なにかのスピーチで橋本 元府知事も仰っていた覚えがあるので、このことは共通認識なのでしょう。上で複数の専門性を持たねばと書いたのはその影響です。

また受け売りなのですが、およそ100万人に1人くらいの人材になれば代替されることはほとんどないそうです。1つの専門性のみで100万人に1人を目指すのは無茶ですが、100人に1人の専門性を3つかけ合わせれば100万人になりますね。(色んな場所で色んな人が言いすぎて、なんだか陳腐にも思えちゃいますが...)

これコモディティ化対策のアプローチとしては最善に思えます。僕は2年かけて、100人に1人であることは確実だろうと言えるエンジニアリングのスキルを得ました。単純計算であと4年あれば、そのレベルの専門性を2つ獲得できることになります。相乗効果を狙えるスキルを身につければ100万人に1人になれます。うん。今のところこれしかなさそうです。

強く、聡く、自由に生きたい。自分と家族と友人の幸せを、何者にも侵されないように。

だから明日もがんばります。なんかそんな話です。
それではまた明日。

徹夜で9時間作ってるアプリのソースコード

色々あって寝ずにコード書いてました。 現在9時間経過しています。 朝になってしまったのでソースコードだけ公開します。

<template>
  <div style="height: 1000px">
    <header-menu></header-menu>
    <div class="p-dialogue">
      <div class="p-dialogue__content">
        <p class="p-dialogue__txtWrap">
          <span
            class="p-dialogue__txt"
            v-if="!roomID">
            {{`ルームを作成するか、作成済みのルームに参加してください。`}}
          </span>
          <span
            class="p-dialogue__txt"
            v-if="roomID">
            {{`ルームIDは${this.roomID}です。`}}
          </span>
          <span
            class="p-dialogue__txt"
            v-if="!isStarted && roomID && isRight">
            {{`十分な人数が集まったら対話を開始してください。`}}
          </span>
          <span
            class="p-dialogue__txt"
            v-if="!isStarted && roomID && !isRight">
            {{`ホストが対話を開始するまで自由にご歓談ください。`}}
          </span>
          <span
            class="p-dialogue__txt"
            v-if="!roomID && isStarted">
            {{`アイスブレイクタイム。お題は「お気に入りのリラックス方法」です。`}}
          </span>
          <span
            class="p-dialogue__txt"
            v-if="isTalking">
            {{`質問セクションです。お題に関係のないことでも構いませんので、1人1回は質問してみましょう。`}}
            {{`残り時間: ${time}秒`}}
          </span>
        </p>
        <div class="p-dialogue__btnWrap">
          <button
            v-if="!roomID"
            @click="makeRoom"
            class="p-dialogue__btn">ルームを作成</button>
          <button
            v-if="!roomID"
            @click="joinRoom"
            class="p-dialogue__btn">ルームに参加</button>
          <button
            v-if="!isStarted && isRight"
            @click="startDialogue"
            class="p-dialogue__btn">哲学対話を開始する</button>
        </div>
        <div
          class="p-dialogue__videoWrap"
          :class="{'p-dialogue__videoWrap--muted': isMuted}">
          <video
            id="my-video"
            class="p-dialogue__video"
            autoplay muted playsinline>
          </video>
          <div class="p-dialogue__person" :class="{'p-dialogue__person--active': isCameraOff}">
            <img
              src="../assets/person.svg"
              class="p-dialogue__person__img">
          </div>
          <div
            class="p-dialogue__mic"
            :class="{'p-dialogue__mic--disabled': (isStarted && !isRight) && !isTalking}"
            @click="isMuted = !isMuted">
            <img :src="require(isMuted ? '../assets/mic_off.svg' : '../assets/mic.svg')">
          </div>
          <div
            class="p-dialogue__camera"
            @click="isCameraOff = !isCameraOff">
            <img :src="require(isCameraOff ? '../assets/camera_off.svg' : '../assets/camera.svg')">
          </div>
          <div
            class="p-dialogue__timer"
            v-show="time !== 0">
            {{ this.time }}
          </div>
        </div>
        <div
          v-for="participant in participants"
          :key="participant.id"
          class="p-dialogue__videoWrap"
          :class="{'p-dialogue__videoWrap--muted': participant.isMuted}">
          <video
            :id="participant.id"
            autoplay
            playsinline
            @click="transferRight(participant.id)"
            class="p-dialogue__video"></video>
          <div class="p-dialogue__person" :class="{'p-dialogue__person--active': participant.isCameraOff}">
            <img
              src="../assets/person.svg"
              class="p-dialogue__person__img">
          </div>
        </div>
      </div>
    </div>
    <p id="my-id"></p>
  </div>
</template>
<script>
  import firebase from 'firebase';
  import Peer from 'skyway-js';
  import Swal from 'sweetalert2';
  import headerMenu from '@/components/headerMenu.vue';
  import $ from 'jquery';

  export default {
    components: {
      headerMenu
    },
    name: 'Dialogue',
    data() {
      return {
        localStream: null,
        roomID: '',
        joinRoomID: '',
        peer: {},
        participants: [],
        isMuted: true,
        isCameraOff: true,
        isStarted: false,
        isQuestion: false,
        isRight: false,
        isTalking: false,
        right: '',
        time: 0,
      }
    },
    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) return;

        if(this.right !== this.peer.id) {
          this.isMuted = true;
          this.isRight = false;
        } else {
          this.isRight = true;
          this.isMuted = true;
          await Swal.fire(
            'トーキングオブジェクトをお渡しします!',
            'アイスブレイクタイムです。\n深呼吸したらマイクをオンにして喋ってみましょう。\n話すことに慣れるのが大事ですよ。',
            'success'
          );
          const self = this;
          await new Promise(resolve => self.timer(resolve, 10));
          await Swal.fire(
            'スピーチお疲れさまでした!',
            '質問セクションに移行します。参加者全員の準備が整うまでお待ち下さい。',
            'success',
          );
          firebase.firestore().collection('rooms').doc(this.roomID).update({
            isQuestion: true,
          });
        }
      },
      async isQuestion(newValue) {
        if (!newValue) return;

        await Swal.fire(
          '質問セクションに移行します!',
          '回答者に質問してみましょう。お題に関係ないことでも構いません。1人1回は質問してみてくださいね。',
          'success'
        );
        await firebase.firestore().collection('rooms').doc(this.roomID).collection('participants')
          .doc(this.peer.id).update({
            isReadyQuestion: true,
          });
      },
      async participants(newValue) {
        if (!this.isStarted) return;
        let isTalking = true;
        await Promise.all(newValue.map(async participant => {
          if (!participant.isReadyQuestion) {
            isTalking = false;
          }
        }));
        this.isTalking = isTalking;
      },
      async isTalking(newValue) {
        const self = this;
        if (!newValue) return;
        await new Promise(resolve => self.timer(resolve, 30));
        await Swal.fire(
          '質問セクション終了です!',
          'お疲れさまでした。\n時間いっぱいになりましたので質問セクションを終了します。\n次の回答者の方の準備が整うまでお待ち下さい。',
          'success',
        );
        await firebase.firestore().collection('rooms').doc(this.roomID)
          .collection('participants').doc(this.peer.id).update({
            isReadyQuestion: false,
          });
        this.isMuted = true;
        this.isTalking = false;
        if (this.isRight) {
          firebase.firestore().collection('rooms').doc(this.roomID).update({
            isQuestion: false,
          });
          const snapshot = await firebase.firestore().collection('rooms')
            .doc(this.roomID).get();
          let respondent = snapshot.data().respondent;

          if (respondent > this.participants.length) {
            Swal.fire(
              'アイスブレイクを終了します!',
              'お疲れさまでした!\n引き続き哲学対話へ移行します。\nしばらくお待ち下さい!'
            )
          }

          console.log('respondentは');
          console.log(respondent);
          console.log('participantsは');
          console.log(this.participants[respondent]);
          await firebase.firestore().collection('rooms').doc(this.roomID)
            .update({right: this.participants[respondent].id});
          await firebase.firestore().collection('rooms').doc(this.roomID)
            .update({respondent: respondent});
        }
      },
      async right(newValue) {
        if (!this.isStarted) return;

        if (newValue !== this.peer.id) {
          this.isMuted = true;
          this.isRight = false;
        } else {
          this.isRight = true;
          await Swal.fire(
            'トーキングオブジェクトを受け取りました!',
            'お題に関係あること、ないこと、どんなことでも構いません。\nあなたの心のままに語ってみましょう。',
            'success'
          );
          const self = this;
          await new Promise(resolve => self.timer(resolve, 30));
          await Swal.fire(
            'スピーチお疲れさまでした!',
            '質問セクションに移行します。参加者全員の準備が整うまでお待ち下さい。',
            'success',
          );
          firebase.firestore().collection('rooms').doc(this.roomID).update({
            isQuestion: true,
          });
        }
      }
    },
    mounted() {
      // カメラ映像取得
      navigator.mediaDevices.getUserMedia({video: true, audio: true})
        .then( stream => {
          stream.getAudioTracks()[0].enabled = !this.isMuted;
          stream.getVideoTracks()[0].enabled = !this.isCameraOff;
          // 成功時にvideo要素にカメラ映像をセットし、再生
          const videoElm = document.getElementById('my-video')
          videoElm.srcObject = stream;
          videoElm.play();
          // 着信時に相手にカメラ映像を返せるように、グローバル変数に保存しておく
          this.localStream = stream;
      }).catch( error => {
          // 失敗時にはエラーログを出力
          console.error('mediaDevice.getUserMedia() error:', error);
          return;
      });

      // skyway接続
      this.peer = new Peer({
        key: 'skywayのid',
        debug: 2,
      });
      this.peer.on('open', () => {
        document.getElementById('my-id').textContent = this.peer.id;
      });

      // エラー時処理
      this.peer.on('error', err => {
        alert(err.message);
      });
    },
    methods: {
      timer(resolve, time) {
        this.time = time;
        const self = this;
        const interval = setInterval(function() {
          console.log(self.time);
          self.time--;
          if (!self.time) {
            clearInterval(interval);
            return resolve();
          }
        }, 1000);
      },
      transferRight(participant) {
        if (!this.isRight) return;

        Swal.fire({
          title: 'トーキングオブジェクトを渡しますか?',
          text: "次にトーキングオブジェクトを受け取るまでマイクはミュートされます。",
          icon: 'question',
          showCancelButton: true,
          confirmButtonColor: '#3085d6',
          cancelButtonColor: '#d33',
          confirmButtonText: '渡す',
          cancelButtonText: 'もう少し話す',
        }).then(async result => {
          if (result.value) {
            await firebase.firestore().collection('rooms').doc(this.roomID).update({right: participant});
            Swal.fire(
              'トーキングオブジェクトを渡しました!',
              'みんなの発言を聞くこともまた対話です。\nゆっくりじっくり聞いてみましょう。',
              'success'
            )
          }
        });
      },
      async startDialogue() {
        await firebase.firestore().collection('rooms').doc(this.roomID).update({isStarted: true});
      },
      async makeRoom() {
        this.roomID = Math.random().toString(32).substring(2);

        const mediaConnection = this.peer.joinRoom(this.roomID, {
          mode: 'sfu',
          stream: this.localStream,
        });

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

        this.isRight = true;

        this.setEventListener(mediaConnection);
      },
      async joinRoom() {
        let joinRoomID = '';

        await Swal.fire({
          title: '参加したいルームのIDをご入力ください',
          input: 'text',
          inputAttributes: {
            autocapitalize: 'off'
          },
          showCancelButton: true,
          confirmButtonText: '参加する',
        }).then((result) => {
          joinRoomID = result.value;
        });

        if (!joinRoomID) {
          Swal.fire(
            'ルームIDが未入力です。',
            'お手数ですがもう一度ご入力ください。',
            'warning'
          );
          return;
        }

        const snapshot = await firebase.firestore().collection('rooms')
          .doc(joinRoomID).get();

        if (!snapshot.exists) {
          Swal.fire(
            'ルームが存在しないようです...',
            '正しいルーム名を入力されているかご確認ください。',
            'warning'
          )
          return;
        }

        const mediaConnection = this.peer.joinRoom(joinRoomID, {
          mode: 'sfu',
          stream: this.localStream,
        });

        // participantsに自分を追加
        firebase.firestore().collection('rooms').doc(joinRoomID).collection('participants')
          .doc(this.peer.id).set({
            isMuted: true,
            isCameraOff: true,
            isReadyQuestion: false,
            joinedAt: firebase.firestore.FieldValue.serverTimestamp(),
          });

        this.roomID = joinRoomID;

        this.setEventListener(mediaConnection);
      },
      async addVideo(mediaConnection) {
        let remoteStreams = [];
        mediaConnection.on('stream', () => {
          // video要素にカメラ映像をセットして再生
          Object.keys(mediaConnection.remoteStreams).forEach( key => {
            const remoteStream = mediaConnection.remoteStreams[key];
            remoteStreams.push(remoteStream);
          });
        });
        await new Promise(resolve => setTimeout(resolve, 1000));
        remoteStreams.forEach(async remoteStream => {
          const videoElm = document.getElementById(remoteStream.peerId);
          videoElm.srcObject = remoteStream;
          videoElm.play();
        });
      },
      async removeVideo(mediaConnection) {
        let remoteStreams = [];
        // video要素にカメラ映像をセットして再生
        Object.keys(mediaConnection.remoteStreams).forEach( key => {
          const remoteStream = mediaConnection.remoteStreams[key];
          remoteStreams.push(remoteStream);
        });
        await new Promise(resolve => setTimeout(resolve, 1000));
        remoteStreams.forEach(async remoteStream => {
          const videoElm = document.getElementById(remoteStream.peerId);
          videoElm.srcObject = remoteStream;
          videoElm.play();
        });
      },
      async setEventListener(mediaConnection) {
        this.addVideo(mediaConnection);
        const self = this;
        mediaConnection.on('peerJoin', () => {
          self.addVideo(mediaConnection);
        });
        mediaConnection.on('peerLeave', () => {
          self.removeVideo(mediaConnection);
        });
        $(window).on('unload', () => {
          mediaConnection.close();
          firebase.firestore().collection('rooms').doc(self.roomID)
            .collection('participants').doc(self.peer.id).delete();
        });

        await firebase.firestore().collection('rooms').doc(this.roomID).onSnapshot(snapshot => {
          const room = snapshot.data();
          this.isStarted = room.isStarted;
          this.right = room.right;
          this.isQuestion = room.isQuestion;
        });

        await firebase.firestore().collection('rooms').doc(this.roomID)
          .collection('participants').orderBy('joinedAt', 'asc').onSnapshot(snapshot => {
            const participants = [];
            snapshot.docs.map(doc => {
              if (doc.id === this.peer.id) return;
              const participant = doc.data();
              const id = doc.id;
              const isMuted = participant.isMuted;
              const isCameraOff = participant.isCameraOff;
              const isReadyQuestion = participant.isReadyQuestion;
              participants.push({id,isMuted,isCameraOff,isReadyQuestion});
            });
            this.participants = participants;
          });
      }
    },
  }
</script>
<style lang="scss">
  #my-id{
    display: none;
  }
  .p-dialogue {
    &__content{
      box-sizing: border-box;
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      flex-wrap: wrap;
      width: 70%;
      max-width: 100%;
      box-shadow: 3px 0 6px rgba(0,0,0,0.4);
      border-radius: 3px;
      padding: 35px;
      @media screen and (max-width: 1000px){
        width: 100%;
      }
    }
    &__txtWrap{
      width: 100%;
    }
    &__txt{
      font-size: 18px;
      display: block;
      text-align: right;
    }
    &__btnWrap{
      display: flex;
      justify-content: flex-end;
      width: 100%;
      margin-bottom: 30px;
    }
    &__btn{
      background: #fff;
      border: 1px solid #3c3c3c;
      border-radius: 3px;
      padding: 10px 30px;
      margin-right: 20px;
      // color: #fff;
      cursor: pointer;
      // font-weight: bold;
      &:hover{
        opacity: 0.7;
      }
    }
    &__videoWrap{
      position: relative;
      width: calc(50% - 15px);
      margin-bottom: 30px;
      box-shadow: 0 0 10px 10px rgba(249, 255, 100, 0.5);
      &--muted{
        box-shadow: 0 0 5px 5px rgba(0, 0, 0, 0.3);
      }
      video{
        vertical-align: bottom;
        width: 100%;
      }
    }
    &__mic,
    &__camera,
    &__timer {
      display: block;
      position: absolute;
      left: 5px;
      bottom: 5px;
      width: 40px;
      height: 40px;
      background: #fff;
      border-radius: 50%;
      padding: 10px;
      box-sizing: border-box;
      cursor: pointer;
    }
    &__mic {
      &--disabled {
        pointer-events: none;
      }
    }
    &__camera {
      left: 50px;
    }
    &__timer{
      text-align: center;
      left: auto;
      right: 10px;
    }
    &__person {
      display: none;
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      justify-content: center;
      align-items: center;
      background: #ddd;
      &--active{
        display: flex;
      }
      &__img{
        width: 30%;
      }
    }
  }
  .swal2-html-container{
    white-space: pre-wrap;
  }
</style>

発言中は口を挟ませないけど、喋り終わったら相手にも喋らせてあげるアプリを作る

2020/07/09の日報

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

  • 発言権を譲渡する機能を作成する

です。
時間は、

  • Hanasot開発 1h58min
  • 日報ブログ 56min

です。
運動は

です。

発言権を譲渡する機能を作成する

はい。今日ちょっとGitLabのエラーが出て、直すのに時間かかったので短めにいきます。ただ基本機能は結構できあがってきたので、UIを整えたら明日か明後日にでもテスト風景の動画を撮影しようかなと思います。

SweetAlert2を導入していい感じに発言権を受け渡しする

まずはUI面で必要なSweetAlert2を導入しました。これ前の案件で使ったんですけど、いいかんじのアラートを出してくれる便利なやつです。

f:id:chusotsuengineer:20200709223111p:plain
これはconfirmです。
f:id:chusotsuengineer:20200709223156p:plain
チェックマークがgifになってて動きます

で、これをnpmでインストールします。
npm install sweetalert2してください。

そしたらimport Swal from 'sweetalert2';で使えるようになります。

で、こんな感じで使います。

f:id:chusotsuengineer:20200709223700p:plain
開始ボタンを押したとき、発言権を持っている人の画面に出す
f:id:chusotsuengineer:20200709223729p:plain
発言権を持っている人が、相手の映像をクリックして発言権を渡そうとした時に出す
f:id:chusotsuengineer:20200709223806p:plain
渡すをクリックしたとき。既にマイクはミュートされています。
f:id:chusotsuengineer:20200709223833p:plain
発言権を受け取った人の画面に出す。

ソースコードはこんな感じです。

<template>
~~~略~~~
    <button v-if="!isStarted" @click="startDialogue">哲学対話を開始する</button>
    <video
      v-for="participant in participants"
      :key="participant"
      :id="participant"
      width="400px"
      autoplay
      playsinline
      @click="transferRight(participant)"></video>
~~~略~~~
</template>
    watch: {
~~~略~~~
     isStarted(newValue) {
        if (!newValue) {
          return;
        }
        if(this.right !== this.peer.id) {
          this.isMuted = true;
          this.isRight = false;
        } else {
          this.isMuted = true;
          Swal.fire(
            'トーキングオブジェクトをお渡しします!',
            '最初の発言者は緊張しますね。\n深呼吸したらマイクをオンにして語ってみましょう。\nHanasotはあなたのありのままの心を映す場所です。',
            'success'
          )
        }
      },
     right(newValue) {
        if (!this.isStarted) {
          return;
        }
        if (newValue !== this.peer.id) {
          this.isMuted = true;
          this.isRight = false;
        } else {
          this.isRight = true;
          Swal.fire(
            'トーキングオブジェクトを受け取りました!',
            'どんなことでも構いません。\nあなたの心のままに語ってみましょう。',
            'success'
          );
        }
      }
    },
    methods: {
      transferRight(participant) {
        if (!this.isRight) {
          return;
        }
        Swal.fire({
          title: 'トーキングオブジェクトを渡しますか?',
          text: "次にトーキングオブジェクトを受け取るまでマイクはミュートされます。",
          icon: 'question',
          showCancelButton: true,
          confirmButtonColor: '#3085d6',
          cancelButtonColor: '#d33',
          confirmButtonText: '渡す',
          cancelButtonText: 'もう少し話す',
        }).then(async result => {
          if (result.value) {
            await firebase.firestore().collection('rooms').doc(this.roomID).update({right: participant});
            Swal.fire(
              'トーキングオブジェクトを渡しました!',
              'みんなの発言を聞くこともまた対話です。\nゆっくりじっくり聞いてみましょう。',
              'success'
            )
          }
        });
      },
~~~略~~~
      async setEventListener(mediaConnection) {
~~~略~~~
        await firebase.firestore().collection('rooms').doc(this.roomID).onSnapshot(snapshot => {
          const room = snapshot.data();
          this.isStarted = room.isStarted;
          this.right = room.right;
        });
      }

動作フローは

  1. 対話開始ボタンが押されて、watchプロパティ内のisStartedメソッドが実行される
  2. 発言権を持っているユーザーが、任意の参加者の映像をクリックすることでtransferRightメソッドが実行される
  3. 発言権の切り替えが行われると、watchプロパティ内のrightが実行される
  4. 切り替え終了

という感じです。

ちなみに発言権のことを哲学対話ではトーキングオブジェクト(以下TO)という物で表します。ぬいぐるみやマイクなど何でもいいのですが、TOを持っている人にのみ発言が許されるというルールです。つまり発言権を可視化したわけですね。

所感

ずっと思っていたのですが、ソースコードの解説だけだと動作イメージがないから進捗が分かりづらいですね(スクショはたまに載せてますが...)。動画が1番わかりやすいですがビデオ通話という性質上、画面録画もしづらいですし...
うーん。とりあえずテストで撮ってみてまた考えます。せっかく記事にするなら読んで楽しいものにしたいので。

Hanasotが段々と完成していく様子を眺められる感じにできたら理想です。

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

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;をあてます。するとクリックやホバーなどマウスによるイベントが全て使用できなくなるんです。マイクのオンオフ切り替えをするにはボタンをクリックする必要があるので、結果的に強制マイクオフを実現できます。

所感

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

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

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

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