Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/VideoEditor/VideoEditorView.swift
1 views
//
// 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
                }
            }
        }
    }
}

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