445 lines
14 KiB
Swift
445 lines
14 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
// MARK: - AudioPlaybackRate
|
|
|
|
enum AudioPlaybackRate: Float {
|
|
case slow = 0.5
|
|
case normal = 1
|
|
case fast = 1.5
|
|
case extraFast = 2
|
|
}
|
|
|
|
// MARK: - AudioMessagePlaybackRateView
|
|
|
|
class AudioMessagePlaybackRateView: ManualLayoutViewWithLayer {
|
|
|
|
private let threadUniqueId: String
|
|
private let audioAttachment: AudioAttachment
|
|
private let isIncoming: Bool
|
|
|
|
private var playbackRate: AudioPlaybackRate
|
|
|
|
private let label = CVLabel()
|
|
private let imageView = CVImageView()
|
|
|
|
init(
|
|
threadUniqueId: String,
|
|
audioAttachment: AudioAttachment,
|
|
playbackRate: AudioPlaybackRate,
|
|
isIncoming: Bool
|
|
) {
|
|
self.threadUniqueId = threadUniqueId
|
|
self.audioAttachment = audioAttachment
|
|
self.isIncoming = isIncoming
|
|
self.playbackRate = playbackRate
|
|
super.init(name: "AudioMessagePlaybackRateView")
|
|
|
|
// layoutBlocks get called once per frame change.
|
|
// no need to set one up per subview added, just
|
|
// have a single block that triggers an update to
|
|
// the frames of all the subviews.
|
|
addSubview(imageView)
|
|
addSubview(label, withLayoutBlock: { [weak self] _ in
|
|
self?.setSubviewFrames()
|
|
})
|
|
|
|
// start invisible
|
|
self.alpha = 0
|
|
self.backgroundColor = _backgroundColor
|
|
self.layer.cornerRadius = Constants.cornerRadius
|
|
|
|
Self.playbackRateLabelConfig(
|
|
playbackRate: playbackRate,
|
|
color: textColor
|
|
).applyForRendering(label: label)
|
|
self.imageView.image = Constants.image?.withTintColor(textColor, renderingMode: .alwaysOriginal)
|
|
}
|
|
|
|
// MARK: - Animating Changes
|
|
|
|
private var isVisible: Bool = false {
|
|
didSet {
|
|
self.alpha = isVisible ? 1 : 0
|
|
}
|
|
}
|
|
private var isAnimatingVisibility: Bool?
|
|
|
|
public func setVisibility(
|
|
_ visible: Bool,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil
|
|
) {
|
|
// NOTE: can't use `isHidden` state because ManualStackView gets
|
|
// unhappy if one of its subviews hides. Use alpha instead.
|
|
guard isVisible != visible, isAnimatingVisibility != visible else {
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
let fromScale = CATransform3DScale(
|
|
CATransform3DIdentity,
|
|
visible ? 0 : 1,
|
|
visible ? 0 : 1,
|
|
1
|
|
)
|
|
let toScale = CATransform3DScale(
|
|
CATransform3DIdentity,
|
|
visible ? 1 : 0,
|
|
visible ? 1 : 0,
|
|
1
|
|
)
|
|
layer.transform = toScale
|
|
|
|
let wrappedCompletion = {
|
|
self.isAnimatingVisibility = nil
|
|
self.isVisible = visible
|
|
completion?()
|
|
}
|
|
|
|
guard animated else {
|
|
wrappedCompletion()
|
|
return
|
|
}
|
|
|
|
// Make it visible so we can see the animation.
|
|
isVisible = true
|
|
isAnimatingVisibility = visible
|
|
|
|
CATransaction.begin()
|
|
layer.removeAnimation(forKey: Constants.animationName)
|
|
|
|
let animation = Self.createSpringAnimation()
|
|
animation.fillMode = .forwards
|
|
animation.fromValue = fromScale
|
|
animation.toValue = toScale
|
|
|
|
CATransaction.setCompletionBlock(wrappedCompletion)
|
|
layer.add(animation, forKey: Constants.animationName)
|
|
CATransaction.commit()
|
|
}
|
|
|
|
public func setPlaybackRate(
|
|
_ playbackRate: AudioPlaybackRate,
|
|
animated: Bool = true,
|
|
completion: (() -> Void)? = nil
|
|
) {
|
|
guard self.playbackRate != playbackRate else {
|
|
completion?()
|
|
return
|
|
}
|
|
self.playbackRate = playbackRate
|
|
|
|
let setContent = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.setSubviewFrames()
|
|
Self.playbackRateLabelConfig(
|
|
playbackRate: strongSelf.playbackRate,
|
|
color: strongSelf.textColor
|
|
).applyForRendering(label: strongSelf.label)
|
|
}
|
|
|
|
// Don't interrupt the appearance animation.
|
|
guard animated, self.isVisible, self.isAnimatingVisibility == nil else {
|
|
setContent()
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
CATransaction.begin()
|
|
layer.removeAnimation(forKey: Constants.animationName)
|
|
|
|
let animation = Self.createSpringAnimation()
|
|
let fromScale = CATransform3DScale(
|
|
CATransform3DIdentity,
|
|
1,
|
|
1,
|
|
1
|
|
)
|
|
animation.fromValue = fromScale
|
|
let toScale = CATransform3DScale(
|
|
CATransform3DIdentity,
|
|
Constants.changeAnimationScale,
|
|
Constants.changeAnimationScale,
|
|
1
|
|
)
|
|
animation.toValue = toScale
|
|
animation.autoreverses = true
|
|
|
|
CATransaction.setCompletionBlock {
|
|
completion?()
|
|
}
|
|
layer.add(animation, forKey: Constants.animationName)
|
|
CATransaction.commit()
|
|
|
|
// Schedule the actual text update to happen halfway through,
|
|
// right at the reversal point.
|
|
DispatchQueue.main.asyncAfter(
|
|
deadline: .now() + Constants.animationDuration,
|
|
execute: setContent
|
|
)
|
|
return
|
|
}
|
|
|
|
private static func createSpringAnimation() -> CASpringAnimation {
|
|
let animation = CASpringAnimation(keyPath: "transform")
|
|
animation.damping = Constants.animationDamping
|
|
animation.stiffness = Constants.animationStiffness
|
|
animation.mass = Constants.animationMass
|
|
animation.duration = Constants.animationDuration
|
|
animation.speed = Constants.animationSpeed
|
|
return animation
|
|
}
|
|
|
|
// MARK: - Tapping
|
|
|
|
public func handleTap(
|
|
sender: UIGestureRecognizer,
|
|
itemModel: CVItemModel,
|
|
audioMessageViewDelegate: AudioMessageViewDelegate?
|
|
) -> Bool {
|
|
guard
|
|
let attachmentId = audioAttachment.attachmentStream?.attachmentStream.id,
|
|
AppEnvironment.shared.cvAudioPlayerRef.audioPlaybackState(forAttachmentId: attachmentId) == .playing
|
|
else {
|
|
return false
|
|
}
|
|
// Check that the tap is within the bounding box, but
|
|
// expand that to a minimum height/width if its too small.
|
|
let location = sender.location(in: self)
|
|
let tapTargetBounds = bounds.insetBy(
|
|
dx: -0.5 * max(0, Constants.minTapTargetSize - bounds.width),
|
|
dy: -0.5 * max(0, Constants.minTapTargetSize - bounds.height)
|
|
)
|
|
guard tapTargetBounds.contains(location) else {
|
|
return false
|
|
}
|
|
let newPlaybackRate = playbackRate.next
|
|
AppEnvironment.shared.cvAudioPlayerRef.setPlaybackRate(newPlaybackRate.rawValue, forThreadUniqueId: threadUniqueId)
|
|
|
|
// Hold off updates until we animate the change.
|
|
let animationCompletion = audioMessageViewDelegate?.beginCellAnimation(
|
|
maximumDuration: Constants.maxAnimationDuration
|
|
)
|
|
|
|
let reloadGroup = DispatchGroup()
|
|
|
|
// First write the update to the db, this persists the change and ensures the
|
|
// reload we do afterwards pulls the updated rate.
|
|
reloadGroup.enter()
|
|
SSKEnvironment.shared.databaseStorageRef.asyncWrite(
|
|
block: {
|
|
itemModel.threadAssociatedData.updateWith(
|
|
audioPlaybackRate: newPlaybackRate.rawValue,
|
|
updateStorageService: true,
|
|
transaction: $0
|
|
)
|
|
},
|
|
completion: {
|
|
reloadGroup.leave()
|
|
})
|
|
|
|
// Trigger the animation which also updates the playback rate value.
|
|
reloadGroup.enter()
|
|
setPlaybackRate(newPlaybackRate) {
|
|
reloadGroup.leave()
|
|
animationCompletion?()
|
|
}
|
|
|
|
reloadGroup.notify(queue: .main) { [weak audioMessageViewDelegate] in
|
|
// Once the animation _and_ the db update complete, issue a reload.
|
|
// This reloads _everything_, which is way overkill, but there's no easy way
|
|
// to reload only ThreadAssociatedData without a heavy refactor.
|
|
// This only happens on direct user input, anyway, so its probably not a
|
|
// big deal since it therefore only happens on human timescales.
|
|
audioMessageViewDelegate?.enqueueReloadWithoutCaches()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Sizing
|
|
|
|
public static func measure(maxWidth: CGFloat) -> CGSize {
|
|
// Always size this view for the max playback rate size.
|
|
let labelConfig = Self.playbackRateLabelConfig(
|
|
playbackRate: AudioPlaybackRate.rateForLargestDisplayText,
|
|
color: .white // Color doesn't matter for sizing.
|
|
)
|
|
let nonLabelWidth = Constants.imageSize + Constants.margins.totalWidth
|
|
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxWidth - nonLabelWidth)
|
|
|
|
let height = Constants.margins.totalHeight + max(labelSize.height, Constants.imageSize)
|
|
|
|
return CGSize(
|
|
width: labelSize.width + nonLabelWidth,
|
|
height: height
|
|
)
|
|
}
|
|
|
|
// MARK: - Laying out subviews
|
|
|
|
private func setSubviewFrames() {
|
|
let labelConfig = Self.playbackRateLabelConfig(
|
|
playbackRate: playbackRate,
|
|
color: .white // Color doesn't matter for sizing.
|
|
)
|
|
let labelSize = CVText.measureLabel(
|
|
config: labelConfig,
|
|
maxWidth: bounds.width
|
|
)
|
|
let labelWidth = labelSize.width
|
|
let imageSize = Constants.imageSize
|
|
|
|
// We want the label and image as a whole to be centered,
|
|
// so pad each side with remaining width equally.
|
|
let contentWidth = labelWidth + imageSize
|
|
let sidePadding = (bounds.width - contentWidth) / 2
|
|
|
|
label.frame = CGRect(
|
|
x: sidePadding,
|
|
y: (bounds.height - labelSize.height) / 2,
|
|
width: labelWidth,
|
|
height: labelSize.height
|
|
)
|
|
imageView.frame = CGRect(
|
|
x: sidePadding + labelWidth,
|
|
y: (bounds.height - imageSize) / 2,
|
|
width: imageSize,
|
|
height: imageSize
|
|
)
|
|
}
|
|
|
|
// MARK: - Colors
|
|
|
|
open func makeBackgroundColor() -> UIColor {
|
|
isIncoming
|
|
? (Theme.isDarkThemeEnabled ? UIColor.ows_white : .ows_black).withAlphaComponent(0.08)
|
|
: UIColor.ows_whiteAlpha20
|
|
}
|
|
|
|
private lazy var _backgroundColor = { makeBackgroundColor() }()
|
|
|
|
open func makeTextColor() -> UIColor {
|
|
return isIncoming
|
|
? (Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_gray60)
|
|
: .ows_white
|
|
}
|
|
|
|
private lazy var textColor: UIColor = { makeTextColor() }()
|
|
|
|
// MARK: - Configs
|
|
|
|
private static func playbackRateLabelConfig(
|
|
playbackRate: AudioPlaybackRate,
|
|
color: UIColor
|
|
) -> CVLabelConfig {
|
|
let text = playbackRate.displayText
|
|
// Limit the max font size to avoid overlap.
|
|
var font = Constants.font
|
|
if font.pointSize > Constants.maxFontSize {
|
|
font = font.withSize(Constants.maxFontSize)
|
|
}
|
|
font = font.semibold()
|
|
return CVLabelConfig.unstyledText(
|
|
text,
|
|
font: font,
|
|
textColor: color,
|
|
textAlignment: .right
|
|
)
|
|
}
|
|
|
|
fileprivate enum Constants {
|
|
static let cornerRadius: CGFloat = 6
|
|
static var font: UIFont { UIFont.dynamicTypeFootnote }
|
|
static let maxFontSize: CGFloat = 20
|
|
|
|
static var imageSize: CGFloat {
|
|
switch UIApplication.shared.preferredContentSizeCategory {
|
|
case .extraSmall, .small, .medium, .large, .extraLarge:
|
|
return 10
|
|
default:
|
|
return 16
|
|
}
|
|
}
|
|
static var image: UIImage? {
|
|
switch UIApplication.shared.preferredContentSizeCategory {
|
|
case .extraSmall, .small, .medium, .large, .extraLarge:
|
|
return UIImage(named: "x-extra-small")
|
|
default:
|
|
return UIImage(named: "x-compact")
|
|
}
|
|
}
|
|
static let margins = UIEdgeInsets(hMargin: 8, vMargin: 2)
|
|
|
|
static let animationName = "scale"
|
|
static let animationDuration: TimeInterval = 0.15
|
|
static let animationDamping: CGFloat = 1.15
|
|
static let animationStiffness: CGFloat = 100
|
|
static let animationMass: CGFloat = 1
|
|
static let animationSpeed: Float = 1
|
|
static let changeAnimationScale: CGFloat = 1.3
|
|
|
|
static var maxAnimationDuration: TimeInterval {
|
|
return animationDuration * 2 // 2x for autoreverse
|
|
}
|
|
|
|
static let minTapTargetSize: CGFloat = 44
|
|
}
|
|
}
|
|
|
|
// MARK: - AudioPlaybackRate extension
|
|
|
|
extension AudioPlaybackRate {
|
|
init(rawValue: Float) {
|
|
switch rawValue {
|
|
case _ where rawValue <= 0.5:
|
|
self = .slow
|
|
case _ where rawValue < 1.5:
|
|
self = .normal
|
|
case _ where rawValue < 2:
|
|
self = .fast
|
|
default:
|
|
self = .extraFast
|
|
}
|
|
}
|
|
|
|
var next: AudioPlaybackRate {
|
|
switch self {
|
|
case .slow:
|
|
return .normal
|
|
case .normal:
|
|
return .fast
|
|
case .fast:
|
|
return .extraFast
|
|
case .extraFast:
|
|
return .slow
|
|
}
|
|
}
|
|
|
|
var displayText: String {
|
|
// Instead of dealing with float formatting, just
|
|
// hardcode since there's only 4 cases anyway.
|
|
switch self {
|
|
case .slow:
|
|
return LocalizationNotNeeded(".5")
|
|
case .normal:
|
|
return LocalizationNotNeeded("1")
|
|
case .fast:
|
|
return LocalizationNotNeeded("1.5")
|
|
case .extraFast:
|
|
return LocalizationNotNeeded("2")
|
|
}
|
|
}
|
|
|
|
static var rateForLargestDisplayText: AudioPlaybackRate {
|
|
return .fast
|
|
}
|
|
}
|