session-ios/Session/Conversations/Message Cells/Content Views/MediaView.swift

473 lines
16 KiB
Swift
Raw Normal View History

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
import SessionMessagingKit
import SignalCoreKit
import SignalUtilitiesKit
2021-01-29 01:46:32 +01:00
public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill
private enum MediaError {
case missing
case invalid
case failed
}
2018-11-08 17:14:30 +01:00
// MARK: -
2023-01-23 07:13:37 +01:00
private let mediaCache: NSCache<NSString, AnyObject>?
public let attachment: Attachment
private let isOutgoing: Bool
2020-02-04 00:25:37 +01:00
private var loadBlock: (() -> Void)?
private var unloadBlock: (() -> Void)?
2018-12-07 21:56:02 +01:00
2018-12-07 22:24:46 +01:00
// MARK: - LoadState
2018-12-07 21:46:43 +01:00
// The loadState property allows us to:
//
// * Make sure we only have one load attempt
// enqueued at a time for a given piece of media.
// * We never retry media that can't be loaded.
// * We skip media loads which are no longer
// necessary by the time they reach the front
// of the queue.
2018-12-07 22:24:46 +01:00
enum LoadState {
case unloaded
case loading
case loaded
case failed
}
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
2018-12-07 22:24:46 +01:00
// MARK: - Initializers
public required init(
2023-01-23 07:13:37 +01:00
mediaCache: NSCache<NSString, AnyObject>? = nil,
attachment: Attachment,
isOutgoing: Bool,
cornerRadius: CGFloat
) {
self.mediaCache = mediaCache
self.attachment = attachment
self.isOutgoing = isOutgoing
super.init(frame: .zero)
themeBackgroundColor = .backgroundSecondary
2018-11-06 15:31:29 +01:00
clipsToBounds = true
2022-07-14 08:40:06 +02:00
layer.masksToBounds = true
layer.cornerRadius = cornerRadius
createContents()
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
2018-12-07 22:24:46 +01:00
deinit {
loadState.mutate { $0 = .unloaded }
2018-12-07 22:24:46 +01:00
}
// MARK: -
private func createContents() {
AssertIsOnMainThread()
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
2018-11-08 17:14:30 +01:00
addDownloadProgressIfNecessary()
return
}
guard attachment.state != .failedDownload else {
2018-11-13 19:14:24 +01:00
configure(forError: .failed)
return
}
guard attachment.isValid else {
configure(forError: .invalid)
return
}
if attachment.isAnimated {
configureForAnimatedImage(attachment: attachment)
}
else if attachment.isImage {
configureForStillImage(attachment: attachment)
}
else if attachment.isVideo {
configureForVideo(attachment: attachment)
}
else {
2019-02-28 21:49:51 +01:00
owsFailDebug("Attachment has unexpected type.")
2018-11-13 19:14:24 +01:00
configure(forError: .invalid)
2018-11-08 17:14:30 +01:00
}
}
2018-11-08 17:14:30 +01:00
private func addDownloadProgressIfNecessary() {
guard attachment.state != .failedDownload else {
2018-11-13 19:14:24 +01:00
configure(forError: .failed)
return
}
guard attachment.state != .uploading && attachment.state != .uploaded else {
// TODO: Show "restoring" indicator and possibly progress.
configure(forError: .missing)
return
}
themeBackgroundColor = .backgroundSecondary
2021-01-29 01:46:32 +01:00
let loader = MediaLoaderView()
addSubview(loader)
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
}
2018-11-08 22:55:54 +01:00
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
2020-02-04 00:25:37 +01:00
guard isOutgoing else { return false }
guard attachment.state != .failedUpload else {
configure(forError: .failed)
return false
}
// If this message was uploaded on a different device it'll now be seen as 'downloaded' (but
// will still be outgoing - we don't want to show a loading indicator in this case)
guard attachment.state != .uploaded && attachment.state != .downloaded else { return false }
2021-01-29 01:46:32 +01:00
let loader = MediaLoaderView()
addSubview(loader)
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
2018-11-08 22:55:54 +01:00
return true
}
private func configureForAnimatedImage(attachment: Attachment) {
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
animatedImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.themeBackgroundColor = .backgroundSecondary
animatedImageView.isHidden = !attachment.isValid
addSubview(animatedImageView)
animatedImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(animatedImageView)
2018-11-08 21:40:43 +01:00
loadBlock = { [weak self] in
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
if animatedImageView.image != nil {
2018-12-07 21:46:43 +01:00
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
guard let filePath: String = attachment.originalFilePath else {
owsFailDebug("Attachment stream missing original file path.")
self?.configure(forError: .invalid)
return
}
applyMediaBlock(YYImage(contentsOfFile: filePath))
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: YYImage = media as? YYImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
animatedImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
animatedImageView.image = nil
}
}
private func configureForStillImage(attachment: Attachment) {
let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.themeBackgroundColor = .backgroundSecondary
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(stillImageView)
loadBlock = { [weak self] in
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
if stillImageView.image != nil {
2018-12-07 21:46:43 +01:00
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .large,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
2018-12-07 21:46:43 +01:00
AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
stillImageView.image = nil
}
}
private func configureForVideo(attachment: Attachment) {
let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.themeBackgroundColor = .backgroundSecondary
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
2018-11-08 22:55:54 +01:00
if !addUploadProgressIfNecessary(stillImageView) {
let videoPlayIcon = UIImage(named: "CirclePlay")
2018-11-08 22:55:54 +01:00
let videoPlayButton = UIImageView(image: videoPlayIcon)
videoPlayButton.set(.width, to: 72)
videoPlayButton.set(.height, to: 72)
2018-11-08 22:55:54 +01:00
stillImageView.addSubview(videoPlayButton)
videoPlayButton.autoCenterInSuperview()
}
loadBlock = { [weak self] in
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
if stillImageView.image != nil {
2018-12-07 21:46:43 +01:00
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .medium,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
2018-12-07 21:46:43 +01:00
AssertIsOnMainThread()
2018-12-07 21:56:02 +01:00
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
},
cacheKey: attachment.id
)
}
unloadBlock = {
2018-12-10 16:12:21 +01:00
AssertIsOnMainThread()
stillImageView.image = nil
}
}
2018-11-13 19:14:24 +01:00
private func configure(forError error: MediaError) {
// When there is a failure in the 'loadMediaBlock' closure this can be called
// on a background thread - rather than dispatching in every 'loadMediaBlock'
// usage we just do so here
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.configure(forError: error)
}
return
}
2018-11-08 22:11:53 +01:00
let icon: UIImage
2019-03-30 15:50:52 +01:00
switch error {
case .failed:
guard let asset = UIImage(named: "media_retry") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .invalid:
guard let asset = UIImage(named: "media_invalid") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .missing: return
2018-11-08 22:11:53 +01:00
}
themeBackgroundColor = .backgroundSecondary
// For failed ougoing messages add an overlay to make the icon more visible
if isOutgoing {
let attachmentOverlayView: UIView = UIView()
attachmentOverlayView.themeBackgroundColor = .messageBubble_overlay
addSubview(attachmentOverlayView)
attachmentOverlayView.pin(to: self)
}
2018-11-08 22:11:53 +01:00
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.themeTintColor = .textPrimary
iconView.alpha = Values.mediumOpacity
addSubview(iconView)
2018-11-08 22:11:53 +01:00
iconView.autoCenterInSuperview()
2018-11-08 17:14:30 +01:00
}
private func tryToLoadMedia(
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
applyMediaBlock: @escaping (AnyObject) -> Void,
cacheKey: String
) {
2018-12-07 21:46:43 +01:00
// It's critical that we update loadState once
// our load attempt is complete.
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
guard self?.loadState.wrappedValue == .loading else {
2018-12-07 21:46:43 +01:00
Logger.verbose("Skipping obsolete load.")
return
}
guard let media: AnyObject = possibleMedia else {
self?.loadState.mutate { $0 = .failed }
2018-12-07 21:46:43 +01:00
// TODO:
// [self showAttachmentErrorViewWithMediaView:mediaView];
return
}
2018-12-07 21:46:43 +01:00
applyMediaBlock(media)
2023-01-23 07:13:37 +01:00
self?.mediaCache?.setObject(media, forKey: cacheKey as NSString)
self?.loadState.mutate { $0 = .loaded }
2018-12-07 21:46:43 +01:00
}
2018-12-07 21:56:02 +01:00
guard loadState.wrappedValue == .loading else {
2018-12-07 21:46:43 +01:00
owsFailDebug("Unexpected load state: \(loadState)")
return
}
2023-01-23 07:13:37 +01:00
if let media: AnyObject = self.mediaCache?.object(forKey: cacheKey as NSString) {
Logger.verbose("media cache hit")
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
2018-12-10 20:55:06 +01:00
loadCompletion(media)
2018-12-07 21:46:43 +01:00
return
}
2018-12-07 21:46:43 +01:00
Logger.verbose("media cache miss")
2018-12-07 21:56:02 +01:00
MediaView.loadQueue.async { [weak self] in
guard self?.loadState.wrappedValue == .loading else {
2018-12-07 22:24:46 +01:00
Logger.verbose("Skipping obsolete load.")
return
}
loadMediaBlock { media in
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
loadCompletion(media)
2018-12-07 21:46:43 +01:00
}
}
2018-12-07 21:46:43 +01:00
}
// We use this queue to perform the media loads.
// These loads are expensive, so we want to:
//
// * Do them off the main thread.
// * Only do one at a time.
// * Avoid this work if possible (obsolete loads for
// views that are no longer visible, redundant loads
// of media already being loaded, don't retry media
// that can't be loaded, etc.).
2018-12-13 17:02:23 +01:00
// * Do them in _reverse_ order. More recently enqueued
// loads more closely reflect the current view state.
// By processing in reverse order, we improve our
// "skip rate" of obsolete loads.
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
2018-12-07 21:46:43 +01:00
public func loadMedia() {
switch loadState.wrappedValue {
case .unloaded:
loadState.mutate { $0 = .loading }
loadBlock?()
case .loading, .loaded, .failed: break
}
}
public func unloadMedia() {
loadState.mutate { $0 = .unloaded }
unloadBlock?()
}
}