「ネコ動」を支える技術

# はじめに
2020年4月18日に開催された <a href="https://protoout.connpass.com/event/172136/">ProtoOutハッカソン</a> で <a href="https://protopedia.net/prototype/a07c2f3b3b907aaf8436a26c6d77f0a2">ねこしぇるじゅ</a> というサービスを作りました。
<img width="50%" alt="スクリーンショット 2020-05-06 1.34.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/e7cfcb36-b45d-80b7-958c-cd114aaed36f.png">
その中の一部機能「ネコ動」の実装についてメモに残しておきます。

# 「ネコ動」とは
ひとことで言うと、ニコニコ動画の「ネコ版」です。
ニコニコ動画では視聴者が入力したコメント文字列が弾幕で流れますが、ネコ動はネコの画像や動画がデスクトップ上に流れます。
<a href="https://youtu.be/k8FHGCGBDbU"><img width="50%" alt="nekodou.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/77583ea9-b34c-a850-b9bb-4c23e26dc036.png"></a>
<a href="https://youtu.be/d54JOLQhwRw"><img width="50%" alt="スクリーンショット 2020-05-06 1.28.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/fa6e19fc-9411-ea0d-87ab-6a3137d6d83a.png"></a>

# 実装
<a href="https://note.com/kitazaki/n/n3681cfcbc279">Milkcocoa ロスから卒業！ </a> というノートで <a href="https://github.com/kitazaki/commetter">commetter</a> という niconicocoa をリファクタリングして MQTT 対応した Electron 実装アプリを作った話を書きました。
commetter はデスクトップ上に文字しか流れませんでしたが、文字のほかに画像・動画を流す機能(ネコ動)と音声を再生する機能(ネコ声)を追加しました。
また、文字列の読み上げ機能(Web Speech API)に対応しました。

# 支える技術
## 伝える
MQTT (図の例では shiftr.io のサービスを利用) で 文字、画像、動画、音声の情報を PC 上の commetter へ伝えています。
送信側は 文字、画像、動画、音声の情報を MQTT ブローカー (shiftr.io) へ Publish で通知し、受信側は MQTT ブローカーから Subscribe で通知を受け取ります。
<img width="75%" alt="スクリーンショット 2020-05-06 0.27.35.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/b8b13a75-9947-9594-de94-04ca3dcfd8ca.png">

## 表現する
オリジナルはブックマークレット方式を採用していたため表現範囲がブラウザの中だけでしたが、commetter はElectron 実装アプリにすることで表現範囲がブラウザ内に閉じずデスクトップ (ウィンドウ全体) に広がりました。
<img width="65%" alt="スクリーンショット 2020-05-06 2.07.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72479/c2970e5f-2b4d-f755-3930-1efeaf9e7021.png">

文字、画像、動画をデスクトップ上に流すのは CSS アニメーションを使用しています。

```css
#comets .comment {
  position: absolute;
  left: 100%;
  width: 100%;
  margin: 0;
  animation-name: textanime;
  animation-duration: 15s;
  animation-iteration-count: 1;
  animation-fill-mode: forwards;
  animation-timing-function: linear;
}
@-webkit-keyframes textanime {
  0% {
    left: 100%;
  }
  100% {
    left: -300%;
  }
}
@keyframes textanime {
  0% {
    left: 100%;
  }
  100% {
    left: -300%;
  }
}
```

画像の表示は HTML5 の img 要素を使用しています。
動画、音声の再生は HTML5 の iframe 要素を使用しています。

- 画像を流す場合、文字列の先頭にタグ(img:)を入れることで、タグに続く文字列をimg要素のsrc属性に指定するURIとして取り扱います。  
- 動画を流す場合、文字列の先頭にタグ(mp4:)を入れることで、タグに続く文字列をiframe要素のsrc属性に指定するURIとして取り扱います。  
- 音声を再生する場合、文字列の先頭にタグ(mp3:)を入れることで、タグに続く文字列をiframe要素のsrc属性に指定するURIとして取り扱います。  
- 文字列を読み上げる場合、文字列の先頭にタグ(say:)を入れることで、タグに続く文字列をWeb Speech API (Speech Synthesis API)のspeak()関数に渡します。

MQTT ブローカーから通知を受け取った後に分岐条件と処理を追加しました。

```javascript
client.on("message", function(topic, message) {
    console.log(message.toString());
    if (message.toString().match(/^img:/)) {
      var i = message.toString();
      i = i.replace(/img:/g, "");
      var commentDom = $("<p></p>", {
        addClass: "comment",
        "id": num
      }).html("<img src="+i+"></img>").css({
        top: (Math.random() * 90) + "%"
      });
    } else if (message.toString().match(/^mp4:/)) {
      var i = message.toString();
      i = i.replace(/mp4:/g, "");
      var commentDom = $("<p></p>", {
        addClass: "comment",
        "id": num
      }).html("<iframe src="+i+" frameborder=0 allow=autoplay allowfullscreen id=iframeVideo></iframe>").css({
        top: (Math.random() * 90) + "%"
      });
    } else if (message.toString().match(/^mp3:/)) {
      var i = message.toString();
      i = i.replace(/mp3:/g, "");
      var commentDom = $("<p></p>", {
        addClass: "comment",
        "id": num
      }).html("<iframe src="+i+" allow=autoplay style=display:none id=iframeAudio></iframe>").css({
        top: (Math.random() * 90) + "%"
      });
    } else if (message.toString().match(/^say:/)) {
      // Speak
      var i = message.toString();
      i = i.replace(/say:/g, "");
      speechSynthesis.speak(
        new SpeechSynthesisUtterance(i)
      );
    } else {
      var commentDom = $("<p></p>", {
        addClass: "comment",
        "id": num
      }).text(message.toString()).css({
        top: (Math.random() * 90) + "%"
      });
    }
    $("#comets").append(commentDom);
    setTimeout(function(id) {
      $("#comets #" + id).remove();
    }, 10000, num);
    num++;
  });
}, 1000);
```

# まとめ
github にソースコードを公開していますので、気になった方は使ってみてください！
https://github.com/kitazaki/commetter