Creating a UIImageView outside a view hierarchy now crashes on iOS 18. We can avoid this offscreen rendering technique by removing UIImage.asTintedImage(), configuring the tint colour and template rendering on the UIImage directly, and rendering with UIGraphicsImageRenderer instead of lower-level Core Graphics APIs.
526 lines
20 KiB
Swift
526 lines
20 KiB
Swift
//
|
|
// Copyright 2019 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Lottie
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
class AudioMessageView: ManualStackView {
|
|
private enum Constants {
|
|
static let animationSize: CGFloat = 40
|
|
static let waveformHeight: CGFloat = 32
|
|
static let vSpacing: CGFloat = 2
|
|
static let innerLayoutMargins = UIEdgeInsets(hMargin: 0, vMargin: 4)
|
|
}
|
|
// MARK: - State
|
|
private var attachment: TSResource { presentation.audioAttachment.attachment }
|
|
private var attachmentStream: TSResourceStream? { presentation.audioAttachment.attachmentStream?.attachmentStream }
|
|
private var durationSeconds: TimeInterval { presentation.audioAttachment.durationSeconds }
|
|
|
|
private var isIncoming: Bool {
|
|
presentation.isIncoming
|
|
}
|
|
private weak var audioMessageViewDelegate: AudioMessageViewDelegate?
|
|
private let mediaCache: CVMediaCache
|
|
|
|
private var audioPlaybackState: AudioPlaybackState {
|
|
cvAudioPlayer.audioPlaybackState(forAttachmentId: attachment.resourceId)
|
|
}
|
|
|
|
private var elapsedSeconds: TimeInterval {
|
|
guard let attachmentStream = self.attachmentStream else {
|
|
return 0
|
|
}
|
|
return cvAudioPlayer.playbackProgress(forAttachmentStream: attachmentStream)
|
|
}
|
|
|
|
private var isViewed = false
|
|
public func setViewed(_ isViewed: Bool, animated: Bool) {
|
|
guard isViewed != self.isViewed else { return }
|
|
self.isViewed = isViewed
|
|
updateContents(animated: animated)
|
|
}
|
|
|
|
// MARK: - Views
|
|
|
|
private let playedDotAnimation: LottieAnimationView
|
|
private let playPauseAnimation: LottieAnimationView
|
|
private let playPauseContainer = ManualLayoutView.circleView(name: "playPauseContainer")
|
|
private let progressSlider = UISlider()
|
|
private let waveformProgress: AudioWaveformProgressView
|
|
private let waveformContainer = ManualLayoutView(name: "waveformContainer")
|
|
private let presentation: AudioPresenter
|
|
|
|
// MARK: Init
|
|
|
|
init(
|
|
presentation: AudioPresenter,
|
|
audioMessageViewDelegate: AudioMessageViewDelegate,
|
|
mediaCache: CVMediaCache
|
|
) {
|
|
self.audioMessageViewDelegate = audioMessageViewDelegate
|
|
self.mediaCache = mediaCache
|
|
|
|
self.waveformProgress = AudioWaveformProgressView(mediaCache: mediaCache)
|
|
self.playedDotAnimation = mediaCache.buildLottieAnimationView(name: "audio-played-dot")
|
|
self.playPauseAnimation = mediaCache.buildLottieAnimationView(name: "playPauseButton")
|
|
self.presentation = presentation
|
|
|
|
super.init(name: presentation.name)
|
|
}
|
|
|
|
// MARK: - Rendering
|
|
|
|
public func configureForRendering(cellMeasurement: CVCellMeasurement, conversationStyle: ConversationStyle) {
|
|
var outerSubviews = [UIView]()
|
|
|
|
if let topLabelConfig = presentation.topLabelConfig(
|
|
audioAttachment: presentation.audioAttachment,
|
|
isIncoming: isIncoming,
|
|
conversationStyle: conversationStyle
|
|
) {
|
|
let topLabel = CVLabel()
|
|
topLabelConfig.applyForRendering(label: topLabel)
|
|
outerSubviews.append(topLabel)
|
|
}
|
|
|
|
waveformProgress.playedColor = presentation.playedColor(isIncoming: isIncoming)
|
|
waveformProgress.unplayedColor = presentation.unplayedColor(isIncoming: isIncoming)
|
|
waveformProgress.thumbColor = presentation.thumbColor(isIncoming: isIncoming)
|
|
waveformContainer.addSubviewToFillSuperviewEdges(waveformProgress)
|
|
|
|
progressSlider.setThumbImage(UIImage(named: "audio_message_thumb")?.withTintColor(presentation.thumbColor(isIncoming: isIncoming), renderingMode: .alwaysTemplate), for: .normal)
|
|
progressSlider.setMinimumTrackImage(trackImage(color: presentation.playedColor(isIncoming: isIncoming)), for: .normal)
|
|
progressSlider.setMaximumTrackImage(trackImage(color: presentation.unplayedColor(isIncoming: isIncoming)), for: .normal)
|
|
progressSlider.isEnabled = presentation.audioAttachment.isDownloaded
|
|
progressSlider.isUserInteractionEnabled = false
|
|
|
|
waveformContainer.addSubview(progressSlider) { [progressSlider] view in
|
|
var sliderFrame = view.bounds
|
|
sliderFrame.height = 12
|
|
sliderFrame.y = (view.bounds.height - sliderFrame.height) * 0.5
|
|
progressSlider.frame = sliderFrame
|
|
}
|
|
|
|
presentation.configureForRendering(conversationStyle: conversationStyle)
|
|
|
|
let leftView: UIView
|
|
switch presentation.audioAttachment.state {
|
|
case .attachmentStream:
|
|
fallthrough
|
|
case .attachmentPointer where presentation.audioAttachment.isDownloaded:
|
|
let playPauseAnimation = self.playPauseAnimation
|
|
let playedDotAnimation = self.playedDotAnimation
|
|
|
|
// TODO: There is a bug with Lottie where animations lag when there are a lot
|
|
// of other things happening on screen. Since this animation generally plays
|
|
// when the progress bar / waveform is rendering we speed up the playback to
|
|
// address some of the lag issues. Once this is fixed we should update lottie
|
|
// and remove this check. https://github.com/airbnb/lottie-ios/issues/1034
|
|
playPauseAnimation.animationSpeed = 3
|
|
playPauseAnimation.backgroundBehavior = .forceFinish
|
|
playPauseAnimation.contentMode = .scaleAspectFit
|
|
|
|
playedDotAnimation.animationSpeed = 3
|
|
playedDotAnimation.backgroundBehavior = .forceFinish
|
|
playedDotAnimation.contentMode = .scaleAspectFit
|
|
|
|
let fillColorKeypath = AnimationKeypath(keypath: "**.Fill 1.Color")
|
|
playPauseAnimation.setValueProvider(
|
|
presentation.playPauseAnimationColor(isIncoming: isIncoming),
|
|
keypath: fillColorKeypath
|
|
)
|
|
playedDotAnimation.setValueProvider(
|
|
presentation.playedDotAnimationColor(conversationStyle: conversationStyle, isIncoming: isIncoming),
|
|
keypath: fillColorKeypath
|
|
)
|
|
|
|
playPauseContainer.backgroundColor = presentation.playPauseContainerBackgroundColor(isIncoming: isIncoming)
|
|
playPauseContainer.addSubviewToCenterOnSuperview(playPauseAnimation, size: CGSize(square: 24))
|
|
|
|
presentation.playedDotContainer.addSubviewToCenterOnSuperview(playedDotAnimation, size: CGSize(square: 16))
|
|
|
|
leftView = playPauseContainer
|
|
case .attachmentPointer(let attachmentPointer, let transitTierDownloadState):
|
|
leftView = CVAttachmentProgressView(
|
|
direction: .download(
|
|
attachmentPointer: attachmentPointer.attachmentPointer,
|
|
transitTierDownloadState: transitTierDownloadState
|
|
),
|
|
diameter: Constants.animationSize,
|
|
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
|
|
mediaCache: mediaCache
|
|
)
|
|
}
|
|
|
|
let topInnerStack = ManualStackView(name: "playerStack")
|
|
topInnerStack.semanticContentAttribute = .playback
|
|
topInnerStack.configure(
|
|
config: Self.topInnerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_topInnerStack,
|
|
subviews: [
|
|
leftView,
|
|
.transparentSpacer(),
|
|
waveformContainer,
|
|
.transparentSpacer()
|
|
]
|
|
)
|
|
outerSubviews.append(topInnerStack)
|
|
|
|
let generators = presentation.bottomSubviewGenerators(conversationStyle: conversationStyle)
|
|
|
|
let bottomInnerStack = ManualStackView(name: "playbackLabelStack")
|
|
bottomInnerStack.configure(
|
|
config: Self.bottomInnerStackConfig(presentation: presentation),
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_bottomInnerStack,
|
|
subviews: generators.map { $0.viewGenerator() }
|
|
)
|
|
outerSubviews.append(bottomInnerStack)
|
|
|
|
self.configure(
|
|
config: Self.outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviews: outerSubviews
|
|
)
|
|
|
|
updateContents(animated: false)
|
|
|
|
cvAudioPlayer.addListener(self)
|
|
}
|
|
|
|
// MARK: - Measurement
|
|
|
|
private static let measurementKey_topInnerStack = "CVComponentAudioAttachment.measurementKey_topInnerStack"
|
|
private static let measurementKey_bottomInnerStack = "CVComponentAudioAttachment.measurementKey_bottomInnerStack"
|
|
private static let measurementKey_outerStack = "CVComponentAudioAttachment.measurementKey_outerStack"
|
|
|
|
public static func measure(
|
|
maxWidth: CGFloat,
|
|
measurementBuilder: CVCellMeasurement.Builder,
|
|
presentation: AudioPresenter
|
|
) -> CGSize {
|
|
owsAssertDebug(maxWidth > 0)
|
|
|
|
var outerSubviewInfos = [ManualStackSubviewInfo]()
|
|
if let topLabelConfig = presentation.topLabelConfig(
|
|
audioAttachment: presentation.audioAttachment,
|
|
isIncoming: presentation.isIncoming,
|
|
conversationStyle: nil
|
|
) {
|
|
let topLabelSize = CGSize(width: 0, height: topLabelConfig.font.lineHeight)
|
|
outerSubviewInfos.append(topLabelSize.asManualSubviewInfo)
|
|
}
|
|
|
|
var topInnerSubviewInfos = [ManualStackSubviewInfo]()
|
|
let leftViewSize = CGSize(square: Constants.animationSize)
|
|
topInnerSubviewInfos.append(leftViewSize.asManualSubviewInfo(hasFixedSize: true))
|
|
|
|
topInnerSubviewInfos.append(CGSize(width: 12, height: 0).asManualSubviewInfo(hasFixedWidth: true))
|
|
|
|
let waveformSize = CGSize(width: 0, height: Constants.waveformHeight)
|
|
topInnerSubviewInfos.append(waveformSize.asManualSubviewInfo(hasFixedHeight: true))
|
|
|
|
topInnerSubviewInfos.append(CGSize(width: 6, height: 0).asManualSubviewInfo(hasFixedWidth: true))
|
|
|
|
let topInnerStackMeasurement = ManualStackView.measure(
|
|
config: topInnerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_topInnerStack,
|
|
subviewInfos: topInnerSubviewInfos
|
|
)
|
|
let topInnerStackSize = topInnerStackMeasurement.measuredSize
|
|
outerSubviewInfos.append(topInnerStackSize.ceil.asManualSubviewInfo)
|
|
|
|
let bottomInnerStackMeasurement = ManualStackView.measure(
|
|
config: bottomInnerStackConfig(presentation: presentation),
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_bottomInnerStack,
|
|
subviewInfos: presentation.bottomSubviewGenerators(conversationStyle: nil).map { $0.measurementInfo(maxWidth) }
|
|
)
|
|
let bottomInnerStackSize = bottomInnerStackMeasurement.measuredSize
|
|
outerSubviewInfos.append(bottomInnerStackSize.ceil.asManualSubviewInfo)
|
|
|
|
let outerStackMeasurement = ManualStackView.measure(
|
|
config: outerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviewInfos: outerSubviewInfos,
|
|
maxWidth: maxWidth
|
|
)
|
|
return outerStackMeasurement.measuredSize
|
|
}
|
|
|
|
// MARK: - View Configs
|
|
|
|
private static var outerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .vertical,
|
|
alignment: .fill,
|
|
spacing: Constants.vSpacing,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
private static var topInnerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: Constants.innerLayoutMargins)
|
|
}
|
|
|
|
private static func bottomInnerStackConfig(presentation: AudioPresenter) -> CVStackViewConfig {
|
|
CVStackViewConfig(axis: .horizontal,
|
|
alignment: .center,
|
|
spacing: presentation.bottomInnerStackSpacing,
|
|
layoutMargins: .zero)
|
|
}
|
|
|
|
// MARK: - Tapping
|
|
|
|
public func handleTap(sender: UIGestureRecognizer, itemModel: CVItemModel) -> Bool {
|
|
presentation.playbackRateView.handleTap(
|
|
sender: sender,
|
|
itemModel: itemModel,
|
|
audioMessageViewDelegate: audioMessageViewDelegate
|
|
)
|
|
}
|
|
|
|
// MARK: - Scrubbing
|
|
|
|
var isScrubbing = false
|
|
|
|
func isPointInScrubbableRegion(_ point: CGPoint) -> Bool {
|
|
// If we have a waveform but aren't done sampling it, don't allow scrubbing yet.
|
|
if waveformProgress.audioWaveformTask != nil, waveformProgress.audioWaveform == nil {
|
|
return false
|
|
}
|
|
|
|
let locationInSlider = convert(point, to: waveformProgress)
|
|
return locationInSlider.x >= 0 && locationInSlider.x <= waveformProgress.width
|
|
}
|
|
|
|
func progressForLocation(_ point: CGPoint) -> CGFloat {
|
|
let sliderContainer = convert(waveformProgress.frame, from: waveformProgress.superview)
|
|
let newRatio = CGFloat.inverseLerp(point.x, min: sliderContainer.minX, max: sliderContainer.maxX).clamp01()
|
|
return newRatio.clamp01()
|
|
}
|
|
|
|
func scrubToLocation(_ point: CGPoint) -> TimeInterval {
|
|
let newRatio = progressForLocation(point)
|
|
|
|
visibleProgressRatio = newRatio
|
|
|
|
return TimeInterval(newRatio) * durationSeconds
|
|
}
|
|
|
|
// MARK: - Contents
|
|
|
|
// If set, the playback should reflect
|
|
// this progress, not the actual progress.
|
|
// During pan gestures, this gives a preview
|
|
// of playback scrubbing.
|
|
private var overrideProgress: CGFloat?
|
|
|
|
func updateContents(animated: Bool) {
|
|
updatePlaybackState(animated: animated)
|
|
updateViewedState(animated: animated)
|
|
updateAudioProgress()
|
|
updatePlaybackRate(animated: animated)
|
|
}
|
|
|
|
// MARK: Progress
|
|
|
|
private var audioProgressRatio: CGFloat {
|
|
if let overrideProgress = self.overrideProgress {
|
|
return overrideProgress.clamp01()
|
|
}
|
|
guard durationSeconds > 0 else { return 0 }
|
|
return CGFloat(elapsedSeconds / durationSeconds)
|
|
}
|
|
|
|
private var visibleProgressRatio: CGFloat {
|
|
get {
|
|
waveformProgress.value
|
|
}
|
|
set {
|
|
waveformProgress.value = newValue
|
|
progressSlider.value = Float(newValue)
|
|
updateElapsedTime(durationSeconds * TimeInterval(newValue))
|
|
}
|
|
}
|
|
|
|
// MARK: Playback State
|
|
|
|
private var playPauseAnimationTarget: AnimationProgressTime?
|
|
private var playPauseAnimationEnd: (() -> Void)?
|
|
|
|
private func updatePlaybackState(animated: Bool = true) {
|
|
let isPlaying = audioPlaybackState == .playing
|
|
let destination: AnimationProgressTime = isPlaying ? 1 : 0
|
|
|
|
// Do nothing if we're already there.
|
|
guard destination != playPauseAnimation.currentProgress else { return }
|
|
|
|
// Do nothing if we are already animating.
|
|
if
|
|
animated,
|
|
playPauseAnimation.isAnimationQueued || playPauseAnimation.isAnimationPlaying,
|
|
playPauseAnimationTarget == destination
|
|
{
|
|
return
|
|
}
|
|
|
|
playPauseAnimationTarget = destination
|
|
|
|
if animated {
|
|
playPauseAnimationEnd?()
|
|
let endCellAnimation = audioMessageViewDelegate?.beginCellAnimation(maximumDuration: 0.2)
|
|
playPauseAnimationEnd = endCellAnimation
|
|
playPauseAnimation.play(toProgress: destination) { _ in
|
|
endCellAnimation?()
|
|
}
|
|
} else {
|
|
playPauseAnimationEnd?()
|
|
playPauseAnimation.currentProgress = destination
|
|
}
|
|
}
|
|
|
|
private func updateElapsedTime(_ elapsedSeconds: TimeInterval) {
|
|
let timeRemaining = max(0, durationSeconds - elapsedSeconds)
|
|
presentation.playbackTimeLabel.text = OWSFormat.localizedDurationString(from: timeRemaining)
|
|
}
|
|
|
|
private func updateAudioProgress() {
|
|
guard !isScrubbing else { return }
|
|
|
|
visibleProgressRatio = audioProgressRatio
|
|
|
|
if let waveformTask = presentation.audioWaveform(attachmentStream: attachmentStream) {
|
|
waveformProgress.audioWaveformTask = waveformTask
|
|
waveformProgress.isHidden = false
|
|
progressSlider.isHidden = true
|
|
} else {
|
|
waveformProgress.isHidden = true
|
|
progressSlider.isHidden = false
|
|
}
|
|
}
|
|
|
|
public func setOverrideProgress(_ value: CGFloat, animated: Bool) {
|
|
overrideProgress = value
|
|
updateContents(animated: animated)
|
|
}
|
|
|
|
public func clearOverrideProgress(animated: Bool) {
|
|
overrideProgress = nil
|
|
updateContents(animated: animated)
|
|
}
|
|
|
|
private func trackImage(color: UIColor) -> UIImage? {
|
|
return UIImage(named: "audio_message_track")?
|
|
.withTintColor(color, renderingMode: .alwaysTemplate)
|
|
.resizableImage(withCapInsets: UIEdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2))
|
|
}
|
|
|
|
// MARK: Viewed State
|
|
|
|
private var playedDotAnimationTarget: AnimationProgressTime?
|
|
private var playedDotAnimationEnd: (() -> Void)?
|
|
|
|
private func updateViewedState(animated: Bool = true) {
|
|
let destination: AnimationProgressTime = isViewed ? 1 : 0
|
|
|
|
// Do nothing if we're already there.
|
|
guard destination != playedDotAnimation.currentProgress else { return }
|
|
|
|
// Do nothing if we are already animating.
|
|
if
|
|
animated,
|
|
playedDotAnimation.isAnimationQueued || playedDotAnimation.isAnimationPlaying,
|
|
playedDotAnimationTarget == destination
|
|
{
|
|
return
|
|
}
|
|
|
|
playedDotAnimationTarget = destination
|
|
playedDotAnimation.stop()
|
|
|
|
if animated {
|
|
playedDotAnimationEnd?()
|
|
let endCellAnimation = audioMessageViewDelegate?.beginCellAnimation(maximumDuration: 0.2)
|
|
playedDotAnimationEnd = endCellAnimation
|
|
playedDotAnimation.play(toProgress: destination) { _ in
|
|
endCellAnimation?()
|
|
}
|
|
} else {
|
|
playedDotAnimationEnd?()
|
|
playedDotAnimation.currentProgress = destination
|
|
}
|
|
}
|
|
|
|
// MARK: Playback Rate
|
|
|
|
private func updatePlaybackRate(animated: Bool) {
|
|
let isPlaying: Bool = {
|
|
guard let attachmentStream = attachmentStream else {
|
|
return false
|
|
}
|
|
return cvAudioPlayer.audioPlaybackState(forAttachmentId: attachmentStream.resourceId) == .playing
|
|
}()
|
|
presentation.playbackRateView.setVisibility(isPlaying, animated: animated)
|
|
}
|
|
}
|
|
|
|
// MARK: - CVAudioPlayerListener
|
|
|
|
extension AudioMessageView: CVAudioPlayerListener {
|
|
func audioPlayerStateDidChange(attachmentId: TSResourceId) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard attachmentId == attachment.resourceId else { return }
|
|
|
|
updateContents(animated: true)
|
|
}
|
|
|
|
func audioPlayerDidFinish(attachmentId: TSResourceId) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard attachmentId == attachment.resourceId else { return }
|
|
|
|
updateContents(animated: true)
|
|
}
|
|
|
|
func audioPlayerDidMarkViewed(attachmentId: TSResourceId) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isViewed, attachmentId == attachment.resourceId else { return }
|
|
|
|
setViewed(true, animated: true)
|
|
}
|
|
}
|
|
|
|
extension AudioAttachment {
|
|
var sizeString: String {
|
|
switch state {
|
|
case .attachmentStream(let stream, _):
|
|
return ByteCountFormatter().string(for: stream.attachmentStream.unencryptedResourceByteCount) ?? ""
|
|
case .attachmentPointer:
|
|
owsFailDebug("Shouldn't get here - undownloaded media not implemented")
|
|
return ""
|
|
}
|
|
}
|
|
var dateString: String {
|
|
switch state {
|
|
case .attachmentStream(_, _):
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.setLocalizedDateFormatFromTemplate("Mdyy")
|
|
return dateFormatter.string(from: receivedAtDate)
|
|
case .attachmentPointer:
|
|
owsFailDebug("Shouldn't get here - undownloaded media not implemented")
|
|
return ""
|
|
}
|
|
}
|
|
}
|