Edited at

第8羽 トランスペアレントプレイング・プレイヤーストーリー (ごちうさ Advent Calendar 2015)

More than 1 year has passed since last update.

最近鬱病が急速に悪化しているerukitiです、ごきげんよう。アドベントカレンダーに四つも登録してしまって今週は二つ締め切りがあり、戦々恐々としています。今日はごちうさ Advent Calendar 2015の第8羽として、リゼ先輩のために透明な動画プレイヤーの作成解説記事をお送りします。


前振り

さて、僕の疲弊しきった脳みそに唯一人間らしい感情が取り戻される時間が毎週土曜日22:30〜23:00です。ごちうさ??の実況タイムだけ人間らしく生きられるのです。それでは僕は一週間のうち30分だけしか人間らしさを保てないのでしょうか。今日はテクノロジーの力を借りて、残り10050分の間も人としての尊厳を保てるようにしたいと思います。

そのためにはごちうさ動画を再生しなければなりません。しかし、無限に動画を再生しているとたとえば仕事をするのにとても邪魔になります。そこで動画画面を半透明にしましょう。こうすればずっと再生していても邪魔になりません。さらに半透明動画画面をクリックしたときに動画以外の画面をクリックできるように(クリックスルー)すれば完璧です。


リゼ先輩の為の記事です

「私はElectronとか動画再生の方法とかわからないからな」「ならば私にお任せを」「シャロはわかるのか?」「色々勉強しているんですよ。技術の勉強するのは、あの会社で生き抜く術ですから。」「く、苦労してるんだな。」「たとえば、そうですね…。Electronを使えばウェブ技術で簡単にデスクトップアプリを作れますし、HTML5のVIDEO要素なら動画再生できますし、クリックスルーを実現するAPIを…」「暗号だ!解読班はどこだー!」

全国のリゼ先輩諸氏のために透明な動画プレイヤーを簡単に作る為の解説を始めましょう。


リゼ先輩の想定スペック


  • ごきげんよう症候群を罹患している (罹患していなくてもかまいません)

  • HTML+JavaScript 少しわかる

  • コマンドライン(CLI)にはアレルギーが無い

  • 何らかのLL言語(Ruby, PHPなど)を触ったことがある

  • 「Electronを使えばウェブ技術で簡単にデスクトップアプリを作れますし、HTML5のVIDEO要素なら動画再生できますし、クリックスルーを実現するAPIを…」というシャロちゃんの説明がちんぷんかんぷん


HTML5のVIDEO要素

「HTML5に追加された要素にVIDEO要素があります。これは動画をウェブブラウザで再生するためのものです。」「YoutubeはHTML5使ってるんだっけ?」「はい、ニコ動はまだAdobe Flashを使っていますけどね。」「Flashってvvvウィルスとかあって危ないんじゃなかったっけ?」「そうですね、AdobeもFlash捨てたいようですし、ニコ動以外のサイトではFlashとかは切っておいた方がいいかもしれませんね。」

<video src="my-secret-rize.mp4"></video> でmy-secret-rize.mp4という動画を再生できます。ただしこのままでは、クリックしないと動画を再生しない、一度だけ再生するという、今回の目的にはそぐわないものになってしまいます。そこで<video src="my-secret-rize.mp4" autoplay loop></video>という風にautoplayloopを追加しましょう。これでクリックしなくても自動的に再生が始まりますしループ再生してくれます。

「他にも色々と動画再生を制御する方法があります。再生速度をいじったり、JavaScriptで色々制御したりなど。詳しくはvideo 要素 - HTML | MDNをご覧ください。ちなみにJavaScriptの情報を調べる時にはMDNで調べるのがおすすめです。」「MicrosoftのMSDNではなくてMDNなのか。」「そうです。MozillaのMDNです。」「JavaScript関連でぐぐると出てくる他のサイトはどうなんだ?」「古い記事とか割と放置されがちで今となっては有害な事が多いので、なるべくMDNとか見た方がいいです。下手なサイト読んじゃうとトラップにひっかかって、とほほな気分になって一日を無駄に過ごすことになりますよ。」「それはとほほだな。」


クリックスルーする

「クリックスルーに関してはElectron on Macでclick-thruを実現する - Qiitaという記事に書いてあるのでそちらを読んでください。」「手抜きじゃないのか?」「そ、そんなことはありません。」

難点はMacに依存するやり方なのでWindowsユーザーの皆さんはMacを買いましょう。もしくはWindows APIに対応したやり方で誰か記事書いてください。NodeでWin32叩けられれば簡単に実現できるはずです。


Electronでデスクトップアプリ

Electronはウェブブラウザの一つChrome(のオープンソース実装Chromium)と、JavaScriptでサーバーサイドプログラミングできるNode.jsが合体したようなフレームワークです。」「最近Electronって流行ってるみたいだな。」「そうですね、Windows版SlackやKobitoをはじめとして多くのアプリで採用されていますね。先日OSSになったMicrosoft/vscodeなんかもElectronですね。これからどんどん増えると思いますよ。」


  • ウェブプログラミングの技術を使える

  • 簡単にデスクトップアプリを作れる

  • Windows, Mac, Linuxで動くアプリを作れる

というリゼ先輩も注目の技術です。詳しくはQiitaのElectronタグElectron Advent Calendar 2015 - Qiitaをご覧ください。


具体的にはどう作る?

「いろいろ説明されて概要はわかったけど、具体的にはどう作ればいいのかわからんぞ。」「はい、これから詳しくご説明いたします。今回の記事では基本的にMacを前提に説明しますが、リゼ先輩は当然Mac使ってますよね!」「当然だ。イマドキのプログラマがMac以外を使うとかあり得ないからな!」

erukiti/rize-transparent-player に完成版のソースを置いているのでご確認ください。


まずはNodeをインストールする

「詳しくはanyenv - 最強の環境切り替えツール2015 - Qiitaを読んでanyenvndenvをインストールしてください、リゼ先輩!」「手抜きじゃないのか?」「手抜きじゃないです!」

少し説明すると、最近のプログラミング環境はバージョンアップに振り回されがちなので、今時はもうプログラミング環境をダイレクトにインストールするのは悪手なのです。そこで環境切り替えツールのanyenvndenvをインストールするのが一番良い手なのです。

「バージョン切り替えなんて何に使うんだ?」「言語がバージョンアップすると互換性が無くなるケースがよくあります。有名なものではRubyの1.9系問題ですね。手元の環境が2.2系なのに1.8向けのアプリをメンテしなきゃいけない時とかに重宝しますね。あと、Rubyのbundlerのような仕組みがNodeのnpmには標準的に備わっているため、プロジェクトフォルダごとに、Nodeのバージョンやインストールするパッケージを管理できます。」


npmでパッケージを入れる

「Nodeをインストールしたらnpmコマンドでパッケージをインストールすることができます。」「パッケージっていうのは何だ?」「色々な人が作ったツールやライブラリですね。基本的にはGoogleでキーワード npmって感じでぐると色々なものがヒットします。」「なるほど、たとえばUUID生成ライブラリを探したくなったら、npm UUIDでぐぐればいいわけだな?」「その通りです、さすがリゼ先輩。ただUUIDのようによく使われているライブラリではnpmも大量にヒットしますのでそこからさらに選ぶ必要があります。」「選ぶ方法は?」「そうですね、私の場合は一通り、npmのページとgithub.comのページを見て、ドキュメントがしっかりしている順に実際に使ってみて、使い方がわかりやすいものを選んでいますね。後、有名なライブラリであればQiitaに記事が書かれていることが多いですね。」

「そうそう、リゼ先輩。最近は言語自体がパッケージマネージャーを標準で持っている事も多くなりました。たとえばPHPなんかでもcomposerが標準的に使われているようですが、先日Appleから公開されたSwiftなんかはパッケージマネージャが最初から入ってたようですね。」「ライブラリのインストールとかで悩むの馬鹿馬鹿しいからな!」「できるエンジニアはなるべくこういうモノを使って標準構成を綺麗に作り出すモノですね!」「そうだな!できるエンジニアなら当然だ!」

まずはnpm init -yとかやって初期化しましょう。package.jsonというファイルが生成されます。詳しい書き方は、package.json 書き方でぐぐりましょう。mainがエントリポイントで、scriptsstartは、npm start実行時の処理方法です。

{

"name": "rize-transparent-player",
"version": "1.0.0",
"description": "リゼ先輩のための透過型プレイヤーのサンプル",
"main": "src/browser/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "electron src/browser/index.js"
},
"keywords": [],
"author": "sharo",
"license": "Apache",
"dependencies": {
"electron-prebuilt": "0.35.4",
"node-ffprobe": "1.2.2",
"nodobjc": "git+https://github.com/TooTallNate/NodObjC.git#e4e3e2fac301fb3f5eb0bc4714850f6b7b1db29d"
}
}

次に必要なパッケージをインストールします。今回はelectron-prebuiltnode-ffprobenodobjcを入れましょう。

$ npm i electron-prebuilt --save

$ npm i node-ffprobe --save
$ npm i https://github.com/TooTallNate/NodObjC.git --save

$ brew install ffmpeg

あと、node-ffprobeの実行の為にはffmpegをインストールする必要があります。


electron-prebuit

「リゼ先輩、これでElectronアプリを作る事ができるようになりました。それでは実際にElectronを起動してみましょう。今回はローカルにしかインストールしてないのでパスを指定しながらElectronを起動してみます。」

$ ./node_modules/.bin/electron

デフォルトウィンドウが開きます。オプションにファイル名を指定すればそのファイルを初期化用のコードとして実行します。


ブラウザプロセス

初期化用のコードは、ブラウザプロセス(browser process)と呼ばれるプロセスで実行されるものでNode.jsであれこれコードが書けます。今のNodeだとデフォルトでES2015(ES6)が書けるので、積極的にES2015を使っていくスタイルで行きましょう。

「なんでES2015推奨なんだ?」「ES2015はそんなに変更点が多いわけじゃ無いですが、革命的に使い心地が良くなる言語要素が導入されてます。また、ES5時代があまりにも長すぎた影響で、いろいろとややこしい事になってるのでES5の事を一度忘れてES2015をやるべきですね。」

「あと、ブラウザプロセスっていうのは何だ?」「Electronは、Node成分のブラウザプロセスと、Chromium成分のレンダラプロセス(renderer process)の二つのプロセスで動いています。ブラウザプロセスからレンダラプロセスを起動したりIPC(プロセス間通信)でやりとりします。」


おまじない

"use strict"

let app = require('electron').app
let BrowserWindow = require('electron').BrowserWindow

"use strict" はES2015のletを使う為のおまじないです。」「require('electron')は、Nodeのrequire文だよな?PHPやRubyなんかでも同じような字面のコード見たことがあるからなんとなくわかるけど、それ以外がさっぱりだな。たとえばvar hoge = 'A'みたいなのは以前JavaScript触った時にも使ってたから変数宣言だとわかるんだが、letってなんだ?」「letはブロックスコープになったvarですね。」「ブロックスコープってブロックから抜けたらその変数が消えて無くなるヤツだよな。」「はい、varだと、ブロックの中でvar宣言してブロックから抜けてもそのままアクセス出来てしまうんですが、letがブロックスコープになった事で他の言語のローカル変数と同様の挙動をするようになったと言えます。」

「ちなみに以前のElectronではapp = require('app')requireしていたんですが、v0.35.0からapp = require('electron').appに変わりました。」


ウィンドウを閉じたら終了する

app.on('window-all-closed', () => {

app.quit()
})

「これはウィンドウがすべて閉じられたら終了するというコードです。」「() => {}ってなんだ?」「それはfunction() {}の新しい書き方ですね。appオブジェクトにwindow-all-closedイベントが発火されたらapp.quit()を実行するというものです。」「イベント」「JavaScriptは元々イベントドリブンの言語として進化してきました。Node.jsやライブラリなんかもイベントという考え方を重視しています。イマドキのJavaScriptエンジニアはNode.js - Node書くならEventEmitterについて知っとくべし - Qiitaの記事は必読です。」

「ちなみに今回はウィンドウを閉じる=アプリケーション終了という前提で作っていますが、Macのアプリはウィンドウを閉じてもアプリケーションは終了しないものが標準的ですよね。そういうのを作る場合は、以下のようなコードにする必要があります。」

if(process.platform != 'darwin') {

app.quit();
}


コマンドライン引数を受け取る

if (process.argv.length < 2) {

console.log("引数に動画ファイルを指定してください、リゼ先輩!")
process.exit(1)
}

「これは見てわかるぞ、process.argvっていう変数の長さが2未満ならエラー終了するコードだな?」「そうですね。process.argvがNodeにおけるコマンドライン引数の配列で、[0]がNode本体, [1]が実行しているスクリプトの名前で、2以後が引数になります。」「引数がargv[2]から始まるのは少し違和感あるな。」


レンダラプロセスを起動する

let win = null

app.on('ready', () => {
win = new BrowserWindow({width: 1024, height: 800})
win.loadURL(`file://${__dirname}/../renderer/index.html`)
win.on('closed', () => {
win = null
})
})

「これも見て想像つくな。起動準備が出来たら1024x800のウィンドウを開くんだろ?」「そうです。引数のオブジェクトでいろいろパラメータを指定してウィンドウを開くことができます。」「で、ウィンドウが開いたら読み込むURLをセットすると。」「はい。補足すると、ES2015ではバッククォートでかこった文字列では${}と書くとPerlやRubyなどにある埋め込み文字列で、__dirnameが文字列の中で展開されます。ES2015では積極的にこれを使っていくといいですね。通常文字列と違って改行コードを入れてもエラーになりませんし。」


  • ウィンドウの外側のフレームを消すのは、frame: false

  • 座標固定は x: 0, y: 0 など

  • 透過するには transparent: true

  • 常に最前面に表示するのは 'always-on-top': true

「詳しくはelectron/browser-window.md at master · atom/electronをご覧ください。」「英語か…。」「Electronは開発が活発なので日本語訳待ってると知識がすぐ劣化するので公式のドキュメント読むのが一番です。ちなみにelectron/docs/api at master · atom/electronは一通り読むといいです。Qiitaの記事と併せて読むと捗るはずです。」


動画のサイズを取得する

「さっきのサンプルではwidth, heightを決め打ちにしちゃいましたがそれだと困ったことになるので、引数で指定した動画ファイルのサイズを取得しちゃいましょう。」

let fn = './my-secret-rize.mp4'

let probe = require('node-ffprobe')

probe(fn, (err, data) => {
if (err) {
console.log(`${fn}: probe error`)
console.dir(err)
process.exit(1)
}
console.dir(data)
})

「なんかファイル名が不穏な気がするんだが、実行したらつらつらと情報が出てきたな。filenamefileext, streams, format, metadata...。見ればなんとなく想像はつくけど、動画ファイルの解析情報か。」「そうですね。mp4なんかは複数ストリームが入り交じったデータコンテナなので、どうしてもこういうややこしい感じになってしまいます。」「サイズ情報は、streams配列のオブジェクトの中にあるwidth, heightか。」「そうですね、標準的なH.264 MPEG4動画ではH.264の動画ストリームとAACの音声ストリームが入っています。」

let width = 0

let height = 0
data.streams.forEach((stream) => {
if (stream.codec_type == 'video' && stream.width && stream.height) {
width = stream.width
height = stream.height
}
})

「複数の動画ストリームを含んでる場合にどうウィンドウサイズを設定すべきなのか悩ましいので、今回はちょっと手抜きをしてこんな方法で抜き出してみます。」「forEachは配列を回して要素ごとに引数の関数を実行するものだな。なるほど、codec_typevideoかつwidth, heightがあるものってことか。」「そうです。複数ストリームあると後ろのストリームで上書きされてしまうリスクはありますが、まぁそんな動画ファイルはそうそう無いだろうということで。」


トレイアイコンとメニュー

「リゼ先輩、今回の仕様だと、クリックがすり抜ける透過ウィンドウで動画が再生されるわけですが、そのままだと終了する時に不便なので、トレイを追加してみたいと思います。」

win.webContents.on('did-finish-load', () => {

let appIcon = new Tray('images/rabbit-tray.png')
let contextMenu = Menu.buildFromTemplate([
{label: '終了', accelerator: 'Command+Q', click: () => {app.quit()}}
])
appIcon.setContextMenu(contextMenu)
appIcon.setToolTip(`${fn}: ${width} x ${height}`)
})

did-finish-loadはまぁ初期化が終わった合図だと思ってください。あと

electron/menu.md at master · atom/electronelectron/menu-item.md at master · atom/electronを読んでください。」「そろそろ疲れてきたか?」「はい。思ったよりもこの記事書くの大変ですね。大体字面で雰囲気つかんでおいてください!」


index.html

「つらつらとブラウザプロセス側を延々紹介してきましたが、Electronアプリはブラウザプロセス側だけだと意味がありません。ここで、ブラウザプロセスから起動されるレンダラプロセス側に移りたいと思います。」

    <!DOCTYPE html>

<html lang="ja">
<meta charset="utf-8">
<link rel="stylesheet" href="./index.css">
<body>

<video autoplay loop></video>

<script>
require('./index.js');
</script>
</body>
</html>

「なんだ、割と普通な感じのウェブページのソースだな?」「そうですね、ウェブブラウザ向けのJavaScriptを書いている人にはおなじみのものですね。」


index.js

"use strict"

let ipc = require('electron').ipcRenderer

ipc.on('open', (ev, packet) => {
document.querySelector('video').src = packet.path
})

「レンダラプロセスのJavaScriptのコードはこれだけです。IPC通信を受け取ったらvideoタグのsrcにパスを代入します。」「ipc通信のモジュールが、ipcRendererになってるってことはこれはメインプロセス用のモジュールで、ブラウザプロセスだとipcBrowserだったりするのか?」「いえ、それはトラップの一つで、正しくはipcMainですね。なぜかブラウザプロセスとメインプロセスという二つの言葉があってElectronプログラマを混乱させています。もう一つトラップが潜んでいて、古いバージョンのElectronと比べて、ipc.onで指定するコールバック関数の引数が変更になっています。」


webContents.send

「レンダラプロセス側でIPC受け取るコードはわかったけど、ブラウザプロセスでIPCを送信するコードはまだ出てきてないよな?」「はい、これから紹介します。」

win.webContents.send('open', {path: fn})

「さっきのwin = new BrowserWindow(...)の時のwinオブジェクトか。」「はい。オープンしたウィンドウごとにwebContents.sendでそれぞれのウィンドウにIPCを送れます。第一引数がチャンネル名で今回はopenとしました。第二引数は任意の引数です。」


振り返り

「駆け足でしたがいかがでしたか?」「うーん、大体わかった気はするんだけど、本当に理解出来たかは自信ないな。」「質問は随時受け付けてますので、是非この記事にコメント書いてください!あとはてブとか付けるときっと筆者が喜びます。」


  • erukiti/rize-transparent-player

  • この記事は全国のリゼ先輩諸氏に送る、Electronでアプリを作る為の記事です

  • 質問などは是非コメントください

それではみなさま、ごきげんよう。