某コメントフロー型動画サイト風プレイヤーを作った

# はじめに
## 背景

僕は学校ではPC部に所属しているのですが、この部では毎年文化祭に部員一人一つづつゲームや動画など何か作品を作ることになっています。

僕は[タイピングゲーム](https://github.com/ygkn/KStype)を作りましたが、~~適当に作ったので~~シンプルなゲームだったので、動画班のためにプレイヤーを作ることにしました。
## 目標
わざわざプレイヤーを作るということは何か付加価値がほしいです。

せっかくの文化祭なのでニ◯生のようにコメントが流せたら楽しいんじゃないかなと思いました。

- サーバーサイドの言語はRuby、フレームワークにSinatraを選択
- 効率化のためSCSSを導入
- 手元のいっぱいあるPC（以下コメント投稿PC）からコメントを投稿
- 前にある一つのPC（以下動画再生PC）に動画とコメントが流れる

## 環境
- 動画ファイルはコメント投稿PCに置いてある。どこかにアップロードすることなくブラウザ上で再生
- コメント投稿PCのブラウザバージョンはIE10、動画再生PCのブラウザはChrome(最新版)
- デプロイは[heroku](https://www.heroku.com/)にて行う。

![実行環境](https://qiita-image-store.s3.amazonaws.com/0/104663/3935b366-6750-5859-9af3-5473c9a359be.png)

|![コメント投稿画面](https://qiita-image-store.s3.amazonaws.com/0/104663/e0062945-05f4-d61a-3ea0-7503f357b949.png)|![動画再生画面](https://qiita-image-store.s3.amazonaws.com/0/104663/6040e80c-0114-033e-4d96-a379050f662e.png)|
|:--|:--|
|▲コメント投稿画面|▲動画再生画面|
# 解説
## サーバーサイド
### HTML
ERBを使っています。
といってもテンプレート的な役割は[サブタイトルつける](http://qiita.com/ygkn/items/7421ec04eff441f52201)くらいです。

### scss

```ruby:app.rb(L8~11)
get %r{^/css/(.*)\.css$} do
  scss "scss/#{params[:captures].first}".to_sym
end
```

`get`で正規表現を使ってコンパイル元のファイル名を取って、コンパイルしています。

このとき、scssファイルは`view/scss`ディレクトリに格納することになります。
### データベースの書き込み/読み取り
```ruby:app.rb(L24~)
post '/api/comment' do
  comment = Comment.create(params)
  json comment
end

get '/api/comment' do
  json Comment.where(id: (params[:lastId].to_i + 1)..Float::INFINITY)
end
```

両方ともJSON形式で返します。
`Float::INFINITY`はfloat型での最大値で、`(id: (params[:lastId].to_i + 1)..Float::INFINITY)`でidがparams[:lastId]より大きいIDのデータを指定しています。
## 投稿画面
### 背景
[こちら](http://qiita.com/nobuhito/items/0d9ae9eda05215cd2983)の星空を拝借しました。
### コマンドパレット
大きさの関するコマンドなのか、位置に関係するコマンドなのか、色に関係するコマンドなのかを取って、同じものに関係するコマンド（`red`だったら`pink`,`cyan`など）を削除、いらないスペースを消したうえで`title`属性の値と結合しています。

```js
$("#palette td").on("click", function (e) {
    var $tr = $(this).closest("tr");
    var commands = $("[name='commands']").val();
    var similarCommand;
    switch (true) {
        case $tr.hasClass("size"):
            similarCommand = /(big|small|medium)/g;
            break;
        
        case $tr.hasClass("position"):
            similarCommand = /(ue|shita|naka)/g;
            break;
            
        case $tr.hasClass("color"):
            similarCommand = /(white|red|pink|orange|yellow|green|cyan|blue|purple|black)/g;
            break;
    }
    commands = commands
        .replace(similarCommand,"")
        .replace(/  +/g, " ");
    commands += " " + $(this).attr("title");
    commands = commands.replace(/^\s/,"");
    $("[name='commands']")
        .val(commands)
        .focus();
})
```

### 投稿
禁止処理（空文字、連続した同じコメント、３０文字以上のコメント）を行ったあと、XSS対策のHTMLエスケープをしたあと、`jQuery.post`しています。

```js:form.js
$("#post-comment").on("submit",function(e){
    e.preventDefault();
    var comment = $("[name='comment']").val();
    if(comment == "" || comment == " "){
        jAlertError("Input someting in comment.");
    } else if(comment == lastComment){
        jAlertError("You can't post same comment in a row.");
    } else if(comment.length > 30){
        jAlertError("You can't post comment which has more than 30 characters.");
    } else {
        var _this = this;
        if(!submiting){
            submiting = true;
            var data = {
                body : comment,
                pc_id : pc_id,
                commands : $("[name='commands']").val()
            };
            var html = data.body
                .replace(/[&"<>]/g, function(match) {return TABLE_FOR_ESCAPE_HTML[match];})
                .replace(/\n/g, '<br>');
            data.html = "<p class='comment" + 
                (data.commands == "" ? "" : " " + data.commands)
                + "'>" + html + "</p>";
            $.post(
                $(_this).attr('action'),
                data,
                function(result) {
                    lastComment = result.body;
                    $(".result").html("Commented! &nbsp;" + result.html);
                    $("#post-comment").get(0).reset(); 
                    $("[name='comment']").focus();
                    submiting = false;
                  },
                'json'
            );
        }
    }
    return false;
}); 
```

## 動画再生画面
### コメントを取得
今回はポーリングを使いました。
コールバック関数がネストしまくりで見苦しいです…。
promiseとか使ってスッキリ書きたいです。
getting関数は同じコメントが複数回流れるのを防いでいます。

```js:comment.js
var lastId = -1;

var getting= false;

var getComment = function(){
    var h= $("#comments").height() - 120;
        console.log(getting);
        if(!getting){
        getting = true;
        $.get("/api/comment", 
        {lastId: lastId},
        function(data){
            if(data.length){
                if(lastId != -1){
                    console.log(lastId);
                    console.log(data);
                    lastId = data[data.length -1].id;
                    data.forEach(function(it, idx){
                      // 要素作って流す処理
                    });
                }else{
                    lastId = data[data.length -1].id;
                }
            }
            getting = false;
        });
    }
};

var commnetInterval = setInterval(getComment, 500)
```

### コメントを流す
フォーム画面で作ったHTMLをjQueryオブジェクト化して、流します。

* :warning: 注意: 攻撃者はフォームを介さないでもコメントを投稿(POSTを投げることが)できるので本来はここでもHTMLエスケープすべきです。*

高さはランダム、左から右に流しています。

jQueryのanimateではなくCSSアニメーションをつかったほうがパフォーマンス的にはいいかもしれません。

```js:↑の中身
var $comment = $(it.html)
var position = it.commands.match(/(ue|shita|naka)/g);
if(position == null || position[position.length -1] == "naka"){
    $comment
        .appendTo("#comments")
        .css("top",Math.floor(Math.random() * h) + "px")
        .animate({
            "transform": "translateX(0)",
            "right": "100%"
        },{
            "duration": 5000,
            "easing": "linear",
            "complete": function(){
                $comment.remove();
            }
        });
} else {
    $comment.appendTo("#comments-" + position[position.length-1]);
    setTimeout(function(){$comment.remove()},3000);
}
```

### 動画ファイルの取得
今回はドラッグアンドドロップでドロップされたファイルをglobオブジェクトとして取得するようにしました。

並べ替えできるように`<li>`要素にしてプレイリストを表示します。

```js:file.js
$(function() {
    // ファイルドロップイベント設定
    $("body").on("dragover", eventStop).on("drop", filedrop);
    // FileReaderオブジェクト生成
    reader = new FileReader();
});

// ファイルがドラッグされた場合
function eventStop(event) {
    // イベントキャンセル
    event.stopPropagation();
    event.preventDefault();
    // 操作をリンクに変更
    event.originalEvent.dataTransfer.dropEffect = "link";
}
// ファイルがドロップされた場合
function filedrop(event) {
    try {
        // イベントキャンセル
        event.stopPropagation();
        event.preventDefault();
        // ファイル存在チェック
        if (event.originalEvent.dataTransfer.files) {
            // ファイル取得
            var files = event.originalEvent.dataTransfer.files;
            if (files.length > 0) {
                videos = [];
                order = [];
                // 読込キャンセル
                if (reader.readyState == 1) {
                    reader.abort();
                }
                // URL存在判定
                if (url != null) {
                    URL.revokeObjectURL(url);
                    url = null;
                }
                var html = "";
                var l = files.length;
                var badfiles = null;
                for(var i = 0; i < l; i++){
                    if(("" + files[i].type).indexOf("video/") == 0 && video.canPlayType(files[i].type) != ""){
                        html += "<li id='" + i + "'>" + files[i].name + "</li>\n";
                        videos.push(URL.createObjectURL(files[i]));
                        order.push(i);
                    } else {
                        badfiles = badfiles == null ? "<b>" + files[i].name + "</b>" : badfiles +  ", <b>" + files[i].name + "</b>";
                    }
                }
                badfiles && jAlertError("File: " + badfiles + " is not movie or is not supported on this browser.");
                $("#playlist ol").html(html);
            } else {
                jAlertError("No file dropped");
            }
        }
    } catch (e) {
        // エラーの場合
        jAlertError(e.message);
    }
}
```

### 動画プレイリストの作成
先ほど作成したli要素を並び替えできるようにします。

ここではjQuery UI Sortableを使用しました。

```js
var $playlist = $("#playlist ol")
    .sortable({
        axis: "y", // 縦方向のみ移動可
        cursor: "move",
        revert: 100
    })
    .disableSelection();
```

### 動画を流す
jQuery Sortableの`toArray`で順番の配列を作成し、最初の動画を再生します。

```js

$("#playlist button").on("click", function(){
    if(videos.length == 0){
        jAlert("No file selected.", "Watch out!")
    } else {
        order = $playlist.sortable("toArray");
        $("#playlist").fadeOut("nomal", function(){
            next();
        });
    }
});
```

次の動画を再生、前の動画に戻る、動画をリロードする、再生/一時停止、スキップ、音量上げる/下げる　関数をそれぞれ作ります。

```js
var video = $("video").get(0);
var playing = 0;


var next = function(p){
    if(playing < videos.length){
        p = !p && video.paused;
        video.src = videos[order[playing]];
        playing++;
        p || play();
    }else{
        jAlert("Last Movie!");
        video.pause();
    }
};

var back = function(p){
    p = !p && video.paused;
    playing--;
    playing = playing > 0 ? playing : videos.length; 
    video.src = videos[order[playing -1]];
    p || play();
};

var reload = function(){
    video.src = videos[playing - 1];
    video.load();
}

var play = function(){
    video.paused ? video.play() : video.pause();
};

var skip = function(value) {
    video.currentTime += value;
};

function upVolume() {
	video.volume = video.volume < 0.75 ? video.volume + 0.25 : 1.0;
}

function downVolume() {
	video.volume = video.volume > 0.25 ? video.volume - 0.25 : 0.0;
}

var skipToStart = function(){
    video.currentTime = 0;
}

var skipToEnd = function(){
    video.currentTime = video.duration;
}

$(video).on("ended",function(){video.src == "" || next($("[name='autoplay']").prop("checked"))})
```

### キーボード・ショートカット

前で操作する動画にコントローラを表示するのもアレなので全てキーボードから操作することにしました。

押されたキーの判別はkeyCodeで行います。

```js
ar shortcutList = {
    13: play,                  // Enter
    32: play,                  // space
    75: play,                  // K
    35: skipToEnd,             // End
    36: skipToStart,           // Home
    37: back,                  // ←
    38: upVolume,              // ↑
    39: next,                  // →
    40: downVolume,            // ↓
    74: function(){skip(-10)}, // J
    76: function(){skip(10)},  // L
    82: reload                 // R
};

$(window).on("keydown", function(e){
    shortcutList[e.keyCode] && shortcutList[e.keyCode]();
})

```

## デプロイメント

heroku Toolbeltを[こちらの方法](https://devcenter.heroku.com/articles/heroku-cli)でインストールします。

gitで管理していない場合は`git init`してコミットします。

ターミナル上で`heroku login` コマンドを打ち、ログインします。

    keroku keys:add        # 鍵を登録し、
    heroku create          # アプリを作成、
    git push heroku master # masterブランチの内容をpushします。

    heroku run rake migrate # heroku上でマイグレート

ダッシュボードからアプリにアクセスできます。

# おわりに
改善すべき点

- リアルタイム通信
  - WebSocketを使いたい。
- 全体的にコードが汚い。
- ES2015を使う

さて、明日は[@sosuke301](http://qiita.com/sosuke301)の「よくわからないClojure」です！ちょっと調べてみましたがなんだかLISPと同じようなやばそうな匂いがします（）

ではまたいつか！