// // AttachmentView.swift // Tusker // // Created by Shadowfacts on 8/31/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import AVFoundation protocol AttachmentViewDelegate: AnyObject { func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? func attachmentViewPresent(_ vc: UIViewController, animated: Bool) } class AttachmentView: GIFImageView { weak var delegate: AttachmentViewDelegate? var playImageView: UIImageView? var gifvView: GifvAttachmentView? var attachment: Attachment! var index: Int! var expectedSize: CGSize! private var attachmentRequest: ImageCache.Request? private var source: Source? var gifData: Data? { switch source { case let .gifData(_, data): return data default: return nil } } private var autoplayGifs: Bool { Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled } private var isGrayscale = false init(attachment: Attachment, index: Int, expectedSize: CGSize) { super.init(image: nil) commonInit() self.attachment = attachment self.index = index self.expectedSize = expectedSize loadAttachment() } deinit { attachmentRequest?.cancel() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) commonInit() } private func commonInit() { contentMode = .scaleAspectFill layer.masksToBounds = true isUserInteractionEnabled = true addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed))) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .NSProcessInfoPowerStateDidChange, object: nil) addInteraction(UIContextMenuInteraction(delegate: self)) isAccessibilityElement = true accessibilityTraits = [.image, .button] } @objc private func preferencesChanged() { gifPlaybackModeChanged() if isGrayscale != Preferences.shared.grayscaleImages { ImageGrayscalifier.queue.async { self.displayImage() } } } @objc private func gifPlaybackModeChanged() { // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread DispatchQueue.main.async { if self.attachment.kind == .image, let gifData = self.gifData { if self.autoplayGifs && !self.isAnimatingGIF { self.animate(withGIFData: gifData) } else if !self.autoplayGifs && self.isAnimatingGIF { self.stopAnimatingGIF() } } else if self.attachment.kind == .gifv, let gifvView = self.gifvView { if self.autoplayGifs { gifvView.player.play() } else { gifvView.player.pause() } } } } func loadAttachment() { guard AttachmentsContainerView.supportedAttachmentTypes.contains(attachment.kind) else { preconditionFailure("invalid attachment type") } if let hash = attachment.blurHash { DispatchQueue.global(qos: .default).async { [weak self] in guard let self = self else { return } let size: CGSize if let meta = self.attachment.meta, let width = meta.width, let height = meta.height { size = CGSize(width: width, height: height) } else if let orig = self.attachment.meta?.original, let width = orig.width, let height = orig.height { size = CGSize(width: width, height: height) } else { size = self.expectedSize } guard var preview = UIImage(blurHash: hash, size: size) else { return } if Preferences.shared.grayscaleImages, let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) { preview = grayscale } DispatchQueue.main.async { [weak self] in guard let self = self, self.image == nil else { return } self.image = preview } } } switch attachment.kind { case .image: loadImage() case .video: loadVideo() case .audio: loadAudio() case .gifv: loadGifv() default: preconditionFailure("invalid attachment type") } } func loadImage() { let attachmentURL = attachment.url attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, _) in guard let self = self, let data = data else { return } DispatchQueue.main.async { self.attachmentRequest = nil } if self.attachment.url.pathExtension == "gif" { self.source = .gifData(attachmentURL, data) if self.autoplayGifs { DispatchQueue.main.async { self.animate(withGIFData: data) } } else { self.displayImage() } } else { self.source = .imageData(attachmentURL, data) self.displayImage() } } } func loadVideo() { if let previewURL = self.attachment.previewURL { attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (data, _ )in guard let self = self, let data = data else { return } DispatchQueue.main.async { self.attachmentRequest = nil self.source = .imageData(previewURL, data) self.displayImage() } }) } else { let attachmentURL = self.attachment.url // todo: use a single dispatch queue DispatchQueue.global(qos: .userInitiated).async { let asset = AVURLAsset(url: attachmentURL) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } self.source = .cgImage(attachmentURL, image) self.displayImage() } } let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) playImageView.translatesAutoresizingMaskIntoConstraints = false addSubview(playImageView) NSLayoutConstraint.activate([ playImageView.widthAnchor.constraint(equalToConstant: 50), playImageView.heightAnchor.constraint(equalToConstant: 50), playImageView.centerXAnchor.constraint(equalTo: centerXAnchor), playImageView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } func loadAudio() { let label = UILabel() label.text = "Audio Only" let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) let stack = UIStackView(arrangedSubviews: [ label, playImageView ]) stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .vertical stack.spacing = 8 stack.alignment = .center addSubview(stack) NSLayoutConstraint.activate([ stack.centerXAnchor.constraint(equalTo: centerXAnchor), stack.centerYAnchor.constraint(equalTo: centerYAnchor), playImageView.widthAnchor.constraint(equalToConstant: 50), playImageView.heightAnchor.constraint(equalToConstant: 50), ]) } func loadGifv() { let attachmentURL = self.attachment.url let asset = AVURLAsset(url: attachmentURL) DispatchQueue.global(qos: .userInitiated).async { let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } self.source = .cgImage(attachmentURL, image) self.displayImage() } let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) self.gifvView = gifvView gifvView.translatesAutoresizingMaskIntoConstraints = false if autoplayGifs { gifvView.player.play() } addSubview(gifvView) NSLayoutConstraint.activate([ gifvView.leadingAnchor.constraint(equalTo: leadingAnchor), gifvView.trailingAnchor.constraint(equalTo: trailingAnchor), gifvView.topAnchor.constraint(equalTo: topAnchor), gifvView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } private func displayImage() { isGrayscale = Preferences.shared.grayscaleImages let image: UIImage? switch source { case nil: image = nil case let .imageData(url, data), let .gifData(url, data): if isGrayscale { image = ImageGrayscalifier.convert(url: url, data: data) } else { image = UIImage(data: data) } case let .cgImage(url, cgImage): if isGrayscale { image = ImageGrayscalifier.convert(url: url, cgImage: cgImage) } else { image = UIImage(cgImage: cgImage) } } DispatchQueue.main.async { self.image = image } } func showGallery() { if let delegate = delegate, let gallery = delegate.attachmentViewGallery(startingAt: index) { delegate.attachmentViewPresent(gallery, animated: true) } } @objc func imagePressed() { showGallery() } // MARK: - Accessibility override func accessibilityActivate() -> Bool { showGallery() return true } } fileprivate extension AttachmentView { enum Source { case imageData(URL, Data) case gifData(URL, Data) case cgImage(URL, CGImage) } } extension AttachmentView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in if self.attachment.kind == .image { return AttachmentPreviewViewController(attachment: self.attachment) } else if self.attachment.kind == .gifv { let vc = GifvAttachmentViewController(attachment: self.attachment) vc.preferredContentSize = self.image?.size ?? .zero return vc } else { return self.delegate?.attachmentViewGallery(startingAt: self.index) } }, actionProvider: nil) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { animator.addCompletion { animator.preferredCommitStyle = .pop if let gallery = animator.previewViewController as? GalleryViewController { self.delegate?.attachmentViewPresent(gallery, animated: true) } else { self.showGallery() } } } }