TM-SGNL-iOS/Signal/ConversationView/CellViews/AudioMessagePlaybackRateView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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
}
}