今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

一文学会iOS画中画浮窗

背景#

以前見たことがある人が画中画を使って時分秒のカウントを実現していたので、ついでに保存しておいたが、ずっと見ることができなかった。最近『每日英语听力』を使用していると、突然画中画を使って聴力文の表示を実現していることに気づき、興味が湧いたので、どのように実現されているのか研究してみることにした。ついでに画中画で時分秒のカウントを実現する方法も研究してみたい —— 特定のプラットフォームで毎日固定の時間に購入が始まるとき、iPhone が秒単位でカウントダウンを表示してくれたら、いつクリックするのが適切かがわかるので、毎回 1 分前からクリックし続けて何も手に入らないということがなくなるのだが。。。

実現#

画中画は一般的に動画を浮かせて再生するために使用されるが、どうやって画中画で動画ではなくカスタムインターフェースを再生させるのか?以下の 5 つのステップに分けて具体的に見ていこう:

  1. 画中画機能を実現するために、どのスイッチを設定し、どのメソッドを実装する必要があるか;
  2. 基本的なシステムプレーヤーを使用する際の画中画の実現;
  3. カスタムプレーヤーを使用する際の画中画機能の実現にはどのような設定が必要で、どのような違いがあるか;
  4. 画中画を通じて時分秒のカウント機能を実現する方法;
  5. 『每日英语听力』が画中画を通じて英語の聴力文を再生する際にどのように実現しているのか?

APP 支持画中画功能#

APP が画中画機能をサポートするにはどうすればよいか?まず、App がBackgroundModesをサポートするように設定し、次にBackgroundModesの中からAudio, Airplay, and Picture in Pictureにチェックを入れる必要がある。

操作は以下の通り:

image image

次にAVAudioSessionを設定する必要があり、AppDelegate.Swiftapplication(_:didFinishLaunchingWithOptions:)メソッドに以下のコードを設定する:

  1. AVFoundationをインポート
  2. AVAudioSessionがバックグラウンド再生をサポートするように設定

// AVFoundationをインポート
import AVFoundation

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // 設定コードを追加
        do {
            // AVAudioSession.Category.playbackを設定後、サイレントモードやAPPがバックグラウンドに入ったり、ロック画面に入ったりしても再生を続けることができる。
            try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: AVAudioSession.Mode.moviePlayback)
        } catch {
            print(error)
        }

        return true
    }

使用系统播放器时画中画的实现#

システムプレーヤーAVPlayerViewControllerを使用してプレーヤーの画中画を実現するには、まずAVKitをインポートし、再生するリソースを取得し、次にAVPlayerViewControllerを使用して再生する。コードは以下の通り:

import AVKit

    /// 再生するリソースを取得
    fileprivate func playerResource() -> AVQueuePlayer? {
        guard let videoURL = Bundle.main.url(forResource: "suancaidegang", withExtension: "mp4") else {
          return nil
        }

        let item = AVPlayerItem(url: videoURL)
        let player = AVQueuePlayer(playerItem: item)
        player.actionAtItemEnd = .pause
        return player
    }

    @IBAction func systemPlayerAction(_ sender: Any) {
        guard let player = playerResource() else {
            return
        }
        let avPlayerVC = AVPlayerViewController()
        avPlayerVC.player = player
        present(avPlayerVC, animated: true) {
            player.play()
        }
    }

ここで注意が必要なのは、必ず実機でなければ画中画の効果を見ることができないということだ。シミュレーターでは動作しない。実行後、AVPlayerViewControllerが直接画中画の再生をサポートしているのがわかる;画中画に入ると、以前の全画面の再生インターフェースが自動的に閉じる;画中画から再生インターフェースに戻ると、画中画は閉じるが、以前の再生インターフェースも再び開かれない。効果は以下の通り:

image

ここで明らかに、画中画から以前の再生インターフェースに戻れないのは問題があるので、少し修正して、画中画に入るときに全画面の再生インターフェースを閉じないように設定できるようにし、画中画から戻るときに正常に戻れるかどうかを確認する。ここでAVPlayerViewControllerDelegateのメソッド playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Boolを使用して、画中画に入るときに現在のインターフェースを閉じるかどうかを制御できる。

    // このメソッドにavPlayerVC.delegate = selfを追加
    @IBAction func systemPlayerAction(_ sender: Any) {
        guard let player = playerResource() else {
            return
        }
        let avPlayerVC = AVPlayerViewController()
        avPlayerVC.delegate = self
        avPlayerVC.player = player
        present(avPlayerVC, animated: true) {
            player.play()
        }
    }

    // AVPlayerViewControllerDelegateを設定
   extension ViewController: AVPlayerViewControllerDelegate {
       func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
            // falseを返すと、画中画に入るときに再生インターフェースは閉じない
            // trueを返すと、画中画に入るときに再生インターフェースは自動的に閉じる。デフォルトはtrue
           return false
       }
   }

実行してデバッグすると、画中画に入るときに再生インターフェースが閉じず、This video is playing in picture in pictureと表示され、閉じるボタンがないことがわかる;画中画から戻ると、再生インターフェースが引き続き再生される。効果は以下の通り:

image

比較すると、処理前は画中画に入ると再生インターフェースが消え、画中画の入るボタンをクリックしても再生インターフェースに戻れない。左側の画像のような効果。処理後の効果は右側の画像のように、画中画に入った後、全画面再生インターフェースに戻れる。

image image

しかし、上記の効果も期待通りではない。通常、画中画モードに入るのは、ページの他の内容を操作し続けるためであり、上記の設定では画中画から戻ると再生を続けることができるが、ページの他の内容を操作することを妨げてしまうため、やはり修正が必要である。期待される効果は、画中画インターフェースに入ると、現在の再生インターフェースが消え、画中画から戻ると再生インターフェースに入れるようにする。以下でその実現方法を見ていこう:

AVPlayerViewControllerDelegateには別のメソッドplayerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void)があり、画中画から戻るときにこのメソッドがトリガーされるので、行うべきことは、このメソッドがトリガーされたときに再生動画インターフェースを再び呼び起こすことだ。コードは以下の通り:


extension ViewController: AVPlayerViewControllerDelegate {
    func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
        // ここをtrueを返すように変更し、画中画に入るときに再生インターフェースを閉じる
        return true
    }

    func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        restore(playerVC: playerViewController, completionHandler: completionHandler)
    }
}

    fileprivate func restore(playerVC: UIViewController, completionHandler: @escaping (Bool) -> Void) {
        if let presentedVC = presentedViewController {
            // 現在再生中のインターフェースがまだ存在することを示す
            // まずインターフェースを閉じてから、再生インターフェースを表示する
            presentedVC.dismiss(animated: false) { [weak self] in
                self?.present(playerVC, animated: false) {
                    completionHandler(true)
                }
            }
        } else {
            // 直接再生インターフェースを表示する
            present(playerVC, animated: false) {
                completionHandler(true)
            }
        }
    }

実行して効果を確認すると、画中画インターフェースに入ると現在の再生インターフェースが消え、画中画から戻ると再生インターフェースに入れることがわかる。完璧なデモは以下の通り:

image

比較すると、処理前は画中画に入ると再生インターフェースが消え、画中画の入るボタンをクリックしても再生インターフェースに戻れない。左側の画像のような効果。処理後の効果は右側の画像のように、画中画に入った後、全画面再生インターフェースに戻れる。

image image // Fixed-Me: image

自定义播放器时画中画的实现#

カスタムプレーヤーは、システムのAVPlayerViewControllerを使用する場合と比較して、カスタムプレーヤーインターフェースでクリックして画中画再生を呼び起こし、画中画のデリゲートメソッドAVPictureInPictureControllerDelegateを実装する必要がある。画中画のデリゲートメソッドでは、画中画から戻るときのロジックを処理する必要がある。特に注意すべき点は、画中画に入ると再生インターフェースが消えるように設定すると、現在の再生インターフェースが解放され、再生インターフェース上の画中画再生も消えてしまうため、特別な処理が必要で、グローバルなを宣言して保存する。参考にPicture in Picture Across All Platforms

コードは以下の通り:


protocol CustomPlayerVCDelegate: AnyObject {
    func playerViewController(
      _ playerViewController: MWCustomPlayerVC,
      restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
        completionHandler: @escaping (Bool) -> Void
    )
}

private var activeCustomPlayerVCs = Set<MWCustomPlayerVC>()
class MWCustomPlayerVC: UIViewController {

    // MARK: - properties
    private var pictureInPictureVC: AVPictureInPictureController?
    weak var delegate: CustomPlayerVCDelegate?
    var autoDismissAtPip: Bool = false // 画中画に入るとき、現在の再生ページを自動的に閉じるかどうか
    var enterPipBtn: CustomPlayerCircularButtonView?

   override func viewDidLoad() {
        super.viewDidLoad()

        // ビューの読み込み後に追加のセットアップを行う。
        
        view.backgroundColor = .black
        
        setupPictureInPictureVC()
        setupEnterPipBtn()
    }

   fileprivate func setupPictureInPictureVC() {
        guard let playerLayer = playerLayer else {
            return
        }

        pictureInPictureVC = AVPictureInPictureController(playerLayer: playerLayer)
        pictureInPictureVC?.delegate = self
    }
       
    fileprivate func setupEnterPipBtn() {
        enterPipBtn = CustomPlayerCircularButtonView(symbolName: "pip.enter", height: 50.0)
        enterPipBtn?.addTarget(self, action: #selector(handleEnterPipAction), for: [.primaryActionTriggered, .touchUpInside])
        view.addSubview(enterPipBtn!)
        
        enterPipBtn?.snp.makeConstraints { make in
            make.right.equalToSuperview().inset(10.0)
            make.centerY.equalTo(self.view.snp.centerY)
            make.width.height.equalTo(50.0)
        }
    }

    // 画中画インターフェースを呼び起こす
    @objc
    fileprivate func handleEnterPipAction() {
        pictureInPictureVC?.startPictureInPicture()
    }
}

extension MWCustomPlayerVC: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        // 画中画再生のデリゲートメソッド
        activeCustomPlayerVCs.insert(self)
        enterPipBtn?.isHidden = true
    }
    
    // 画中画が開始された後、現在の再生インターフェースは消えるか
    func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        if autoDismissAtPip {
            dismiss(animated: true)
        }
    }
    
    // 画中画の開始に失敗した
    func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
        activeCustomPlayerVCs.remove(self)
        enterPipBtn?.isHidden = false
    }
    
    // 画中画から戻るデリゲートメソッド
    func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        delegate?.playerViewController(self, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler: completionHandler)
    }
}

そして、外部で使用する場所で呼び出し、画中画の閉じるコールバックデリゲートメソッドを処理する。以下の通り:


    @IBAction func customPlayerAction(_ sender: Any) {
        guard let player = playerResource() else {
            return
        }
        
        let playerVC = MWCustomPlayerVC()
        playerVC.modalPresentationStyle = .fullScreen
        playerVC.delegate = self
        playerVC.player = player
        playerVC.autoDismissAtPip = true
        present(playerVC, animated: true) {
            player.play()
        }
    }

 extension ViewController: CustomPlayerVCDelegate {
    func playerViewController(
      _ playerViewController: MWCustomPlayerVC,
      restoreUserInterfaceForPictureInPictureStopWithCompletionHandler
      completionHandler: @escaping (Bool) -> Void) {
          restore(playerVC: playerViewController, completionHandler: completionHandler)
      }
}

実行後の効果は以下の通りで、カスタムプレーヤーが処理された後、システムのプレーヤーと同じ画中画効果を持つことがわかる:

image

次のステップに進む前に、皆さんに考えてもらいたいのは、上記の画中画の例を通じて、画中画がどのように使用されるかがわかったということだ。もしあなたが画中画のカウントを実現するなら、どのように実現するか、どのような方法があるか?

  • 筆者が考えた方法は、画中画が動画を再生するので、ビューを動画に変換できるのではないか?そして再生動画の方法を使って、ビューの内容を再生することができるのではないか?
  • その後、筆者は他の資料を調べて、さらにトリッキーな考え方があることを発見した。画中画が APP 内でポップアップするので、画中画のウィンドウを取得できるのではないか?ウィンドウを取得した後、直接ウィンドウにビューを追加することで表示できるのではないか?

以下でこれら 2 つの方法がどちらも実行可能かを検証していく。

まず、画中画のカウントを実現するために、方法 1 が実行可能かを検証する;『每日英语听力』の文の表示を通じて、方法 2 が実行可能かを検証する。

時分秒计时画中画的实现#

ここでは方法 1 を使用し、ビューを動画に変換し、再生動画の方法を使ってビューの内容を再生することでカウントダウンタイマーを実現する。問題は、ビューを動画に変換する方法はどうするか?

直接の変換方法は見つからないが、参考にUIPiPViewを見つけると、以下の手順があることがわかる:

  1. viewCMSampleBufferに変換する(参考:https://soranoba.net/programming/uiview-to-cmsamplebuffer)
  2. initWithSampleBufferDisplayLayerメソッドを使用してAVSampleBufferDisplayLayerを初期化し、AVPictureInPictureController.ContentSourceを作成する。
  3. その後、AVPictureInPictureController.ContentSourceを使用してAVPictureInPictureControllerを初期化する。
  4. 最後にAVSampleBufferDisplayLayerを使用してCMSampleBufferを表示し、
  5. 最終的にビューをAVPictureInPictureControllerに表示する。

ここで注意すべき問題は、上記のviewCMSampleBufferに変換し、CMSampleBufferAVPictureInPictureControllerに表示するプロセスは単一のビューであり、どのようにスムーズな動画再生にするか?タイマーを定義して絶えず更新する必要があるが、その更新のタイミングはどのくらいが適切か?肉眼でカクつきが見えないのが適切であり、UIPiPViewでは 0.1/60 秒の使用を推奨している。

ここでは再封装を繰り返さず、直接UIPiPViewを使用し、タイマーを作成する必要がある。表示するビューはUIPipViewに追加されることに注意。

コードは以下の通り:


import UIKit
import UIPiPView
import SnapKit

class MWFullTimerVC: MWBaseVC {
    
    // MARK: - properties
    private let pipView = UIPiPView()
    private let timeLabel = UILabel()
    private let dateFormatStr = "yyyy-MM-dd HH:mm:ss"
    private var timer: Timer?

    // MARK: - view life cycle
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // ビューの読み込み後に追加のセットアップを行う。
        view.backgroundColor = UIColor.black
        
        setupPipView()
        setupTimeLabel()
        createDisplayLink()
    }
    
    fileprivate func setupPipView() {
        let width = UIScreen.main.bounds.width
        pipView.frame = CGRect(x: 10.0, y: 0, width: width - 20.0, height: 50.0)
        view.addSubview(pipView)
        pipView.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(10.0)
            make.top.equalToSuperview().inset(100.0)
            make.height.equalTo(50.0)
        }
    }
    
    fileprivate func setupTimeLabel() {
        timeLabel.font = UIFont.boldSystemFont(ofSize: 16.0)
        timeLabel.textColor = UIColor.white
        timeLabel.backgroundColor = UIColor.orange
        timeLabel.textAlignment = .center
        pipView.addSubview(timeLabel)
        timeLabel.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview()
            make.top.equalToSuperview()
            make.height.equalToSuperview()
        }
    }
    
    func createDisplayLink() {
        timer = Timer(timeInterval: 0.1/60, repeats: true, block: { [weak self] _ in
            self?.refresh()
        })
        RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)
        timer?.fire()
    }

    
    // MARK: - init
    
    
    // MARK: - utils
    func reloadTime() {
        let date = Date()
        let formatter = DateFormatter()
        formatter.dateFormat = dateFormatStr
        self.timeLabel.text = formatter.string(from: date)
    }

    
    // MARK: - action
    
    func refresh() {
        reloadTime()
    }
    
    override func handleEnterPipAction() {
        super.handleEnterPipAction()
        if pipView.isPictureInPictureActive() {
            pipView.stopPictureInPicture()
        } else {
            pipView.startPictureInPicture(withRefreshInterval: 0.1/60.0)
        }
    }
    
    // MARK: - other
  
}

実行後のデバッグ効果は以下の通り:

image

上記の方法が実行可能であり、画中画のサイズは自分で定義でき、空白の動画ファイルを内蔵する必要がないことがわかる。

《每日英语听力》画中画的实现#

ここでは画中画のウィンドウを取得し、ウィンドウを取得した後、直接ウィンドウにカスタムテキスト再生ビューを追加することで、画中画にカスタムビューを表示する方法を検証する。

画中画が表示される直前のデリゲートメソッドpictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)の中で、最上位のウィンドウを取得し、そのウィンドウにカスタムテキスト再生ビューを追加する。テキスト再生ビューは、2 秒ごとに次の文を再生するように設定する。

最終的な全体コードは以下の通り:

class MWPipWindowVC: UIViewController {
    func setupTextPlayerView(on targetView: UIView) {
        targetView.addSubview(textPlayView)
        textPlayView.text = text1
        
        textPlayView.snp.makeConstraints { make in
            make.centerY.equalTo(targetView.snp.centerY)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(250.0)
        }
    }

       fileprivate func setupTimer() {
        timer = Timer(timeInterval: 2.0, repeats: true, block: { [weak self] _ in
            self?.handleTimerAction()
        })
        RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)
        timer?.fire()
    }

    // MARK: - utils
    
    // MARK: - action
    func handleTimerAction() {
        let dataList = [text1, text2, text3, text4, text5]
        count += 1
        let index = count % 5
        let str = dataList[index]
        textPlayView.text = str
    }
  }

extension MWPipWindowVC: AVPictureInPictureControllerDelegate {
    func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
        activeCustomPlayerVCs.insert(self)
        enterPipBtn?.isHidden = true
        if let window = UIApplication.shared.windows.first {
            setupTextPlayerView(on: window)
        }
    }
    xxx
}

実行してデバッグすると、最終的な効果は以下の通り:

image

上記の方法が実行可能であることがわかり、画中画に入るときに再生動画のインターフェースが一瞬フラッシュし、画中画のサイズは動画のサイズと一致することがわかる。したがって、この方法を使用する場合は、事前に対応するサイズの空白動画を準備する必要がある。

"ビューを動画に変換して再生する" と "空白動画を再生してから、その上にビューを表示する" の 2 つの実装を比較すると、まず両者ともカスタム画中画表示の効果を実現できることがわかる。しかし、筆者のテストによると、"ビューを動画に変換して再生する" 方法は CPU の使用率が非常に高く、絶えず読み込みと更新を行う必要があるため、"空白動画を再生してから、その上にビューを表示する" 方法は CPU の使用率が低くなるが、空白動画ファイルに依存するため、インストールパッケージのサイズが大きくなる。また、複数のスタイルの画中画効果が必要な場合は、複数の空白動画ファイルが必要になる。

したがって、どちらの方法を使用するかを選択するには、通常、複数の画中画スタイルの必要がない場合は、"空白動画を再生してから、その上にビューを表示する" 方法を選択することをお勧めする。一方、インストールパッケージのサイズに敏感で、ユーザーがカスタム画中画や異なるスタイルの画中画を必要とする場合は、"ビューを動画に変換して再生する" 方法を検討することができる。

総括#

image

参考#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。