はじめに
背景
僕は学校ではPC部に所属しているのですが、この部では毎年文化祭に部員一人一つづつゲームや動画など何か作品を作ることになっています。
僕はタイピングゲームを作りましたが、適当に作ったのでシンプルなゲームだったので、動画班のためにプレイヤーを作ることにしました。
目標
わざわざプレイヤーを作るということは何か付加価値がほしいです。
せっかくの文化祭なのでニ◯生のようにコメントが流せたら楽しいんじゃないかなと思いました。
- サーバーサイドの言語はRuby、フレームワークにSinatraを選択
- 効率化のためSCSSを導入
- 手元のいっぱいあるPC(以下コメント投稿PC)からコメントを投稿
- 前にある一つのPC(以下動画再生PC)に動画とコメントが流れる
環境
- 動画ファイルはコメント投稿PCに置いてある。どこかにアップロードすることなくブラウザ上で再生
- コメント投稿PCのブラウザバージョンはIE10、動画再生PCのブラウザはChrome(最新版)
- デプロイはherokuにて行う。
▲コメント投稿画面 | ▲動画再生画面 |
解説
サーバーサイド
HTML
ERBを使っています。
といってもテンプレート的な役割はサブタイトルつけるくらいです。
scss
get %r{^/css/(.*)\.css$} do
scss "scss/#{params[:captures].first}".to_sym
end
get
で正規表現を使ってコンパイル元のファイル名を取って、コンパイルしています。
このとき、scssファイルはview/scss
ディレクトリに格納することになります。
データベースの書き込み/読み取り
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のデータを指定しています。
投稿画面
背景
こちらの星空を拝借しました。
コマンドパレット
大きさの関するコマンドなのか、位置に関係するコマンドなのか、色に関係するコマンドなのかを取って、同じものに関係するコマンド(red
だったらpink
,cyan
など)を削除、いらないスペースを消したうえでtitle
属性の値と結合しています。
$("#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();
})
投稿
禁止処理(空文字、連続した同じコメント、30文字以上のコメント)を行ったあと、XSS対策のHTMLエスケープをしたあと、jQuery.post
しています。
$("#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! " + result.html);
$("#post-comment").get(0).reset();
$("[name='comment']").focus();
submiting = false;
},
'json'
);
}
}
return false;
});
動画再生画面
コメントを取得
今回はポーリングを使いました。
コールバック関数がネストしまくりで見苦しいです…。
promiseとか使ってスッキリ書きたいです。
getting関数は同じコメントが複数回流れるのを防いでいます。
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オブジェクト化して、流します。
- 注意: 攻撃者はフォームを介さないでもコメントを投稿(POSTを投げることが)できるので本来はここでもHTMLエスケープすべきです。*
高さはランダム、左から右に流しています。
jQueryのanimateではなくCSSアニメーションをつかったほうがパフォーマンス的にはいいかもしれません。
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>
要素にしてプレイリストを表示します。
$(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を使用しました。
var $playlist = $("#playlist ol")
.sortable({
axis: "y", // 縦方向のみ移動可
cursor: "move",
revert: 100
})
.disableSelection();
動画を流す
jQuery SortableのtoArray
で順番の配列を作成し、最初の動画を再生します。
$("#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();
});
}
});
次の動画を再生、前の動画に戻る、動画をリロードする、再生/一時停止、スキップ、音量上げる/下げる 関数をそれぞれ作ります。
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で行います。
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をこちらの方法でインストールします。
gitで管理していない場合はgit init
してコミットします。
ターミナル上でheroku login
コマンドを打ち、ログインします。
keroku keys:add # 鍵を登録し、
heroku create # アプリを作成、
git push heroku master # masterブランチの内容をpushします。
heroku run rake migrate # heroku上でマイグレート
ダッシュボードからアプリにアクセスできます。
おわりに
改善すべき点
- リアルタイム通信
- WebSocketを使いたい。
- 全体的にコードが汚い。
- ES2015を使う
さて、明日は@sosuke301の「よくわからないClojure」です!ちょっと調べてみましたがなんだかLISPと同じようなやばそうな匂いがします()
ではまたいつか!