// // AttachmentView.swift // Tusker // // Created by Shadowfacts on 8/31/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import AVFoundation import TuskerComponents @MainActor protocol AttachmentViewDelegate: AnyObject { func attachmentViewGallery(startingAt index: Int) -> UIViewController? func attachmentViewPresent(_ vc: UIViewController, animated: Bool) } class AttachmentView: GIFImageView { static let queue = DispatchQueue(label: "Attachment Thumbnail", qos: .userInitiated, attributes: .concurrent) weak var delegate: AttachmentViewDelegate? private var playImageView: UIImageView? private(set) var gifvView: GifvPlayerView? private var badgeContainer: UIStackView? var attachment: Attachment! var index: Int! private var loadAttachmentTask: Task? private var source: Source? var attachmentImage: UIImage? { switch source { case .image(_, _, let image): return image case .gifData(_, _, let image): return image case nil: return nil } } var originalData: Data? { switch source { case .image(_, let data, _): return data case .gifData(_, let data, _): return data case nil: return nil } } private var autoplayGifs: Bool { Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled } private var isGrayscale = false init(attachment: Attachment, index: Int) { super.init(image: nil) commonInit() self.attachment = attachment self.index = index self.loadAttachmentTask = Task { await self.loadAttachment() } } deinit { loadAttachmentTask?.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 { Task { await displayImage() } } if getBadges().isEmpty != Preferences.shared.showAttachmentBadges { createBadgesView(getBadges()) } } @objc private func gifPlaybackModeChanged() { // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread DispatchQueue.main.async { if self.attachment.kind == .image, let gifController = self.gifController { if self.autoplayGifs && !self.isAnimatingGIF { gifController.attach(to: self) gifController.startAnimating() } else if !self.autoplayGifs && self.isAnimatingGIF { // detach instead of stopping so that any other attached gif views keep animating self.detachGIFController() } } else if self.attachment.kind == .gifv, let gifvView = self.gifvView { if self.autoplayGifs { gifvView.controller.play() } else { gifvView.controller.pause() } } } } private func loadAttachment() async { let blurHashTask: Task? if let hash = attachment.blurHash { blurHashTask = Task { guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) else { return } try Task.checkCancellation() if Preferences.shared.grayscaleImages, let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) { preview = grayscale } try Task.checkCancellation() self.image = preview } } else { blurHashTask = nil } createBadgesView(getBadges()) switch attachment.kind { case .image: await loadImage() case .video: await loadVideo() case .audio: loadAudio() case .gifv: loadGifv() case .unknown: createUnknownLabel() } blurHashTask?.cancel() } private func getBadges() -> Badges { guard Preferences.shared.showAttachmentBadges else { return [] } var badges: Badges = [] if attachment.description?.isEmpty == false { badges.formUnion(.alt) } if attachment.kind == .gifv || attachment.url.pathExtension == "gif" { badges.formUnion(.gif) } return badges } var attachmentAspectRatio: CGFloat? { if let meta = self.attachment.meta { if let width = meta.width, let height = meta.height { return CGFloat(width) / CGFloat(height) } else if let orig = meta.original, let width = orig.width, let height = orig.height { return CGFloat(width) / CGFloat(height) } } return nil } private func blurHashSize() -> CGSize { if let aspectRatio = attachmentAspectRatio { if aspectRatio > 1 { return CGSize(width: 32, height: 32 / aspectRatio) } else { return CGSize(width: 32 * aspectRatio, height: 32) } } else { return CGSize(width: 32, height: 32) } } private func loadImage() async { let (data, image) = await ImageCache.attachments.get(attachment.url) guard !Task.isCancelled else { return } if attachment.url.pathExtension == "gif", let data { source = .gifData(attachment.url, data, image) if autoplayGifs { let controller = GIFController(gifData: data) controller.attach(to: self) controller.startAnimating() } else { await displayImage() } } else if let image { source = .image(attachment.url, data, image) await displayImage() } } private func loadVideo() async { 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), ]) if let previewURL = attachment.previewURL { guard let image = await ImageCache.attachments.get(previewURL).1, !Task.isCancelled else { return } source = .image(previewURL, nil, image) await displayImage() } else { let asset = AVURLAsset(url: attachment.url) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true let image: CGImage? #if os(visionOS) image = try? await generator.image(at: .zero).image #else if #available(iOS 16.0, *) { image = try? await generator.image(at: .zero).image } else { image = try? generator.copyCGImage(at: .zero, actualTime: nil) } #endif guard let image, let prepared = await UIImage(cgImage: image).byPreparingForDisplay(), !Task.isCancelled else { return } source = .image(attachment.url, nil, prepared) await displayImage() } } private 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), ]) } private func loadGifv() { let asset = AVURLAsset(url: attachment.url) let controller = GifvController(asset: asset) let gifvView = GifvPlayerView(controller: controller, gravity: .resizeAspectFill) self.gifvView = gifvView gifvView.translatesAutoresizingMaskIntoConstraints = false if autoplayGifs { controller.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 createUnknownLabel() { backgroundColor = .appSecondaryBackground let label = UILabel() label.text = "Unknown Attachment Type" label.numberOfLines = 0 label.textAlignment = .center label.textColor = .secondaryLabel label.font = .preferredFont(forTextStyle: .body) label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor), label.trailingAnchor.constraint(equalTo: trailingAnchor), label.topAnchor.constraint(equalTo: topAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } private func displayImage() async { isGrayscale = Preferences.shared.grayscaleImages switch source { case nil: self.image = nil case let .image(url, _, sourceImage): if isGrayscale { self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) } else { self.image = sourceImage } case let .gifData(url, _, sourceImage): if isGrayscale, let sourceImage { self.image = await ImageGrayscalifier.convert(url: url, image: sourceImage) } else { self.image = sourceImage } } } private func createBadgesView(_ badges: Badges) { guard !badges.isEmpty else { badgeContainer?.removeFromSuperview() badgeContainer = nil return } let stack = UIStackView() self.badgeContainer = stack stack.axis = .horizontal stack.spacing = 2 stack.translatesAutoresizingMaskIntoConstraints = false let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .bold)) func makeBadgeView(text: String) { let container = UIView() container.backgroundColor = .secondarySystemBackground.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)) let label = UILabel() label.font = font label.adjustsFontForContentSizeCategory = true label.textColor = .white label.text = text label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 2), label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -2), label.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), ]) stack.addArrangedSubview(container) } if badges.contains(.gif) { makeBadgeView(text: "GIF") } if badges.contains(.alt) { makeBadgeView(text: "ALT") } let first = stack.arrangedSubviews.first! first.layer.masksToBounds = true first.layer.cornerRadius = 4 first.layer.cornerCurve = .continuous if stack.arrangedSubviews.count > 1 { first.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] let last = stack.arrangedSubviews.last! last.layer.masksToBounds = true last.layer.cornerRadius = 4 last.layer.cornerCurve = .continuous last.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] } addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), ]) } // MARK: Interaction 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 image(URL, Data?, UIImage) case gifData(URL, Data, UIImage?) } struct Badges: OptionSet { static let gif = Badges(rawValue: 1 << 0) static let alt = Badges(rawValue: 1 << 1) let rawValue: Int } } extension AttachmentView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration { [unowned self] () -> UIViewController? in if self.attachment.kind == .image, let image { return ImageGalleryContentViewController(url: self.attachment.url, caption: nil, originalData: nil, image: image, gifController: self.gifController) } else if self.attachment.kind == .gifv, let gifvView { return GifvGalleryContentViewController(controller: gifvView.controller, url: self.attachment.url, caption: nil) } else if self.attachment.kind == .video || self.attachment.kind == .audio { let vc = VideoGalleryContentViewController(url: self.attachment.url, caption: nil) vc.player.isMuted = true return vc } else { return self.delegate?.attachmentViewGallery(startingAt: self.index) } } actionProvider: { [unowned self] _ in let itemSource: UIActivityItemSource let itemData: Task if self.attachment.kind == .image, let source { switch source { case .image(let url, let data, let image): let imageData: Data if let data { imageData = data } else if let data = image.pngData() { imageData = data } else { return nil } itemSource = ImageActivityItemSource(data: imageData, url: url, image: image) itemData = Task { imageData } case .gifData(let url, let data, let image): itemSource = ImageActivityItemSource(data: data, url: url, image: image) itemData = Task { data } } } else if self.attachment.kind == .gifv || self.attachment.kind == .video { itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url) itemData = Task { try? await URLSession.shared.data(from: self.attachment.url).0 } } else { return nil } var actions = [ UIAction(title: "Share…", image: UIImage(systemName: "square.and.arrow.up")) { [unowned self] _ in let vc = UIActivityViewController(activityItems: [itemSource], applicationActivities: [SaveToPhotosActivity()]) vc.popoverPresentationController?.sourceView = self self.delegate?.attachmentViewPresent(vc, animated: true) } ] let activity = SaveToPhotosActivity() if activity.canPerform(withActivityItems: [self.attachment.url]) { actions.append(UIAction(title: "Save to Photos", image: UIImage(systemName: "square.and.arrow.down"), handler: { _ in Task { guard let itemData = await itemData.value else { return } let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(self.attachment.url.lastPathComponent) try itemData.write(to: tempURL) activity.prepare(withActivityItems: [tempURL]) activity.perform() } })) } return UIMenu(children: actions) } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { animator.addCompletion { animator.preferredCommitStyle = .pop self.showGallery() } } }