Files
Morgan Pretty 365d604d63 Fixed remaining known issues
• Added thumbnailing logic for animated images (shaving a decent amount of memory usage from the media grid)
• Added a flag to use PNG instead of WebP for image fallbacks (much faster in debug mode)
• Simplified the ImageDataManager to use a single `FrameBuffer` type instead of varying `ProcessedImageData` types
• Fixed an issue where reuploading a display picture could result in it being deleted
• Fixed an issue where old display pictures wouldn't be removed when replaced
• Fixed a minor modal button layout issue
• Fixed a missing string variable issue
2025-10-15 09:09:41 +11:00

289 lines
9.2 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill
private enum MediaError {
case missing
case invalid
case failed
}
// MARK: -
private let dependencies: Dependencies
public let attachment: Attachment
private let isOutgoing: Bool
private let shouldSupressControls: Bool
private var loadBlock: (() -> Void)?
private var unloadBlock: (() -> Void)?
// MARK: - LoadState
// 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.
enum LoadState: ThreadSafeType {
case unloaded
case loading
case loaded
case failed
}
@ThreadSafe private var loadState: LoadState = .unloaded
// MARK: - Initializers
public required init(
attachment: Attachment,
isOutgoing: Bool,
shouldSupressControls: Bool,
cornerRadius: CGFloat,
using dependencies: Dependencies
) {
self.dependencies = dependencies
self.attachment = attachment
self.isOutgoing = isOutgoing
self.shouldSupressControls = shouldSupressControls
super.init(frame: .zero)
themeBackgroundColor = .backgroundSecondary
clipsToBounds = true
layer.masksToBounds = true
layer.cornerRadius = cornerRadius
createContents()
}
@available(*, unavailable, message: "use other init() instead.")
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
loadState = .unloaded
}
// MARK: - UI
public lazy var imageView: SessionImageView = {
let result: SessionImageView = SessionImageView(
dataManager: dependencies[singleton: .imageDataManager]
)
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
result.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
result.layer.minificationFilter = .trilinear
result.layer.magnificationFilter = .trilinear
result.themeBackgroundColor = .backgroundSecondary
result.isHidden = !attachment.isValid
return result
}()
private lazy var overlayView: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .messageBubble_overlay
result.isHidden = !isOutgoing
return result
}()
private lazy var errorIconView: UIImageView = {
let result: UIImageView = UIImageView()
result.themeTintColor = .textPrimary
result.alpha = Values.mediumOpacity
result.isHidden = true
return result
}()
private lazy var durationBackgroundView: GradientView = {
let result: GradientView = GradientView()
result.themeBackgroundGradient = [
.value(.black, alpha: 0),
.value(.black, alpha: 0.75),
.value(.black, alpha: 0.75)
]
result.isHidden = true
return result
}()
private lazy var durationLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = attachment.duration.map { Format.duration($0) }
result.themeTextColor = .white
return result
}()
private lazy var playButtonIcon: UIImageView = {
let result: UIImageView = UIImageView(image: UIImage(named: "CirclePlay"))
result.isHidden = true
return result
}()
private let loadingIndicator: MediaLoaderView = {
let result: MediaLoaderView = MediaLoaderView()
result.isHidden = true
return result
}()
// MARK: -
@MainActor
private func createContents() {
addSubview(imageView)
imageView.pin(to: self)
addSubview(overlayView)
overlayView.pin(to: self)
addSubview(errorIconView)
errorIconView.center(in: self)
addSubview(durationBackgroundView)
durationBackgroundView.pin(.leading, to: .leading, of: imageView)
durationBackgroundView.pin(.trailing, to: .trailing, of: imageView)
durationBackgroundView.pin(.bottom, to: .bottom, of: imageView)
durationBackgroundView.addSubview(durationLabel)
durationLabel.pin(to: durationBackgroundView, withInset: Values.smallSpacing)
addSubview(playButtonIcon)
playButtonIcon.set(.width, to: 72)
playButtonIcon.set(.height, to: 72)
playButtonIcon.center(in: self)
addSubview(loadingIndicator)
loadingIndicator.pin(.leading, to: .leading, of: self)
loadingIndicator.pin(.trailing, to: .trailing, of: self).setting(priority: .defaultHigh)
loadingIndicator.pin(.bottom, to: .bottom, of: self)
/// Load in image data if possible
switch (attachment.state, attachment.isValid, attachment.isVisualMedia) {
case (.pendingDownload, _, _), (.downloading, _, _):
loadingIndicator.isHidden = false
themeBackgroundColor = .backgroundSecondary
case (.failedDownload, _, _): return configure(forError: .failed)
/// This **must** come after the `pendingDownload`/`downloading` since `isValid` will be `false` until
/// an image has been downloaded
case (_, false, _), (_, _, false): return configure(forError: .invalid)
case (_, true, true):
imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] buffer in
guard buffer == nil else { return }
Log.error("[MediaView] Could not load thumbnail")
self?.configure(forError: .invalid)
}
}
/// For files which are being uploaded we also want to show the `loadingIndicator` (or an error), the main difference with
/// the above cases is that these would appear on top of any image data
switch (isOutgoing, attachment.state) {
case (false, _), (_, .uploaded), (_, .downloaded): break
case (true, .failedUpload): configure(forError: .failed)
case (true, _): loadingIndicator.isHidden = false
}
/// Show the controls if needed
playButtonIcon.isHidden = (
!loadingIndicator.isHidden ||
!attachment.isVideo
)
durationBackgroundView.isHidden = (
shouldSupressControls ||
attachment.duration == nil ||
!loadingIndicator.isHidden ||
!attachment.isVideo
)
}
@MainActor
private func configure(forError error: MediaError) {
switch error {
case .failed:
errorIconView.image = UIImage(named: "media_retry")?
.withRenderingMode(.alwaysTemplate)
errorIconView.isHidden = false
self.isAccessibilityElement = true
self.accessibilityIdentifier = "Media retry"
case .invalid:
errorIconView.image = UIImage(named: "media_invalid")?
.withRenderingMode(.alwaysTemplate)
errorIconView.isHidden = false
self.isAccessibilityElement = true
self.accessibilityIdentifier = "Media invalid"
case .missing: return
}
themeBackgroundColor = .backgroundSecondary
}
}
// MARK: - SwiftUI
import SwiftUI
struct MediaView_SwiftUI: UIViewRepresentable {
public typealias UIViewType = MediaView
private let dependencies: Dependencies
public let attachment: Attachment
private let isOutgoing: Bool
private let shouldSupressControls: Bool
private let cornerRadius: CGFloat
public init(
attachment: Attachment,
isOutgoing: Bool,
shouldSupressControls: Bool,
cornerRadius: CGFloat,
using dependencies: Dependencies
) {
self.dependencies = dependencies
self.attachment = attachment
self.isOutgoing = isOutgoing
self.shouldSupressControls = shouldSupressControls
self.cornerRadius = cornerRadius
}
func makeUIView(context: Context) -> MediaView {
let mediaView = MediaView(
attachment: attachment,
isOutgoing: isOutgoing,
shouldSupressControls: shouldSupressControls,
cornerRadius: cornerRadius,
using: dependencies
)
return mediaView
}
func updateUIView(_ mediaView: MediaView, context: Context) {}
}