[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)
         }
     }
    
}

[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
        }
    }
   
}

「Repete Plus」をアップデートしました。(version 7.8)

「Repete Plus」をアップデートしました。(version 7.7 -> version 7.8)

Repete Plusは、語学学習を支援するオーディオプレイヤーです。
無音部分を自動認識し、語学教材をフレーズごとに間隔をあけて再生できます。

App Storeはこちら

使い方を紹介しているサポートサイトはこちら
Repete Plus – nackpan Blog

変更点

次の曲に変わった際のテキストの表示位置を修正しました。

修正前(バージョン7.7まで)の動作。
ファイルAでテキスト20行目を表示していたとき、ファイルBに切り替えるとそちらのテキストでも20行目が表示されていました。


これは意図した仕様ではなかったので修正しました。
修正後の動作。
ファイルAでテキスト20行目を表示していたとき、ファイルBに切り替えるとそちらのテキストで最後に表示されていたところから表示されます。最後にファイルBのテキストを見たとに5行目から表示したなら5行目から表示されます。


語学学習を助ける「Repete Plus」をよろしくおねがいします。

「Repete」をアップデートしました(version 6.3)

iPhone/iPadアプリ「Repete」(レペテ)(旧「語学学習支援プレイヤー」)をアップデートしました。(version 6.2 -> version 6.3)

「Repete」は、語学学習の手助けをするオーディオプレイヤーです。ファイルの無音部分を自動的に検出し、フレーズごとにあいだをおいて再生します
リピーティングに便利なオーディオプレイヤーです。

変更点

  • 選曲画面の「ストレージ」タブの名称を「フォルダ」タブに変更しました。
  • 「フォルダ」タブ画面にアイテムがない場合、「フォルダ」タブ画面にファイルが読み込めることを示す説明を加えました。
  • ファイル読み込みに、iOSが提供するドキュメントピッカーを導入しました。iPhone(iPad)内、iCloud Drive、使用しているクラウドストレージからファイルを読み込むことができます。(「Dropboxから読み込み」から「ファイルの読み込み」へ変更しました)
    (選曲画面 > 「フォルダ」タブ > •••ボタン > ファイルの読み込み)

開発者後記

Repete Plus(version 7.7)と同様にドキュメントピッカーを導入しました。
ドキュメントピッカーではDropboxからフォルダ選択ができず、その点で利便性が下がりました。一方、従来のDropboxのみから、端末上のファイル、iCloud Drive、クラウドストレージ各種と読み込める場所の幅が広がりました。

よろしくおねがいします

「Repete Plus」をアップデートしました。(version 7.7)

「Repete Plus」をアップデートしました。(version 7.6 -> version 7.7)

Repete Plusは、語学学習を支援するオーディオプレイヤーです。
無音部分を自動認識し、語学教材をフレーズごとに間隔をあけて再生できます。

App Storeはこちら

使い方を紹介しているサポートサイトはこちら
Repete Plus – nackpan Blog

変更点

  • 選曲画面の「ストレージ」タブの名称を「フォルダ」タブに変更しました。
  • 「フォルダ」タブ画面にアイテムがない場合、「フォルダ」タブ画面にファイルが読み込めることを示す説明を加えました。
  • ファイル読み込みに、iOSが提供するドキュメントピッカーを導入しました。iPhone(iPad)内、iCloud Drive、使用しているクラウドストレージからファイルを読み込むことができます。
    (選曲画面 > 「フォルダ」タブ > •••ボタン > ファイルの読み込み)
  • 従来のクラウドストレージAPIを用いた読み込み方法は、初期状態では表示されなくなりました。設定 > 詳細 > 「クラウドストレージから読み込み」を表示 をONにすることで表示されます。
  • テキストの読み込みにもドキュメントピッカーを導入しました。
  • 全体リピートをONにしているときに原曲再生を行うと、2回目からも原曲再生を続ける挙動に修正しました。

開発者後記

今回のアップデート(7.7)では、ファイル読み込み機能に大きな改変を加えました。
iOSが提供するドキュメントピッカーを導入しました。
従来のクラウドストレージAPIを用いた読み込み機能は、初期状態では表示されません。
従来の方法では、リスト表示されて一覧性が高く、ファイルだけでなくフォルダの選択も可能でした。利便性は良いのですが、アプリ連携をしてRepete Plusからクラウドストレージにログインする必要があります。このハードルが高いと感じていました。
ドキュメントピッカーなら、iPhone/iPadでクラウドストレージアプリを利用している場合、あらためてアプリ連携をする必要がありません。
よって、今回から導入しました。
ドキュメントピッカーは、iOS 8から追加された機能で当初は単独ファイル用ピッカーでした。iOS 11から複数選択可能になりました。複数選択の時、操作がわかりにくいです。説明を加えました。

また、今回「ストレージ」タブを「フォルダ」タブに名称変更しました。
語学学習支援プレイヤー(Repeteに改名前の名前)リリース当初(iOS 6時代)は、メディアライブラリのファイルのみが使用可能でした。
Marieさんからの要望で、メディアライブラリの曲と別建てで扱えるように「ストレージ」タブを作り、そこにファイルをインポートできるようにしたのでした。
その後、Apple Musicが始まり(2015)、ミュージック Appの姿も変わりました。ストリーミングで聞くのを前提としたアプリに変わりました。また、ファイル AppがiOS 9(2015)から登場し、iPhone/iPadでもファイルやフォルダの操作がパソコン風に行えるようになっていきました。
語学学習の教材は、メディアライブラリの曲と別建てであつかいたいと思っている方はより多くなっていると思います。しかし、Repete Plusで、別建てであつかえる、ファイルを読み込めることに、気付きづらいのでは?「ストレージ」タブの機能に気づいてない人も多いのでは?ということで、今回、名前を「フォルダ」タブと改称したうえで、アイテムがない場合には、フォルダタブでのファイル読み込みについて説明を表示しました。

今回のアップデートで、ファイル読み込み機能がひろく使われるようになれば、さいわいです。


「Repete」をアップデートしました(version 6.2)

iPhone/iPadアプリ「Repete」(レペテ)(旧「語学学習支援プレイヤー」)をアップデートしました。(version 6.1.2 -> version 6.2)

「Repete」は、語学学習の手助けをするオーディオプレイヤーです。ファイルの無音部分を自動的に検出し、フレーズごとにあいだをおいて再生します
リピーティングに便利なオーディオプレイヤーです。

変更点

従来は、無音位置を検知し区間分割する際に、ファイルの最後の区間はフレーズの後ろの無音部分がほぼありませんでした。
今回のアップデートで、中間の区間と同様に最後の区間にも少し無音を加えるようにしました。

今回のアップデートでは、6.2より前のバージョンからアップデートしたさいに、いったん、データベースから区切り情報を削除しています。選曲後、あらためてオーディオデータ分析がおこなわれ、最後の区間末尾の終点を少し後ろにして区切りなおしています。


語学学習を助ける「Repete」をよろしくおねがいします。

「Repete Plus」をアップデートしました。(version 7.6)

「Repete Plus」をアップデートしました。(version 7.5.1 -> version 7.6)

Repete Plusは、語学学習を支援するオーディオプレイヤーです。
無音部分を自動認識し、語学教材をフレーズごとに間隔をあけて再生できます。

App Storeはこちら

使い方を紹介しているサポートサイトはこちら
Repete Plus – nackpan Blog

変更点

バージョン7.5.1までは、区間に分割した際にファイルの最後の区間はフレーズの後ろの無音部分がほぼありません。そのため状況によっては、再生時にフレーズ末尾が途切れるケースがありました。また、最後の区間での待ち時間がほかの区間に比べて短くなっています。
今回のアップデートで、この問題を修正しました。

  • 自動的に分割する際に、中間の区間と同様に最後の区間にも少し無音を加えるようにしました。
  • すでに区切り情報が保存されているオーディオファイルについては、区間データ管理の「末尾無音追加」から、最後の区間に少し無音を加えられるようになりました。
    (設定 > 詳細 > 区間データ管理 から、オーディオファイルのデータを選び、「末尾無音追加」ボタンを押すことで、最後の区間に少し無音が加えられます)
  • 設定> 「区切りデータ管理」を、設定>「区間データ管理」に名前変更。区間データ管理に「末尾無音追加」ボタンを設置。ヘルプを設置。
  • バグ修正

開発者後記

サポートページのツダトモさんのコメントおよびApp Storeのtomohirosoraさんのコメントで、DUO 3.0使用の際に、最後の区間を再生する際に途中で途切れる症状が出ることがあると報告がありました。二人から同時期に報告があったということで、ただちに調査に取り掛かりました。手元の環境では再現できなかったものの、最後の区間が途切れる症状に関しては、思い当たる点がありました。
今回のアップデートでは、この問題の修正を行いました。

無音位置検知・区間分割処理のパートはRepete(語学学習支援プレイヤー)のリリース前に開発した後は、その後のアップデートでもほぼ修正なしで使い続けていました。

当時採用した手順。
無音位置を検知して、有音区間の始点・終点を求めましたが、そのままではブツ切れになることがありました。フレーズの後ろに余裕を持たせるため、区間の終点を次の区間の始点としました。
最後の区間の終点はファイルの末端としました。
しかし、多くの語学教材では、ファイルの最後に長めの無音が入っています。
待ち時間が長すぎたので、最後の区間では無音部分をカットしました。
このときに、最後の区間では、無音をまるまるカットするのではなく、有音部分の最後の位置を求めた後すこし余裕を持たせて終点を決めればよかったのですが、考えが思い至りませんでした。
今回のアップデートで、最後の区間では有音区間のすこし後ろに終点を設定しました。
(また、今回のアップデートでは対処していませんが、現行の区間の終点を次の区間の始点とする方式では、フレーズのあいだが長いばあいに待ち時間が長くなってしまう問題があります。DDDさんのコメントでも要望がありましたが、よくよく調査しなければ影響範囲が定め難いので、いまだ手をつけていません)

バージョン2.7で、ファイル最後の区間の待ち時間が短いので対処してほしいとjshinさんのコメントがあり、「末尾無音追加」という項目を設定> 詳細> 末尾無音追加 に加えたのですが、このときの処置は無音分析処理時点で対処するのでなく、作成した区間に後付けで無音ファイルをつけるものでした。
この方法でも、最後の区間の待ち時間が短い問題に対しては、対応できました。
しかし、無音分析処理時点で有音部分の最後を削り気味にしていた場合、フレーズの最後が削られる症状が出たままになります。
また、このときの処理は、とってつけたパラメータが一つ増えたかたちになり、プログラム内部で管理が煩雑になっていました。

今回のアップデートで、バージョン2.7方式の「末尾無音追加」は取り外しました。
かわって、最後の区間の終点をずらす「末尾無音追加」(区間データ管理>末尾無音追加)を追加しました。
これならば、フレーズの最後が削られることはありません。

プログラム内部もすっきりしました。


Repete Plusが語学学習の助けになれば幸いです。
Repete Plusの応援よろしくお願いします。

「Repete Plus」をアップデートしました。(version 7.5.1)

「Repete Plus」をアップデートしました。(version 7.4 -> version 7.5.1)

Repete Plusは、語学学習を支援するオーディオプレイヤーです。
無音部分を自動認識し、語学教材をフレーズごとに間隔をあけて再生できます。

App Storeはこちら

使い方を紹介しているサポートサイトはこちら
Repete Plus – nackpan Blog

変更点

シャッフルに新たに2種類が加わりました。

+ 周回ごとに再シャッフル
従来のシャッフルでは、選曲後に一度シャッフルを行うものでしたが、こちらは一周完了すると再度シャッフルを行います。

+ 問い+シャッフルされた選択肢
ファイルごとに先頭以外の区間をシャッフルします。そのあと、ファイル単位でシャッフルします。
先頭に質問があり、残りが選択肢となっているファイルで、選択肢および出題順をシャッフルすることができます。

設定項目に「再生順を表示」が加わりました。

設定 > 詳細 > 生成順を表示 をONにすると、現在の再生順を表すアイコンが表示されます。

開発者後記

シャッフル機能

今回は、以前サポートページのコメントでいただいていた要望を実装しました。
とくに、「問い+シャッフルされた選択肢」型のシャッフルについては、思いもよらないアイデア、いままで検討したことがまるでなかったアイデアでしたので、ちょっくらやってみよう、と実装すると決めた機能でした。やろうと思った時点で、そんなに改造する箇所ないだろう、という読みもありました。
シャッフルは選曲直後に一度行っており、そこで行うシャッフルに新たなバリエーションを加えれば良いだろう、と考えていました。
しかし、実装に取り掛かると、結構修正箇所があり大変でした。
選曲直後、自動再生していた場合はオーディオ再生が始まると同時に分割した各区間のAVPlayerItemを生成します。これに数秒かかります。分割区間AVPlayerItem生成が完了した時点で、シャッフル用配列が作成され、現在再生中の箇所に対応するアイテムが選ばれます。対応するアイテムがシャッフル用配列の末尾近くにあるとすぐ周回終了になってしまうので、調整が必要となり、その点を修正することとなりました。

再生順の表示

シャッフルに新たなタイプを加えた後、Repete Plusを使用。
現在のシャッフルタイプを知りたくなりました。
シャッフルタイプの表示機能を作成開始。

再生状況を表示している箇所におさまりきらなくなってきました。
場所を作って、テキスト表示を実験。
しかし、「一周ごと再シャッフル」という文言が、目に入ってしまい読んでしまう。テキストエリアに書き込んだテキストを見ようとしても、気が持っていかれてしまう。
良くないと感じて、アイコン表示に変更。

再生速度のわきに再生順タイプのアイコンを表示しました。
アイコンは文章に比べて何を示しているのか、ときに分かりづらい。
バリエーションが増えたときに、把握するのが大変。
など弱点はあります。しかし、文言に気を取られてしまう問題のほうが嫌でしたので、アイコン表示方式でいくことにしました。

それでは、シャッフルに新たなタイプが加わったRepete Plusをよろしくお願いします。