[iOS]動画を再生する(バックグラウンドでの早戻り、早送り、シーク)

前回は、動画を再生・ポーズ・早送り・早戻し・シークする機能を実装しました。
今回は、バックグラウンドで、動画ファイルのオーディオを再生・ポーズ・早送り・早戻し・シークする機能を追加します。
以前、バックグラウンドで動画ファイルのオーディオを再生・ポーズする記事([iOS]動画を再生する(AVPlayerLayer使用))を記しました。今回は、早送り・早戻し・シークが加わっています。

前回の記事で作成したプロジェクトに書き加える形で作成します。
ポイントになる箇所について、説明をおこないました。
全体のソースコードは記事の最後にまとめてあります。

バックグラウンド再生の準備

今回のサンプルでは、バックグラウンドでのオーディオ再生を行うので、その設定を行います。
TARGET > Signing & Capabilitiesを選択。
「+ Capability」ボタンから-> Background Modes

Audio, AirPlay and Picture in Pictureをチェック。

これにより、info.plistにRequired background modesが加わり、そのitemがApp plays audio or streams audio/video using AirPlayとなります。バックグラウンド再生が可能になります。

リモートコントロール対応

イヤホンからの操作や、バックグラウンド移行後のコントロールセンターやロックスクリーンの再生コントロールからの操作に対応します。
MPRemoteCommandCenterクラスを用います。
また、コントロールセンターやロックスクリーンの再生コントロールに再生中アプリの現在の状況を表示する必要があります。それには、MPNowPlayingInfoCenterクラスを用います。
これらの実装については、Controlling Background Audio | Apple Developer Documentationが参考になります。

リモートコントロール対応部分


// MARK: Remote Command Event
func addRemoteCommandEvent() {
    
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteTogglePlayPause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePlay(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePause(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteNextTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remotePrevTrack(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // 早送り
    commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
    commandCenter.skipForwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteSkipForward(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // 早戻し
    commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
    commandCenter.skipBackwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteSkipBackward(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    // ポジション移動(バーのボタン位置変更)
    commandCenter.changePlaybackPositionCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
        self.remoteChangePlaybackPosition(commandEvent)
        return MPRemoteCommandHandlerStatus.success
    }
    
    commandCenter.skipBackwardCommand.isEnabled = false
}
 
 /// イヤホンのセンターボタンを押した時の処理
 func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
     // (略)
 }
 
 func remotePlay(_ event: MPRemoteCommandEvent) {
     player.play()
 }
 
 func remotePause(_ event: MPRemoteCommandEvent) {
     player.pause()
 }
 
 /// リモートコマンドで「次へ」ボタンが押された時の処理
 func remoteNextTrack(_ event: MPRemoteCommandEvent) {
     // (略)
 }
 
 /// リモートコマンドで「前へ」ボタンが押された時の処理
 func remotePrevTrack(_ event: MPRemoteCommandEvent) {
     // (略)
     
 }
 
 /// リモートコマンドで「早送り」ボタンが押された時の処理
 func remoteSkipForward(_ event: MPRemoteCommandEvent) {
     skipForward()
 }
 
 /// リモートコマンドで「早戻し」ボタンが押された時の処理
 func remoteSkipBackward(_ event: MPRemoteCommandEvent) {
     skipBackward()
 }

/// リモートコマンドでシークバー(プログレスバー)の位置を変更した時の処理
 func remoteChangePlaybackPosition(_ event: MPRemoteCommandEvent) {
     if let evt = event as? MPChangePlaybackPositionCommandEvent {
         let timeScale = CMTimeScale(NSEC_PER_SEC)
         let time = CMTime(seconds: evt.positionTime, preferredTimescale: timeScale)
         changePosition(time: time)
     }
 }

NowPlayingInfo部分

今回のサンプルでは、動画ファイルのurlの末尾部分がタイトルとして表示されます。
また、再生に連れてバーが進み、現在の再生時刻が表示されます。再生に連れて変化する箇所は、addPeriodicTimeObserver(forInterval:queue:using:)のblockに記述して、周期的に更新しています。
MPNowPlayingInfoCenterに示すことができるメタデータはほかにも様々なものがあります。
Now Playing Metadata Properties | MPNowPlayingInfoCenter – Media Player | Apple Developer Documentation

func setupNowPlaying(url: URL) {
    // Define Now Playing Info
    var nowPlayingInfo = [String : Any]()
    
    // ここでは、urlのlastPathComponentを表示しています。
    nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
    nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
    nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
    nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
    nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
    
    // Set the metadata
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

func updateNowPlaying(time: Double) {
    var nowPlayingInfo = [String : Any]()
    nowPlayingInfo[MPMediaItemPropertyTitle] = itemURL?.lastPathComponent ?? ""
    nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
    if let duration = player.currentItem?.duration {
        nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = CMTimeGetSeconds(duration)
    }
    MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}

再生コントロール

今回のサンプルでのロックスクリーン上の再生コントロール

再生ボタン、早戻しボタン、早送りボタンが表示されています。
ここで、「次のトラック」「前のトラック」ボタンのほうを表示したい時には、

let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.isEnabled = false

とすると、skipForwardCommandとskipBackwardCommandが使用不可になり、再生コントロールにも表示されません。以下のようになります。
代わって、previousTrackCommandとnextTrackCommandを示すボタンが表示されています。

使用したソースコード

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 itemURL: URL?
    var itemDuration: Double = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        setupAudioSession()
        
        setupPlayer()
        
        addRemoteCommandEvent()
    }
    
    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
        }
    
        itemURL = url
        let asset = AVAsset(url: url)
        itemDuration = CMTimeGetSeconds(asset.duration)
    
        let item = AVPlayerItem(url: url)
        player.replaceCurrentItem(with: item)
    
        setupNowPlaying(url: url)
    }

    @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: Now Playing Info
    func setupNowPlaying(url: URL) {
        // Define Now Playing Info
        var nowPlayingInfo = [String : Any]()
        
        // ここでは、urlのlastPathComponentを表示しています。
        nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = url
        nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
        nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false
        nowPlayingInfo[MPMediaItemPropertyTitle] = url.lastPathComponent
        nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = ""
        
        // Set the metadata
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }
    
    func updateNowPlaying(time: Double) {
        var nowPlayingInfo = [String : Any]()
        nowPlayingInfo[MPMediaItemPropertyTitle] = itemURL?.lastPathComponent ?? ""
        nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = time
        if let duration = player.currentItem?.duration {
            nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = CMTimeGetSeconds(duration)
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
    }
    
    
    // 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 {
                // sliderを更新
                self?.updateSlider()
                
                // NowPlayingInfoCenterを更新
                self?.updateNowPlaying(time: CMTimeGetSeconds(time))
            }
        }
    }

    func removePeriodicTimeObserver() {
        if let timeObserverToken = timeObserverToken {
            player.removeTimeObserver(timeObserverToken)
            self.timeObserverToken = nil
        }
    }
    
    // MARK: Remote Command Event
    func addRemoteCommandEvent() {
        
        let commandCenter = MPRemoteCommandCenter.shared()
        commandCenter.togglePlayPauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteTogglePlayPause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.playCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePlay(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.pauseCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePause(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        commandCenter.nextTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteNextTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        commandCenter.previousTrackCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remotePrevTrack(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // 早送り
        commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
        commandCenter.skipForwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteSkipForward(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // 早戻し
        commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: skipInterval)]
        commandCenter.skipBackwardCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteSkipBackward(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
        
        // ポジション移動(バーのボタン位置変更)
        commandCenter.changePlaybackPositionCommand.addTarget{ [unowned self] commandEvent -> MPRemoteCommandHandlerStatus in
            self.remoteChangePlaybackPosition(commandEvent)
            return MPRemoteCommandHandlerStatus.success
        }
    }
     
     /// イヤホンのセンターボタンを押した時の処理
     func remoteTogglePlayPause(_ event: MPRemoteCommandEvent) {
         // (略)
     }
     
     func remotePlay(_ event: MPRemoteCommandEvent) {
         player.play()
     }
     
     func remotePause(_ event: MPRemoteCommandEvent) {
         player.pause()
     }
     
     /// リモートコマンドで「次へ」ボタンが押された時の処理
     func remoteNextTrack(_ event: MPRemoteCommandEvent) {
         // (略)
     }
     
     /// リモートコマンドで「前へ」ボタンが押された時の処理
     func remotePrevTrack(_ event: MPRemoteCommandEvent) {
         // (略)
         
     }
     
     /// リモートコマンドで「早送り」ボタンが押された時の処理
     func remoteSkipForward(_ event: MPRemoteCommandEvent) {
         skipForward()
     }
     
     /// リモートコマンドで「早戻し」ボタンが押された時の処理
     func remoteSkipBackward(_ event: MPRemoteCommandEvent) {
         skipBackward()
     }

    /// リモートコマンドでシークバー(プルグレスバー)の位置を変更した時の処理
     func remoteChangePlaybackPosition(_ event: MPRemoteCommandEvent) {
         if let evt = event as? MPChangePlaybackPositionCommandEvent {
             let timeScale = CMTimeScale(NSEC_PER_SEC)
             let time = CMTime(seconds: evt.positionTime, preferredTimescale: timeScale)
             changePosition(time: time)
         }
     }
    
}

投稿者:

nackpan

nackpan

iOSアプリを作っています。 リピーティングに便利な「語学学習支援プレイヤー」つくりました。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

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