497 lines
18 KiB
Swift
497 lines
18 KiB
Swift
//
|
|
// AttachmentView.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 8/31/18.
|
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Pachyderm
|
|
import AVFoundation
|
|
import TuskerComponents
|
|
|
|
protocol AttachmentViewDelegate: AnyObject {
|
|
func attachmentViewGallery(startingAt index: Int) -> GalleryViewController?
|
|
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
|
|
}
|
|
|
|
class AttachmentView: GIFImageView {
|
|
|
|
static let queue = DispatchQueue(label: "Attachment Thumbnail", qos: .userInitiated, attributes: .concurrent)
|
|
|
|
weak var delegate: AttachmentViewDelegate?
|
|
|
|
var playImageView: UIImageView?
|
|
var gifvView: GifvAttachmentView?
|
|
private var badgeContainer: UIStackView?
|
|
|
|
var attachment: Attachment!
|
|
var index: Int!
|
|
|
|
private var attachmentRequest: ImageCache.Request?
|
|
private var source: Source?
|
|
|
|
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
|
|
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 {
|
|
self.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.player.play()
|
|
} else {
|
|
gifvView.player.pause()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadAttachment() {
|
|
if let hash = attachment.blurHash {
|
|
AttachmentView.queue.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
guard var preview = UIImage(blurHash: hash, size: self.blurHashSize()) 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
|
|
}
|
|
}
|
|
}
|
|
|
|
createBadgesView(getBadges())
|
|
|
|
switch attachment.kind {
|
|
case .image:
|
|
loadImage()
|
|
case .video:
|
|
loadVideo()
|
|
case .audio:
|
|
loadAudio()
|
|
case .gifv:
|
|
loadGifv()
|
|
case .unknown:
|
|
createUnknownLabel()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func loadImage() {
|
|
let attachmentURL = attachment.url
|
|
attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data, image) in
|
|
guard let self = self,
|
|
self.attachment.url == attachmentURL else {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.attachmentRequest = nil
|
|
|
|
if attachmentURL.pathExtension == "gif",
|
|
let data {
|
|
self.source = .gifData(attachmentURL, data, image)
|
|
if self.autoplayGifs {
|
|
let controller = GIFController(gifData: data)
|
|
controller.attach(to: self)
|
|
controller.startAnimating()
|
|
} else {
|
|
self.displayImage()
|
|
}
|
|
} else if let image {
|
|
self.source = .image(attachmentURL, image)
|
|
self.displayImage()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadVideo() {
|
|
if let previewURL = self.attachment.previewURL {
|
|
attachmentRequest = ImageCache.attachments.get(previewURL, completion: { [weak self] (_, image) in
|
|
guard let self, let image else { return }
|
|
DispatchQueue.main.async {
|
|
self.attachmentRequest = nil
|
|
self.source = .image(previewURL, image)
|
|
self.displayImage()
|
|
}
|
|
})
|
|
} else {
|
|
let attachmentURL = self.attachment.url
|
|
AttachmentView.queue.async { [weak self] in
|
|
let asset = AVURLAsset(url: attachmentURL)
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.appliesPreferredTrackTransform = true
|
|
#if os(visionOS)
|
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
#else
|
|
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
|
|
UIImage(cgImage: image).prepareForDisplay { [weak self] image in
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, let image else { return }
|
|
self.source = .image(attachmentURL, image)
|
|
self.displayImage()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
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)
|
|
AttachmentView.queue.async {
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.appliesPreferredTrackTransform = true
|
|
#if os(visionOS)
|
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
|
#else
|
|
guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return }
|
|
DispatchQueue.main.async {
|
|
self.source = .cgImage(attachmentURL, image)
|
|
self.displayImage()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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),
|
|
])
|
|
}
|
|
|
|
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),
|
|
])
|
|
}
|
|
|
|
@MainActor
|
|
private func displayImage() {
|
|
isGrayscale = Preferences.shared.grayscaleImages
|
|
|
|
switch source {
|
|
case nil:
|
|
self.image = nil
|
|
|
|
case let .image(url, sourceImage):
|
|
if isGrayscale {
|
|
ImageGrayscalifier.queue.async { [weak self] in
|
|
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.image = grayscale
|
|
}
|
|
}
|
|
} else {
|
|
self.image = sourceImage
|
|
}
|
|
|
|
case let .gifData(url, _, sourceImage):
|
|
if isGrayscale,
|
|
let sourceImage {
|
|
ImageGrayscalifier.queue.async { [weak self] in
|
|
let grayscale = ImageGrayscalifier.convert(url: url, image: sourceImage)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.image = grayscale
|
|
}
|
|
}
|
|
} else {
|
|
self.image = sourceImage
|
|
}
|
|
|
|
case let .cgImage(url, cgImage):
|
|
if isGrayscale {
|
|
ImageGrayscalifier.queue.async { [weak self] in
|
|
let grayscale = ImageGrayscalifier.convert(url: url, cgImage: cgImage)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.image = grayscale
|
|
}
|
|
}
|
|
} else {
|
|
image = UIImage(cgImage: cgImage)
|
|
}
|
|
}
|
|
}
|
|
|
|
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, UIImage)
|
|
case gifData(URL, Data, UIImage?)
|
|
case cgImage(URL, CGImage)
|
|
}
|
|
|
|
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(identifier: nil, previewProvider: { () -> UIViewController? in
|
|
if self.attachment.kind == .image {
|
|
return AttachmentPreviewViewController(sourceView: self)
|
|
} 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()
|
|
}
|
|
}
|
|
}
|
|
}
|