This included: - Removing unavailable inits wholesale if no longer `required` - Marking a few classes `final` so they could continue using `Self(...)` rather than `OWSWhatever(...)`
314 lines
11 KiB
Swift
314 lines
11 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVFoundation
|
|
import Photos
|
|
import SignalServiceKit
|
|
import UIKit
|
|
|
|
protocol VideoEditorViewDelegate: AnyObject {
|
|
func videoEditorViewPlaybackTimeDidChange(_ videoEditorView: VideoEditorView)
|
|
}
|
|
|
|
protocol VideoEditorViewControllerProviding: AnyObject {
|
|
func viewController(forVideoEditorView videoEditorView: VideoEditorView) -> UIViewController
|
|
}
|
|
|
|
// A view for editing outgoing video attachments.
|
|
class VideoEditorView: UIView {
|
|
|
|
weak var delegate: VideoEditorViewDelegate?
|
|
weak var dataSource: VideoEditorDataSource?
|
|
weak var viewControllerProvider: VideoEditorViewControllerProviding?
|
|
|
|
private let model: VideoEditorModel
|
|
|
|
var isTrimmingVideo: Bool = false
|
|
|
|
private lazy var playerView: VideoPlayerView = {
|
|
let playerView = VideoPlayerView()
|
|
playerView.videoPlayer = VideoPlayer(url: URL(fileURLWithPath: model.srcVideoPath))
|
|
playerView.delegate = self
|
|
return playerView
|
|
}()
|
|
private lazy var playButton: UIButton = {
|
|
let playButton = RoundMediaButton(image: UIImage(imageLiteralResourceName: "play-fill-32"), backgroundStyle: .blur)
|
|
playButton.accessibilityLabel = OWSLocalizedString("PLAY_BUTTON_ACCESSABILITY_LABEL",
|
|
comment: "Accessibility label for button to start media playback")
|
|
// this makes the blur circle 72 pts in diameter
|
|
playButton.contentEdgeInsets = UIEdgeInsets(margin: 26)
|
|
// play button must be slightly off-center to appear centered
|
|
playButton.imageEdgeInsets = UIEdgeInsets(top: 0, leading: 3, bottom: 0, trailing: -3)
|
|
playButton.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
|
|
return playButton
|
|
}()
|
|
|
|
init(model: VideoEditorModel,
|
|
delegate: VideoEditorViewDelegate,
|
|
dataSource: VideoEditorDataSource,
|
|
viewControllerProvider: VideoEditorViewControllerProviding) {
|
|
|
|
self.model = model
|
|
self.delegate = delegate
|
|
self.dataSource = dataSource
|
|
self.viewControllerProvider = viewControllerProvider
|
|
|
|
super.init(frame: .zero)
|
|
|
|
backgroundColor = .black
|
|
}
|
|
|
|
@available(*, unavailable, message: "use other init() instead.")
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: - Views
|
|
|
|
func configureSubviews() {
|
|
let aspectRatio: CGFloat = model.displaySize.width / model.displaySize.height
|
|
addSubviewWithScaleAspectFitLayout(view: playerView, aspectRatio: aspectRatio)
|
|
playerView.setContentHuggingLow()
|
|
playerView.setCompressionResistanceLow()
|
|
playerView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapPlayerView(_:))))
|
|
|
|
addSubview(playButton)
|
|
playButton.autoAlignAxis(.horizontal, toSameAxisOf: playerView)
|
|
playButton.autoAlignAxis(.vertical, toSameAxisOf: playerView)
|
|
|
|
ensureSeekReflectsTrimming()
|
|
}
|
|
|
|
private func addSubviewWithScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) {
|
|
addSubview(view)
|
|
// This emulates the behavior of contentMode = .scaleAspectFit using iOS auto layout constraints.
|
|
addConstraints({
|
|
let constraints = [ view.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
view.centerYAnchor.constraint(equalTo: centerYAnchor) ]
|
|
constraints.forEach { $0.priority = .defaultHigh - 100 }
|
|
return constraints
|
|
}())
|
|
addConstraint(view.topAnchor.constraint(greaterThanOrEqualTo: topAnchor))
|
|
view.autoPin(toAspectRatio: aspectRatio)
|
|
view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
|
|
view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)
|
|
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
|
|
view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .equal)
|
|
view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .equal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Handlers
|
|
|
|
@objc
|
|
private func didTapPlayerView(_ gestureRecognizer: UIGestureRecognizer) {
|
|
togglePlayback()
|
|
}
|
|
|
|
@objc
|
|
private func playButtonTapped() {
|
|
togglePlayback()
|
|
}
|
|
|
|
private func togglePlayback() {
|
|
if isPlaying {
|
|
pauseVideo()
|
|
} else {
|
|
playVideo()
|
|
}
|
|
}
|
|
|
|
// MARK: - Video
|
|
|
|
var trimmedStartSeconds: TimeInterval {
|
|
return model.trimmedStartSeconds
|
|
}
|
|
|
|
var trimmedEndSeconds: TimeInterval {
|
|
return model.trimmedEndSeconds
|
|
}
|
|
|
|
@discardableResult
|
|
func pauseIfPlaying() -> Bool {
|
|
guard playerView.isPlaying else {
|
|
return false
|
|
}
|
|
playerView.pause()
|
|
return true
|
|
}
|
|
|
|
func seek(toSeconds seconds: TimeInterval) {
|
|
playerView.seek(to: CMTime(seconds: seconds, preferredTimescale: model.untrimmedDuration.timescale))
|
|
}
|
|
|
|
func playVideo() {
|
|
if ensureSeekReflectsTrimming() {
|
|
// If this delay isn't induced VideoPlayer.play() would reset
|
|
// current position to 0, likely because AVPlayer hasn't yet
|
|
// had a chance to update its currentTime.
|
|
DispatchQueue.main.async {
|
|
self.playerView.play()
|
|
}
|
|
} else {
|
|
playerView.play()
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func ensureSeekReflectsTrimming() -> Bool {
|
|
var shouldSeekToStart = false
|
|
if currentTimeSeconds < trimmedStartSeconds {
|
|
// If playback cursor is before the start of the clipping,
|
|
// restart playback.
|
|
shouldSeekToStart = true
|
|
} else {
|
|
// If playback cursor is very near the end of the clipping,
|
|
// restart playback.
|
|
let toleranceSeconds: TimeInterval = 0.1
|
|
if currentTimeSeconds > trimmedEndSeconds - toleranceSeconds {
|
|
shouldSeekToStart = true
|
|
}
|
|
}
|
|
|
|
if shouldSeekToStart {
|
|
seek(toSeconds: trimmedStartSeconds)
|
|
}
|
|
return shouldSeekToStart
|
|
}
|
|
|
|
private func pauseVideo() {
|
|
playerView.pause()
|
|
}
|
|
|
|
private var isShowingPlayButton = true
|
|
|
|
private func updateControls() {
|
|
AssertIsOnMainThread()
|
|
|
|
if isPlaying {
|
|
if isShowingPlayButton {
|
|
isShowingPlayButton = false
|
|
UIView.animate(withDuration: 0.1) {
|
|
self.playButton.alpha = 0.0
|
|
}
|
|
}
|
|
} else {
|
|
if !isShowingPlayButton {
|
|
isShowingPlayButton = true
|
|
UIView.animate(withDuration: 0.1) {
|
|
self.playButton.alpha = 1.0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc
|
|
private func didTapSave(sender: UIButton) {
|
|
playerView.pause()
|
|
|
|
guard let viewControllerProvider = viewControllerProvider else {
|
|
owsFailDebug("Missing viewControllerProvider.")
|
|
return
|
|
}
|
|
let viewController = viewControllerProvider.viewController(forVideoEditorView: self)
|
|
viewController.ows_askForMediaLibraryPermissions { isGranted in
|
|
AssertIsOnMainThread()
|
|
|
|
guard isGranted else { return }
|
|
|
|
ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { modalVC in
|
|
firstly {
|
|
self.saveVideoPromise()
|
|
}.done(on: DispatchQueue.main) {
|
|
modalVC.dismiss()
|
|
}.catch { _ in
|
|
modalVC.dismiss {
|
|
OWSActionSheets.showErrorAlert(message: OWSLocalizedString("ERROR_COULD_NOT_SAVE_VIDEO", comment: "Error indicating that 'save video' failed."))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveVideoPromise() -> Promise<Void> {
|
|
// Creates a copy of a file in a new temporary path
|
|
// The file path returned in a Result is guaranteed valid for the Result's lifetime
|
|
// Making a copy protects us from any modifications to a file we don't own
|
|
func createCopyOfFile(_ path: String) throws -> String {
|
|
guard let fileExtension = path.fileExtension else {
|
|
throw OWSAssertionError("Missing fileExtension.")
|
|
}
|
|
let dstPath = OWSFileSystem.temporaryFilePath(fileExtension: fileExtension)
|
|
try FileManager.default.copyItem(atPath: path, toPath: dstPath)
|
|
return dstPath
|
|
}
|
|
|
|
return firstly(on: DispatchQueue.global()) { () -> Promise<String> in
|
|
guard self.model.needsRender else {
|
|
// Nothing to render, just use the original file
|
|
let copy = try createCopyOfFile(self.model.srcVideoPath)
|
|
return Promise.value(copy)
|
|
}
|
|
|
|
return self.model.ensureCurrentRender().result.map(on: DispatchQueue.global()) { result in
|
|
try createCopyOfFile(result.getResultPath())
|
|
}
|
|
|
|
}.then(on: DispatchQueue.global()) { (videoFilePath: String) -> Promise<Void> in
|
|
Promise { future in
|
|
let videoUrl = URL(fileURLWithPath: videoFilePath)
|
|
|
|
PHPhotoLibrary.shared().performChanges {
|
|
PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: videoUrl)
|
|
} completionHandler: { (didSucceed, error) in
|
|
OWSFileSystem.deleteFileIfExists(videoFilePath)
|
|
|
|
if let error = error {
|
|
future.reject(error)
|
|
return
|
|
}
|
|
guard didSucceed else {
|
|
future.reject(OWSAssertionError("Video export failed."))
|
|
return
|
|
}
|
|
future.resolve()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VideoEditorView: VideoPlaybackState {
|
|
|
|
var isPlaying: Bool { playerView.isPlaying }
|
|
|
|
var currentTimeSeconds: TimeInterval { playerView.currentTimeSeconds }
|
|
}
|
|
|
|
extension VideoEditorView: VideoPlayerViewDelegate {
|
|
|
|
func videoPlayerViewStatusDidChange(_ view: VideoPlayerView) {
|
|
updateControls()
|
|
}
|
|
|
|
func videoPlayerViewPlaybackTimeDidChange(_ view: VideoPlayerView) {
|
|
// Trimming the video also changes current playback position
|
|
// and we don't need the code below to be executed when that happens.
|
|
guard !isTrimmingVideo else {
|
|
return
|
|
}
|
|
|
|
// Prevent playback past the end of the trimming.
|
|
guard currentTimeSeconds <= trimmedEndSeconds else {
|
|
playerView.stop()
|
|
return
|
|
}
|
|
|
|
delegate?.videoEditorViewPlaybackTimeDidChange(self)
|
|
}
|
|
}
|