session-ios/Session/Media Viewing & Editing/MediaDetailViewController.s...

367 lines
13 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import AVKit
import AVFoundation
import YYImage
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
import SignalCoreKit
import SessionUtilitiesKit
public enum MediaGalleryOption {
case sliderEnabled
case showAllMediaButton
}
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
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 lazy var playVideoButton: UIButton = {
let result: UIButton = UIButton()
result.contentMode = .scaleAspectFill
result.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
result.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
result.alpha = 0
let playButtonSize: CGFloat = ScaleFromIPhone5(70)
result.set(.width, to: playButtonSize)
result.set(.height, to: playButtonSize)
return result
}()
// 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")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
self.view.themeBackgroundColor = .newConversation_background
self.view.addSubview(scrollView)
self.view.addSubview(playVideoButton)
scrollView.pin(to: self.view)
playVideoButton.center(in: self.view)
self.updateContents()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.resetMediaFrame()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if self.parent == nil || !(self.parent is MediaPageViewController) {
parentDidAppear()
}
}
public func parentDidAppear() {
if mediaView is YYAnimatedImageView {
(mediaView as? YYAnimatedImageView)?.startAnimating()
}
if self.galleryItem.attachment.isVideo {
UIView.animate(withDuration: 0.2) { self.playVideoButton.alpha = 1 }
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.updateMinZoomScale()
self.centerMediaViewConstraints()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
UIView.animate(withDuration: 0.15) { [weak playVideoButton] in playVideoButton?.alpha = 0 }
}
// MARK: - Functions
private func updateMinZoomScale() {
let maybeImageSize: CGSize? = {
switch self.mediaView {
case let imageView as UIImageView: return (imageView.image?.size ?? .zero)
case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero)
default: return nil
}
}()
guard let imageSize: CGSize = maybeImageSize else {
self.scrollView.minimumZoomScale = 1
self.scrollView.maximumZoomScale = 1
self.scrollView.zoomScale = 1
return
}
let viewSize: CGSize = self.scrollView.bounds.size
guard imageSize.width > 0 && imageSize.height > 0 else {
SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))")
return
}
let scaleWidth: CGFloat = (viewSize.width / imageSize.width)
let scaleHeight: CGFloat = (viewSize.height / imageSize.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.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.themeBackgroundColor = .newConversation_background
}
}
else if self.image == nil {
// Still loading thumbnail.
self.mediaView = UIView()
self.mediaView.themeBackgroundColor = .newConversation_background
}
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
}
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)
}
public func didPressPlayBarButton() {
self.playVideo()
}
// 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() {
guard
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return SNLog("Missing video file") }
let videoUrl: URL = URL(fileURLWithPath: originalFilePath)
let player: AVPlayer = AVPlayer(url: videoUrl)
let viewController: AVPlayerViewController = AVPlayerViewController()
viewController.player = player
self.present(viewController, animated: true) { [weak player] in
player?.play()
}
}
}
// MARK: - MediaDetailViewControllerDelegate
protocol MediaDetailViewControllerDelegate: AnyObject {
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
}