329 lines
12 KiB
Swift
329 lines
12 KiB
Swift
//
|
|
// Copyright 2018 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
public import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
public class CVMediaView: ManualLayoutViewWithLayer {
|
|
|
|
private enum MediaError {
|
|
case missing
|
|
case invalid
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private let mediaCache: CVMediaCache
|
|
public let attachment: CVAttachment
|
|
private let interaction: TSInteraction
|
|
private let conversationStyle: ConversationStyle
|
|
private let maxMessageWidth: CGFloat
|
|
private let isBorderless: Bool
|
|
private let isLoopingVideo: Bool
|
|
private let thumbnailQuality: AttachmentThumbnailQuality
|
|
private let isBroken: Bool
|
|
private var reusableMediaView: ReusableMediaView?
|
|
|
|
// MARK: - Initializers
|
|
|
|
public init(
|
|
mediaCache: CVMediaCache,
|
|
attachment: CVAttachment,
|
|
interaction: TSInteraction,
|
|
maxMessageWidth: CGFloat,
|
|
isBorderless: Bool,
|
|
isLoopingVideo: Bool,
|
|
isBroken: Bool,
|
|
thumbnailQuality: AttachmentThumbnailQuality,
|
|
conversationStyle: ConversationStyle
|
|
) {
|
|
self.mediaCache = mediaCache
|
|
self.attachment = attachment
|
|
self.interaction = interaction
|
|
self.maxMessageWidth = maxMessageWidth
|
|
self.isBorderless = isBorderless
|
|
self.isLoopingVideo = isLoopingVideo
|
|
self.isBroken = isBroken
|
|
self.thumbnailQuality = thumbnailQuality
|
|
self.conversationStyle = conversationStyle
|
|
|
|
super.init(name: "CVMediaView")
|
|
|
|
backgroundColor = isBorderless ? .clear : Theme.washColor
|
|
clipsToBounds = true
|
|
|
|
createContents()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func createContents() {
|
|
AssertIsOnMainThread()
|
|
|
|
switch attachment {
|
|
case .backupThumbnail(let thumbnail):
|
|
configureForBackupThumbnailMedia(thumbnail.attachmentBackupThumbnail)
|
|
case .pointer(let pointer, _):
|
|
return configureForUndownloadedMedia(pointer.attachmentPointer)
|
|
case .stream(let attachmentStream):
|
|
let attachmentStream = attachmentStream.attachmentStream
|
|
switch attachmentStream.contentType {
|
|
case .image:
|
|
configureForStillImage(attachmentStream: attachmentStream)
|
|
case .animatedImage:
|
|
configureForAnimatedImage(attachmentStream: attachmentStream)
|
|
case .video where isLoopingVideo:
|
|
configureForLoopingVideo(attachmentStream: attachmentStream)
|
|
case .video:
|
|
configureForVideo(attachmentStream: attachmentStream)
|
|
case .audio, .file, .invalid:
|
|
owsFailDebug("Attachment has unexpected type.")
|
|
configure(forError: .invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func configureForBackupThumbnailMedia(_ thumbnail: AttachmentBackupThumbnail) {
|
|
configureForBackupThumbnail(attachmentBackupThumbnail: thumbnail)
|
|
|
|
_ = addProgressIfNecessary()
|
|
}
|
|
|
|
private func configureForUndownloadedMedia(_ pointer: AttachmentTransitPointer) {
|
|
tryToConfigureForBlurHash(pointer: pointer)
|
|
|
|
_ = addProgressIfNecessary()
|
|
}
|
|
|
|
private func addProgressIfNecessary() -> Bool {
|
|
|
|
let direction: CVAttachmentProgressView.Direction
|
|
switch CVAttachmentProgressView.progressType(
|
|
forAttachment: attachment,
|
|
interaction: interaction
|
|
) {
|
|
case .none:
|
|
return false
|
|
case .uploading(let attachmentStream):
|
|
direction = .upload(attachmentStream: attachmentStream)
|
|
case .pendingDownload:
|
|
// We don't need to add a download indicator for pending
|
|
// attachments; CVComponentBodyMedia will add a download
|
|
// button if any media in the gallery is pending.
|
|
return false
|
|
case .downloading(let attachmentPointer, let transitTierDownloadState):
|
|
backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
|
|
|
|
direction = .download(
|
|
attachmentPointer: attachmentPointer,
|
|
transitTierDownloadState: transitTierDownloadState
|
|
)
|
|
case .unknown:
|
|
owsFailDebug("Unknown progress type.")
|
|
return false
|
|
}
|
|
|
|
let progressView = CVAttachmentProgressView(direction: direction,
|
|
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
|
|
mediaCache: mediaCache)
|
|
addSubviewToCenterOnSuperview(progressView, size: progressView.layoutSize)
|
|
|
|
return true
|
|
}
|
|
|
|
private func configureImageView(_ imageView: UIImageView) {
|
|
// We need to specify a contentMode since the size of the image
|
|
// might not match the aspect ratio of the view.
|
|
imageView.contentMode = .scaleAspectFill
|
|
// Use trilinear filters for better scaling quality at
|
|
// some performance cost.
|
|
imageView.layer.minificationFilter = .trilinear
|
|
imageView.layer.magnificationFilter = .trilinear
|
|
}
|
|
|
|
private func applyReusableMediaView(_ reusableMediaView: ReusableMediaView) {
|
|
reusableMediaView.owner = self
|
|
self.reusableMediaView = reusableMediaView
|
|
let mediaView = reusableMediaView.mediaView
|
|
|
|
mediaView.removeFromSuperview()
|
|
mediaView.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubviewToFillSuperviewEdges(mediaView)
|
|
|
|
if let imageView = mediaView as? UIImageView {
|
|
configureImageView(imageView)
|
|
}
|
|
mediaView.backgroundColor = isBorderless ? .clear : Theme.washColor
|
|
|
|
if !addProgressIfNecessary() {
|
|
if reusableMediaView.isVideo {
|
|
addVideoPlayButton()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createNewReusableMediaView(mediaViewAdapter: MediaViewAdapter, isAnimated: Bool) {
|
|
let reusableMediaView = ReusableMediaView(mediaViewAdapter: mediaViewAdapter, mediaCache: mediaCache)
|
|
mediaCache.setMediaView(reusableMediaView, forKey: mediaViewAdapter.cacheKey, isAnimated: isAnimated)
|
|
applyReusableMediaView(reusableMediaView)
|
|
}
|
|
|
|
private func tryToConfigureForBlurHash(pointer: AttachmentTransitPointer) {
|
|
guard let blurHash = pointer.attachment.blurHash?.nilIfEmpty else { return }
|
|
// NOTE: in the blurhash case, we use the blurHash itself as the
|
|
// cachekey to avoid conflicts with the actual attachment contents.
|
|
let cacheKey = CVMediaCache.CacheKey.blurHash(blurHash)
|
|
let isAnimated = false
|
|
if let reusableMediaView = mediaCache.getMediaView(cacheKey, isAnimated: isAnimated) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
return
|
|
}
|
|
|
|
let mediaViewAdapter = MediaViewAdapterBlurHash(blurHash: blurHash)
|
|
createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, isAnimated: isAnimated)
|
|
}
|
|
|
|
private func configureForLoopingVideo(attachmentStream: AttachmentStream) {
|
|
if let reusableMediaView = mediaCache.getMediaView(
|
|
.attachment(attachmentStream.id),
|
|
isAnimated: true
|
|
) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
} else {
|
|
createNewReusableMediaView(
|
|
mediaViewAdapter: MediaViewAdapterLoopingVideo(
|
|
attachmentStream: attachmentStream),
|
|
isAnimated: true)
|
|
}
|
|
}
|
|
|
|
private func configureForAnimatedImage(attachmentStream: AttachmentStream) {
|
|
let cacheKey = CVMediaCache.CacheKey.attachment(attachmentStream.id)
|
|
let isAnimated = attachmentStream.contentType.isAnimatedImage
|
|
if let reusableMediaView = mediaCache.getMediaView(cacheKey, isAnimated: isAnimated) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
return
|
|
}
|
|
|
|
let mediaViewAdapter = MediaViewAdapterAnimated(attachmentStream: attachmentStream)
|
|
createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, isAnimated: isAnimated)
|
|
}
|
|
|
|
private func configureForStillImage(attachmentStream: AttachmentStream) {
|
|
let cacheKey = CVMediaCache.CacheKey.attachment(attachmentStream.id)
|
|
let isAnimated = attachmentStream.contentType.isAnimatedImage
|
|
if let reusableMediaView = mediaCache.getMediaView(cacheKey, isAnimated: isAnimated) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
return
|
|
}
|
|
|
|
let mediaViewAdapter = MediaViewAdapterStill(attachmentStream: attachmentStream,
|
|
thumbnailQuality: thumbnailQuality)
|
|
createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, isAnimated: isAnimated)
|
|
}
|
|
|
|
private func configureForVideo(attachmentStream: AttachmentStream) {
|
|
let cacheKey = CVMediaCache.CacheKey.attachment(attachmentStream.id)
|
|
let isAnimated = attachmentStream.contentType.isAnimatedImage
|
|
if let reusableMediaView = mediaCache.getMediaView(cacheKey, isAnimated: isAnimated) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
return
|
|
}
|
|
|
|
let mediaViewAdapter = MediaViewAdapterVideo(attachmentStream: attachmentStream,
|
|
thumbnailQuality: thumbnailQuality)
|
|
createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, isAnimated: isAnimated)
|
|
}
|
|
|
|
private func configureForBackupThumbnail(attachmentBackupThumbnail: AttachmentBackupThumbnail) {
|
|
let cacheKey = CVMediaCache.CacheKey.backupThumbnail(attachmentBackupThumbnail.id)
|
|
if let reusableMediaView = mediaCache.getMediaView(cacheKey, isAnimated: false) {
|
|
applyReusableMediaView(reusableMediaView)
|
|
return
|
|
}
|
|
|
|
let mediaViewAdapter = MediaViewAdapterBackupThumbnail(attachmentBackupThumbnail: attachmentBackupThumbnail)
|
|
createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, isAnimated: false)
|
|
}
|
|
|
|
private func addVideoPlayButton() {
|
|
|
|
let playVideoButtonWidth: CGFloat = 44
|
|
let playVideoIconWidth: CGFloat = 20
|
|
|
|
let playVideoButton = UIView.transparentContainer()
|
|
addSubviewToCenterOnSuperview(playVideoButton, size: CGSize(square: playVideoButtonWidth))
|
|
|
|
let playVideoCircleView = OWSLayerView.circleView()
|
|
playVideoCircleView.backgroundColor = UIColor.ows_black.withAlphaComponent(0.7)
|
|
playVideoCircleView.isUserInteractionEnabled = false
|
|
playVideoButton.addSubview(playVideoCircleView)
|
|
layoutSubviewToFillSuperviewEdges(playVideoCircleView)
|
|
|
|
let playVideoIconView = CVImageView()
|
|
if isBroken {
|
|
playVideoIconView.setTemplateImageName("play-slash-fill", tintColor: UIColor.ows_white)
|
|
} else {
|
|
playVideoIconView.setTemplateImageName("play-fill-32", tintColor: UIColor.ows_white)
|
|
}
|
|
playVideoIconView.isUserInteractionEnabled = false
|
|
addSubviewToCenterOnSuperview(playVideoIconView,
|
|
size: CGSize(square: playVideoIconWidth))
|
|
}
|
|
|
|
private var hasBlurHash: Bool {
|
|
return BlurHash.isValidBlurHash(attachment.attachment.attachment.blurHash)
|
|
}
|
|
|
|
private func configure(forError error: MediaError) {
|
|
backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
|
|
let icon: UIImage
|
|
switch error {
|
|
case .invalid:
|
|
guard let asset = UIImage(named: "photo-slash-36") else {
|
|
owsFailDebug("Missing image")
|
|
return
|
|
}
|
|
icon = asset
|
|
case .missing:
|
|
return
|
|
}
|
|
let iconView = CVImageView(image: icon)
|
|
iconView.tintColor = Theme.primaryTextColor.withAlphaComponent(0.6)
|
|
addSubviewToCenterOnSuperview(iconView, size: icon.size)
|
|
}
|
|
|
|
public func loadMedia() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let reusableMediaView = reusableMediaView else {
|
|
return
|
|
}
|
|
guard reusableMediaView.owner != nil else {
|
|
Logger.warn("No longer owner of reusableMediaView.")
|
|
return
|
|
}
|
|
guard reusableMediaView.owner == self else {
|
|
owsFailDebug("No longer owner of reusableMediaView.")
|
|
return
|
|
}
|
|
|
|
reusableMediaView.load()
|
|
}
|
|
|
|
public func unloadMedia() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let reusableMediaView = reusableMediaView else {
|
|
return
|
|
}
|
|
guard reusableMediaView.owner == self else {
|
|
// No longer owner of reusableMediaView.
|
|
return
|
|
}
|
|
|
|
reusableMediaView.unload()
|
|
}
|
|
}
|