技術ブログ

プログラミング、IT関連の記事中心

動画のプレビュー【Swift】

■はじめに

画像は、UIImageViewを使用して画面に表示するだけだが、動画のプレビューはどうやって作ろう?
と考える事がある。

ライブラリを使用するという手もあるが、ライブラリを使用しない場合の一般的には以下の方法になるかと思う。
・AVPlayerViewControllerを使用する
・独自でプレビュー画面を作成する

ここでは、上記の方法に関して記載する。
※ここでは、「sample.mov」をプレビューする方法を記載しているが、再生したい動画に読み替えてください。

◾️AVPlayerViewControllerを使用する

Appleさんが提供している「AVPlayerViewController」をそのまま使用する方法を記載します。
まずは、以下の通り、「AVKit」をインポートします。

import AVKit

その後、画面の初期化処理などで、以下のように「AVPlayerViewController」をViewに表示します。

let path = Bundle.main.path(forResource: "sample.mov", ofType: nil)

let player = AVPlayer(url: URL(fileURLWithPath:path!))
let playerController = AVPlayerViewController()
playerController.player = player
self.addChild(playerController)
self.view.addSubview(playerController.view)
playerController.view.frame = self.view.frame

◾️独自でプレビュー画面を作成する

基本的には、「AVPlayerViewController」を使用していれば、満足すると思いますが
「AVPlayerViewController」では、動画のコントロールバー(再生やスキップがある部分)の背景色が
変更できないなど、自由度がないです。

こういった場合に、独自でプレビュー画面を作成することになると思います。
独自での作成なので、正解はないのですが、私が作成した「AVPlayerViewController」に近づけたプレビュー画面を以下に記載します。
あくまで参考程度に見てください。
※こちらを元に、独自のプレビュー画面を作るでもいいですし。

「CustomAVPlayerViewController.swift」を作成して、以下のソースコードをまるまるコピペして貼り付けてください。

import UIKit
import AVFoundation
import CoreMedia

open class CustomAVPlayerViewController: UIViewController, UIGestureRecognizerDelegate {
    // AVPlayer.
    open var player : AVPlayer!
    
    // シークバー.
    private var seekBar : UISlider = UISlider()
    private var layer: AVPlayerLayer!
    // フッター
    private var footer: UIView = UIView()
    // 再生、停止ボタン
    private var movieBtn:UIButton = UIButton()
    private var skipNextBtn:UIButton = UIButton()
    private var skipPrevBtn:UIButton = UIButton()
    private var durationLabel: UILabel = UILabel()
    private var currentLabel: UILabel = UILabel()
    private var backBtn:UIButton = UIButton()
    // 動画の再生状況保持ステータス
    private var movieStatus = 0
    private var showControllFlag = true
    
    private var screenWidth = UIScreen.main.bounds.size.width
    private var screenHeight = UIScreen.main.bounds.size.height
    
    override open func viewDidLoad() {
        let playerItem = player.currentItem
        // 動画の再生の終了を検知
        NotificationCenter.default.addObserver(self, selector: #selector(didPlayToEndTime), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
        
        // Viewを生成.
        let videoPlayerView = AVPlayerView(frame: CGRect(x: 0, y: 0, width: screenWidth, height: screenHeight))
        // UIViewのレイヤーをAVPlayerLayerにする.
        layer = videoPlayerView.layer as? AVPlayerLayer
        layer.videoGravity = AVLayerVideoGravity.resizeAspect
        layer.player = player
        
        // レイヤーを追加する.
        self.view.layer.addSublayer(layer)
        
        // フッター
        footer.backgroundColor = UIColor.darkGray
        footer.layer.cornerRadius = 10
        
        // 動画のシークバーとなるUISliderを生成.
        seekBar.minimumValue = 0
        seekBar.maximumValue = Float(CMTimeGetSeconds(playerItem!.asset.duration))
        seekBar.addTarget(self, action: #selector(onSliderValueChange), for: UIControl.Event.valueChanged)
        let greenImage = UIImage.image(color: .green, size: CGSize(width: 10, height: 5))
        seekBar.setThumbImage(greenImage, for: .normal)
        footer.addSubview(seekBar)
        
        /* シークバーを動画とシンクロさせる為の処理 */
        // 0.5分割で動かす事が出来る様にインターバルを指定.
        let interval : Double = Double(0.5 * seekBar.maximumValue) / Double(seekBar.bounds.maxX)
        // CMTimeに変換する.
        let time : CMTime = CMTimeMakeWithSeconds(interval, preferredTimescale: Int32(NSEC_PER_SEC))
        // time毎に呼び出される.
        player.addPeriodicTimeObserver(forInterval: time, queue: nil) { (time) -> Void in
            // 総再生時間を取得.
            let duration = CMTimeGetSeconds((self.player.currentItem?.duration)!)
            // 現在の時間を取得.
            let time = CMTimeGetSeconds(self.player.currentTime())
            
            self.setTime()
            
            // シークバーの位置を変更.
            let value = Float(self.seekBar.maximumValue - self.seekBar.minimumValue) * Float(time) / Float(duration) + Float(self.seekBar.minimumValue)
            self.seekBar.value = value
        }
        
        // 動画の再生ボタンを生成.
        movieBtn.layer.masksToBounds = true
        movieBtn.layer.cornerRadius = 20.0
        movieBtn.backgroundColor = UIColor.orange
        movieBtn.setTitle("Start", for: UIControl.State.normal)
        movieBtn.addTarget(self, action: #selector(onButtonClick), for: UIControl.Event.touchUpInside)
        footer.addSubview(movieBtn)
        
        // 次へスキップボタン
        skipNextBtn.layer.masksToBounds = true
        skipNextBtn.layer.cornerRadius = 20.0
        skipNextBtn.backgroundColor = UIColor.orange
        skipNextBtn.setTitle("15", for: UIControl.State.normal)
        skipNextBtn.addTarget(self, action: #selector(skipNextAction), for: UIControl.Event.touchUpInside)
        footer.addSubview(skipNextBtn)
        
        // 前へスキップボタン
        skipPrevBtn.layer.masksToBounds = true
        skipPrevBtn.layer.cornerRadius = 20.0
        skipPrevBtn.backgroundColor = UIColor.orange
        skipPrevBtn.setTitle("-15", for: UIControl.State.normal)
        skipPrevBtn.addTarget(self, action: #selector(skipPrevAction), for: UIControl.Event.touchUpInside)
        footer.addSubview(skipPrevBtn)
        
        // 現在の動画の再生時間
        currentLabel.text = "00:00"
        currentLabel.textAlignment = NSTextAlignment.left
        currentLabel.textColor = UIColor.white
        footer.addSubview(currentLabel)
        
        // 動画の残りの時間
        durationLabel.text = "-00:00"
        durationLabel.textAlignment = NSTextAlignment.right
        durationLabel.textColor = UIColor.white
        footer.addSubview(durationLabel)
        
        self.view.addSubview(footer)
        
        backBtn.frame = CGRect(x: 10, y: 20, width: 50, height: 50)
        backBtn.setTitle("×", for: UIControl.State.normal)
        backBtn.tintColor = UIColor.white
        backBtn.backgroundColor = UIColor.darkGray
        backBtn.layer.cornerRadius = 10.0
        backBtn.addTarget(self, action: #selector(backAction), for: UIControl.Event.touchUpInside)
        self.view.addSubview(backBtn)
        
        // TAP検知
        let tapGesture:UITapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped))
        self.view.addGestureRecognizer(tapGesture)
    }
    
    // 画面タップ時処理
    @objc private func tapped(){
        if showControllFlag {
            footer.isHidden = true
            backBtn.isHidden = true
            showControllFlag = false
        } else {
            footer.isHidden = false
            backBtn.isHidden = false
            showControllFlag = true
        }
    }
    
    override open func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        self.setTime()
    }
    
    // 時間をラベルに反映する
    private func setTime() {
        let videoPlayerItem: AVPlayerItem? = player.currentItem
        let videoDuration:CMTime = (videoPlayerItem?.duration)!
        let videoCurrentTime:CMTime = (videoPlayerItem?.currentTime())!
        var remaining:Int = 0
        if videoDuration.seconds > 0 {
            remaining = Int(videoDuration.seconds - videoCurrentTime.seconds)
        }
        
        currentLabel.text = String(describing: Int(videoCurrentTime.seconds) / 60) + ":" + self.setSecond(Int(videoCurrentTime.seconds) % 60)
        durationLabel.text = "-" + String(describing: remaining / 60) + ":" + self.setSecond(remaining % 60)
    }
    
    // 1桁の秒数の場合、十の位に0を入れる
    private func setSecond(_ target: Int) -> String {
        if target < 10 {
            return "0" + String(describing: target)
        } else {
            return String(describing: target)
        }
    }
    
    override open func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        screenWidth = UIScreen.main.bounds.size.width
        screenHeight = UIScreen.main.bounds.size.height
        
        let deviceOrientation: UIDeviceOrientation! = UIDevice.current.orientation
        switch deviceOrientation {
        case .portrait?, .portraitUpsideDown?:
            // 縦の場合(ホームボタンが下に来る向き)
            // 反対の場合(ホームボタンが上に来る向き)
            self.verticalUi()
        case .landscapeLeft?, .landscapeRight?:
            // 左の場合(ホームボタンが右に来る向き)
            // 右の場合(ホームボタンが左に来る向き)
            self.sideUi()
        default:
            // 向きを取得できなかった場合には、縦の場合として処理する。
            self.verticalUi()
        }
        
        // Viewを生成.
        layer.frame = CGRect(x: 0, y: 0, width: screenWidth, height: screenHeight)
    }
    
    // 縦向きの場合のUI構築
    private func verticalUi () {
        let footerWidth: CGFloat = screenWidth - 10
        let footerHeight: CGFloat = 100
        footer.frame = CGRect(x: 5, y: screenHeight - footerHeight - 5, width: footerWidth, height: footerHeight)
        seekBar.frame = CGRect(x: 5, y: 10, width: footerWidth - 20, height: 40)
        movieBtn.frame = CGRect(x: (footerWidth - 50) / 2, y: footerHeight - 50, width: 50, height: 40)
        skipNextBtn.frame = CGRect(x: (footerWidth + 50) / 2 + 10, y: footerHeight - 50, width: 50, height: 40)
        skipPrevBtn.frame = CGRect(x: (footerWidth - 50) / 2 - 60, y: footerHeight - 50, width: 50, height: 40)
        backBtn.frame = CGRect(x: 10, y: 20, width: 50, height: 50)
        
        currentLabel.frame = CGRect(x: 5, y: 40, width: 40, height: 10)
        currentLabel.font = UIFont.systemFont(ofSize: 12)
        durationLabel.frame = CGRect(x: footerWidth - 45, y: 40, width: 40, height: 10)
        durationLabel.font = UIFont.systemFont(ofSize: 12)
    }
    // 横向きの場合のUI構築
    private func sideUi() {
        let footerWidth: CGFloat = screenWidth - 10
        footer.frame = CGRect(x: 5, y: screenHeight - 50 - 5, width: footerWidth, height: 55)
        seekBar.frame = CGRect(x: 215, y: 5, width: footerWidth - 265, height: 40)
        movieBtn.frame = CGRect(x: 60, y: 5, width: 50, height: 40)
        backBtn.frame = CGRect(x: 10, y: 20, width: 50, height: 50)
        
        skipNextBtn.frame = CGRect(x: 115, y: 5, width: 50, height: 40)
        skipPrevBtn.frame = CGRect(x: 5, y: 5, width: 50, height: 40)
        
        currentLabel.frame = CGRect(x: 175, y: 20, width: 40, height: 10)
        currentLabel.font = UIFont.systemFont(ofSize: 12)
        durationLabel.frame = CGRect(x: footerWidth - 50, y: 20, width: 40, height: 10)
        durationLabel.font = UIFont.systemFont(ofSize: 12)
    }
    
    // 再生ボタンが押された時に呼ばれるメソッド
    @objc private func onButtonClick(){
        if movieStatus == 0 {
            /* 停止中の場合 */
            self.movieStart()
        } else if movieStatus == 1 {
            /* 再生中の場合 */
            self.movieStop()
        } else if movieStatus == 2 {
            /* 動画の再生が終了した場合 */
            self.movieBeginningStart()
        }
    }
    
    @objc private func backAction() {
        // 1つ前の画面へ戻る
        self.dismiss(animated: true, completion: nil)
    }
    
    // スキップボタン押下時の処理
    @objc private func skipNextAction() {
        let videoPlayerItem: AVPlayerItem? = player.currentItem
        let videoDuration:CMTime = (videoPlayerItem?.duration)!
        
        var currentTime:Float64 = CMTimeGetSeconds(player.currentTime())
        
        currentTime += 15
        
        if currentTime < videoDuration.seconds {
            player.seek(to: CMTimeMakeWithSeconds(currentTime, preferredTimescale: Int32(NSEC_PER_SEC)), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero)
        } else {
            player.seek(to: CMTimeMakeWithSeconds(videoDuration.seconds - 0.04, preferredTimescale: Int32(NSEC_PER_SEC)), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero)
            movieStatus = 2
        }
    }
    
    // 前にスキップボタン押下時の処理
    @objc private func skipPrevAction() {
        var currentTime:Float64 = CMTimeGetSeconds(player.currentTime())
        currentTime -= 15
        player.seek(to: CMTimeMakeWithSeconds(currentTime, preferredTimescale: Int32(NSEC_PER_SEC)), toleranceBefore: CMTime.zero, toleranceAfter: CMTime.zero)
        if movieStatus == 2 {
            movieStatus = 0
        }
    }
    
    // シークバーの値が変わった時に呼ばれるメソッド
    @objc private func onSliderValueChange(){
        // 動画の再生時間をシークバーとシンクロさせる
        player.seek(to: CMTimeMakeWithSeconds(Float64(seekBar.value), preferredTimescale: Int32(NSEC_PER_SEC)))
    }
    
    // 動画の再生が終了したら呼び出される
    @objc private func didPlayToEndTime() {
        movieStatus = 2
        movieBtn.setTitle("Start", for: UIControl.State.normal)
    }
    
    private func movieStart() {
        player.play()
        movieStatus = 1
        movieBtn.setTitle("Stop", for: UIControl.State.normal)
    }
    
    private func movieStop() {
        player.pause()
        movieStatus = 0
        movieBtn.setTitle("Start", for: UIControl.State.normal)
    }
    
    private func movieBeginningStart() {
        player.seek(to: CMTimeMakeWithSeconds(0, preferredTimescale: Int32(NSEC_PER_SEC)))
        player.play()
        movieStatus = 1
        movieBtn.setTitle("Stop", for: UIControl.State.normal)
    }
}

// レイヤーをAVPlayerLayerにする為のラッパークラス.
fileprivate class AVPlayerView : UIView{
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    override class var layerClass : AnyClass {
        return AVPlayerLayer.self
    }
}

extension UIImage {
    static func image(color: UIColor, size: CGSize) -> UIImage {
        UIGraphicsBeginImageContextWithOptions(size, false, 5.0)
        let context = UIGraphicsGetCurrentContext()!
        context.setFillColor(color.cgColor)
        context.fill(CGRect(origin: .zero, size: size))
        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()
        return image
    }
}

上記を使用するために、動画のプレビューを表示したいViewControllerの画面の初期化処理などで、以下のように「CustomAVPlayerViewController」をViewに表示します。
※「AVPlayerViewController」と同じになるようにしたので、違和感はないはず。。。

let path = Bundle.main.path(forResource: "sample.mov", ofType: nil)

let player = AVPlayer(url: URL(fileURLWithPath:path!))
let playerController = CustomAVPlayerViewController()
playerController.player = player
self.addChild(playerController)
self.view.addSubview(playerController.view)
playerController.view.frame = self.view.frame