AVCustomEdit-Swift/APLViewController.swift
/* |
Copyright (C) 2017 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
UIViewController subclasses which handles setup, playback and export of AVMutableComposition along with other user |
interactions like scrubbing, toggling play/pause, selecting transition type. |
*/ |
import UIKit |
import AVFoundation |
import Foundation |
import CoreFoundation |
import CoreGraphics |
import Photos |
// MARK: APLPlayerView Class |
// A simple `UIView` subclass that is backed by an `AVPlayerLayer` layer. |
class APLPlayerView: UIView { |
override class var layerClass: AnyClass { |
return AVPlayerLayer.self |
} |
var player: AVPlayer? { |
get { |
return playerLayer.player |
} |
set { |
playerLayer.player = newValue |
} |
} |
var playerLayer: AVPlayerLayer { |
return layer as! AVPlayerLayer |
} |
} |
class APLViewController: UIViewController, UIGestureRecognizerDelegate, UIPopoverPresentationControllerDelegate, |
APLTransitionTypePickerDelegate, UIAdaptivePresentationControllerDelegate, APLExportStatus { |
// MARK: Properties |
/// Context used in KVO to identify rate changes. |
fileprivate var playerRateObservationContext = 0 |
/// Context used in KVO to identify player status changes. |
fileprivate var playerStatusObservationContext = 1 |
/// Storyboard Seque identifier for the 'Set Transition'. |
static let transition = "Transition" |
/// APLSimpleEditor object instance used to build a composition from the clips. |
fileprivate var editor: APLSimpleEditor = APLSimpleEditor() |
/// The movie clips. |
fileprivate var clips = [AVAsset]() |
/// The available time ranges for the movie clips. |
fileprivate var clipTimeRanges = [CMTimeRange]() |
/// Instance of AVPlayer used for movie playback. |
fileprivate var player = AVPlayer() |
/// Instance of AVPlayerItem used to represent the presentation state of the asset played by the AVPlayer. |
fileprivate var playerItem: AVPlayerItem? = nil { |
didSet { |
// Replace the current player item with the new item. |
player.replaceCurrentItem(with: self.playerItem) |
} |
} |
/// The `UIView` subclass containing an AVPlayerLayer layer to which the output of AVPlayer can be directed. |
@IBOutlet fileprivate weak var playerView: APLPlayerView! |
/// The `UIToolbar` control that will display the scrubber, playPauseButton, and other elements. |
@IBOutlet fileprivate weak var toolbar: UIToolbar! |
/// The `UISlider` for scrubbing through the video. |
@IBOutlet fileprivate weak var scrubber: UISlider! |
/// The `UIBarButtonItem` for starting/stopping video playback. |
@IBOutlet fileprivate weak var playPauseButton: UIBarButtonItem! |
/// The `UIBarButtonItem` for selecting the desired transition (diagonal wipe or cross dissolve) |
@IBOutlet fileprivate weak var transitionButton: UIBarButtonItem! |
/// The `UIBarButtonItem` for exporting the video clips to a single file. |
@IBOutlet fileprivate weak var exportButton: UIBarButtonItem! |
/// The 'UILabel` for displaying the current time during playback. |
@IBOutlet fileprivate weak var currentTimeLabel: UILabel! |
/// The 'UIProgressView` for displaying the status of the export operation. |
@IBOutlet fileprivate weak var exportProgressView: UIProgressView! |
/// Indicates whether the movie is playing. |
fileprivate var playing = false |
/// Indicates whether the user is currently scrubbing video using the toolbar slider. |
fileprivate var scrubInFlight = false |
/// After the movie has played to its end time, seek back to time zero to play it again. |
fileprivate var seekToZeroBeforePlaying = false |
/// Last position of the scrubber slider. |
fileprivate var lastScrubSliderValue: Float = 0 |
/// Player rate prior to stopping playback. |
fileprivate var playRateToRestore: Float = 0 |
/// Used to update scrubber control and playback time value. |
fileprivate var timeObserver: Any? |
// Defaults for the transition settings. |
fileprivate var transitionDuration = 2.0 |
fileprivate var transitionType = TransitionType.diagonalWipe.rawValue |
fileprivate var transitionsEnabled = true |
// MARK: Initialization |
required init?(coder aDecoder: NSCoder) { |
super.init(coder: aDecoder) |
player.addObserver(self, forKeyPath: "rate", options: [.new, .old], |
context: &playerRateObservationContext) |
} |
// MARK: View Loading |
override func viewDidLoad() { |
super.viewDidLoad() |
// Do any additional setup after loading the view, typically from a nib. |
playerView.player = self.player |
updateScrubber() |
updateTimeLabel() |
// Add the clips from the main bundle to create a composition using them. |
setupEditingAndPlayback() |
} |
override func viewDidAppear(_ animated: Bool) { |
super.viewDidAppear(animated) |
addTimeObserverToPlayer() |
} |
override func viewWillDisappear(_ animated: Bool) { |
super.viewWillDisappear(animated) |
player.pause() |
removeTimeObserverFromPlayer() |
} |
// MARK: Set Transition |
override func prepare(for segue: UIStoryboardSegue, sender:Any?) { |
if segue.identifier == APLViewController.transition { |
// Setup transition type picker controller before it is shown. |
guard let transitionTypePickerController = segue.destination as? APLTransitionTypeController else { return } |
guard let controller = transitionTypePickerController.popoverPresentationController else { return } |
/* |
This will cause the 'adaptivePresentationStyleForPresentationController' and |
'viewControllerForAdaptivePresentationStyle' functions to be called. |
*/ |
controller.delegate = self |
transitionTypePickerController.delegate = self |
transitionTypePickerController.currentTransition = transitionType |
if transitionType == TransitionType.crossDissolve.rawValue { |
// Make sure the view is loaded first. |
if transitionTypePickerController.crossDissolveCell == nil { |
transitionTypePickerController.loadView() |
} |
transitionTypePickerController.crossDissolveCell.accessoryType = .checkmark |
} else { |
// Make sure the view is loaded first. |
if transitionTypePickerController.diagonalWipeCell == nil { |
transitionTypePickerController.loadView() |
} |
transitionTypePickerController.diagonalWipeCell.accessoryType = .checkmark |
} |
} |
} |
// Specify the presentation style to use (called for iPhone only). |
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { |
return .fullScreen |
} |
// Called when the Set Transition view controller 'Done' button is pressed. |
func doneAction() { |
// Dismiss the view controller that was presented. |
self.dismiss(animated: true) {} |
} |
/* |
Present/wrap the view controller in a navigation controller (for iPhone/compact). |
If this method is not implemented, or returns nil, then the originally presented view controller is used. |
*/ |
func presentationController(_ controller: UIPresentationController, |
viewControllerForAdaptivePresentationStyle style: UIModalPresentationStyle) -> UIViewController? { |
let navController = UINavigationController(rootViewController: controller.presentedViewController) |
let presentedViewController = controller.presentedViewController |
presentedViewController.navigationItem.rightBarButtonItem = |
UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneAction)) |
return navController |
} |
// MARK: Editor |
func setupEditingAndPlayback() { |
guard let clip1Path = Bundle.main.path(forResource: "sample_clip1", ofType: "m4v") else { |
print("Failed to get clip1 from main bundle!"); return |
} |
let asset1 = AVURLAsset(url: URL(fileURLWithPath: clip1Path)) |
guard let clip2Path = Bundle.main.path(forResource: "sample_clip2", ofType: "mov") else { |
print("Failed to get clip2 from main bundle!"); return |
} |
let asset2 = AVURLAsset(url: URL(fileURLWithPath: clip2Path)) |
let dispatchGroup = DispatchGroup() |
let assetKeysToLoadAndTest: [String] = ["tracks", "duration", "composable"] |
loadAsset(asset1, withKeys:assetKeysToLoadAndTest, usingDispatchGroup:dispatchGroup) |
loadAsset(asset2, withKeys:assetKeysToLoadAndTest, usingDispatchGroup:dispatchGroup) |
dispatchGroup.notify(queue: DispatchQueue.main, execute: { |
// Wait until all the above clips have loaded before synchronizing with the editor. |
if self.clips.count > 1 { |
self.synchronizeWithEditor() |
} |
}) |
} |
func loadAsset(_ asset: AVAsset, withKeys assetKeysToLoad: [String], |
usingDispatchGroup dispatchGroup: DispatchGroup) { |
dispatchGroup.enter() |
asset.loadValuesAsynchronously(forKeys: assetKeysToLoad, completionHandler: { |
// First test whether the values of each of the keys we need have been successfully loaded. |
for item in assetKeysToLoad { |
var error: NSError? |
if asset.statusOfValue(forKey: item, error: &error) == AVKeyValueStatus.failed { |
print("Key value loading failed for key:\(item) with error:\(error!)") |
dispatchGroup.leave() |
return |
} |
} |
if asset.isComposable == false { |
print("Asset is not composable.") |
dispatchGroup.leave() |
return |
} |
self.clips.append(asset) |
// This code assumes that both assets are atleast 5 seconds long. |
self.clipTimeRanges.append(CMTimeRange(start: CMTimeMakeWithSeconds(0, 1), |
duration: CMTimeMakeWithSeconds(5, 1))) |
dispatchGroup.leave() |
}) |
} |
func synchronizePlayerWithEditor() { |
guard let playerItem = editor.playerItem() else { |
print("APLSimpleEditor has no playerItem.") |
return |
} |
if self.playerItem != playerItem { |
if let currentPlayerItem = self.playerItem { |
currentPlayerItem.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.status)) |
NotificationCenter.default.removeObserver(self, name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: currentPlayerItem) |
} |
self.playerItem = playerItem |
self.playerItem!.seekingWaitsForVideoCompositionRendering = true |
// Observe the player item "status" key to determine when it is ready to play. |
self.playerItem!.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), |
options: [.new], |
context: &playerStatusObservationContext) |
/* |
When the player item has played to its end time we'll set a flag |
so that the next time the play method is issued the player will |
be reset to time zero first. |
*/ |
NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidReachEnd(_:)), |
name: .AVPlayerItemDidPlayToEndTime, object: self.playerItem) |
self.player.replaceCurrentItem(with: playerItem) |
} |
} |
func synchronizeWithEditor() { |
// Clips. |
synchronizeEditorClipsWithOurClips() |
synchronizeEditorClipTimeRangesWithOurClipTimeRanges() |
// Transitions. |
if transitionsEnabled { |
self.editor.transitionDuration = CMTimeMakeWithSeconds(transitionDuration, 600) |
self.editor.transitionType = transitionType |
} else { |
self.editor.transitionDuration = kCMTimeInvalid |
} |
// Build AVComposition and AVVideoComposition objects for playback. |
self.editor.buildCompositionObjectsForPlayback(true, overwriteExistingObjects: true) |
synchronizePlayerWithEditor() |
} |
func synchronizeEditorClipsWithOurClips() { |
var validClips = [AVAsset]() |
for item in self.clips { |
validClips.append(item) |
} |
guard let clips = validClips as? [AVURLAsset] else { return } |
editor.clips = clips |
} |
func synchronizeEditorClipTimeRangesWithOurClipTimeRanges() { |
var validClipTimeRanges = [CMTimeRange]() |
for item in self.clipTimeRanges { |
validClipTimeRanges.append(item) |
} |
self.editor.clipTimeRanges = validClipTimeRanges |
} |
// MARK: Utilities |
// Update the scrubber and time label periodically. |
func addTimeObserverToPlayer() { |
guard let currentPlayerItem = self.player.currentItem else { return } |
if currentPlayerItem.status != .readyToPlay { return } |
let duration: Double = CMTimeGetSeconds(playerItemDuration()) |
if __inline_isfinited(duration) != 0 { |
let width = (Double(scrubber.bounds.width)) |
var interval = 0.5 * duration.divided(by: width) |
// The time label needs to update at least once per second. |
if interval > 1.0 { |
interval = 1.0 |
} |
let updateTime = CMTimeMakeWithSeconds(interval, Int32(NSEC_PER_SEC)) |
timeObserver = |
self.player.addPeriodicTimeObserver(forInterval: updateTime, queue: DispatchQueue.main, |
using: { [unowned self] _ in |
self.updateScrubber() |
self.updateTimeLabel() |
}) |
} |
} |
func removeTimeObserverFromPlayer() { |
guard let timeObserver = self.timeObserver else { return } |
player.removeTimeObserver(timeObserver) |
self.timeObserver = nil |
} |
func playerItemDuration() -> CMTime { |
var itemDuration = kCMTimeInvalid |
guard let playerItem = self.player.currentItem else { return itemDuration } |
if playerItem.status == AVPlayerItemStatus.readyToPlay { |
itemDuration = playerItem.duration |
} |
return itemDuration |
} |
// MARK: - KVO Observation |
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, |
context: UnsafeMutableRawPointer?) { |
// Make sure the this KVO callback was intended for this view controller. |
if context == &playerRateObservationContext { |
guard let newRate = change?[.newKey] as? Float, |
let oldRate = change?[.oldKey] as? Float else { return } |
if newRate != oldRate { |
playing = (newRate != 0) || (playRateToRestore != 0) |
updatePlayPauseButton() |
updateScrubber() |
updateTimeLabel() |
} |
} else if context == &playerStatusObservationContext { |
guard let playerItem = object as? AVPlayerItem else { return } |
if playerItem.status == .readyToPlay { |
/* |
Once the AVPlayerItem becomes ready to play, i.e. |
playerItem.status == AVPlayerItemStatusReadyToPlay, |
its duration can be fetched from the item. |
*/ |
addTimeObserverToPlayer() |
} else if playerItem.status == .failed { |
if let error = playerItem.error { |
reportError(error) |
} |
} |
} else { |
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) |
} |
} |
func updatePlayPauseButton() { |
let style = playing ? UIBarButtonSystemItem.pause : UIBarButtonSystemItem.play |
let newPlayPauseButton = UIBarButtonItem(barButtonSystemItem: style, target: self, |
action: #selector(togglePlayPause(_:))) |
guard var items = self.toolbar?.items else { |
return |
} |
if let indexOfFirstSuchElement = items.index(where: { $0 == playPauseButton }) { |
items[indexOfFirstSuchElement] = newPlayPauseButton |
playPauseButton = newPlayPauseButton |
} |
self.toolbar.setItems(items, animated: false) |
} |
func updateTimeLabel() { |
var seconds = CMTimeGetSeconds(player.currentTime()) |
if __inline_isfinited(seconds) <= 0 { |
seconds = 0 |
} |
let formatter = DateComponentsFormatter() |
formatter.allowedUnits = [.minute, .second] |
formatter.unitsStyle = .positional |
formatter.zeroFormattingBehavior = .pad |
guard let formattedString = formatter.string(from: TimeInterval(seconds)) else { return } |
currentTimeLabel.text = formattedString |
} |
func updateScrubber() { |
let duration = CMTimeGetSeconds(playerItemDuration()) |
if __inline_isfinited(duration) != 0 { |
let time = CMTimeGetSeconds(player.currentTime()) |
scrubber.setValue(Float(time.divided(by: duration)), animated: true) |
} else { |
scrubber.setValue(0, animated: true) |
} |
} |
func updateProgress(_ timer: Timer) { |
guard let session = timer.userInfo as? AVAssetExportSession else { return } |
if session.status == AVAssetExportSessionStatus.exporting { |
exportProgressView?.progress = session.progress |
} |
} |
func reportError(_ error: Error) { |
DispatchQueue.main.async { |
let alertController = UIAlertController(title: error.localizedDescription, |
message: error.localizedDescription, preferredStyle: .alert) |
alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Alert OK button"), |
style: .cancel, handler: nil)) |
self.present(alertController, animated: true, completion: nil) |
} |
} |
// MARK: Playback |
@IBAction func togglePlayPause(_ sender: AnyObject) { |
playing = !playing |
if playing { |
if seekToZeroBeforePlaying { |
player.seek(to: kCMTimeZero) |
seekToZeroBeforePlaying = false |
updateScrubber() |
} |
player.play() |
} else { |
player.pause() |
} |
} |
@IBAction func beginScrubbing(_ sender: AnyObject) { |
seekToZeroBeforePlaying = false |
playRateToRestore = player.rate |
player.rate = 0 |
removeTimeObserverFromPlayer() |
} |
@IBAction func scrub(_ sender: AnyObject) { |
lastScrubSliderValue = scrubber.value |
if !scrubInFlight { |
scrubToSliderValue(lastScrubSliderValue) |
} |
} |
func scrubToSliderValue(_ sliderValue: Float) { |
let duration: Float64 = CMTimeGetSeconds(playerItemDuration()) |
if __inline_isfinited(duration) > 0 { |
guard let scrubber = self.scrubber else { |
return |
} |
let width = scrubber.bounds.width |
let time = duration.multiplied(by: Float64(sliderValue)) |
let tolerance = 1 * duration.divided(by: Float64(width)) |
scrubInFlight = true |
player.seek(to: CMTimeMakeWithSeconds(time, Int32(NSEC_PER_SEC)), |
toleranceBefore: CMTimeMakeWithSeconds(tolerance, Int32(NSEC_PER_SEC)), |
toleranceAfter: CMTimeMakeWithSeconds(tolerance, Int32(NSEC_PER_SEC)), |
completionHandler: { (_) in |
self.scrubInFlight = false |
self.updateTimeLabel() |
}) |
} |
} |
@IBAction func endScrubbing(_ sender: AnyObject) { |
if scrubInFlight { |
scrubToSliderValue(lastScrubSliderValue) |
} |
addTimeObserverToPlayer() |
player.rate = playRateToRestore |
playRateToRestore = 0 |
} |
// Called when the player item has played to its end time. |
func playerItemDidReachEnd(_ notification: Notification) { |
// After the movie has played to its end time, seek back to time zero to play it again. |
seekToZeroBeforePlaying = true |
} |
@IBAction func handleTapGesture(_ tapGestureRecognizer: UITapGestureRecognizer) { |
toolbar.isHidden = !toolbar.isHidden |
currentTimeLabel.isHidden = !currentTimeLabel.isHidden |
} |
// MARK: Export |
@IBAction func exportToMovie(_ sender: AnyObject) { |
exportProgressView.isHidden = false |
player.pause() |
playPauseButton.isEnabled = false |
transitionButton.isEnabled = false |
scrubber.isEnabled = false |
exportButton.isEnabled = false |
editor.buildCompositionObjectsForPlayback(false, overwriteExistingObjects: false) |
// Get the assets to be used in the export operation. |
guard let theComposition = editor.composition, let theVideoComposition = editor.videoComposition else { return } |
// Use the APLExport object to perform the actual transcode of the assets. |
guard let exporter = APLExport(theComposition, videoComposition: theVideoComposition, |
presetName: AVAssetExportPresetMediumQuality, controller: self) else { return } |
// Transcode the assets. |
exporter.export() |
} |
func exportCompleted() { |
exportProgressView.isHidden = true |
currentTimeLabel.isHidden = false |
// Reset progress bar now that export has completed. |
exportProgressView.progress = 1 |
player.play() |
playPauseButton.isEnabled = true |
transitionButton.isEnabled = true |
scrubber.isEnabled = true |
exportButton.isEnabled = true |
} |
// MARK: Transitions |
@IBAction func selectTransition(_ sender: AnyObject) { |
// Show the view controller as a popover (iPad) or as a modal view controller (iPhone / iPhone Plus). |
guard let contentVC = |
self.storyboard?.instantiateViewController(withIdentifier: "SetTransition") else { return } |
contentVC.edgesForExtendedLayout = UIRectEdge.all |
contentVC.modalPresentationStyle = UIModalPresentationStyle.popover |
guard let presentationController = contentVC.popoverPresentationController else { return } |
// Display popover from the UIButton (sender) as the anchor. |
presentationController.sourceRect = sender.frame |
guard let button = sender as? UIButton else { return } |
presentationController.sourceView = button.superview |
presentationController.permittedArrowDirections = .any |
/* |
Present content view controller in a compact screen so that it can be dismissed as a full screen |
view controller. |
*/ |
presentationController.delegate = self |
// Present the view controller modally. |
self.present(contentVC, animated: false) { |
// Done. |
} |
} |
// MARK: Gesture recognizer delegate |
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { |
guard let touchView = touch.view else { return false } |
// Ignore touch on toolbar. |
if touchView != playerView { return false } |
return true |
} |
// MARK: APLTransitionTypePickerDelegate |
func transitionTypeController(_ controller: APLTransitionTypeController, transitionType: Int) { |
self.transitionType = transitionType |
// Let the editor know of the change in transition type. |
synchronizeWithEditor() |
} |
func transitionTypeControllerDismiss() { |
self.dismiss(animated: true, completion: nil) |
} |
} |
Copyright © 2017 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2017-08-17