// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import YYImage import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit public enum MediaGalleryOption { case sliderEnabled case showAllMediaButton } class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate { public let galleryItem: MediaGalleryViewModel.Item public weak var delegate: MediaDetailViewControllerDelegate? private var image: UIImage? // MARK: - UI private var mediaViewBottomConstraint: NSLayoutConstraint? private var mediaViewLeadingConstraint: NSLayoutConstraint? private var mediaViewTopConstraint: NSLayoutConstraint? private var mediaViewTrailingConstraint: NSLayoutConstraint? private lazy var scrollView: UIScrollView = { let result: UIScrollView = UIScrollView() result.showsVerticalScrollIndicator = false result.showsHorizontalScrollIndicator = false result.contentInsetAdjustmentBehavior = .never result.decelerationRate = .fast result.delegate = self return result }() public var mediaView: UIView = UIView() private var playVideoButton: UIButton = UIButton() private var videoProgressBar: PlayerProgressBar = PlayerProgressBar() private var videoPlayer: OWSVideoPlayer? // MARK: - Initialization init( galleryItem: MediaGalleryViewModel.Item, delegate: MediaDetailViewControllerDelegate? = nil ) { self.galleryItem = galleryItem self.delegate = delegate super.init(nibName: nil, bundle: nil) // We cache the image data in case the attachment stream is deleted. galleryItem.attachment.thumbnail( size: .large, success: { [weak self] image, _ in // Only reload the content if the view has already loaded (if it // hasn't then it'll load with the image immediately) let updateUICallback = { self?.image = image if self?.isViewLoaded == true { self?.updateContents() self?.updateMinZoomScale() } } guard Thread.isMainThread else { DispatchQueue.main.async { updateUICallback() } return } updateUICallback() }, failure: { SNLog("Could not load media.") } ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.stopAnyVideo() } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = Colors.navigationBarBackground self.view.addSubview(scrollView) scrollView.pin(to: self.view) self.updateContents() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.resetMediaFrame() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if mediaView is YYAnimatedImageView { // Add a slight delay before starting the gif animation to prevent it from looking // buggy due to the custom transition DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in (self?.mediaView as? YYAnimatedImageView)?.startAnimating() } } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() self.updateMinZoomScale() self.centerMediaViewConstraints() } // MARK: - Functions private func updateMinZoomScale() { guard let image: UIImage = image else { self.scrollView.minimumZoomScale = 1 self.scrollView.maximumZoomScale = 1 self.scrollView.zoomScale = 1 return } let viewSize: CGSize = self.scrollView.bounds.size guard image.size.width > 0 && image.size.height > 0 else { SNLog("Invalid image dimensions (\(image.size.width), \(image.size.height))") return; } let scaleWidth: CGFloat = (viewSize.width / image.size.width) let scaleHeight: CGFloat = (viewSize.height / image.size.height) let minScale: CGFloat = min(scaleWidth, scaleHeight) if minScale != self.scrollView.minimumZoomScale { self.scrollView.minimumZoomScale = minScale self.scrollView.maximumZoomScale = (minScale * 8) self.scrollView.zoomScale = minScale } } public func zoomOut(animated: Bool) { if self.scrollView.zoomScale != self.scrollView.minimumZoomScale { self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated) } } // MARK: - Content private func updateContents() { self.mediaView.removeFromSuperview() self.playVideoButton.removeFromSuperview() self.videoProgressBar.removeFromSuperview() self.scrollView.zoomScale = 1 if self.galleryItem.attachment.isAnimated { if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath { let animatedView: YYAnimatedImageView = YYAnimatedImageView() animatedView.autoPlayAnimatedImage = false animatedView.image = YYImage(contentsOfFile: originalFilePath) self.mediaView = animatedView } else { self.mediaView = UIView() self.mediaView.backgroundColor = Colors.unimportant } } else if self.image == nil { // Still loading thumbnail. self.mediaView = UIView() self.mediaView.backgroundColor = Colors.unimportant } else if self.galleryItem.attachment.isVideo { if self.galleryItem.attachment.isValid { self.mediaView = self.buildVideoPlayerView() } else { self.mediaView = UIView() self.mediaView.backgroundColor = Colors.unimportant } } else { // Present the static image using standard UIImageView self.mediaView = UIImageView(image: self.image) } // We add these gestures to mediaView rather than // the root view so that interacting with the video player // progres bar doesn't trigger any of these gestures. self.addGestureRecognizers(to: self.mediaView) self.scrollView.addSubview(self.mediaView) self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView) self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView) self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView) self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView) self.mediaView.contentMode = .scaleAspectFit self.mediaView.isUserInteractionEnabled = true self.mediaView.clipsToBounds = true self.mediaView.layer.allowsEdgeAntialiasing = true self.mediaView.translatesAutoresizingMaskIntoConstraints = false // Use trilinear filters for better scaling quality at // some performance cost. self.mediaView.layer.minificationFilter = .trilinear self.mediaView.layer.magnificationFilter = .trilinear if self.galleryItem.attachment.isVideo { self.videoProgressBar = PlayerProgressBar() self.videoProgressBar.delegate = self self.videoProgressBar.player = self.videoPlayer?.avPlayer // We hide the progress bar until either: // 1. Video completes playing // 2. User taps the screen self.videoProgressBar.isHidden = false self.view.addSubview(self.videoProgressBar) self.videoProgressBar.autoPinWidthToSuperview() self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top) self.videoProgressBar.autoSetDimension(.height, toSize: 44) self.playVideoButton = UIButton() self.playVideoButton.contentMode = .scaleAspectFill self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal) self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside) self.view.addSubview(self.playVideoButton) self.playVideoButton.set(.width, to: 72) self.playVideoButton.set(.height, to: 72) self.playVideoButton.center(in: self.view) } } private func buildVideoPlayerView() -> UIView { guard let originalFilePath: String = self.galleryItem.attachment.originalFilePath, FileManager.default.fileExists(atPath: originalFilePath) else { owsFailDebug("Missing video file") return UIView() } self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath)) self.videoPlayer?.seek(to: .zero) self.videoPlayer?.delegate = self let imageSize: CGSize = (self.image?.size ?? .zero) let playerView: VideoPlayerView = VideoPlayerView() playerView.player = self.videoPlayer?.avPlayer NSLayoutConstraint.autoSetPriority(.defaultLow) { playerView.autoSetDimensions(to: imageSize) } return playerView } public func setShouldHideToolbars(_ shouldHideToolbars: Bool) { self.videoProgressBar.isHidden = shouldHideToolbars } private func addGestureRecognizers(to view: UIView) { let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer( target: self, action: #selector(didDoubleTapImage(_:)) ) doubleTap.numberOfTapsRequired = 2 view.addGestureRecognizer(doubleTap) let singleTap: UITapGestureRecognizer = UITapGestureRecognizer( target: self, action: #selector(didSingleTapImage(_:)) ) singleTap.require(toFail: doubleTap) view.addGestureRecognizer(singleTap) } // MARK: - Gesture Recognizers @objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) { self.delegate?.mediaDetailViewControllerDidTapMedia(self) } @objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) { guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else { // If already zoomed in at all, zoom out all the way. self.zoomOut(animated: true) return } let doubleTapZoomScale: CGFloat = 2 let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale) let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale) // Center zoom rect around tapLocation let tapLocation: CGPoint = gesture.location(in: self.scrollView) let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2) let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2) let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight) let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView) self.scrollView.zoom(to: translatedRect, animated: true) } @objc public func didPressPlayBarButton() { self.playVideo() } @objc public func didPressPauseBarButton() { self.pauseVideo() } // MARK: - UIScrollViewDelegate func viewForZooming(in scrollView: UIScrollView) -> UIView? { return self.mediaView } private func centerMediaViewConstraints() { let scrollViewSize: CGSize = self.scrollView.bounds.size let imageViewSize: CGSize = self.mediaView.frame.size // We want to modify the yOffset so the content remains centered on the screen (we can do this // by subtracting half the parentViewController's y position) // // Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either // up or down depending on which direction the partial-pixel would end up rounded to make it // align correctly let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2) let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0) let yOffset: CGFloat = ( round((scrollViewSize.height - imageViewSize.height) / 2) - (shouldRoundUp ? ceil((self.parent?.view.frame.origin.y ?? 0) / 2) : floor((self.parent?.view.frame.origin.y ?? 0) / 2) ) ) self.mediaViewTopConstraint?.constant = yOffset self.mediaViewBottomConstraint?.constant = yOffset let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2) self.mediaViewLeadingConstraint?.constant = xOffset self.mediaViewTrailingConstraint?.constant = xOffset } func scrollViewDidZoom(_ scrollView: UIScrollView) { self.centerMediaViewConstraints() self.view.layoutIfNeeded() } private func resetMediaFrame() { // HACK: Setting the frame to itself *seems* like it should be a no-op, but // it ensures the content is drawn at the right frame. In particular I was // reproducibly seeing some images squished (they were EXIF rotated, maybe // related). similar to this report: // https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect self.view.layoutIfNeeded() self.mediaView.frame = self.mediaView.frame } // MARK: - Video Playback @objc public func playVideo() { self.playVideoButton.isHidden = true self.videoPlayer?.play() self.delegate?.mediaDetailViewController(self, isPlayingVideo: true) } private func pauseVideo() { self.videoPlayer?.pause() self.delegate?.mediaDetailViewController(self, isPlayingVideo: false) } public func stopAnyVideo() { guard self.galleryItem.attachment.isVideo else { return } self.stopVideo() } private func stopVideo() { self.videoPlayer?.stop() self.playVideoButton.isHidden = false self.delegate?.mediaDetailViewController(self, isPlayingVideo: false) } // MARK: - OWSVideoPlayerDelegate func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) { self.stopVideo() } // MARK: - PlayerProgressBarDelegate func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) { self.videoPlayer?.pause() } func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) { self.videoPlayer?.seek(to: time) } func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) { self.videoPlayer?.seek(to: time) if shouldResumePlayback { self.videoPlayer?.play() } } } // MARK: - MediaDetailViewControllerDelegate protocol MediaDetailViewControllerDelegate: AnyObject { func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController) }