Yii2 でプロモーション用の音楽トラックを一元管理するアプリを作る


## はじめに

個人的な趣味で、各音楽レーベルがプロモーション用に公開している音楽 (Bandcamp, SoundCloud, Vimeo, YouTube などに散らばっている) を一元管理するアプリを Yii2 で作っています。で、この記事では、そのアプリの一部分をよりシンプルにした形で書こうと思います。それでもわりとごちゃごちゃしてしまうかもですが、Yii2 でアプリを作る流れを少しでも感じ取っていただければ幸いです。

## このアプリでできること

* 曲の URL をユーザが入力すると、自動的に曲の情報をデータベースに保存する
* 保存されたデータをリスト表示して、サムネイルをクリック(タップ)すると曲が再生される

## 使ったもの

* PHP 5.6.16
* MySQL 5.7.9
* Yii Framework 2.0.7-dev
* jamband/ripple 0.1.5

## インストールと設定

Composer で Yii2 などをインストール:
(Composer や composer asset plugin などのインストール方法は公式ガイドの [Yii をインストールする](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/start-installation.md) を参考にしてください)

```
cd /path/to/somewhere
composer create-project --prefer-dist yiisoft/yii2-app-basic basic
cd ./basic
```

で、今回は Yii の 2.0.7 以降の機能も少し使いたいので yiisoft/yii2 を dev-master にして、ついでに jamband/ripple もインストールするようにします。
( [jamband/ripple](https://github.com/jamband/ripple) は URL から曲の情報を取得したりするのに使います )

```composer.json
{
    "require": {
        "yiisoft/yii2": "dev-master",
        "jamband/ripple": "*",
}
```

```
composer update
```

yii2basic というデータベースを作成:
( データベース名は任意のものでかまわない )

```
mysql.server start
mysql --login-path=xxxxxxx
mysql> CREATE DATABASE yii2basic;
```

アプリ側で DB の設定をする:
( ユーザ名、パスワードは自分の環境に合わせる )

```php:config/db.php
return [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=localhost;dbname=yii2basic',
    'username' => 'xxxxxxx',
    'password' => 'xxxxxxx',
    'charset' => 'utf8',
];
```

track テーブルの作成:
( 曲の情報をこのテーブルに挿入していきます )

```
./yii migrate/create create_track --fields=url:string:notNull,title:string:notNull,image:string:notNull,provider_name:string:notNull,provider_key:string:notNull
```

上記のやり方は Yii 2.0.7 以降で使えるやり方です ([データベースマイグレーション](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/db-migrations.md)) 。

各カラムは以下のような感じ:

* url: 曲の URL
* title: 曲のタイトル
* image: 曲のサムネイル
* provider_name: 曲を提供元 (Bandcamp, SoundCloud, Vimeo, YouTube のいずれか)
* provider_key: 曲の ID

適用します:

```
./yii migrate
```

PHP ビルトインウェブサーバを起動:

```
php -S localhost:8080 -t /path/to/basic/web
```

ブラウザで localhost:8080 にアクセスします。「Congratulations!」と出れば OK です。あとデフォルトでは URL が若干汚いので以下の設定で綺麗にしておきます:

```php:config/web.php
'components' => [
    // ...
    'urlManager' => [
        'enablePrettyUrl' => true,
        'showScriptName' => false,
    ],
```

これで準備は完了 :)


## Track モデルの作成

* http://localhost:8080/gii にアクセスし Model Generator の Start ボタンをクリック
* Table Name に t と入力し候補の track をクリック
* タブで Model Class に移動し、自動で Track と入力されているのを確認
* Generate Relations のチェックを外す
* Preview ボタンをクリックし Generate ボタンをクリック

これで Track モデルが作成されます。

## Track モデルの中身を書いていく

Track モデルでは、URL から自動的に曲の情報を取得するコードとバリデーションルールを書いていきます。新たに作るメソッドは以下:

```php:models/Track.php
public function setContents(\Goutte\Client $client) {
    // URL から自動で title, image, provider_name, provider_key を取得するコードを書く
}
public function validateUrl($attribute, $params) {
    // URL が曲の URL として正しいものかをチェックし
    // 正しくない場合はエラーメッセージを追加する
}
public function validateContent($attribute, $params) {
    // URL から曲の情報が取得できているかをチェックし
    // 取得できていない場合はエラーメッセージを追加する
}
```

まずは Track::setContents() から:

```php:models/Track.php
use jamband\ripple\Ripple;
use Goutte\Client;

class Track extends \yii\db\ActiveRecord
{
    /**
     * @param Client $client
     * @return Track
     */
    public function setContents(Client $client)
    {
        $ripple = new Ripple($this->url);
        if ($ripple->isValidUrl()) {
            $ripple->request($client);

            if ($this->title === '') {
                $this->title = $ripple->title();
            }
            $this->image = $ripple->image();
            $this->provider_name = $ripple->provider();
            $this->provider_key = $ripple->id();
        }
        return $this;
    }
```

URL を Ripple に食わせて、それが正しい URL ならば曲の情報を自動取得して、各カラムにセットしている感じです。

バリデーションルール:

```php:models/Track.php
public function rules()
{
    return [
        [['url'], 'required'],
        [['url', 'title'], 'trim'],
        [['url', 'title'], 'unique'],
        [['title'], 'string', 'max' => 200],
        ['url', 'validateUrl'],
        ['url', 'validateContent'],
    ];
}

public function validateUrl($attribute, $params)
{
    if (!(new Ripple($this->$attribute))->isValidUrl()) {
        $this->addError($attribute, '曲の URL が正しくありません。');
    }
}

public function validateContent($attribute, $params)
{
    if (!isset($this->image) || !isset($this->provider_key)) {
        $this->addError($attribute, 'URL から曲の情報が取得できません。');
    }
}
```

URL が曲の URL として正しいかを検証するものと、URL からちゃんと曲の情報が取得できているかを検証するものをカスタムなバリデーションルールとして定義しています。バリデーションの作り方は公式ガイドの [入力を検証する](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/input-validation.md) が参考になります。

## TrackController とビューの作成

* http://localhost:8080/gii にアクセスし CRUD Generator の Start ボタンをクリック
* Model Class に app\models\Track と入力
* Controller Class に app\controllers\TrackController と入力
* Widget Used in Index Page の下をクリックして ListView に合わせる
* Preview ボタンをクリックし Generate ボタンをクリック

これでコントローラとビューが作成されます。ついでに http://localhost:8080 にアクセスしたときに track/index を呼び出す形に変えておきます。

```php:config/web.php
$config = [
    'id' => 'basic',
    'defaultRoute' => 'track', // 追加
    'basePath' => dirname(__DIR__),
    // ...
```

## TrackController の中身を書いていく

モデルで書いた Track::setContents() をコントローラの actionCreate() と actionUpdate() で呼び出すようにします。

```php:controllers/TrackController.php
use jamband\ripple\Ripple;
use Goutte\Client;

// この行を
if ($model->load(Yii::$app->request->post()) && $model->save()) {

// こう書き換える
if ($model->load(Yii::$app->request->post()) && $model->setContents(new Client())->save()) {
```

これでコントローラは OK です :)

## 曲を追加してみる

* http://localhost:8080/ にアクセスし Create Track をクリック
* Url に https://linneshelvete.bandcamp.com/track/tjeresten と入力し Create ボタンをクリック

これで曲のいろんな情報がデータベースに自動で挿入されます ( 以下は例です ) 。

|カラム|値|
|---|---|
|ID|1|
|Url|https://linneshelvete.bandcamp.com/track/tjeresten|
|Title|Linnés Helvete - Tjeresten|
|Image|https://f1.bcbits.com/img/a3144407673_16.jpg|
|Provider Name|Bandcamp|
|Provider Key|932292198|

## 曲を再生してみる

再生するにあたって、以下のような流れをとりました:

* 追加した曲のサムネイルをリスト表示する
* サムネイルをクリックするとモーダルウィンドウが表示されて曲が再生される
* 再生されている場合、ページ上部に現在再生されている曲のタイトルを表示する
* そのタイトルをクリックすると現在再生されている曲がまたモーダルウィンドウで表示される
* x アイコンをクリックすると再生はストップする (一時ではなく完全に)
* 再生していても他の曲を探せるようにする (なにかしらの操作をしていても再生を停止させない)

最後は「ある程度」そうさせるくらいで。

## TrackController::actionIndex() と views/track/index.php の修正

わかりやすくするために 1 ページに 2 件だけリスト表示する形に変更:

```php:controllers/TrackController.php
$dataProvider = new ActiveDataProvider([
    'query' => Track::find(),
    'pagination' => ['pageSize' => 2], // 追加
    'sort' => false, // 追加
]);
```

サムネイルをギャラリー風に:

```html+php:views/track/index.php
<?php
use yii\helpers\Html;
use yii\helpers\Url;
use yii\widgets\LinkPager;
use yii\widgets\Pjax;
?>
<h1><?= Html::encode($this->title) ?></h1>
<p><?= Html::a('Create Track', ['create'], ['class' => 'btn btn-success']) ?></p>

<div id="track-embed"></div>
<div id="track-now" class="text-center"></div>

<?php Pjax::begin(['scrollTo' => 0]) ?>
    <div class="row">
        <?php $embedUrl = Url::to(['embed']) ?>
        <?php foreach ($dataProvider->models as $model): ?>
            <div class="col-xs-12 col-sm-6">
                <div class="thumbnail">
                    <?= Html::img(Html::encode($model->image), [
                        'class' => 'track-image',
                        'data-url' => $embedUrl,
                        'data-name' => $model->provider_name,
                        'data-key' => $model->provider_key,
                        'data-title' => $model->title,
                    ]) ?>
                    <div class="caption">
                        <h4><?= Html::a(Html::encode($model->title), Html::encode($model->url), [
                            'target' => '_blank',
                        ]) ?></h4>
                        <?= Html::a('View', ['view', 'id' => $model->id]) ?>
                    </div>
                </div>
            </div>
        <?php endforeach ?>
    </div>
    <?= LinkPager::widget(['pagination' => $dataProvider->pagination]) ?>
<?php Pjax::end() ?>

<?php
$this->registerJs(<<<'JS'
$(document).on('click', '.track-image', function() {
    var $el = $(this);
    $.ajax($el.attr('data-url'), {
        timeout: 10000,
        data: {
            name: $el.attr('data-name'),
            key: $el.attr('data-key')
        },
        context: {
            name: $el.attr('data-name'),
            title: $el.attr('data-title')
        }
    }).done(function(data) {
        $('#track-embed').html('<div class="fade modal" id="track-modal"><div class="modal-dialog">' +
            '<iframe src="' + data.embed + '" frameborder="0" allowfullscreen /></div></div>');

        var $modal = $('#track-modal');
        var $iframe = $modal.find('iframe');

        if (/^(Vimeo|YouTube)$/.test(this.name)) {
            $iframe.wrap('<div class="embed-responsive embed-responsive-16by9" />')
                .addClass('embed-responsive-item');
        } else {
            $iframe.attr({
                'width': '100%',
                'height': '120'
            });
        }
        $modal.modal('show');
        $('#track-now').html('<p><span id="track-now-title" /><span id="track-now-clear" /></p>');
        $('#track-now-title').text(this.title);
    }).fail(function(data) {
        alert('通信に失敗しました。');
    });
});
$(document).on('click', '#track-now-title', function() {
    $('#track-modal').modal('show');
});
$(document).on('click', '#track-now-clear', function() {
    $('#track-modal, #track-now').empty();
});
JS
);
```

インラインの JavaScript 部分は、サムネイルがクリックされると /track/embed に name, key のパラメータを付加して Ajax で GET リクエストします。成功すれば #track-embed 部分の HTML を組み立ててモーダルウィンドウを表示し、 #track-now 部分にタイトルを表示します。

## TrackController::actionEmbed() のコードを書く

```php:controllers/TrackController.php
use yii\web\Response;

/**
 * @param string $name プロバイダ名
 * @param string $key 曲の ID
 * @return mixed
 */
public function actionEmbed($name, $key)
{
    if (Yii::$app->request->isAjax) {
        $ripple = new Ripple();
        $ripple->setEmbedParams(Yii::$app->params['ripple.embed']['index']);

        Yii::$app->response->format = Response::FORMAT_JSON;
        return ['embed' => $ripple->embed($name, $key)];
    }
}
```

リクエストが Ajax 通信のものであれば、iframe の生成に必要な src を Ripple::embed() で組み立てて値を返します。また、埋め込み用のオプションを事前に設定しておくと、自動再生にしたり、見た目のカスタマイズが可能になります。Ripple::setEmbedParams() で設定します。例としては以下:

```php:config/params.php
return [
    'ripple.embed' => [
        'index' => [
            'Bandcamp' => 'size=large/tracklist=false/artwork=small/',
            'SoundCloud' => '&auto_play=true&show_comments=false',
            'YouTube' => '?autoplay=1',
            'Vimeo' => '?autoplay=1',
        ]
    ],
];
```

## スタイルの微調整

仕上げにスタイルを良い感じにします:

```html:views/layouts/main.php
<!-- <?= Html::csrfMetaTags() ?> の下らへんに以下を追加 -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">
```
```css:web/css/site.css
#track-now-title,
#track-now-clear {
  cursor: pointer;
}
#track-now-title:before,
#track-now-clear:before {
  font-family: FontAwesome;
  padding: 0 5px;
}
#track-now-title:before {
  content: "\f028";
}
#track-now-clear:before {
  content: "\f00d";
}
.track-image {
  width: 100%;
  cursor: pointer;
}
```

これでデモアプリは完成です :)

## 注意点

* このデモアプリは端末によって曲の自動再生ができるものもあれば、そうでないものもあります
* また PC 端末であっても YouTube などをバックグラウンドで再生させる Web サービスはよろしくないので、見せ方を工夫する必要があります 

## リンク

* [Yii 2 ガイド](https://github.com/yiisoft/yii2/tree/master/docs/guide-ja)
* [Yii 2 API](http://www.yiiframework.com/doc-2.0/)
* [oEmbed](http://oembed.com/)
* [FriendsOfPHP/Goutte](https://github.com/FriendsOfPHP/Goutte)
* [jamband/ripple](https://github.com/jamband/ripple)