[iOS]動画を再生する(早戻り、早送り、シーク)

(Xcode 11, Swift 5.1, iOS 13での実装)

iOSで、動画を再生する機能を作成します。

AVPlayer – AVFoundation | Apple Developer Documentation

AVPlayerクラスを用いることで、映像やオーディオの再生ができます。
AVPlayerには画面に表示する機能がありません。
以前、画面表示にAVPlayerViewControllerを用いたものAVPlayerLayerを用いたものを作成しました。
AVPlayerViewControllerは画面表示だけでなく、再生コントロール一式があらかじめ備わっています。
AVPlayerLayerを用いたものでは、再生・ポーズを実装しました。
今回はAVPlayerLayerを用いて、再生・ポーズに加えて早戻し、早送り、シークバー(プログレスバー)でのシーク機能を実装します。

AVPlayerLayer – AVFoundation | Apple Developer Documentation

AVPlayerLayerはplayerプロパティを持ち、そこにAVPlayerを渡すことで映像を表示できます。
実際に使う際には、このAVPlayerLayerのドキュメントに記されているプレイヤー用のUIViewサブクラスを用いる方法が扱いやすいです。

class PlayerView: UIView {
    var player: AVPlayer? {
        get {
            return playerLayer.player
        }
        set {
            playerLayer.player = newValue
        }
    }
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Override UIView property
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

Appleのドキュメントに乗っているコード例です。layerClassをオーバーライドしてこのUIViewがもつlayerはAVPlayerLayerクラスを返すようにしています。プロパティとしてplayerがあり、そこにAVPlayerをセットします。

この記事では、画面表示にこのPlayerViewを用いて、プロジェクトのリソースに加えた動画ファイルを再生する方法を記します。

動画を再生するサンプル

プロジェクトに追加してある動画ファイルを再生・一時停止・早戻し・早送り・シークバーでのシークを行うサンプルを作成しました。全体のソースコードは記事の最後にまとめてあります。
ポイントになる箇所について、説明をおこないました。
(今回の記事ではバックグラウンドでのオーディオ再生機能は実装していません。次の記事で実装しています。[iOS]動画を再生する(バックグラウンドでの早戻り、早送り、シーク)

動画再生機能作成については、Appleのサンプルが参考になります。
Creating a Movie Player App with Basic Playback Controls | Apple Developer Documentation

PlayerViewクラスを作成

今回は、ViewController.swiftのほかに、PlayerView.swiftを作成しました。
File > New > File > Cocoa Touch Classから、UIViewをスーパークラスとするPlayerViewクラスを作成します。
内容は、前段に記したAppleのドキュメントに載っているPlayerViewコード例の通りです。記事の最後にも載せています。

動画ファイルをプロジェクトに加える

File > Add Files to <プロジェクト名>。
動画ファイルを選び、プロジェクトに加えます。
今回は、clip.mp4という名前の動画ファイルが加えられたとして話を進めました。

UI作成

Main.storyboard > View Controller。
UIViewとボタン4つとSliderを貼り付けます。
(画像ではUIViewのBackgroundはオレンジ色にしてありますが、これはどこに貼り付けたかを見やすくするためで、実際にはSystem Background Colorのままで構いません)

貼り付けたUIViewを選択し、Identity inspector > Custom Class > Classで、PlayerViewとします。

Player ViewとViewController.swiftをOutletで結び、名前をplayerViewとします。

4つのボタンのテキストを「Play」「Pause」「Skip Forward」「Skip Backward」とします。
PlayボタンとViewController.swiftをActionで結び、名前をplayBtnTappedとします。
PauseボタンとViewController.swiftをActionで結び、名前をpauseBtnTappedとします。
Skip ForwardボタンとViewController.swiftをActionで結び、名前をskipForwardBtnTappedとします。
Skip BackwardボタンとViewController.swiftをActionで結び、名前をskipBackwardBtnTappedとします。

SliderとViewController.swiftをOutletで結び、名前をsliderとします。
SliderとViewController.swiftをActionで結び、名前をsliderValueChangedとします。
そのさい、TypeにはUISliderを選びます。
@IBAction func sliderValueChanged(_ sender: UISlider) {}が生成されます。スライダーの位置を変更した時に呼ばれます。

sliderのValueを0にします。

sliderのAttributes indicatorでContinuous Updatesのチェックを外します。
Continuous Updatesをチェックしているとスライダーのボタンを押している間、Eventが何度も送信されます。
外すと、スライダーのボタンを押して動かした後ボタンから指が離れた時にEventが送信されます。

Audio Sessionの準備

AVAudioSession – AVFoundation | Apple Developer Documentation

Audio Sessionとはアプリがどのようにオーディオを扱うかを示すオブジェクトです。再生のみなのか、録音再生を行うのか、バックグラウンド再生ありにするのかなどを設定します。用意されたカテゴリ・モード・オプションから適切なものを選んで設定する形式になっています。
今回は、カテゴリをAVAudioSessionCategoryPlayback(オーディオ機能をメインとしたアプリ用カテゴリ。バックグラウンド再生可能)、モードをAVAudioSessionModeMoviePlayback(ムービー再生用)に設定します。

/// Audio sessionを動画再生向けのものに設定し、activeにします
let audioSession = AVAudioSession.sharedInstance()
do {
    try audioSession.setCategory(.playback, mode: .moviePlayback)
        
} catch {
    print("Setting category to AVAudioSessionCategoryPlayback failed.")
}

do {
    try audioSession.setActive(true)
    print("Audio session set active !!")
} catch {
    
}

動画プレイヤー

AVPlayerを動画表示を担当するAVPlayerLayerと結びつけます。PlayerViewのプロパティであるplayerにAVPlayerをセットします。これによって、PlayerViewの動画表示をになうlayerと結びつけることができます。

playerView.player = player

urlからAVPlayerItemを作成します。AVPlayerに作成したAVPlayerItemをセットします。

let fileName = "clip"
let fileExtension = "mp4"
guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
    print("Url is nil")
    return
}

let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)

ViewControllerクラスに追加したitemDurationプロパティに動画ファイルの長さを示す秒数を渡しています。これは、スライダーの操作のさいに用います。

let asset = AVAsset(url: url)
itemDuration = CMTimeGetSeconds(asset.duration)

早戻し、早送り、シーク

AVPlayerで、指定した時間に移動するには、
seek(to:)
seek(to:completionHandler:)
メソッドを使います。
(シークについてのAppleのドキュメントはこちら
Seeking Through Media | Apple Developer Documentation
早送りボタンを押した時は、その時の時刻から10秒なり15秒なり指定した秒数を足した時刻へ移動させます。
今回のサンプルでは、changePosition(time:)で指定した時間に移動して元の再生速度で再開する処理を行っています。

private func changePosition(time: CMTime) {
    let rate = player.rate
    // いったんplayerをとめる
    player.rate = 0
    // 指定した時間へ移動
    player.seek(to: time, completionHandler: {_ in
        // playerをもとのrateに戻す(0より大きいならrateの速度で再生される)
        self.player.rate = rate
    })
}

再生にあわせてプログレスバーを進める

再生にあわせてプログレスバーを進める必要があります。
AVPlayerのメソッドで、時間変化にあわせて周期的に処理を実行する
addPeriodicTimeObserver(forInterval:queue:using:)を用います。
intervalで指定した時間毎に、blockに記述した処理を実行します。
今回は、0.5秒毎にプログレスバーの位置を更新します。
また、addPeriodicTimeObserver(forInterval:queue:using:)のblockは、時刻がジャンプした時にも呼ばれます。今回のサンプルでは、早送り・早戻し・スライダーの操作でseekが行われ、時刻がジャンプします。そのさいにも呼ばれます。
AVPlayerでの再生時間の監視に関してはこちらのドキュメントが参考になります。
(Observing the Playback Time | Apple Developer Documentation)

func addPeriodicTimeObserver() {
    // Notify every half second
    let timeScale = CMTimeScale(NSEC_PER_SEC)
    let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
    
    timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                       queue: .main)
    { [weak self] time in
        // update player transport UI
        DispatchQueue.main.async {
            // sliderを更新
            self?.updateSlider()
        }
    }
}

使用したソースコード

PlayerView.swift

import UIKit
import AVFoundation

class PlayerView: UIView {
    
    // The player assigned to this view, if any.
    
    var player: AVPlayer? {
        get { return playerLayer.player }
        set { playerLayer.player = newValue }
    }
    
    // The layer used by the player.
    
    var playerLayer: AVPlayerLayer {
        return layer as! AVPlayerLayer
    }
    
    // Set the class of the layer for this view.
    override static var layerClass: AnyClass {
        return AVPlayerLayer.self
    }
}

ViewController.swift

import UIKit
import AVFoundation
import MediaPlayer
import AVKit

class ViewController: UIViewController {

    @IBOutlet weak var playerView: PlayerView!
    @IBOutlet weak var slider: UISlider!
    
    let skipInterval: Double = 10
    
    var player = AVPlayer()
    var timeObserverToken: Any?
    
    var itemDuration: Double = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupAudioSession()
        
        setupPlayer()
    }
    
    private func setupAudioSession() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback, mode: .moviePlayback)
        } catch {
            print("Setting category to AVAudioSessionCategoryPlayback failed.")
        }
        do {
            try audioSession.setActive(true)
            print("audio session set active !!")
        } catch {
            
        }
    }
    
    private func setupPlayer() {
        playerView.player = player
        addPeriodicTimeObserver()
        
        replacePlayerItem(fileName: "clip", fileExtension: "mp4")
        
    }

    private func replacePlayerItem(fileName: String, fileExtension: String) {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: fileExtension) else {
            print("Url is nil")
            return
        }
    
        let asset = AVAsset(url: url)
        itemDuration = CMTimeGetSeconds(asset.duration)
    
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
    
   
    }
  

    @IBAction func playBtnTapped(_ sender: Any) {
        player.play()
    }
    
    @IBAction func pauseBtnTapped(_ sender: Any) {
        player.pause()
    }
    
    @IBAction func sliderValueChanged(_ sender: UISlider) {
        let seconds = Double(sender.value) * itemDuration
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: seconds, preferredTimescale: timeScale)
        
        changePosition(time: time)
    }
    
    @IBAction func skipForwardBtnTapped(_ sender: Any) {
        skipForward()
    }
    
    @IBAction func skipBackwardBtnTapped(_ sender: Any) {
        skipBackward()
    }
    
    private func skipForward() {
        skip(interval: skipInterval)
    }
    
    private func skipBackward() {
        skip(interval: -skipInterval)
    }
    
    private func skip(interval: Double) {
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let rhs = CMTime(seconds: interval, preferredTimescale: timeScale)
        let time = CMTimeAdd(player.currentTime(), rhs)
        
        changePosition(time: time)
    }
    
    private func updateSlider() {
        let time = player.currentItem?.currentTime() ?? CMTime.zero
        if itemDuration != 0 {
            slider.value = Float(CMTimeGetSeconds(time) / itemDuration)
        }
    }
    
    private func changePosition(time: CMTime) {
        let rate = player.rate
        // いったんplayerをとめる
        player.rate = 0
        // 指定した時間へ移動
        player.seek(to: time, completionHandler: {_ in
            // playerをもとのrateに戻す(0より大きいならrateの速度で再生される)
            self.player.rate = rate
        })
    }
    
    // MARK: Periodic Time Observer
    func addPeriodicTimeObserver() {
        // Notify every half second
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
        
        timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                           queue: .main)
        { [weak self] time in
            // update player transport UI
            DispatchQueue.main.async {
                print("update timer:\(CMTimeGetSeconds(time))")
                // sliderを更新
                self?.updateSlider()
            }
        }
    }

    func removePeriodicTimeObserver() {
        if let timeObserverToken = timeObserverToken {
            player.removeTimeObserver(timeObserverToken)
            self.timeObserverToken = nil
        }
    }
   
}

「[iOS]動画を再生する(早戻り、早送り、シーク)」への4件のフィードバック

  1. nackpan様
    kkです。
    いつも丁寧な記事でのご解説をありがとうございます。
    本記事のスライダーによるシークバーの動作について、質問があります。
    下記の症状が出るのですが、解決策のアドバイスを頂けないでしょうか。
    手順
    ①Playボタンを押して動画を再生
    ②スライダーで、再生ポジションを移動
    ③手を離すと動画の再生位置が意図通りに移動
    症状
    ・シミュレータでは、この症状はでない。
    ・Pause中では、この症状はでない。
    ・③で手を離した時に、Thumbの位置が、一旦、移動させる前の位置に一瞬戻りその後、意図した位置に動く。
    ・確認した実機は、iPhone 6plus、iPad Pro(2nd Gene)。
    試したこと
    ・プログレスバーの位置を更新の時間を、0.5秒から、1秒、10秒、60秒と変更してみたが効果なし
    ・スライダーのAttribute Inspectorで、Events:Contimuous Updatesにチェックを入れたが効果なし
    ・総再生時間の3分動画、2時間動画、どちらでも同じ現象
    ・DispatchQをコメントアウトしても症状は同じ
    =>(紹介頂いていたAppleDocumenntのプログラム例に記載がなかったので試してみた)
    //DispatchQueue.main.async {
    // print(“update timer:\(CMTimeGetSeconds(time))”)
    // sliderを更新
    self?.updateSlider()
    // }
    お手数をお掛けしますが、アドバイスを頂きたく、よろしくお願いします。

  2. kkさん、こんにちは。

    再生中にThumbを移動させた時、Thumbの位置が、移動させる前の位置に一瞬戻りその後、意図した位置に動く

    症状を確認しました。

    private func updateSlider() {
    	let time = player.currentItem?.currentTime() ?? CMTime.zero
    	print("(update slider) time:\(CMTimeGetSeconds(time)) player.rate:\(player.rate)")
    	if itemDuration != 0  {
    		slider.value = Float(CMTimeGetSeconds(time) / itemDuration)
    	}
    }
    

    updateSlider()にprint文をいれて、timeとrateを見てみました。
    (updateSlider()は、addPeriodicTimeObserver(forInterval: , queue: using:)によって定期的に呼び出されます。)

    (update slider) time:91.05534444444444 player.rate:0.0
    (update slider) time:37.70433333333333 player.rate:0.0
    (update slider) time:37.627349952 player.rate:1.0
    (update slider) time:37.707247553 player.rate:1.0
    (update slider) time:38.001819565 player.rate:1.0
    

    Thumbを動かして離した直後の様子です。
    timeが変更される前に、updateSlider()が呼ばれてスライダーの描画が行われているのがわかります。
    そのため、移動させる前の位置に一瞬戻っています。
(playerのtimeの変更にはすこし時間がかかります)

    今回のサンプルでは、Thumbの位置を変えた時に呼ばれるchangePosition(time: )で、 いったんplayer.rate=0にしています。
    そして、time変更完了後に、rateを戻しています。
    そのため、

    (update slider) time:91.05534444444444 player.rate:0.0
    (update slider) time:37.70433333333333 player.rate:0.0
    (update slider) time:37.627349952 player.rate:1.0
    (update slider) time:37.707247553 player.rate:1.0
    (update slider) time:38.001819565 player.rate:1.0
    

    timeが移動完了前の時点ではrateが0となっています。
    これを用いてrateが0の時には、updateSlider()を呼び出さないとすることで、「Thumbが移動させる前の位置に一瞬戻る」症状を発生させないようにします。

    func addPeriodicTimeObserver() {
        // Notify every half second
        let timeScale = CMTimeScale(NSEC_PER_SEC)
        let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
        
        timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                           queue: .main)
        { [weak self] time in
            // update player transport UI
            
            
            DispatchQueue.main.async {
                // sliderを更新
                if let player = self?.player, player.rate > 0 {
                    self?.updateSlider()
                }
                // NowPlayingInfoCenterを更新
                self?.updateNowPlaying(time: CMTimeGetSeconds(time))
            }
        }
    }
    

    updateSliderをplayer.rateが0より大きい時のみ呼び出す、としました。
    これで、「Thumbが移動させる前の位置に一瞬戻る」問題は、解消されます。

    しかし、再生中のスライダーの操作のさいに、Thumbが一瞬別の箇所に動いて戻る、という現象は、まだ発生しますので、それについて記します。

    Thumbを押して動かしたあと離さないでいると、指のある箇所と再生中の箇所を行き来します。

    これは、updateSlider()が定期的に呼び出されるため、playerのtimeに合わせてスライダーを描画しようとする動作と、指の位置にスライダーのThumbを描画しようとする動作が行われているためです。
    これを解消するために、
スライダーのThumbに触った時点でupdateSlider()を呼び出すのをやめて、
Thumbから離れた時点でupdateSlider()を呼び出す、
    とします。

    [iOS][Swift]UISliderで押し始めと指を離した時点を知る – nackpan Blog
    こちらの記事で、UISliderの押し始めと指を離す時点を検知するやり方について記しました。
    これを用いて、

    import UIKit
    
    class ViewController: UIViewController {
    
        // (略)
    
        var nowTouching = false
        
        @IBAction func sliderDidTouchDown(_ sender: Any) {
            nowTouching = true
        }
        
        @IBAction func sliderValueChanged(_ sender: UISlider) {
            // (略)
            nowTouching = false
        }
        
        func addPeriodicTimeObserver() {
            // Notify every half second
            let timeScale = CMTimeScale(NSEC_PER_SEC)
            let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
            
            timeObserverToken = player.addPeriodicTimeObserver(forInterval: time,
                                                               queue: .main)
            { [weak self] time in
                // update player transport UI
                DispatchQueue.main.async {
                    // sliderを更新
                    if let nowTouching = self?.nowTouching, nowTouching == false {
                        if let player = self?.player, player.rate > 0 {
                            self?.updateSlider()
                        }
                    }
                    
                    // NowPlayingInfoCenterを更新
                    self?.updateNowPlaying(time: CMTimeGetSeconds(time))
                }
            }
        }
        
        // (略)
    
    }
    

    のようにすることで、スライダーのThumbに触っている間は、updateSlider()を呼び出さないようにしました。
    これで、

    Thumbを押して動かしたあと離さないでいると、指のある箇所と再生中の箇所を行き来する

    問題を解消できます。

    結構手を入れる必要があるので、求める機能が果たせていた場合はThumbの位置の乱れは許容してしまう、というのも一つの方法かもしれませんが、参考になれば幸いです。

  3. nackpan様
    ご丁寧でわかりやすい解説をありがとうございます。
    これから、実装して確認してみます。まずは、お礼を申し上げます。

コメントする

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください