WebデザインとJavaScriptの技術があれば、デスクトップアプリも自在に作れる時代です。ElectronとReactを使って、SoundCloudの音楽を自由に再生できるデスクトップアプリを作ってみました。
本記事はMark Brown、Dan Prince、Bruno Motaが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
GitHubが開発したElectronは、Webデザインのスキルを存分に活用し、クロスプラットホームで軽快に動くデスクトップアプリを作成できます。この記事では、ElectronをReact、ES6、SoundCloud APIと組み合わせて、デスクトップでお気に入りの曲を流せるスタイリッシュな音楽ストリーミングアプリを作る方法を紹介します。また、この音楽ストリーミングアプリをパッケージ化し、移植可能なOS固有のバンドルとして配布する方法も説明します。
記事はReactの基本的な知識があることを前提としています。開始する前に入門編を知りたい人はSitePointの記事『Getting Started with React: Building a Hello World Demo』をチェックしてください。 記事に必要なコードは、GitHub repoから取得できます。
開発するアプリの概要
次のような見た目の音楽ストリーミングアプリを作ります。
ReactをUIの作成、SoundCloud APIをトラックの取得、Electronはアプリが疑似ブラウザーのような環境で実行できるようにします。見て分かるように、このアプリには再生する音楽を検索するための検索フィールドがあり、個別の検索結果がそのままオーディオプレーヤーとなります。SoundCloudのWebサイトによく似ているアプリです。
最初に、SoundCloudのアカウントとSoundCloud appを取得してください。API keyは後ほど使いますので、メモを取っておいてください。
Electronとそのほかの依存オブジェクトを追加する
音楽ストリーミングアプリの構築を始めるにはsoundcloud-playerとフォルダー名を付けた場所にGithubにあるElectronのクイックスタートをコピーします。
git clone https://github.com/atom/electron-quick-start soundcloud-player
フォルダー内のpackage.jsonファイルを開き、以下のdev依存オブジェクトを追加します。
"devDependencies": {
"electron-prebuilt": "^1.2.0",
"babel-preset-es2015": "^6.9.0",
"babel-preset-react": "^6.5.0",
"babelify": "^7.3.0",
"browserify": "^13.0.1"
}
以下が各パッケージの概要です。
- electron-prebuiltはコマンドライン用のElectronビルド済みバイナリをインストール
- babel-preset-es2015はES6コードをES5コード(すべてのモダンブラウザーで実行できる)に変換
- babel-preset-reactはJSXコードをJavaScriptに変換
- babelifyはBabelをブラウザー化するためのトランスフォーマー
- browserifyは<script>タグだけでブラウザーの提供を可能にするバンドルを構築
dependenciesに、次のコードを追加します。
"dependencies": {
"node-soundcloud": "0.0.5",
"react": "^0.14.8",
"react-dom": "^0.14.8",
"react-loading": "0.0.9",
"react-soundplayer": "^0.3.6"
}
以下が各パッケージの概要です。
- node-soundcloudはSoundCloud APIを呼び出せる
- reactはReactのライブラリーで、UIコンポーネントの作成がでる
- react-domはDOMに挿入するReactコンポーネントのレンダリングができる
- react-loadingはアプリを読み込むときのインジケーターとして使う
- react-soundplayerはSoundCloudのカスタムオーディオプレーヤーを簡単に作成できるReactのコンポーネント
dependenciesとdevDependenciesを追加したら、npm installを実行してすべてインストールします。
最後に、コンパイル用のスクリプトを追加して、アプリを起動します。アプリをコンパイルするにはnpm run compileを、起動するにはnpm startを実行します。
"scripts": {
"compile": "browserify -t [ babelify --presets [ react es2015 ] ] src/app.js -o js/app.js",
"start": "electron main.js"
}
実行している間、Electronクイックスタート固有の値を削除し、アプリに合った適切なデフォルト値を追加します。
{
"name": "electron-soundcloud-player",
"version": "1.0.0",
"description": "Plays music from SoundCloud",
"main": "main.js",
"scripts": {
"start": "electron main.js",
"compile": "browserify -t [ babelify --presets [ react es2015 ] ] src/app.js -o js/app.js"
},
"author": "Wern Ancheta",
...
}
すべて完了すると、package.jsonファイルはこのようになります。
プロジェクトの構造
以下が、プロジェクトの構造です。
.
├── css
│ └── style.css
├── index.html
├── js
├── main.js
├── package.json
├── README.md
└── src
├── app.js
└── components
├── ProgressSoundPlayer.js
└── Track.js
足りないディレクトリを補って作成します。
mkdir -p css js src/components
次のファイルを作成します。
touch css/style.css src/app.js src/components/ProgressSoundPlayer.js src/components/Track.js
jsディレクトリはアプリ用のコンパイル済みJavaScript、cssディレクトリはアプリのスタイル、srcディレクトリはアプリのコンポーネントを保存します。
Electronのクイックスタートから引用したファイルから、次を削除します。
rm renderer.js LICENSE.md
main.jsとìndex.htmlを残します。2つのファイルのうち、main.jsはアプリを実行する新しいブラウザーウィンドウを作成する役割を果たすものです。ただし、いくつか変更を加える必要があります。はじめに、13行目で幅を調整します。
mainWindow = new BrowserWindow({width: 1000, height: 600})
次に、19行目から次のものを削除します(そうしなければ、Devツールを表示してしまいます)。
mainWindow.webContents.openDevTools()
main.jsが新しいブラウザーウィンドウを作成するとき、index.htmlを読み込みます(このファイルについては、後ほど説明します)。ここからアプリは、ブラウザーウィンドウと同じように実行されます。
アプリを構築する
トラックコンポーネント
src/components/Track.js内の、オーディオプレーヤー用のトラックコンポーネントを作成します。
ReactとReact SoundPlayerから提供されているコンポーネントが必要です。
import React, {Component} from 'react';
import { PlayButton, Progress, Timer } from 'react-soundplayer/components';
この構文を使用するとReactからComponentクラスが効果的に抽出されることに注目してください。名前が示すように、Componentは新しいコンポーネントを作成するためのものです。
続いてTrackという名前の新しいコンポーネントを作成し、それにrenderメソッドを加えます。このクラスをエクスポートし、のちに別のファイルにインポートできるということに注目してください。
export default class Track extends Component {
render() {
...
}
}
renderメソッドの内部では、propsから受け取った現在のオーディオトラックの情報を抽出し、destructuring assignmentを使用して抽出した情報を独自の変数に割り当てます。この方法を使えばthis.props.trackの代わりにtrackを使用できます。
const { track, soundCloudAudio, playing, seeking, currentTime, duration } = this.props;
それからトラックの現在の進行状況を計算します。
const currentProgress = currentTime / duration * 100 || 0;
コンポーネントのUIを返します。
return (
<div className="player">
<PlayButton
className="orange-button"
soundCloudAudio={soundCloudAudio}
playing={playing}
seeking={seeking} />
<Timer
duration={duration}
className="timer"
soundCloudAudio={soundCloudAudio}
currentTime={currentTime} />
<div className="track-info">
<h2 className="track-title">{track && track.title}</h2>
<h3 className="track-user">{track && track.user && track.user.username}</h3>
</div>
<Progress
className="progress-container"
innerClassName="progress"
soundCloudAudio={soundCloudAudio}
value={currentProgress} />
</div>
);
上記のコードから分かるように、標準的なオーディオプレーヤーです。再生ボタン、(現在の再生時間と長さを表わす)タイマー、タイトル、アップロードしたユーザーのユーザー名、プログレスバーが表示されます。
完成したコンポーネントはこのようになります。
ProgressSoundPlayerのコンポーネント
ProgressSoundPlayerコンポーネント(src/components/ProgressSoundPlayer.js)の作成に移ります。上で作成したTrackコンポーネントのラッパーとなります。
ReactとTrackのコンポーネントとは別に、SoundPlayerContainerをインポートする必要があります。SoundPlayerContainerは、オーディオプレイヤー作成に必要なpropsによる子要素のパブリングを伴ったより高いレベルのコンテナです。
import React, {Component, PropTypes} from 'react';
import { SoundPlayerContainer } from 'react-soundplayer/addons';
import Track from './Track';
次に、ProgressSoundPlayerコンポーネントを作成します。TrackコンポーネントをラップするSoundPlayerContainerをレンダリングするためです。SoundPlayerContainerが裏で自動実行するので、Trackコンポーネントへのパスを作成する必要はないことに注意します。ただし、SoundPlayerContainerのプロパティとしてresolveUrlとclientIdのパスは作成する必要があります。
export default class ProgressSoundPlayer extends Component {
render() {
const {resolveUrl, clientId} = this.props;
return (
<SoundPlayerContainer resolveUrl={resolveUrl} clientId={clientId}>
<Track />
</SoundPlayerContainer>
);
}
}
最後にコンポーネントに必要なプロパティを指定します。このコンポーネントをレンダリングする際、resolveUrlとclientIdのパスが必要です。
ProgressSoundPlayer.propTypes = {
resolveUrl: PropTypes.string.isRequired,
clientId: PropTypes.string.isRequired
};
propTypesを指定するのは良い練習になります。コンポーネントが要求するdevツールコンソールへのプロパティのパスが正しくない場合に表示する警告のトリガーとなるものです。SoundPlayerContainerが必要なすべてのパスを担っているため、それより前にTrackコンポーネントでこの作業をする必要はありませんので注意してください。
完成したコンポーネントはこのようになります。
メインコンポーネント
主なファイルはsrc/app.jsです。src/app.jsはアプリのすべてのUI、つまり検索フィールドとオーディオプレーヤーのレンダリングを担っています。
コードを分析して、アプリに必要なすべてのライブラリーをインポートします。それぞれ(独自に作成したProgressSoundPlayerを除く)依存オブジェクトの説明で先ほど示したものです。
import React, {Component} from 'react';
import ReactDOM from 'react-dom';
import ProgressSoundPlayer from './components/ProgressSoundPlayer';
import SC from 'node-soundcloud';
import Loading from 'react-loading';
SoundCloudのクライアントIDを入力します。
var client_id = 'YOUR SOUNDCLOUD APP ID';
dotenvなどを使えるので、データ部分をリポジトリにプッシュする必要はないということを覚えておいてください。
SoundCloudのクライアントIDを含むオブジェクトを指定してnode-soundcloudライブラリーを初期化します。
SC.init({
id: client_id
});
Mainコンポーネントを作成します。
class Main extends Component {
...
}
クラス内で、コンストラクターメソッドを定義します。定義によってクラスを初期化するためのコードを追加できます。constructorメソッド内でComponentクラスのコンストラクターを呼ぶためのsuper()とComponentクラスにある初期化コードを呼び出します。
constructor(props){
super();
}
次に、アプリの既定状態をセットします。
- queryは既定の検索クエリ
- hasResultsは現在コンポーネントがAPIからの結果を得たか得ないかを追跡するのに使用
- searchResultsは現在の検索結果を保存
- isLoadingは現在アプリがAPIからの結果をフェッチしているかどうかを追跡するのに使用。trueに設定されているとスピナーが表示され、何らかの処理が進行中であることが分かる
this.state = {
query: '',
hasResults: false,
searchResults: [],
isLoading: false
};
handleTextChangeメソッドも設定します。state内のqueryの値を更新し、Enterキーが押されたときにはsearchメソッドを呼び出します。このhandleTextChangeメソッドは、onKeyUpイベントが検索フィールドで発生した場合にも呼び出されます。
handleTextChange(event){
this.setState({
query: event.target.value
});
if(event.key === 'Enter'){
this.search.call(this);
}
}
searchメソッドを設定した後は、SoundCloud APIにクエリが送信され応答を処理します。メソッドがisLoadingの状態をtrueに設定すると、スピナーが表示されます。それからSoundcloud APIのエンドポイントでtracksに対しGET要求をします。このエンドポイントは必要なパラメーターとしてクエリを受け取りますが、ここで追加のembeddable_byパラメーターのパスを作成し、誰もがエンベッド可能なトラックのみをフェッチできるようにします。いったん応答が戻ってきた時点でエラーがないかどうかチェックし、エラーがなければ検索結果とともにstateを更新します。この時点で、コンポーネントは検索結果を再度レンダリングして最新の状態を表示します。
search(){
this.setState({
isLoading: true
});
SC.get('/tracks', {
q: this.state.query,
embeddable_by: 'all'
}, (err, tracks) => {
if(!err){
this.setState({
hasResults: true,
searchResults: tracks,
isLoading: false
});
}
});
}
renderメソッドは、コンポーネントのUIをレンダリングします。曲名やアーティストを入力するための検索フィールドや、検索の送信ボタンを備えています。また、Loadingコンポーネント(isLoadingが真の値を持つ場合のみ表示されます)と検索結果(hasResultsが真であるがisLoadingは誤である場合のみ表示されます)をレンダリングするための、1組の条件付きステートメントも含まれています。
render(){
return (
<div>
<h1>Electron SoundCloud Player</h1>
<input type="search"
onKeyUp={this.handleTextChange.bind(this)}
className="search-field"
placeholder="Enter song name or artist..." />
<button className="search-button"
onClick={this.search.bind(this)}>Search</button>
<div className="center">
{this.state.isLoading && <Loading type="bars" color="#FFB935" />}
</div>
{this.state.hasResults && !this.state.isLoading ?
this.renderSearchResults.call(this) :
this.renderNoSearchResults.call(this)}
</div>
);
}
handleTextChangeメソッドにはbind()、renderSearchResultsとrenderNoSearchResultsメソッドにはcall()を使用しなければならないことに注意してください。ES6クラス構文を使うときに、Reactではこれらのメソッドを自動的にバインドできないためです。もしくは、特定のメソッドをこのクラスに自動的にバインドするにはdeckoなどが使用できます。たとえば以下のような形です。
import { bind } from 'decko';
// ...
@bind
handleTextChange(event){
this.setState({
query: event.target.value
});
if(event.key == 'Enter'){
this.search();
}
}
次に、コンポーネントが最初にレンダリングされるとき、検索結果に該当がないために既定で呼び出されるメソッドがあります。
renderNoSearchResults(){
return (
<div id="no-results"></div>
);
}
表示する検索結果がある場合に呼び出されるメソッドもあります。すべての結果をループするためにsearchResults内のmapメソッドを呼び出し、ループのたびにrenderPlayer関数を実行します。
renderSearchResults(){
return (
<div id="search-results">
{this.state.searchResults.map(this.renderPlayer.bind(this))}
</div>
);
}
renderPlayer関数は、その引数として個別のtrackオブジェクトを受信します。受信した個別のtrackオブジェクトはkeyとresolveUrlプロパティのソースとして使用されます。過去にReactを使った経験があれば、リストをレンダリングするのにmapメソッドを使うことや、常に固有のkeyに対するパスを作成しなければReactが正常に動作しないことを知っているはずです。ほかに2つのプロパティ、clientIdとresolveUrlが ProgressSoundPlayerコンポーネントに対して必要になります。clientIdとは、以前に定義済みのSoundCloud API用のキーで、resolveUrlは特定のオーディオトラックを参照する固有のURLのことです。このURLは、SoundCloudで表示される特定のオーディオトラックのページと同じです。
renderPlayer(track){
return (
<ProgressSoundPlayer
key={track.id}
clientId={client_id}
resolveUrl={track.permalink_url} />
);
}
最後に、DOM内にコンポーネントをレンダリングします。
var main = document.getElementById('main');
ReactDOM.render(<Main />, main);
完成したコンポーネントはこのようになります。
アプリをスタイリングする
アプリのスタイルは、css/style.cssに格納されます。スタイルシートには、各コンポーネント(再生ボタン、検索ボタン、プログレスバーや使用するそのほかの要素)のスタイル宣言が含まれています。
インデックスファイル
先に述べたとおり、Electronのmain.jsファイルが新しいブラウザーウィンドウを作成するときにindex.htmlを読み込みます。ここにあるのはただ標準的なスタイルシート付きHTMLファイルとJavaScriptファイルだけで、特別なものはありません。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Electron Soundcloud Player</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="main"></div>
<script src="js/app.js"></script>
</body>
</html>
アプリをコンパイルする
Electronの環境内では事実上、標準のNode.jsアプリと同じようなものが必要になります。つまり、具体的には次のようなデータです。
import fs from 'fs';
const buffer = fs.readFileSync(`${__dirname}/index.html`);
console.log(buffer.toString());
これでElectronが快適に動くようになりました。
しかし、アプリの記述にES6とJSXを使用しているので、この機能はあまり使えません。代替策の1つとしては、Babelを使ってJSXとES6のコードをブラウザー(ES5)で読み取り可能なコードに変換することです。先ほどの「Electronとそのほかの依存オブジェクトを追加する」で、このアプリが機能するために必要なパッケージはすべてインストールしました。ですから、この段階の作業は、メインのJavaScriptファイルを生成するための、以下のコマンドの実行だけです。
npm run compile
アプリの実行とパッケージ
アプリを実行するには、プロジェクトのルートでnpm startを実行します。しかし、これではおもしろくも何ともないはずです。ただブラウザーでアプリを実行して終了するだけですから。少し工夫をして、1つのフォルダーにアプリをパッケージするのがお勧めです。そのフォルダーに、アプリの実行に必要なすべてのファイルを含めるのです。そうするとアプリを配布するフォルダーからアーカイブの作成ができます。
アプリのパッケージには、electron-packagerのインストールが必要です。
npm install electron-packager -g
インストール後、プロジェクトのルートから階層を1つ上がって次のコマンドを実行できます。
electron-packager ./soundcloud-player SoundCloudPlayer --version=1.2.4 --platform=linux --out=/home/jim/Desktop --arch=all --ignore="(node_modules|src)"
コマンドを詳しく見てみます。
- ./soundcloud-player — プロジェクトディレクトリ
- SoundCloudPlayer — アプリ名
- -version=1.2.0 — 使用するElectronのバージョン。記事執筆時のバージョンは1.2.0ですが、APIに重大な変更がない限り、もっと新しいバージョンを使える
- --platform=linux — 展開するプラットホーム。今回はUbuntuを導入したので、Linuxを使用。主要なプラットホーム(Windows、OS X、Linux)でパッケージ化する場合は、--allを代わりに使える
- --out=/home/wern/Desktop — 出力ディレクトリ。パッケージが作成される場所
- --arch=all — プロセッサーアーキテクチャ。32ビットと64ビットの両方のOS用にビルドした意味を込めて、allと規定
- --ignore="(node_modules|src)" — このアプリはElectronとChromeでパッケージ化されるので、サイズが非常に大きくなる。さらにデータが重くなるのを防ぐため、不要なファイルを削除するしかない。すでに1つのJavaScriptファイルにコンパイルしているので、node_modulesとsrcディレクトリ内のデータは不要
プロジェクトのWebサイトに、electron-packagerの詳しいドキュメントがあります。こちらでは、紹介した以外の使用可能なコマンドライン引数についても記載されています。
今後の展開について
記事では、かなりシンプルなElectronアプリを構築しました。動作はしますが、まだ改善の余地はあります。改善できる項目に関する提案をいくつか挙げます。
- 検索結果のページ制御
- ユーザーが検索を開始した際、自動的に再生中のトラックを停止する機能の追加
- 検索ボタンを削除し、handleTextChangeメソッドからの直接検索を可能に
- ソースコードを全体に公開するのを避けるため、asarアーカイブにアプリをパッケージ化
- アプリを世界中に配布したいと考えているなら改善が必要。主要なプラットホーム(Windows、OS XとLinux)のインストーラーを作成可能にするのが、electron-builderと呼ばれるプロジェクト
さらにヒントが欲しければ、SoundNode appをチェックしてください。デスクトップMac、Windows、Linuxをサポートしているオープンソースプロジェクトです。
Electronについての詳細や、一般的なWebテクノロジーを使ったデスクトップアプリを構築する際のヒントについては、以下のリソースをチェックしてください。
- NW.js — 以前、node-webkitとして知られていたリソース。DOMから直接nodeモジュールの呼び出しができる。代わりになるものを探しているならチェック
- Create Cross-Platform Desktop Node Apps with Electron — 最新のSitePointの記事
- Electron Video Tutorials
- Electron Official Docs
最後に
記事では、Electronを使って洗練されたスタイリッシュでクロスプラットホームなアプリを作成する方法を紹介しました。これを機に既存のWeb開発スキルをより伸ばせると良いですね。また、このアプリをOS固有のバンドルにしてパッケージ化したり、配布したりするのがいかに簡単か分かってもらえたと思います。
(原文:Build a Music Streaming App with Electron, React & ES6
[翻訳:皐月弥生]
[編集:Livit]