session-ios/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift

703 lines
27 KiB
Swift

// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
import UIKit
import Combine
import MediaPlayer
import YYImage
import NVActivityIndicatorView
import SessionUIKit
import SessionMessagingKit
import SignalCoreKit
import SessionUtilitiesKit
public protocol MediaMessageViewAudioDelegate: AnyObject {
func progressChanged(_ progressSeconds: CGFloat, durationSeconds: CGFloat)
}
public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
public enum Mode: UInt {
case large
case small
case attachmentApproval
}
// MARK: Properties
private var disposables: Set<AnyCancellable> = Set()
public let mode: Mode
public let attachment: SignalAttachment
public lazy var audioPlayer: OWSAudioPlayer? = {
guard let dataUrl = attachment.dataUrl else { return nil }
return OWSAudioPlayer(mediaUrl: dataUrl, audioBehavior: .playback, delegate: self)
}()
public var wasPlayingAudio: Bool = false
public var audioProgressSeconds: CGFloat = 0
public var audioDurationSeconds: CGFloat = 0
public weak var audioDelegate: MediaMessageViewAudioDelegate?
public var playbackState = AudioPlaybackState.stopped {
didSet {
AssertIsOnMainThread()
ensureButtonState()
}
}
private lazy var validImage: UIImage? = {
if attachment.isImage {
guard
attachment.isValidImage,
let image: UIImage = attachment.image(),
image.size.width > 0,
image.size.height > 0
else {
return nil
}
return image
}
else if attachment.isVideo {
guard
attachment.isValidVideo,
let image: UIImage = attachment.videoPreview(),
image.size.width > 0,
image.size.height > 0
else {
return nil
}
return image
}
return nil
}()
private lazy var validAnimatedImage: YYImage? = {
guard
attachment.isAnimatedImage,
attachment.isValidImage,
let dataUrl: URL = attachment.dataUrl,
let image: YYImage = YYImage(contentsOfFile: dataUrl.path),
image.size.width > 0,
image.size.height > 0
else {
return nil
}
return image
}()
private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
// MARK: Initializers
@available(*, unavailable, message:"use other constructor instead.")
required public init?(coder aDecoder: NSCoder) {
notImplemented()
}
// Currently we only use one mode (AttachmentApproval), so we could simplify this class, but it's kind
// of nice that it's written in a flexible way in case we'd want to use it elsewhere again in the future.
public required init(attachment: SignalAttachment, mode: MediaMessageView.Mode) {
if attachment.hasError { owsFailDebug(attachment.error.debugDescription) }
self.attachment = attachment
self.mode = mode
// Set the linkPreviewUrl if it's a url
if attachment.isUrl, let linkPreviewURL: String = LinkPreview.previewUrl(for: attachment.text()) {
self.linkPreviewInfo = (url: linkPreviewURL, draft: nil)
}
super.init(frame: CGRect.zero)
setupViews()
setupLayout()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
private lazy var stackView: UIStackView = {
let stackView: UIStackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = .center
stackView.distribution = .fill
switch mode {
case .attachmentApproval: stackView.spacing = 2
case .large: stackView.spacing = 10
case .small: stackView.spacing = 5
}
return stackView
}()
private let loadingView: NVActivityIndicatorView = {
let result: NVActivityIndicatorView = NVActivityIndicatorView(
frame: CGRect.zero,
type: .circleStrokeSpin,
color: .black,
padding: nil
)
result.translatesAutoresizingMaskIntoConstraints = false
result.isHidden = true
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
result?.color = textPrimary
}
return result
}()
private lazy var imageView: UIImageView = {
let view: UIImageView = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.contentMode = .scaleAspectFit
view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate)
view.themeTintColor = .textPrimary
view.isHidden = true
// Override the image to the correct one
if attachment.isImage || attachment.isVideo {
if let validImage: UIImage = validImage {
view.layer.minificationFilter = .trilinear
view.layer.magnificationFilter = .trilinear
view.image = validImage
}
}
else if attachment.isUrl {
view.clipsToBounds = true
view.image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate)
view.themeTintColor = .messageBubble_outgoingText
view.contentMode = .center
view.themeBackgroundColor = .messageBubble_overlay
view.layer.cornerRadius = 8
}
return view
}()
private lazy var fileTypeImageView: UIImageView = {
let view: UIImageView = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
return view
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let view: YYAnimatedImageView = YYAnimatedImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.isHidden = true
if let image: YYImage = validAnimatedImage {
view.image = image
}
else {
view.contentMode = .scaleAspectFit
view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate)
view.themeTintColor = .textPrimary
}
return view
}()
lazy var videoPlayButton: UIImageView = {
let view: UIImageView = UIImageView(image: UIImage(named: "CirclePlay"))
view.translatesAutoresizingMaskIntoConstraints = false
view.contentMode = .scaleAspectFit
view.isHidden = true
return view
}()
/// Note: This uses different assets from the `videoPlayButton` and has a 'Pause' state
private lazy var audioPlayPauseButton: UIButton = {
let button: UIButton = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.clipsToBounds = true
button.setThemeBackgroundColorForced(
.theme(.classicLight, color: .settings_tabBackground),
for: .normal
)
button.setThemeBackgroundColorForced(
.theme(.classicLight, color: .highlighted(.settings_tabBackground)),
for: .highlighted
)
button.addTarget(self, action: #selector(audioPlayPauseButtonPressed), for: .touchUpInside)
button.isHidden = true
return button
}()
private lazy var titleStackView: UIStackView = {
let stackView: UIStackView = UIStackView()
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.alignment = (attachment.isUrl && linkPreviewInfo?.url != nil ? .leading : .center)
stackView.distribution = .fill
switch mode {
case .attachmentApproval: stackView.spacing = 2
case .large: stackView.spacing = 10
case .small: stackView.spacing = 5
}
return stackView
}()
private lazy var titleLabel: UILabel = {
let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
// Styling
switch mode {
case .attachmentApproval:
label.font = UIFont.boldSystemFont(ofSize: ScaleFromIPhone5To7Plus(16, 22))
label.themeTextColor = .textPrimary
case .large:
label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(18, 24))
label.themeTextColor = .primary
case .small:
label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(14, 14))
label.themeTextColor = .primary
}
// Content
if attachment.isUrl {
// If we have no link preview info at this point then assume link previews are disabled
if let linkPreviewURL: String = linkPreviewInfo?.url {
label.font = .boldSystemFont(ofSize: Values.smallFontSize)
label.text = linkPreviewURL
label.textAlignment = .left
label.lineBreakMode = .byTruncatingTail
label.numberOfLines = 2
}
else {
label.text = "vc_share_link_previews_disabled_title".localized()
}
}
// Title for everything except these types
else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo {
if let fileName: String = attachment.sourceFilename?.trimmingCharacters(in: .whitespacesAndNewlines), fileName.count > 0 {
label.text = fileName
}
else if let fileExtension: String = attachment.fileExtension {
label.text = String(
format: "ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT".localized(),
fileExtension.uppercased()
)
}
label.textAlignment = .center
label.lineBreakMode = .byTruncatingMiddle
}
// Hide the label if it has no content
label.isHidden = ((label.text?.count ?? 0) == 0)
return label
}()
private lazy var subtitleLabel: UILabel = {
let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
// Styling
switch mode {
case .attachmentApproval:
label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(12, 18))
label.themeTextColor = .textSecondary
case .large:
label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(18, 24))
label.themeTextColor = .primary
case .small:
label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(14, 14))
label.themeTextColor = .primary
}
// Content
if attachment.isUrl {
// We only load Link Previews for HTTPS urls so append an explanation for not
if let linkPreviewURL: String = linkPreviewInfo?.url {
if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" {
label.font = UIFont.systemFont(ofSize: Values.verySmallFontSize)
label.text = "vc_share_link_previews_unsecure".localized()
label.themeTextColor = (mode == .attachmentApproval ?
.textSecondary :
.primary
)
}
}
// If we have no link preview info at this point then assume link previews are disabled
else {
label.text = "vc_share_link_previews_disabled_explanation".localized()
label.themeTextColor = .textPrimary
label.textAlignment = .center
label.numberOfLines = 0
}
}
// Subtitle for everything else except these types
else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo {
// Format string for file size label in call interstitial view.
// Embeds: {{file size as 'N mb' or 'N kb'}}.
let fileSize: UInt = attachment.dataLength
label.text = String(format: "ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT".localized(), Format.fileSize(fileSize))
label.textAlignment = .center
}
// Hide the label if it has no content
label.isHidden = ((label.text?.count ?? 0) == 0)
return label
}()
// MARK: - Layout
private func setupViews() {
// Plain text will just be put in the 'message' input so do nothing
guard !attachment.isText else { return }
// Setup the view hierarchy
addSubview(stackView)
addSubview(loadingView)
addSubview(videoPlayButton)
stackView.addArrangedSubview(imageView)
stackView.addArrangedSubview(animatedImageView)
if !titleLabel.isHidden { stackView.addArrangedSubview(UIView.vhSpacer(10, 10)) }
stackView.addArrangedSubview(titleStackView)
titleStackView.addArrangedSubview(titleLabel)
titleStackView.addArrangedSubview(subtitleLabel)
imageView.addSubview(fileTypeImageView)
// Type-specific configurations
if attachment.isAnimatedImage {
animatedImageView.isHidden = false
}
else if attachment.isImage {
imageView.isHidden = false
}
else if attachment.isVideo {
// Note: The 'attachmentApproval' mode provides it's own play button to keep
// it at the proper scale when zooming
imageView.isHidden = false
videoPlayButton.isHidden = (mode == .attachmentApproval)
}
else if attachment.isAudio {
// Hide the 'audioPlayPauseButton' if the 'audioPlayer' failed to get created
imageView.isHidden = false
audioPlayPauseButton.isHidden = (audioPlayer == nil)
setAudioIconToPlay()
setAudioProgress(0, duration: (audioPlayer?.duration ?? 0))
fileTypeImageView.image = UIImage(named: "table_ic_notification_sound")?
.withRenderingMode(.alwaysTemplate)
fileTypeImageView.themeTintColor = .textPrimary
fileTypeImageView.isHidden = false
// Note: There is an annoying bug where the MediaMessageView will fill the screen if the
// 'audioPlayPauseButton' is added anywhere within the view hierarchy causing issues with
// the min scale on 'image' and 'animatedImage' file types (assume it's actually any UIButton)
addSubview(audioPlayPauseButton)
}
else if attachment.isUrl {
imageView.isHidden = false
imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView
loadingView.isHidden = false
if let linkPreviewUrl: String = linkPreviewInfo?.url {
// Don't want to change the axis until we have a URL to start loading, otherwise the
// error message will be broken
stackView.axis = .horizontal
loadLinkPreview(linkPreviewURL: linkPreviewUrl)
}
}
else {
imageView.isHidden = false
}
}
private func setupLayout() {
// Plain text will just be put in the 'message' input so do nothing
guard !attachment.isText else { return }
// Sizing calculations
let clampedRatio: CGFloat = {
if attachment.isUrl {
return 1
}
if attachment.isAnimatedImage {
let imageSize: CGSize = (animatedImageView.image?.size ?? CGSize(width: 1, height: 1))
let aspectRatio: CGFloat = (imageSize.width / imageSize.height)
return CGFloatClamp(aspectRatio, 0.05, 95.0)
}
// All other types should maintain the ratio of the image in the 'imageView'
let imageSize: CGSize = (imageView.image?.size ?? CGSize(width: 1, height: 1))
let aspectRatio: CGFloat = (imageSize.width / imageSize.height)
return CGFloatClamp(aspectRatio, 0.05, 95.0)
}()
let maybeImageSize: CGFloat? = {
if attachment.isImage || attachment.isVideo {
if validImage != nil { return nil }
// If we don't have a valid image then use the 'generic' case
}
else if attachment.isAnimatedImage {
if validAnimatedImage != nil { return nil }
// If we don't have a valid image then use the 'generic' case
}
else if attachment.isUrl {
return 80
}
// Generic file size
switch mode {
case .large: return 200
case .attachmentApproval: return 120
case .small: return 80
}
}()
let imageSize: CGFloat = (maybeImageSize ?? 0)
let audioButtonSize: CGFloat = (imageSize / 2.5)
audioPlayPauseButton.layer.cornerRadius = (audioButtonSize / 2)
// Actual layout
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
stackView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
(maybeImageSize != nil ?
stackView.widthAnchor.constraint(
equalTo: widthAnchor,
constant: (attachment.isUrl ? -(32 * 2) : 0) // Inset stackView for urls
) :
stackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor)
),
imageView.widthAnchor.constraint(
equalTo: imageView.heightAnchor,
multiplier: clampedRatio
),
animatedImageView.widthAnchor.constraint(
equalTo: animatedImageView.heightAnchor,
multiplier: clampedRatio
),
// Note: AnimatedImage, Image and Video types should allow zooming so be lessThanOrEqualTo
// the view size but some other types should have specific sizes
animatedImageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor),
animatedImageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor),
(maybeImageSize != nil ?
imageView.widthAnchor.constraint(equalToConstant: imageSize) :
imageView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor)
),
(maybeImageSize != nil ?
imageView.heightAnchor.constraint(equalToConstant: imageSize) :
imageView.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor)
),
fileTypeImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
fileTypeImageView.centerYAnchor.constraint(
equalTo: imageView.centerYAnchor,
constant: ceil(imageSize * 0.15)
),
fileTypeImageView.widthAnchor.constraint(
equalTo: fileTypeImageView.heightAnchor,
multiplier: ((fileTypeImageView.image?.size.width ?? 1) / (fileTypeImageView.image?.size.height ?? 1))
),
fileTypeImageView.widthAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 0.5),
videoPlayButton.centerXAnchor.constraint(equalTo: centerXAnchor),
videoPlayButton.centerYAnchor.constraint(equalTo: centerYAnchor),
loadingView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
loadingView.widthAnchor.constraint(equalToConstant: ceil(imageSize / 3)),
loadingView.heightAnchor.constraint(equalToConstant: ceil(imageSize / 3))
])
// No inset for the text for URLs but there is for all other layouts
if !attachment.isUrl {
NSLayoutConstraint.activate([
titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)),
subtitleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2))
])
}
// Note: There is an annoying bug where the MediaMessageView will fill the screen if the
// 'audioPlayPauseButton' is added anywhere within the view hierarchy causing issues with
// the min scale on 'image' and 'animatedImage' file types (assume it's actually any UIButton)
if attachment.isAudio {
NSLayoutConstraint.activate([
audioPlayPauseButton.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
audioPlayPauseButton.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
audioPlayPauseButton.widthAnchor.constraint(equalToConstant: audioButtonSize),
audioPlayPauseButton.heightAnchor.constraint(equalToConstant: audioButtonSize),
])
}
}
// MARK: - Link Loading
private func loadLinkPreview(linkPreviewURL: String) {
loadingView.startAnimating()
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure:
self?.loadingView.alpha = 0
self?.loadingView.stopAnimating()
self?.imageView.alpha = 1
self?.titleLabel.numberOfLines = 1 // Truncates the URL at 1 line so the error is more readable
self?.subtitleLabel.isHidden = false
// Set the error text appropriately
if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" {
// This error case is handled already in the 'subtitleLabel' creation
}
else {
self?.subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize)
self?.subtitleLabel.text = "vc_share_link_previews_error".localized()
self?.subtitleLabel.themeTextColor = (self?.mode == .attachmentApproval ?
.textSecondary :
.primary
)
self?.subtitleLabel.textAlignment = .left
}
}
},
receiveValue: { [weak self] draft in
// TODO: Look at refactoring this behaviour to consolidate attachment mutations
self?.attachment.linkPreviewDraft = draft
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
// Update the UI
self?.titleLabel.text = (draft.title ?? self?.titleLabel.text)
self?.loadingView.alpha = 0
self?.loadingView.stopAnimating()
self?.imageView.alpha = 1
if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) {
self?.imageView.image = loadedImage
self?.imageView.contentMode = .scaleAspectFill
}
}
)
.store(in: &disposables)
}
// MARK: - Functions
public func playAudio() {
audioPlayer?.play()
ensureButtonState()
}
public func pauseAudio() {
wasPlayingAudio = (audioPlayer?.isPlaying == true)
// If the 'audioPlayer' has a duration of 0 then we probably haven't played previously which
// will result in the audioPlayer having a 'duration' of 0 breaking the progressBar. We play
// the audio to get it to properly load the file right before pausing it so the data is
// loaded correctly
if audioPlayer?.duration == 0 {
audioPlayer?.play()
}
audioPlayer?.pause()
ensureButtonState()
}
public func setAudioTime(currentTime: TimeInterval) {
audioPlayer?.setCurrentTime(currentTime)
}
// MARK: - Event Handlers
@objc func audioPlayPauseButtonPressed(sender: UIButton) {
audioPlayer?.togglePlayState()
}
// MARK: - OWSAudioPlayerDelegate
public func audioPlaybackState() -> AudioPlaybackState {
return playbackState
}
public func setAudioPlaybackState(_ value: AudioPlaybackState) {
playbackState = value
}
public func showInvalidAudioFileAlert() {
let modal: ConfirmationModal = ConfirmationModal(
targetView: CurrentAppContext().frontmostViewController()?.view,
info: ConfirmationModal.Info(
title: CommonStrings.errorAlertTitle,
body: .text("INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
)
CurrentAppContext().frontmostViewController()?.present(modal, animated: true)
}
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully flag: Bool) {
// Do nothing
}
private func ensureButtonState() {
switch playbackState {
case .playing: setAudioIconToPause()
default: setAudioIconToPlay()
}
}
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
// Note: When the OWSAudioPlayer stops it sets the duration to 0 (which we want to ignore so
// the UI doesn't look buggy)
let finalDuration: CGFloat = (duration > 0 ? duration : audioDurationSeconds)
audioProgressSeconds = progress
audioDurationSeconds = finalDuration
audioDelegate?.progressChanged(progress, durationSeconds: finalDuration)
}
private func setAudioIconToPlay() {
audioPlayPauseButton.setImage(UIImage(named: "Play"), for: .normal)
}
private func setAudioIconToPause() {
audioPlayPauseButton.setImage(UIImage(named: "Pause"), for: .normal)
}
}