SoundCloudでプレイリストのリピートを実現するChrome拡張を作ったよ

これは [Redux Advent Calendar 2016](http://qiita.com/advent-calendar/2016/redux) の3日目の記事になります。

## はじめに

もはやReduxである必要がまったくないのですが、redux-sagaを使いたい一心で決めました。redux-saga Advent Calendarを作るわけにもいかなかったのでこちらに投稿します。redux-sagaはflowtypeとの相性が悪いというのをよく聞くので、そのあたりについて共有できればなと思います。

## 作ったもの

[SoundCloud](https://soundcloud.com/) のウェブ版のプレーヤにはリピート機能がついているんですが、トラック1曲のみのリピートなのでプレイリストのリピートができません。というわけで、無理矢理プレイリストレベルのリピートを実現するべく、Chrome拡張を作ってみました。

[ソースコード](https://github.com/kuy/sc-repeat-playlist) / [インストール](https://chrome.google.com/webstore/detail/soundcloud-repeat-playlis/fammfbeojlkefamjiabfmhickdjiehgc)

![Screen Shot 0028-12-02 at 18.29.42.png](https://qiita-image-store.s3.amazonaws.com/0/69860/e8f0e504-7562-4b7b-e751-ef3ba77943f7.png)

## ちょっと解説

全体の動作としては、再生中のトラックまたはプレイリストを監視して、そこから抜けたら元のプレイリストの再生を開始する、って感じで実現しています。元のプレーヤにはリピートのトグルボタンがありますが、今回は新しいモードを追加するため、代わりに３値のトグルが設置しました。それぞれの機能、役割と使用ライブラリへの対応関係をまとめると以下のようになります。

+ UI: React
    + 「なし」「トラック」「プレイリスト」の3つのリピートモードを指定するトグルボタン
+ 同期: Redux, redux-saga
    + 再生中かどうか、再生中のトラックまたはプレイリストをSoundCloudの画面から検出してReduxのストアに同期する
+ 検出とリピート: redux-saga
    + プレイリストレベルのリピート処理の判定ロジックです

このようにほとんどの処理がバックグラウンド動作になるため、redux-sagaのコードが幅を利かせています。肥大化を防ぐために役割をわけて分割しています。

+ sync.js
    + 汚れ仕事担当。SoundCloudの画面から生DOMをスクレイピングして整理された情報をReduxのストアにせっせと反映させます。
+ control.js
    + 汚れ仕事担当。SoundCloudに対する操作コマンド(Action)を生DOMと戦いながら実現するやつです。
+ tracker.js
    + キレイな世界。Reduxストアの情報を使って状況把握して、必要に応じてリピート処理を抽象的なコマンド(Action)で指示します。

汚れ仕事とキレイな世界を分けることでSoundCloudの画面の仕様変更や、よりロバストなSoundCloudに対する操作やスクレイピングに改善するときにリピート自体のロジックを変更する必要がなくなります。この設計手法は [Obelisk.jsとReduxで3Dテトリス「Oberis」を作ってみた](http://qiita.com/kuy/items/fd4ae1e3e3b34f934756) でも解説済みです。

## 得られた知見

### redux-sagaをflowtypeで型付けする

[flow-typed](https://github.com/flowtype/flow-typed)にはredux-sagaの型情報ファイルが登録されているのでそれを使えば簡単だ！くらいに構えていたのですが、そんなに甘くなかったです。まず、redux-sagaの最新版は0.13.0になっているので、flow-typedにある0.11.xだとスタブファイルが生成されるという残念な結果に・・・。仕方がないのでとりあえず0.11.xのファイルをダウンロードして直接置きました。問題が出たら適宜直すということで。

今回sagaでは以下のような型付けを使ってみました。

```control.js
import type { IOEffect } from 'redux-saga/effects';

// ...

export default function* controlSaga(): Generator<IOEffect,void,*> {
  yield fork(handleOutOfPlaylist);
  yield fork(handleToggleRepeatMode);
}
```

`Generator<IOEffect,void,*>` という部分がsagaの型になります。各型パラメータはそれぞれ「yieldする値の型」「returnする値の型」「nextに渡す値の型」になっています。従って今回の例だと`IOEffect`をyieldして、なにもreturnせず、何かをnextする、って意味ですね。`IOEffect` というのはredux-sagaにおける副作用(Effect)を記述したオブジェクトの型です。redux-sagaでyieldするものはすべて`IOEffect`を満たしているので最低限のチェックという感じです。それでもsagaの中で `yield true` とか書くとflowがエラーを出すので無駄ではありません。

returnの型としてvoidを指定しましたが、`sync.js` の `createObserver` 関数では `Channel` を指定しています。

```sync.js
function* createObserver(selector, options: Object, valueFn?: Function): Generator<IOEffect,Channel,*> {
  // Wait until target element is shown
  let $target;
  while (true) {
    $target = $(selector);
    if (0 < $target.length) {
      break;
    }

    // TODO: Enable timeout to avoid inifinite loop
    yield call(delay, 500);
  }

  return eventChannel(emit => {
    let last;
    const observer = new MutationObserver(() => {
      if (typeof valueFn === 'function') {
        const value = valueFn($target);
        // Emit only if value is changed
        if (last !== value) {
          emit(last = value);
        }
      } else {
        emit($target);
      }
    });

    // Start observing target
    observer.observe($target.get(0), options);

    return () => {
      observer.disconnect();
    };
  });
}
```

`createObserver`関数は特定のhtml要素の状態変更を MutationObserver を使って検出し、sagaから扱いやすいようにチャネルを作成してくれるユーティリティ関数です。CSSセレクタで要素の指定を行っているため、呼び出されたタイミングによってはまだページ上に要素が出現していない可能性もあります。そこでGenerator関数にすることで要素の出現を待ってから監視を開始します。

さて、ここまでは順調でしたが、Generator関数ではない通常の関数を`call`するとflowがエラーを吐くことがわかりました。例えば以下のようなコードです。

```sample.js
function* handleToggleRepeatMode(): Generator<IOEffect,void,*> {
  while (true) {
    yield take(TOGGLE_REPEAT_MODE);
    const repeat = yield select((state: State) => state.player.repeat);
    yield call(toggleRepeat, repeat === 'track'); // ここ！
  }
}
```

最後の`toggleRepeat`関数は通常の関数で、動作自体には問題ありませんが、大量のflowのエラーが出ます。これはredux-sagaの関数パラメータの定義に問題があり、以下のようになっていました。

```libdef.js
declare type Fn1<T1, R> = (t1: T1) => Promise<R> | Generator<*,R,*>;
```

これだと`call`などに渡す関数は常にPromiseを返すかGenerator関数である必要があり、何も返さない関数や数字を返すような普通の関数が考慮されていません。というわけでひとまず以下のように直しました。

```libdef.js
declare type Fn1<T1, R> = (t1: T1) => R | Promise<R> | Generator<*,R,*>;
```

エラーも消えてスッキリです。報告したところ「You直しちゃいなYo!」みたいなノリで、さらにカバレッジ上げてくれ！とも言われたのですが、もうちょい時間かかりそうです。flowtypeはいろいろ謎の部分が多くて・・・。でもredux-sagaヘビーユーザーとして0.13.0対応版のPRを投げたいと思います。

![Screen Shot 0028-12-02 at 18.03.22.png](https://qiita-image-store.s3.amazonaws.com/0/69860/e6702663-b69a-1e5f-3aa3-60d16e2e223d.png)

[redux-saga: Support calling normal function for "call" effect](https://github.com/flowtype/flow-typed/issues/469#issuecomment-262857299)

### 気になるライブラリ redux-typed-saga

最近 [redux-typed-saga](https://github.com/goshakkk/redux-typed-saga) というのを見つけました。

> The inspiration for typing side effects came from Redux Ship. However, Redux Ship has a totally different paradigm, and I don't want to buy into that completely.

これ、自分とまったく同じ感想です。ちなみにredux-shipについては別記事で書く予定です。
まだ絶賛開発中っぽいので今後に期待です（手伝え）。

![Screen Shot 0028-12-02 at 18.26.58.png](https://qiita-image-store.s3.amazonaws.com/0/69860/7f262b54-de7c-9f20-60b2-d78e8daf08b3.png)


## さいごに

ずっと不満を抱えていた問題が解決できたので満足です。ただ、せっかく作ったのにインストール数が伸び悩んでいるのでもしよかったら使って下さい。