iframe
YouTube
Elm
YouTubeIFramePlayerAPI
Elm 2Day 21

YouTube IFrame Player APIをElmから利用する

はじめに

Qiitaデビューということで、まずはライトめ(?)なネタを書いてみることにしました。

もうすぐお正月ですが、お正月といえば百人一首。昔は誰かが読み手をしないといけなかったのですが、今はYouTubeに読み上げの動画があったりします。便利な世の中になったものです。

ところが、この動画は手軽でいいのですが、いざこれでプレイしようとすると「1プレイ毎に止めないといけない」「毎回同じ順番になってしまう」というのが困ったところです。

YouTubeからは、Javascriptで制御できる「YouTube Iframe API」があったりしまして、この程度の軽い話なら、生JavaScriptで作っても作れちゃいますが、Elmでやってみたくなったのでした。

YouTube Iframe APIとは

iframe内で動作する埋め込みYouTubeをJavaScriptで制御できるAPIです。公式サイトはこちら。使い方をざっくり書くと、こんなあたりでしょうか

  • HTMLに<div id="hoge">を配置し、JavaScriptでidやビデオIDを引数にセットして、インスタンスを作成
  • HTMLに<iframe id="hoge" src="https://www.youtube.com/embed/video_id?enablejsapi=1">を配置し、JavaScriptでidを引数にセットしてインスタンスを作成

divを配置するやり方でも、Javascript側でdivがiframeに置き換えられるそうなので、結局どの方法でも「iframe内のYouTubeプレーヤーを操作する」となります。

前提&アプローチ

今回の記事は、下記の前提とします
- craete-elm-appで作成したプロジェクトをベースに実装する。create-elm-appバージョンは1.10.0
- Elmはバージョン0.18
- Youtube IFrame APIは2017年12月現在の仕様に基づく

今回は、src/index.jsをいろいろといじることになるわけですが、「Elmでもindex.jsでもどちらでもできる場合は、なるべくElm側に実装する」とします。Elmにした方が型チェックの恩恵がありますからね。

YouTube Iframe APIを利用するインターフェース部分の実装は、JavaScript側はsrc/index.jsに追加、Elm側は新規にsrc/YTPlayer.elmを作成して、そこに載せます。

ElmからYouTube APIを利用する(失敗編)

ElmでYouTube Iframe APIを使ってみるべく、まずはiframeタグを設置します。

src/Main.elm
 iframe [id "hoge"
       , src "https://www.youtube.com/embed/video_id?enablejsapi=1"
       , width 560
       , height 315
        ] []

次に、src/index.jsに公式サイトのコードにあるようなYouTube Playerインスタンスを作ってみるものの、制御がうまくいきません。いろいろ調べてみると、このAPIはIframe間でのPostMessageを利用しているようです。

そこで、ElmのPortsを通して、このPostMessageを投げてみると、playVideoやstopVideoが動くようになりました。

src/index.js
app.ports.playVideo.subscribe(function(args) {
     var target_id = args[0];

     var player = document.getElementById(target_id).contentWindow;
     var reqbody = JSON.stringify({event: "command", func: "playVideo", args: {}});
     player.postMessage(reqbody, '*');
});

今回やりたいことは「動画を所定のタイミングから所定の時間だけ再生する」なのですが、今のYouTube Iframe APIでは、再生開始時刻は設定できますが、再生終了時刻は指定できません。ですので、「所定の時間だけ再生する(=所定の時刻で再生を止める)」ためには、再生中の時刻を取得できる必要があります。

ところが、このPostMessageを直接使う方法だと、APIで紹介されているgetCurrentTime()にあるような、再生時刻を取得することができませんでした。APIの機能を使い切るには、公式サイトに書いてあるスタイルをElmに適用しないといけないようです。

ElmでYouTube Playerインスタンスを生成する

ElmでYouTube Playerインスタンスを生成するためには、最初に、YouTube Iframe APIのモジュールをロードします。これはsrc/index.jsに記述してOKです。

src/index.js
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

さて、公式サイトにあるコードのうち、一番ハードルが高かったのが、function onYouTubeIframeAPIReady()を定義しておいて、これをAPIモジュールから呼んでもらう、というところでした。この部分のコードをsrc/index.jsに直接書いても、APIからは認識してもらえません。原因は、「src/index.jsにfunction onYouTubeIframeAPIReady()と実装しても、それはグローバル(正確にはwindow配下)の名前空間ではない」というところでした。

ElmもJavaScriptもまだまだ不慣れなのでいろいろと試行錯誤をした結果、公式サイトのこの部分を以下のようにアレンジすることで、ようやくYouTube Playerインスタンスを取得できるようになったのでした。

src/index.js
var ytPlayer;
window.onYouTubeIframeAPIReady = function () {
    ytPlayer = new YT.Player("hoge", {});
}

portからYouTube Playerを制御する

src/index.jsで取得したYouTube Playerインスタンスは、上記の変数ytPlayerに保持してもらえ、ElmのPortsでアクセスされる関数からも利用できるので、Elmからは全てのメソッドが利用できます。例えば、playVideoであれば

src/index.js
app.ports.playVideo.subscribe(function() {
    ytPlayer.playVideo();
});
YTPlayer.elm
port playVideo : () -> Cmd msg

とすることで、update関数でのAPI利用が可能になります。

YouTube Playerの情報を取得

今回の実装では、「単純にgetCurrentTime()の値を返す」という実装はしてないですが、仮にするとしたら、portとsubscriptionの組み合わせになるでしょう。

src/index.js
app.ports.getCurrentTime.subscribe(function() {
   app.ports.currentTime.send(ytPlayer.getCurrentTime());
});
src/YTPlayer.elm
type Msg = CurrentTime Float

port getCurrentTime : () -> Cmd msg -- 取得用コマンド
port currentTime : (Float -> msg) -> Sub msg -- 戻り値用サブスクリプション
subscriptions : Sub Msg
subscriptions = currentTime CurrentTime

としておいて、Main側でこのsubscriptionを登録する、というような流れになります。

あるいは、YouTube Player側のイベント(API ready, Player readyなど)を捕捉したい場合には、それもsubscription経由での捕捉になります。そのためには、YouTube Playerインスタンス生成時に、イベントのコールバックを定義してやって、そこでsubscription側にメッセージを投げる、とします。

src/index.js
window.onYouTubeIframeAPIReady = function () {
    ytPlayer = new YT.Player(playerId, {
        events: {
            'onReady' : onPlayerReady
          , 'onStateChange': onPlayerStateChange
        }
    });
    app.ports.apiReady.send(null);
}

function onPlayerReady(event) {
    app.ports.playerReady.send(null);
}

function onPlayerStateChange(event) {
    app.ports.playerStateChange.send(event.data);
}
src/YTPlayer.elm
--- Msg defs ---

type Msg
    = ApiReady
    | PlayerReady
    | PlayerStateChange PlayerState

type PlayerState
    = PlayerStateUnstarted
    | PlayerStateEnded
    | PlayerStatePlaying
    | PlayerStatePaused
    | PlayerStateBuffering
    | PlayerStateCued
    | PlayerStateUnknown Int

--- Sub msg ports ---

port apiReady : (() -> msg) -> Sub msg
port playerReady : (() -> msg) -> Sub msg
port playerStateChange : (Int -> msg) -> Sub msg

subscriptions : Sub Msg
subscriptions = Sub.batch
                [ apiReady (\_ -> ApiReady)
                , playerReady (\_ -> PlayerReady)
                , playerStateChange (PlayerStateChange << playerStateDecode)
                ]

--- decoder --

playerStateDecode : Int -> PlayerState
playerStateDecode n = case n of
                          -1 -> PlayerStateUnstarted
                          0 -> PlayerStateEnded
                          1 -> PlayerStatePlaying
                          2 -> PlayerStatePaused
                          3 -> PlayerStateBuffering
                          5 -> PlayerStateCued
                          others -> PlayerStateUnknown others
src/Main.elm
main : Program String Model Msg
main =
    Html.programWithFlags
        { view = view
        , init = init
        , update = update
        , subscriptions = \_ -> Sub.map MsgYTPlayer YP.subscriptions
        }

リポジトリ

今回作成した百人一首の動作制御アプリをGithubにあげていますので、より詳細については、コードも参考にしてください

https://github.com/cyclone-t/karuta-elm