AVPlayerのリソース取得結果を扱いやすくする

AVPlayer[^1]はURLを渡すだけでリモートやバンドル内からリソースを取得してくれます．
が，AVPlayer内部でリクエストが処理されるため，単なるAPIリクエストの実行のような扱い方はできません．
そこで，一般的なAPIリクエストに近しい使い勝手で処理できるようにしてみました．
サンプルは以下にあります．
[gaussbeam/AVPlayerLoaderSample](https://github.com/gaussbeam/AVPlayerLoaderSample)

この記事では，以下のポイントについて説明します．
1. リクエストの実行結果のハンドリング
2. 任意のタイムアウト時間を設定

### 1. リクエストの実行結果のハンドリング
リソース取得の成功・失敗は，`AVPlayer.currentItem.status`により把握できます[^2]．
このプロパティは変更を監視できるので，変更されたタイミングでクロージャを実行することで，実行結果を外部からハンドリングできます．

なお，`AVPlayer`自体にも`status`というプロパティもありますが，この値は存在しないファイルURLでもreadyToPlayになるので，上記の値を監視しています．

```swift
final class AVPlayerLoader {
    enum Result {
        case success(AVPlayer)
        case failed
    }

    var itemUrl: URL {
        return (self.playerItem.asset as! AVURLAsset).url
    }
    
    private let playerItem: AVPlayerItem

    private var player: AVPlayer?
    private var observation: NSKeyValueObservation?

    private var completion: ((Result) -> Void)?
    
    init(_ url: URL) {
        self.playerItem = AVPlayerItem(url: url)
    }
    
    func load(completion: @escaping (Result) -> Void) {
        self.completion = completion
        
        self.startObservation()

        // AVPlayerを生成し，リソースの取得を行う
        print("Load: \(self.itemUrl.absoluteString)")
        self.player = AVPlayer(playerItem: self.playerItem)   
    }

    func startObservation() {
        guard self.observation == nil else { return }

        // `AVPlayer.status`は存在しないファイルURLでもreadyToPlayになるので，`AVPlayerItem.status`の変化を監視
        self.observation = self.playerItem.observe(\.status) { item, change in
            switch item.status {
            case .readyToPlay:
                print("Completed")
                self.finishLoading(.success(self.player!))

            case .failed:
                print("Failed")
                self.finishLoading(.failed)

            case .unknown:
                // unknownは初期値のため，この処理は実行されない
                break
            }
        }
    }

    func finishLoading(_ result: Result) {
        self.observation?.invalidate()
        self.observation = nil
        self.completion?(result)
    }
}
```

#### インタフェースを分けた理由
`AVPlayer`はイニシャライザが実行されるとただちにリソースの取得を行います．
(より正確には，`AVURLAsset`を持った`AVPlayerItem`が`AVPlayer`に紐付けられることによってリソースの取得が行われます[^3])
`init(_:)`と`load(completion:)`に分けることで，抽象リクエストのようにインスタンス生成とリクエストの実行を任意のタイミングで実行できるので，２つのインタフェースを設けました．
(とはいえ，`Session`に`Request`を渡して`Task`を生成…といった形式にはなっていないので，`AVPlayerRequest`とはせず，現状の命名にしています)

### 2. 任意のタイムアウト時間を設定
ここまでの内容であれば，単なるKVOの追加であるため，責務の分担に目をつぶればViewControllerでやってしまえる範囲内かと思います．
ですが，AVPlayerはタイムアウト時間を任意に設定できないため，通信環境によっては，いつまでも再生が始まらないといったことが起こりえます．
そのため，タイムアウトを設定できるよう，AVPlayerLoaderを以下のように変更します[^4]．

#### 1. Enumにタイムアウト状態を追加

```diff
    enum Result {
        …
+        case timedOut
    }
```

#### 2. タイムアウトインターバルを追加

```diff
   …
+    let timeoutInterval: TimeInterval
    private let playerItem: AVPlayerItem
   …
    private var observation: NSKeyValueObservation?
+　　　　　　　　// timerの設定については後述
+    private var timer: Timer?

-    init(_ url: URL) {
+    init(_ url: URL, timeoutInterval: TimeInterval = 5.0) {
        self.playerItem = AVPlayerItem(url: url)
+        self.timeoutInterval = timeoutInterval
    }
```

#### 3. `Timer`による独自のタイムアウト処理を追加

```diff
    func load(completion: @escaping (Result) -> Void) {
        …
        self.startObservation()
+        self.startTimer()
        …
        self.player = AVPlayer(playerItem: self.playerItem)  
    }
    
+    @objc func didTimeout() {
+        print(message: "Timed out")
+        self.finishLoading(.timedOut)
+    }
+
+    func startTimer() {
+        self.timer = Timer.scheduledTimer(
+            timeInterval: timeoutInterval,
+            target: self,
+            selector: #selector(didTimeout),
+            userInfo: nil,
+            repeats: false)
+    }

    func finishLoading(_ result: Result) {
+        self.timer?.invalidate()
+        self.timer = nil
        …
        self.completion?(result)
    }
```

このようにすることで，失敗時やタイムアウト時にはエラーメッセージの表示やローカルリソースへの差し替えが可能になります．
([サンプル](https://github.com/gaussbeam/AVPlayerLoaderSample)では，１つ前の画面でリソースを先読みしておき，取得に成功したらビデオ再生画面に遷移する，というケースを想定して実装しています)

### 備考
この記事の内容は，以下のような状況を想定しています．

- リソース自体の再生自体が目的ではない(=そのための待ちやエラー表示は許容できない)
  - e.g. オンボーディングやヘルプなどのちょっとした説明に動画を使う
- ファイルサイズが小さい
  - 検証で用いたファイルは100KB程度(3G相当の環境でも1.5秒程度で取得可能)

そのため，動画や音声の再生自体が目的である(=リソース自体の取得に時間がかかることも許容されやすい)場合や大容量ファイルの場合には，ストリーミング再生を行うなどより適切な方法での対応が必要かと思います．

### 参考
[^1]: [AVPlayer | Apple Developer](https://developer.apple.com/documentation/avfoundation/avplayer)
[^2]: [AVFoundationプログラミングガイド](https://developer.apple.com/jp/documentation/AVFoundationPG.pdf)
[^3]: [AVPlayerItem | Apple Developer](https://developer.apple.com/documentation/avfoundation/avplayeritem)
[^4]: [AVPlayerにタイムアウト時間を設定したい | Stack Overflow](https://ja.stackoverflow.com/questions/21243/avplayer%E3%81%AB%E3%82%BF%E3%82%A4%E3%83%A0%E3%82%A2%E3%82%A6%E3%83%88%E6%99%82%E9%96%93%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%97%E3%81%9F%E3%81%84)