SH Lab の アプリ開発部屋

リリースしたアプリの告知とかサポートとか、技術的なお話とか、そんな感じでつぶやきます。

tvOS向けアプリとフレンチトーストの作り方を解説してみる

AppleTVのアプリ開発していたらお腹が空いた

f:id:hoshi0523:20160508120855j:plain

自宅でtvOS向けアプリの開発していたら、無性にフレンチトーストが食べたくなったので、ノリで作ってみました。これがまずまずの美味しさだったので、アプリ作りフレンチトースト作りについて、備忘録を兼ねて解説していきたいと思います。フレンチトースト - Wikipedia

ちなみにアプリは、テレビ向けっぽく動画のアプリがよかったので、Youtube動画を再生できるアプリを作ってみます。

用意したものはこちら

f:id:hoshi0523:20160508120931j:plain

今回用意したのはこちらです(※1人分)。

  • パン → 1枚(厚手のもの)
  • バター → 少々
  • たまご → 1個
  • 牛乳 → 60cc
  • 砂糖 → スティックシュガー1本分
  • 開発機Macbook Pro
  • AppleTV → 第4世代
  • Siri RemoteiPhoneでも代用可能
  • USBケーブル → Type-C

バニラエッセンスがあると尚良いらしいですが、最寄りのスーパーに売ってなかったので今回は見送りました。

アプリの概要

今回作成したアプリはこちらに置いてあります。

github.com

作ろうとしているアプリの概要は、こんな感じです。

  1. あらかじめ、Youtubeのプレイリストを作成しておく
  2. アプリ起動時、Youtube API を使ってプレイリストを取得する
  3. 取得したプレイリスト内のサムネイルを、グリッド状に表示する
  4. 任意のサムネイルを選択すると、プレイヤー画面に遷移して再生開始

まずはプロジェクト作りから

複雑なアプリではないので、アプリのテンプレはSingle View Applicationを選択し、アプリ名をYoutubeViewerとしておきます。

f:id:hoshi0523:20160507234855p:plain

cocoapodsでライブラリを入れよう

cocoapodsを使って、必要なライブラリを入れておきます。Podfileはこんな感じで設定してみました。

platform :tvos, '9.0'
use_frameworks!

target 'YoutubeViewer' do
  pod 'HCYoutubeParser'
  pod 'Unbox'
  pod 'AlamofireImage'
end
  • HCYoutubeParser
    • Youtubeのコンテンツに対して、AppleTVでも再生可能な形式のURLを生成してくれるライブラリ。
  • Unbox
  • AlamofireImage
    • 画像の非同期取得ライブラリ
    • Alamofireを使うので、せっかくなのでこれも使おう


Unboxの使い方については、ギャップロさんを参考にさせてもらっていますよ。

www.gaprot.jp

また、フレンチトーストについては、こちらのサイトさんを参考にさせてもらっていますよ。

http://food.kihon.jp/breadtoast/1924food.kihon.jp

卵液を作ろう

f:id:hoshi0523:20160508121007j:plain:w600

パンに染み込ませるための卵液の作り方です。とは言っても、卵をよく溶いて、牛乳・砂糖(とバニラエッセンス)を加えて、しっかりと混ぜるだけです。

パンを浸そう

f:id:hoshi0523:20160508121029j:plain:w600

出来上がった卵液に、パンを浸します。

f:id:hoshi0523:20160508121035j:plain:w600

箸でつまむと、卵液をよく吸います。卵液が全て吸われてなくなるまで、両面をしっかりと浸しましょう。このまま放置するのも良いと思います。
それでも卵液が余るようなら、第2・第3のパンを投入しましょう。

Youtubeでプレイリストを作っておく

www.youtube.com

YoutubeViewer - YouTube

今回のアプリ内に並べるYoutubeのコンテンツは、プレイリストを作って用意することにします。適当なプレイリストを作成して、そのプレイリストのIDを控えておきましょう。

ちなみに自分は、今回のアプリ用にこんな感じのプレイリストを作ってみました。昔よく聞いていたバンドや照井春佳さん出演コンテンツ、なにかと所縁のあるコンテンツなどを並べてみました。特に先頭の動画はとてもカッコよろしいです。

このプレイリストのIDは PLrFwetrdOQ9ixp8Kj0mqBLKQAG9DTViJA だそうな。こちらはURLを見るとわかります。

YoutubeAPIを利用してプレイリストを取得する

まずはアプリからYoutubeAPIを実行して、プレイリストのデータを取得してみましょう。YoutubeAPIの仕様については、この辺りの公式ドキュメントを参考にするとよさそうです。

なお、YoutubeAPIを利用するには、Google API Key が必要です。API Keyは、各々で取得してください。以下のドキュメントから API キー → ブラウザ キー あたりを参考に取得すれば、とりあえず動作可能です。

APIの実行とJSONのパース(ViewController.swift)

API通信部分は、Alamofireを使って、こんな感じでメソッドとして定義しています。

// API実行用のメソッド.
private func requestPlaylist(completeHander: Response<AnyObject, NSError> -> Void) {
  // プレイリストを取得するためのURL.
  let url = "https://www.googleapis.com/youtube/v3/playlistItems"
  // リクエストに利用するパラメータ.
  var parameters = [String: String]()
  parameters["part"] = "snippet,contentDetails"
  parameters["maxResults"] = "50"
  parameters["playlistId"] = "PLrFwetrdOQ9ixp8Kj0mqBLKQAG9DTViJA"
  parameters["key"] = "自分の API Key を使ってね"
  // リクエスト実施.
  Alamofire
    .request(.GET, url, parameters: parameters)
    .responseJSON(completionHandler: completeHander)
}

viewDidLoad()あたりで、上記のメソッドを呼び出しています。

// APIを実行する.
self.requestPlaylist { [weak self] response in
  // レスポンスをパースする.
  if let data = response.data {
    do {
      self?.playlistItems = try Unbox(data) as PlaylistItems
    } catch let error {
      print("error = \(error)")
    }
  }
}

JSONデータのパースのためのDTO(YoutubePlaylistModel.swift)

受け取ったJSONデータの構造に対応するDTOを、ひたすら定義していきます。今回は、利用する項目が限られているので、不要な項目に対応する構造体やプロパティは、省略しております。

struct PlaylistItems {
  var items: [VideoItem]
}
struct VideoItem {
  let contentDetails: ContentDetails
  let snippet: Snippet
}
struct ContentDetails {
  let videoId: String
}
struct Snippet {
  let thumbnails: Thumbnails
}
struct Thumbnails {
  let mediumThumb: Thumbnail
}
struct Thumbnail {
  let url: NSURL
}

DTOを、Unboxableプロトコルに準拠させます。

extension PlaylistItems: Unboxable {
  init(unboxer: Unboxer) {
    self.items = unboxer.unbox("items")
  }
}
extension VideoItem: Unboxable {
  init(unboxer: Unboxer) {
    self.contentDetails = unboxer.unbox("contentDetails")
    self.snippet = unboxer.unbox("snippet")
  }
}
extension ContentDetails: Unboxable {
  init(unboxer: Unboxer) {
    self.videoId = unboxer.unbox("videoId")
  }
}
extension Snippet: Unboxable {
  init(unboxer: Unboxer) {
    self.thumbnails = unboxer.unbox("thumbnails")
  }
}
extension Thumbnails: Unboxable {
  init(unboxer: Unboxer) {
    self.mediumThumb = unboxer.unbox("medium")
  }
}
extension Thumbnail: Unboxable {
  init(unboxer: Unboxer) {
    self.url = unboxer.unbox("url")
  }
}

CollectionViewで、画面上にサムネイルを表示する

データの取得が成功したら、CollectionViewを使って画面にサムネイルを並べていきます。

f:id:hoshi0523:20160508130123p:plain

Storyboardでは、UICollectionViewを画面いっぱいに広げて配置しています。CellのクラスはYoutubeCellに変更し、サイズいっぱいにUIImageViewを配置しています。dataSource, delegate, IBOutlet の接続なども忘れずに行いましょう。

collectionViewの準備と表示処理(ViewController.swift)

通信の取得後に、collectionViewを更新する処理を追加します。

// APIを実行する.
self.requestPlaylist { [weak self] response in
  // 省略.
  // リクエスト取得後にcollectionViewをリロード.
  self?.collectionView.reloadData()
}

UICollectionView関連のプロトコルを、いい感じで実装しましょう。

extension ViewController: UICollectionViewDataSource {
  func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return self.playlistItems?.items.count ?? 0
  }
  func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCellWithReuseIdentifier(YoutubeCell.CellIdentifier, forIndexPath: indexPath) as! YoutubeCell
    cell.videoItem = self.playlistItems?.items[indexPath.item]
    return cell
  }
}
extension ViewController: UICollectionViewDelegate {
  func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
    // TODO: 保留.
  }
}
extension ViewController: UICollectionViewDelegateFlowLayout {
  // セルの大きさ.
  func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize {
    return YoutubeCell.CellSize
  }
  // collectionView全体のInsets.
  func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAtIndex section: Int) -> UIEdgeInsets {
    return UIEdgeInsets(top: 60, left: 90, bottom: 60, right: 90)
  }
  // セル間のスペースの最小値(Y軸方向).
  func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAtIndex section: Int) -> CGFloat {
    return 60 // 適当に.
  }
  // セル間のスペースの最小値(X軸方向).
  func collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAtIndex section: Int) -> CGFloat {
    return 60 // 適当に.
  }
}

サムネイルの表示処理など(YoutubeCell.swift)

セルのクラスでは、画像の非同期取得処理と、フォーカス時の拡大処理などを実装しておきました。

import UIKit
import AlamofireImage
class YoutubeCell: UICollectionViewCell {
  static let CellIdentifier = "YoutubeCell"
  static let CellSize = CGSize(width: 320.0, height: 180.0)
  @IBOutlet weak var thumbnailImageView: UIImageView!
  var videoItem: VideoItem? {
    didSet {
      // URLが存在する場合は、画像を取得して当て込む.
      if let url = self.videoItem?.snippet.thumbnails.mediumThumb.url {
        self.thumbnailImageView.af_setImageWithURL(url, imageTransition: .CrossDissolve(0.5))
      }
    }
  }
  override func awakeFromNib() {
    super.awakeFromNib()
    // フォーカスが当たった時のアレ.
    self.thumbnailImageView.clipsToBounds = false
    self.thumbnailImageView.adjustsImageWhenAncestorFocused = true
  }
}

アプリを起動してみよう

ここまでくれば、画面上にプレイリストのサムネイルが表示されます。なかなかいい感じですね。「お?このPV知ってっぞ」と思った方は、なかなかのおっさんです。

f:id:hoshi0523:20160508131747p:plain:w600
f:id:hoshi0523:20160508133435g:plain:w600

パンを焼こう

ここまでくると、パンが卵液をしっかり吸い込んでいると思いますので、さっそくパンを焼いていきましょう。

f:id:hoshi0523:20160508135112j:plain:w600

フライパンにバターを入れ、中火で溶かしていきます。溶けたら弱火にしましょう。

f:id:hoshi0523:20160508135348j:plain:w600

パンを投入します。弱火のまま、じっくりと焼いていきましょう。

f:id:hoshi0523:20160508135446j:plain:w600

両面とも焼いていきます。こんがりきつね色の焦げ目がついたら、フレンチトーストは完成です!腹減ったね!

画面遷移を実装してみよう

プレイリストが取得できて、コンテンツが表示されるようになりました。あとは動画が再生できれば目標達成です。まずは「任意のコンテンツを選択したらその動画の情報を次の画面に渡す」という処理を実装してみます。

遷移先の画面を作成(PlayerViewController.swift)

プレイヤー用のViewControllerとしてPlayerViewControllerクラスを作成しておきます。AVKitをimportし、AVPlayerViewControllerを継承します。また、前の画面からコンテンツ情報を受け取る必要があるので、インスタンス変数としてVideoItemを定義しておきましょう。

import UIKit
import AVKit

class PlayerViewController: AVPlayerViewController {
  var videoItem: VideoItem?
}

storyboardで画面とsegueを追加

storyboard上では、AVPlayerViewControllerを配置して、トップ画面からsegueをつないでおきます。segueのidentifierは、適当に設定しておきましょう。配置したAVPlayerViewControllerのクラスは、先ほど作成したPlayerViewControllerに変更しておきましょう。

f:id:hoshi0523:20160508142252p:plain

コンテンツが選択された際の挙動(ViewController.swift)

トップ画面で任意のコンテンツを選択された際は、選択されたindexPathをもとにVideoItemを取得し、インスタンス変数として保持します。あとは、先ほど設定したsegueを呼び出して画面遷移を行います。

extension ViewController: UICollectionViewDelegate {
  func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
    // 選択されたコンテンツを保持してプレイヤー画面へ.
    if let videoItem = self.playlistItems?.items[indexPath.item] {
      self.selectedVideoItem = videoItem
      self.performSegueWithIdentifier(ViewController.SEGUE_PLAY_MOVIE, sender: nil)
    }
  }
}

遷移のタイミングで呼ばれるメソッドで、PlayerViewControllerに対してVideoItemを渡します。

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  // プレイヤー画面への遷移の場合.
  if segue.identifier == ViewController.SEGUE_PLAY_MOVIE {
    // 次の画面に、選択されたVideoItemを渡す.
    let playerViewController = segue.destinationViewController as! PlayerViewController
    playerViewController.videoItem = self.selectedVideoItem
  }
}

この辺りのロジックはいくらでもやりようがあるので、お好みで実装してくださいな。

動画の再生を行う

VideoItemを渡して再生画面まできたら、あとはURLをAVPlayerに渡せば再生開始となります。プレイリストに含まれるvideoIdから、AppleTVで再生可能なURLを生成するために、HCYoutubeParserというライブラリを利用します。iPhoneであれば、他にもWebViewを利用した再生も可能のなのですが、tvOSの場合はAVKitを使う必要があるため、この方法をで再生します。

import UIKit
import AVKit
import HCYoutubeParser
class PlayerViewController: AVPlayerViewController {
  var videoItem: VideoItem?
  override func viewDidLoad() {
    super.viewDidLoad()
    // videoIdを取り出す.
    guard let videoId = self.videoItem?.contentDetails.videoId else { return }
    // videoIdを元にコンテンツ情報を取得する.
    guard let dic = HCYoutubeParser.h264videosWithYoutubeID(videoId) else { return }
    // コンテンツのURLを取得して再生開始.
    if let urlString = dic["medium"] as? String, let url = NSURL(string: urlString) {
      self.player = AVPlayer(URL: url)
      self.player?.play()
    }
  }
}

HCYoutubeParserのメソッドで、videoIdからコンテンツURLを取得しています。取得したURLをAVPlayerに渡して再生を開始します。

f:id:hoshi0523:20160508153516p:plain:w600
f:id:hoshi0523:20160508153818p:plain:w600

ちゃんと再生できました!ちょっとわかりづらいですが、シークなども何もせずに利用可能になります。

tvOS向けアプリに関しては、動画アプリとはとても相性がいいと思うので、こんな感じで動画ビューワアプリを作ってみるのもいいかもしれないですね。

再生できない動画もあるみたい...?

作成したプレイリストのうち、再生できない動画が一定数ありました。試行回数もそれほどではないので定かではありませんが、どうやら古めのコンテンツについては再生ができないみたいでした。

アプリもフレンチトーストも完成した!

f:id:hoshi0523:20160508154750j:plain

というわけで、無事にアプリもフレンチトーストも完成しました。どちらもそれほど苦なく作成することが可能なので、よかったらお試しくださいませ!

swiftへのツッコミ、コード埋め込みやシンタックスハイライトへのツッコミ、調理へのツッコミ、撮影技術へのツッコミ等、お待ちしておりますm( _ _ )m