TM-SGNL-iOS/SignalUI/VideoEditor/VideoEditorView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

297 lines
10 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(decryptedFileUrl: 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.ows_contentEdgeInsets = UIEdgeInsets(margin: 26)
// play button must be slightly off-center to appear centered
playButton.ows_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
Task {
do {
try await self.saveVideo(self.model)
modalVC.dismiss()
} catch {
Logger.error("Failed to save video: \(error)")
modalVC.dismiss {
OWSActionSheets.showErrorAlert(message: OWSLocalizedString("ERROR_COULD_NOT_SAVE_VIDEO", comment: "Error indicating that 'save video' failed."))
}
}
}
}
}
}
nonisolated private func saveVideo(_ model: VideoEditorModel) async throws {
// 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
}
let renderedVideoPath = if model.needsRender {
try await model.ensureCurrentRender().render().getResultPath()
} else {
// Nothing to render, just use the original file
model.srcVideoPath
}
let copy = try createCopyOfFile(renderedVideoPath)
defer { OWSFileSystem.deleteFileIfExists(copy) }
try await PHPhotoLibrary.shared().performChanges {
let url = URL(fileURLWithPath: copy)
PHAssetCreationRequest.creationRequestForAssetFromVideo(atFileURL: url)
}
}
}
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)
}
}