AVFoundationPiPPlayer/PlayerViewController.swift
/* |
Copyright (C) 2018 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
PlayerViewController is a subclass of UIViewController which manages the UIView used for playback and also |
sets up AVPictureInPictureController for video playback in picture in picture. |
*/ |
import AVFoundation |
import UIKit |
import AVKit |
/* |
KVO context used to differentiate KVO callbacks for this class versus other |
classes in its class hierarchy. |
*/ |
private var playerViewControllerKVOContext = 0 |
/* |
Manages the view used for playback and sets up the `AVPictureInPictureController` |
for video playback in picture in picture. |
*/ |
class PlayerViewController: UIViewController, AVPictureInPictureControllerDelegate { |
// MARK: - Properties |
/// The `NSKeyValueObservation` for the KVO on \AVPlayerItem.status. |
private var playerItemStatusObserver: NSKeyValueObservation? |
/// The `NSKeyValueObservation` for the KVO on \AVPlayerItem.duration. |
private var playerItemDurationObserver: NSKeyValueObservation? |
/// The `NSKeyValueObservation` for the KVO on \AVPlayer.rate. |
private var playerRateObserver: NSKeyValueObservation? |
/* |
The `NSKeyValueObservation` for the KVO on |
\PlayerViewController.pictureInPictureController.pictureInPicturePossible. |
*/ |
private var pictureInPicturePossibleObserver: NSKeyValueObservation? |
@objc lazy var player = AVPlayer() |
@objc var pictureInPictureController: AVPictureInPictureController! |
var playerView: PlayerView { |
return self.view as! PlayerView |
} |
var playerLayer: AVPlayerLayer? { |
return playerView.playerLayer |
} |
/// The AVPlayerItem associated with AssetPlaybackManager.asset.urlAsset |
private var playerItem: AVPlayerItem? = nil { |
willSet { |
/// Remove any previous KVO observer. |
guard let playerItemStatusObserver = playerItemStatusObserver else { return } |
playerItemStatusObserver.invalidate() |
} |
didSet { |
/* |
If needed, configure player item here before associating it with a player |
(example: adding outputs, setting text style rules, selecting media options) |
*/ |
player.replaceCurrentItem(with: playerItem) |
if playerItem == nil { |
cleanUpPlayerPeriodicTimeObserver() |
} else { |
setupPlayerPeriodicTimeObserver() |
} |
// Use KVO to get notified of changes in the AVPlayerItem duration property. |
playerItemDurationObserver = |
playerItem?.observe(\AVPlayerItem.duration, options: [.new, .initial]) { [weak self] (item, _) in |
guard let strongSelf = self else { return } |
// Update `timeSlider` and enable/disable controls when `duration` > 0.0 |
let newDuration = item.duration |
let hasValidDuration = newDuration.isNumeric && newDuration.value != 0 |
let newDurationSeconds = hasValidDuration ? CMTimeGetSeconds(newDuration) : 0.0 |
strongSelf.timeSlider.maximumValue = Float(newDurationSeconds) |
let currentTime = CMTimeGetSeconds(strongSelf.player.currentTime()) |
strongSelf.timeSlider.value = hasValidDuration ? Float(currentTime) : 0.0 |
strongSelf.playPauseButton.isEnabled = hasValidDuration |
strongSelf.timeSlider.isEnabled = hasValidDuration |
} |
// Use KVO to get notified of changes in the AVPlayerItem status property. |
playerItemStatusObserver = playerItem?.observe(\AVPlayerItem.status, options: [.new, .initial]) { [weak self] (item, _) in |
guard let strongSelf = self else { return } |
// Display an error if status becomes Failed |
if item.status == .failed { |
strongSelf.handle(error: strongSelf.player.currentItem?.error as NSError?) |
} else if item.status == .readyToPlay { |
if let asset = strongSelf.player.currentItem?.asset { |
/* |
First test whether the values of `assetKeysRequiredToPlay` we need |
have been successfully loaded. |
*/ |
for key in PlayerViewController.assetKeysRequiredToPlay { |
var error: NSError? |
if asset.statusOfValue(forKey: key, error: &error) == .failed { |
strongSelf.handle(error: error) |
return |
} |
} |
if !asset.isPlayable || asset.hasProtectedContent { |
// We can't play this asset. |
strongSelf.handle(error: nil) |
return |
} |
/* |
The player item is ready to play, |
setup picture in picture. |
*/ |
if strongSelf.pictureInPictureController == nil { |
strongSelf.setupPictureInPicturePlayback() |
} |
} |
} |
} |
} |
} |
var timeObserverToken: AnyObject? |
// Attempt to load and test these asset keys before playing |
static let assetKeysRequiredToPlay = [ |
"playable", |
"hasProtectedContent" |
] |
var currentTime: Double { |
get { |
return CMTimeGetSeconds(player.currentTime()) |
} |
set { |
let newTime = CMTimeMakeWithSeconds(newValue, 1) |
player.seek(to: newTime, toleranceBefore: kCMTimeZero, toleranceAfter: kCMTimeZero) |
} |
} |
var duration: Double { |
guard let currentItem = player.currentItem else { return 0.0 } |
return CMTimeGetSeconds(currentItem.duration) |
} |
required init?(coder aDecoder: NSCoder) { |
super.init(coder: aDecoder) |
playerRateObserver = player.observe(\AVPlayer.rate, options: [.new]) { [weak self] (player, _) in |
guard let strongSelf = self else { return } |
// Update playPauseButton type. |
let newRate = player.rate |
let style: UIBarButtonSystemItem = newRate == 0.0 ? .play : .pause |
let newPlayPauseButton = UIBarButtonItem(barButtonSystemItem: style, target: self, |
action: #selector(PlayerViewController.playPauseButtonWasPressed(_:))) |
// Replace the current button with the updated button in the toolbar. |
var items = strongSelf.toolbar.items! |
if let playPauseItemIndex = items.index(of: strongSelf.playPauseButton) { |
items[playPauseItemIndex] = newPlayPauseButton |
strongSelf.playPauseButton = newPlayPauseButton |
strongSelf.toolbar.setItems(items, animated: false) |
} |
} |
} |
deinit { |
/// Remove any KVO observer. |
playerRateObserver?.invalidate() |
} |
// MARK: - IBOutlets |
@IBOutlet weak var timeSlider: UISlider! |
@IBOutlet weak var playPauseButton: UIBarButtonItem! |
@IBOutlet weak var pictureInPictureButton: UIBarButtonItem! |
@IBOutlet weak var toolbar: UIToolbar! |
// MARK: - IBActions |
@IBAction func playPauseButtonWasPressed(_ sender: UIButton) { |
if player.rate != 1.0 { |
// Not playing foward, so play. |
if currentTime == duration { |
// At end, so got back to beginning. |
currentTime = 0.0 |
} |
player.play() |
} else { |
// Playing, so pause. |
player.pause() |
} |
} |
@IBAction func togglePictureInPictureMode(_ sender: UIButton) { |
/* |
Toggle picture in picture mode. |
If active, stop picture in picture and return to inline playback. |
If not active, initiate picture in picture. |
Both these calls will trigger delegate callbacks which should be used |
to set up UI appropriate to the state of the application. |
*/ |
if pictureInPictureController.isPictureInPictureActive { |
pictureInPictureController.stopPictureInPicture() |
} else { |
pictureInPictureController.startPictureInPicture() |
} |
} |
@IBAction func timeSliderDidChange(_ sender: UISlider) { |
currentTime = Double(sender.value) |
} |
// MARK: - View Handling |
override func viewWillAppear(_ animated: Bool) { |
super.viewWillAppear(animated) |
playerView.playerLayer.player = player |
setupPlayback() |
timeSlider.translatesAutoresizingMaskIntoConstraints = true |
timeSlider.autoresizingMask = .flexibleWidth |
// Set the UIImage provided by AVPictureInPictureController as the image of the pictureInPictureButton |
guard let backingButton = pictureInPictureButton.customView as? UIButton else { |
return |
} |
backingButton.setImage(AVPictureInPictureController.pictureInPictureButtonStartImage(compatibleWith: nil), for: UIControlState.normal) |
} |
override func viewDidDisappear(_ animated: Bool) { |
super.viewDidDisappear(animated) |
player.pause() |
cleanUpPlayerPeriodicTimeObserver() |
removeObserver(self, forKeyPath: #keyPath(PlayerViewController.player.currentItem.duration), context: &playerViewControllerKVOContext) |
removeObserver(self, forKeyPath: #keyPath(PlayerViewController.player.rate), context: &playerViewControllerKVOContext) |
removeObserver(self, forKeyPath: #keyPath(PlayerViewController.player.currentItem.status), context: &playerViewControllerKVOContext) |
} |
private func setupPlayback() { |
let movieURL = Bundle.main.url(forResource: "samplemovie", withExtension: "mov")! |
let asset = AVURLAsset(url: movieURL, options: nil) |
/* |
Create a new `AVPlayerItem` and make it our player's current item. |
Using `AVAsset` now runs the risk of blocking the current thread (the |
main UI thread) whilst I/O happens to populate the properties. It's prudent |
to defer our work until the properties we need have been loaded. |
These properties can be passed in at initialization to `AVPlayerItem`, |
which are then loaded automatically by `AVPlayer`. |
*/ |
self.playerItem = AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: PlayerViewController.assetKeysRequiredToPlay) |
} |
private func setupPlayerPeriodicTimeObserver() { |
// Only add the time observer if one hasn't been created yet. |
guard timeObserverToken == nil else { return } |
let time = CMTimeMake(1, 30) |
// Use a weak self variable to avoid a retain cycle in the block. |
timeObserverToken = |
player.addPeriodicTimeObserver(forInterval: time, queue:DispatchQueue.main) { [weak self] time in |
self?.timeSlider.value = Float(CMTimeGetSeconds(time)) |
} as AnyObject? |
} |
private func cleanUpPlayerPeriodicTimeObserver() { |
if let timeObserverToken = timeObserverToken { |
player.removeTimeObserver(timeObserverToken) |
self.timeObserverToken = nil |
} |
} |
private func setupPictureInPicturePlayback() { |
/* |
Check to make sure Picture in Picture is supported for the current |
setup (application configuration, hardware, etc.). |
*/ |
if AVPictureInPictureController.isPictureInPictureSupported() { |
/* |
Create `AVPictureInPictureController` with our `playerLayer`. |
Set self as delegate to receive callbacks for picture in picture events. |
Add observer to be notified when pictureInPicturePossible changes value, |
so that we can enable `pictureInPictureButton`. |
*/ |
pictureInPictureController = AVPictureInPictureController(playerLayer: playerView.playerLayer) |
pictureInPictureController.delegate = self |
// Use KVO to get notified of changes in the pictureInPicturePossible value. |
pictureInPicturePossibleObserver = self.observe(\PlayerViewController.pictureInPictureController.pictureInPicturePossible, |
options: [.new]) { [weak self] (playerViewController, _) in |
guard let strongSelf = self else { return } |
/* |
Enable the `pictureInPictureButton` only if `pictureInPicturePossible` |
is true. If this returns false, it might mean that the application |
was not configured as shown in the AppDelegate. |
*/ |
strongSelf.pictureInPictureButton.isEnabled = |
playerViewController.pictureInPictureController.isPictureInPicturePossible |
} |
} else { |
pictureInPictureButton.isEnabled = false |
} |
} |
// MARK: - AVPictureInPictureControllerDelegate |
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { |
/* |
If your application contains a video library or other interesting views, |
this delegate callback can be used to dismiss player view controller |
and to present the user with a selection of videos to play next. |
*/ |
toolbar.isHidden = true |
} |
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { |
/* |
Picture in picture mode will stop soon, show the toolbar. |
*/ |
toolbar.isHidden = false |
} |
func pictureInPictureControllerFailedToStartPictureInPicture(pictureInPictureController: AVPictureInPictureController, withError error: NSError) { |
/* |
Picture in picture failed to start with an error, restore UI to continue |
inline playback. Show the toolbar. |
*/ |
toolbar.isHidden = false |
handle(error: error) |
} |
// MARK: - KVO |
// Trigger KVO for anyone observing our properties affected by player and player.currentItem |
override class func keyPathsForValuesAffectingValue(forKey key: String) -> Set<String> { |
let affectedKeyPathsMappingByKey: [String: Set<String>] = [ |
"duration": [#keyPath(PlayerViewController.player.currentItem.duration)], |
"rate": [#keyPath(PlayerViewController.player.rate)] |
] |
return affectedKeyPathsMappingByKey[key] ?? super.keyPathsForValuesAffectingValue(forKey: key) |
} |
// MARK: - Error Handling |
func handle(error: NSError?) { |
let alertController = UIAlertController(title: "Error", message: error?.localizedDescription, preferredStyle: .alert) |
let alertAction = UIAlertAction(title: "OK", style: .default, handler: nil) |
alertController.addAction(alertAction) |
present(alertController, animated: true, completion: nil) |
} |
} |
Copyright © 2018 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2018-02-08