Path: blob/main/Signal/ConversationView/ConversationInputToolbar+VoiceMemo.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Lottie
import SignalServiceKit
import SignalUI
extension ConversationInputToolbar {
class VoiceMemoLockView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
directionalLayoutMargins = .init(top: 12, leading: 8, bottom: 8, trailing: 8)
if #available(iOS 26, *) {
visualEffectView.clipsToBounds = true
visualEffectView.cornerConfiguration = .capsule()
}
addSubview(visualEffectView)
visualEffectView.contentView.addSubview(lockIconView)
visualEffectView.contentView.addSubview(chevronView)
addConstraints([
lockIconView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
lockIconView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
lockIconView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
chevronView.centerXAnchor.constraint(equalTo: centerXAnchor),
chevronView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
iconSpacingConstraint,
])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
visualEffectView.frame = bounds
if #unavailable(iOS 26) {
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(
roundedRect: bounds,
cornerRadius: bounds.size.smallerAxis / 2,
).cgPath
visualEffectView.layer.mask = maskLayer
}
}
func update(ratioComplete: CGFloat) {
iconSpacingConstraint.constant = CGFloat.lerp(left: initialIconSpacing, right: 0, alpha: ratioComplete)
}
// MARK: - Subviews
private let initialIconSpacing: CGFloat = 16
private lazy var iconSpacingConstraint = chevronView.topAnchor.constraint(equalTo: lockIconView.bottomAnchor, constant: initialIconSpacing)
private lazy var lockIconView: UIImageView = {
let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "lock"))
imageView.tintColor = Style.primaryTextColor
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var chevronView: UIImageView = {
let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "chevron-up"))
imageView.tintColor = Style.primaryTextColor
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var visualEffectView: UIVisualEffectView = {
let visualEffect: UIVisualEffect = {
if #available(iOS 26, *) {
UIGlassEffect(style: .regular)
} else {
UIBlurEffect(style: .systemThinMaterial)
}
}()
return UIVisualEffectView(effect: visualEffect)
}()
}
class VoiceMessageDraftView: UIView, AudioPlayerDelegate {
private let playbackTimeLabel: UILabel = {
let label = UILabel()
label.font = .monospacedDigitSystemFont(ofSize: UIFont.dynamicTypeSubheadlineClamped.pointSize, weight: .regular)
label.textColor = Style.secondaryTextColor
label.setContentHuggingHorizontalHigh()
label.setCompressionResistanceHorizontalHigh()
return label
}()
private lazy var playPauseButton: LottieToggleButton = {
let button = LottieToggleButton()
button.animationName = "playPauseButton"
button.animationSize = CGSize(square: 24)
button.animationSpeed = 3
button.setValueProvider(
ColorValueProvider(Theme.primaryIconColor.lottieColorValue),
keypath: AnimationKeypath(keypath: "**.Fill 1.Color"),
)
button.addAction(
UIAction { [weak self] _ in
self?.didTogglePlayPause()
},
for: .primaryActionTriggered,
)
return button
}()
private let waveformView: AudioWaveformProgressView
private let voiceMessageInterruptedDraft: VoiceMessageInterruptedDraft
var audioPlaybackState: AudioPlaybackState = .stopped
init(
voiceMessageInterruptedDraft: VoiceMessageInterruptedDraft,
mediaCache: CVMediaCache,
) {
self.voiceMessageInterruptedDraft = voiceMessageInterruptedDraft
self.waveformView = AudioWaveformProgressView(mediaCache: mediaCache)
super.init(frame: .zero)
directionalLayoutMargins = .init(hMargin: 12, vMargin: 0)
voiceMessageInterruptedDraft.audioPlayer.delegate = self
waveformView.thumbColor = .Signal.label
waveformView.playedColor = .Signal.label
waveformView.unplayedColor = .Signal.tertiaryLabel
waveformView.audioWaveformTask = voiceMessageInterruptedDraft.audioWaveformTask
let stackView = UIStackView(arrangedSubviews: [
playPauseButton,
waveformView,
playbackTimeLabel,
])
stackView.axis = .horizontal
stackView.spacing = 12
stackView.alignment = .center
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
addConstraints([
waveformView.heightAnchor.constraint(equalToConstant: 28),
stackView.topAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.topAnchor),
stackView.centerYAnchor.constraint(equalTo: layoutMarginsGuide.centerYAnchor),
stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
])
addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan)))
updateAudioProgress(currentTime: 0)
}
deinit {
voiceMessageInterruptedDraft.audioPlayer.stop()
}
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var isScrubbing = false
@objc
private func handlePan(_ sender: UIPanGestureRecognizer) {
let location = sender.location(in: waveformView)
var progress = CGFloat.clamp01(location.x / waveformView.width)
// When in RTL mode, the slider moves in the opposite direction so inverse the ratio.
if CurrentAppContext().isRTL {
progress = 1 - progress
}
guard let duration = voiceMessageInterruptedDraft.duration else {
return owsFailDebug("Missing duration")
}
let currentTime = duration * Double(progress)
switch sender.state {
case .began:
isScrubbing = true
updateAudioProgress(currentTime: currentTime)
case .changed:
updateAudioProgress(currentTime: currentTime)
case .ended:
isScrubbing = false
voiceMessageInterruptedDraft.audioPlayer.setCurrentTime(currentTime)
case .cancelled, .failed, .possible:
isScrubbing = false
@unknown default:
isScrubbing = false
}
}
func updateAudioProgress(currentTime: TimeInterval) {
guard let duration = voiceMessageInterruptedDraft.duration else { return }
waveformView.value = CGFloat.clamp01(CGFloat(currentTime / duration))
playbackTimeLabel.text = OWSFormat.localizedDurationString(from: duration - currentTime)
}
private func didTogglePlayPause() {
AppEnvironment.shared.cvAudioPlayerRef.stopAll()
playPauseButton.setSelected(!playPauseButton.isSelected, animated: true)
voiceMessageInterruptedDraft.audioPlayer.togglePlayState()
}
func setAudioProgress(_ progress: TimeInterval, duration: TimeInterval, playbackRate: Float) {
guard !isScrubbing else { return }
updateAudioProgress(currentTime: progress)
}
func audioPlayerDidFinish() {
playPauseButton.setSelected(false, animated: true)
updateAudioProgress(currentTime: 0)
}
}
}