// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import Foundation import UIKit import AVFoundation protocol AttachmentPrepViewControllerDelegate: class { func prepViewControllerUpdateNavigationBar() func prepViewControllerUpdateControls() } // MARK: - public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarDelegate, OWSVideoPlayerDelegate { // We sometimes shrink the attachment view so that it remains somewhat visible // when the keyboard is presented. public enum AttachmentViewScale { case fullsize, compact } // MARK: - Properties weak var prepDelegate: AttachmentPrepViewControllerDelegate? let attachmentItem: SignalAttachmentItem var attachment: SignalAttachment { return attachmentItem.attachment } private var videoPlayer: OWSVideoPlayer? private(set) var mediaMessageView: MediaMessageView! private(set) var scrollView: UIScrollView! private(set) var contentContainer: UIView! private(set) var playVideoButton: UIView? private var imageEditorView: ImageEditorView? public var shouldHideControls: Bool { guard let imageEditorView = imageEditorView else { return false } return imageEditorView.shouldHideControls } // MARK: - Initializers init(attachmentItem: SignalAttachmentItem) { self.attachmentItem = attachmentItem super.init(nibName: nil, bundle: nil) assert(!attachment.hasError) } public required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - View Lifecycle override public func loadView() { self.view = UIView() self.mediaMessageView = MediaMessageView(attachment: attachment, mode: .attachmentApproval) // Anything that should be shrunk when user pops keyboard lives in the contentContainer. let contentContainer = UIView() self.contentContainer = contentContainer view.addSubview(contentContainer) contentContainer.autoPinEdgesToSuperviewEdges() // Scroll View - used to zoom/pan on images and video scrollView = UIScrollView() contentContainer.addSubview(scrollView) scrollView.delegate = self scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false // Panning should stop pretty soon after the user stops scrolling scrollView.decelerationRate = UIScrollView.DecelerationRate.fast // We want scroll view content up and behind the system status bar content // but we want other content (e.g. bar buttons) to respect the top layout guide. self.automaticallyAdjustsScrollViewInsets = false scrollView.autoPinEdgesToSuperviewEdges() let backgroundColor = UIColor.black self.view.backgroundColor = backgroundColor // Create full screen container view so the scrollView // can compute an appropriate content size in which to center // our media view. let containerView = UIView.container() scrollView.addSubview(containerView) containerView.autoPinEdgesToSuperviewEdges() containerView.autoMatch(.height, to: .height, of: self.view) containerView.autoMatch(.width, to: .width, of: self.view) containerView.addSubview(mediaMessageView) mediaMessageView.autoPinEdgesToSuperviewEdges() if let imageEditorModel = attachmentItem.imageEditorModel { let imageEditorView = ImageEditorView(model: imageEditorModel, delegate: self) if imageEditorView.configureSubviews() { self.imageEditorView = imageEditorView mediaMessageView.isHidden = true view.addSubview(imageEditorView) imageEditorView.autoPinEdgesToSuperviewEdges() imageEditorUpdateNavigationBar() } } // Hide the play button embedded in the MediaView and replace it with our own. // This allows us to zoom in on the media view without zooming in on the button if attachment.isVideo { guard let videoURL = attachment.dataUrl else { owsFailDebug("Missing videoURL") return } let player = OWSVideoPlayer(url: videoURL) self.videoPlayer = player player.delegate = self let playerView = VideoPlayerView() playerView.player = player.avPlayer self.mediaMessageView.addSubview(playerView) playerView.autoPinEdgesToSuperviewEdges() let pauseGesture = UITapGestureRecognizer(target: self, action: #selector(didTapPlayerView(_:))) playerView.addGestureRecognizer(pauseGesture) let progressBar = PlayerProgressBar() progressBar.player = player.avPlayer progressBar.delegate = self // we don't want the progress bar to zoom during "pinch-to-zoom" // but we do want it to shrink with the media content when the user // pops the keyboard. contentContainer.addSubview(progressBar) progressBar.autoPin(toTopLayoutGuideOf: self, withInset: 0) progressBar.autoPinWidthToSuperview() progressBar.autoSetDimension(.height, toSize: 44) self.mediaMessageView.videoPlayButton?.isHidden = true let playButton = UIButton() self.playVideoButton = playButton playButton.accessibilityLabel = NSLocalizedString("PLAY_BUTTON_ACCESSABILITY_LABEL", comment: "Accessibility label for button to start media playback") playButton.setBackgroundImage(#imageLiteral(resourceName: "play_button"), for: .normal) playButton.contentMode = .scaleAspectFit let playButtonWidth = ScaleFromIPhone5(70) playButton.autoSetDimensions(to: CGSize(width: playButtonWidth, height: playButtonWidth)) self.contentContainer.addSubview(playButton) playButton.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside) playButton.autoCenterInSuperview() } } override public func viewWillAppear(_ animated: Bool) { Logger.debug("") super.viewWillAppear(animated) prepDelegate?.prepViewControllerUpdateNavigationBar() prepDelegate?.prepViewControllerUpdateControls() } override public func viewDidAppear(_ animated: Bool) { Logger.debug("") super.viewDidAppear(animated) prepDelegate?.prepViewControllerUpdateNavigationBar() prepDelegate?.prepViewControllerUpdateControls() } override public func viewWillLayoutSubviews() { Logger.debug("") super.viewWillLayoutSubviews() // e.g. if flipping to/from landscape updateMinZoomScaleForSize(view.bounds.size) ensureAttachmentViewScale(animated: false) } // MARK: - Navigation Bar public func navigationBarItems() -> [UIView] { guard let imageEditorView = imageEditorView else { return [] } return imageEditorView.navigationBarItems() } // MARK: - Event Handlers @objc public func didTapPlayerView(_ gestureRecognizer: UIGestureRecognizer) { assert(self.videoPlayer != nil) self.pauseVideo() } @objc public func playButtonTapped() { self.playVideo() } // MARK: - Video private func playVideo() { Logger.info("") guard let videoPlayer = self.videoPlayer else { owsFailDebug("video player was unexpectedly nil") return } guard let playVideoButton = self.playVideoButton else { owsFailDebug("playVideoButton was unexpectedly nil") return } UIView.animate(withDuration: 0.1) { playVideoButton.alpha = 0.0 } videoPlayer.play() } private func pauseVideo() { guard let videoPlayer = self.videoPlayer else { owsFailDebug("video player was unexpectedly nil") return } videoPlayer.pause() guard let playVideoButton = self.playVideoButton else { owsFailDebug("playVideoButton was unexpectedly nil") return } UIView.animate(withDuration: 0.1) { playVideoButton.alpha = 1.0 } } @objc public func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { guard let playVideoButton = self.playVideoButton else { owsFailDebug("playVideoButton was unexpectedly nil") return } UIView.animate(withDuration: 0.1) { playVideoButton.alpha = 1.0 } } public func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) { guard let videoPlayer = self.videoPlayer else { owsFailDebug("video player was unexpectedly nil") return } videoPlayer.pause() } public func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) { guard let videoPlayer = self.videoPlayer else { owsFailDebug("video player was unexpectedly nil") return } videoPlayer.seek(to: time) } public func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) { guard let videoPlayer = self.videoPlayer else { owsFailDebug("video player was unexpectedly nil") return } videoPlayer.seek(to: time) if (shouldResumePlayback) { videoPlayer.play() } } // MARK: - Helpers var isZoomable: Bool { return attachment.isImage || attachment.isVideo } func zoomOut(animated: Bool) { if self.scrollView.zoomScale != self.scrollView.minimumZoomScale { self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated) } } // When the keyboard is popped, it can obscure the attachment view. // so we sometimes allow resizing the attachment. var shouldAllowAttachmentViewResizing: Bool = true var attachmentViewScale: AttachmentViewScale = .fullsize public func setAttachmentViewScale(_ attachmentViewScale: AttachmentViewScale, animated: Bool) { self.attachmentViewScale = attachmentViewScale ensureAttachmentViewScale(animated: animated) } func ensureAttachmentViewScale(animated: Bool) { let animationDuration = animated ? 0.2 : 0 guard shouldAllowAttachmentViewResizing else { if self.contentContainer.transform != CGAffineTransform.identity { UIView.animate(withDuration: animationDuration) { self.contentContainer.transform = CGAffineTransform.identity } } return } switch attachmentViewScale { case .fullsize: guard self.contentContainer.transform != .identity else { return } UIView.animate(withDuration: animationDuration) { self.contentContainer.transform = CGAffineTransform.identity } case .compact: guard self.contentContainer.transform == .identity else { return } UIView.animate(withDuration: animationDuration) { let kScaleFactor: CGFloat = 0.7 let scale = CGAffineTransform(scaleX: kScaleFactor, y: kScaleFactor) let originalHeight = self.scrollView.bounds.size.height // Position the new scaled item to be centered with respect // to it's new size. let heightDelta = originalHeight * (1 - kScaleFactor) let translate = CGAffineTransform(translationX: 0, y: -heightDelta / 2) self.contentContainer.transform = scale.concatenating(translate) } } } } // MARK: - extension AttachmentPrepViewController: UIScrollViewDelegate { public func viewForZooming(in scrollView: UIScrollView) -> UIView? { if isZoomable { return mediaMessageView } else { // don't zoom for audio or generic attachments. return nil } } fileprivate func updateMinZoomScaleForSize(_ size: CGSize) { Logger.debug("") // Ensure bounds have been computed mediaMessageView.layoutIfNeeded() guard mediaMessageView.bounds.width > 0, mediaMessageView.bounds.height > 0 else { Logger.warn("bad bounds") return } let widthScale = size.width / mediaMessageView.bounds.width let heightScale = size.height / mediaMessageView.bounds.height let minScale = min(widthScale, heightScale) scrollView.maximumZoomScale = minScale * 5.0 scrollView.minimumZoomScale = minScale scrollView.zoomScale = minScale } // Keep the media view centered within the scroll view as you zoom public func scrollViewDidZoom(_ scrollView: UIScrollView) { // The scroll view has zoomed, so you need to re-center the contents let scrollViewSize = self.scrollViewVisibleSize // First assume that mediaMessageView center coincides with the contents center // This is correct when the mediaMessageView is bigger than scrollView due to zoom var contentCenter = CGPoint(x: (scrollView.contentSize.width / 2), y: (scrollView.contentSize.height / 2)) let scrollViewCenter = self.scrollViewCenter // if mediaMessageView is smaller than the scrollView visible size - fix the content center accordingly if self.scrollView.contentSize.width < scrollViewSize.width { contentCenter.x = scrollViewCenter.x } if self.scrollView.contentSize.height < scrollViewSize.height { contentCenter.y = scrollViewCenter.y } self.mediaMessageView.center = contentCenter } // return the scroll view center private var scrollViewCenter: CGPoint { let size = scrollViewVisibleSize return CGPoint(x: (size.width / 2), y: (size.height / 2)) } // Return scrollview size without the area overlapping with tab and nav bar. private var scrollViewVisibleSize: CGSize { let contentInset = scrollView.contentInset let scrollViewSize = scrollView.bounds.standardized.size let width = scrollViewSize.width - (contentInset.left + contentInset.right) let height = scrollViewSize.height - (contentInset.top + contentInset.bottom) return CGSize(width: width, height: height) } } // MARK: - extension AttachmentPrepViewController: ImageEditorViewDelegate { public func imageEditor(presentFullScreenView viewController: UIViewController, isTransparent: Bool) { let navigationController = OWSNavigationController(rootViewController: viewController) navigationController.modalPresentationStyle = (isTransparent ? .overFullScreen : .fullScreen) navigationController.ows_prefersStatusBarHidden = true if let navigationBar = navigationController.navigationBar as? OWSNavigationBar { navigationBar.overrideTheme(type: .clear) } else { owsFailDebug("navigationBar was nil or unexpected class") } self.present(navigationController, animated: false) { // Do nothing. } } public func imageEditorUpdateNavigationBar() { prepDelegate?.prepViewControllerUpdateNavigationBar() } public func imageEditorUpdateControls() { prepDelegate?.prepViewControllerUpdateControls() } }