Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/CellViews/CVMediaView.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

class CVMediaView: ManualLayoutViewWithLayer {

    // MARK: -

    private let mediaCache: CVMediaCache
    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?
    private var progressView: CVAttachmentProgressView?

    // Circular Play / Pause / Download progress etc
    private static let centerButtonSize: CGFloat = 44

    // MARK: - 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()
    }

    func loadMedia() {
        AssertIsOnMainThread()

        guard let 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()
    }

    func unloadMedia() {
        AssertIsOnMainThread()

        guard let reusableMediaView else {
            return
        }
        guard reusableMediaView.owner === self else {
            // No longer owner of reusableMediaView.
            return
        }

        reusableMediaView.unload()
    }

    // MARK: -

    private func createContents() {
        AssertIsOnMainThread()

        switch attachment {
        case .undownloadable(let attachment):
            configureForError(attachment: attachment.attachment)

        case .backupThumbnail(let thumbnail):
            configureForBackupThumbnailMedia(thumbnail.attachmentBackupThumbnail)

        case .pointer(let pointer, _):
            configureForUndownloadedMedia(pointer.attachment)

        case .stream(let attachmentStream, isUploading: _):
            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.")
                configureForError(attachment: attachmentStream.attachment)
            }
        }
    }

    private func configureForBackupThumbnailMedia(_ thumbnail: AttachmentBackupThumbnail) {
        configureForBackupThumbnail(attachmentBackupThumbnail: thumbnail)

        addProgressViewIfNeeded()
    }

    private func configureForUndownloadedMedia(_ attachment: Attachment) {
        if let thumbnail = attachment.asBackupThumbnail() {
            configureForBackupThumbnail(attachmentBackupThumbnail: thumbnail)
        } else {
            tryToConfigureForBlurHash(attachment: attachment)
        }

        addProgressViewIfNeeded()
    }

    @discardableResult
    private func addProgressViewIfNeeded() -> Bool {
        let direction: CVAttachmentProgressView.Direction
        switch CVAttachmentProgressView.progressType(
            cvAttachment: attachment,
        ) {
        case .none:
            removeProgressView()
            return false

        case .skipped:
            // 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.
            removeProgressView()
            return false

        case .uploading(let attachmentStream):
            direction = .upload(attachmentStream: attachmentStream)

        case .downloading(let attachmentPointer, let downloadState):
            direction = .download(
                attachmentPointer: attachmentPointer,
                downloadState: downloadState,
            )
        }

        let progressView = ensureProgressView(direction: direction)
        if progressView.superview == nil {
            addSubviewToCenterOnSuperview(progressView, size: .square(44))
        }

        return true
    }

    private func ensureProgressView(direction: CVAttachmentProgressView.Direction) -> CVAttachmentProgressView {
        if let progressView {
            return progressView
        }
        let progressView = CVAttachmentProgressView(
            direction: direction,
            colorConfiguration: .forMediaOverlay(),
        )
        self.progressView = progressView
        return progressView
    }

    private func removeProgressView() {
        guard let progressView else { return }

        progressView.removeFromSuperview()
        self.progressView = nil
    }

    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 addProgressViewIfNeeded() == false, 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(attachment: Attachment) {
        guard let blurHash = 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)
            return
        }

        let mediaViewAdapter = MediaViewAdapterLoopingVideo(attachmentStream: attachmentStream)
        createNewReusableMediaView(mediaViewAdapter: mediaViewAdapter, 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 playIconImage = isBroken ? UIImage(named: "play-slash-fill")! : UIImage(named: "play-fill")!
        addIconOverCircularBlurBackground(playIconImage)
    }

    private var hasBlurHash: Bool {
        return BlurHash.isValidBlurHash(attachment.attachment.attachment.blurHash)
    }

    private func configureForError(attachment: Attachment) {
        if attachment.blurHash != nil {
            tryToConfigureForBlurHash(attachment: attachment)
        } else {
            backgroundColor = (Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05)
        }

        addIconOverCircularBlurBackground(UIImage(named: "photo-slash")!)
    }

    private func addIconOverCircularBlurBackground(_ image: UIImage) {
        let circleView = ManualLayoutView.circleView(name: "circleView")
        circleView.clipsToBounds = true
        circleView.isUserInteractionEnabled = false
        addSubviewToCenterOnSuperview(circleView, size: CGSize(square: Self.centerButtonSize))

        let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemThinMaterial))
        circleView.addSubviewToFillSuperviewEdges(blurView)

        let iconView = CVImageView(image: image)
        iconView.tintColor = .Signal.label
        iconView.isUserInteractionEnabled = false
        circleView.addSubviewToCenterOnSuperview(iconView, size: image.size)

    }
}