407 lines
15 KiB
Swift
407 lines
15 KiB
Swift
import AVFAudio
|
|
import QuartzCore
|
|
import UIKit
|
|
|
|
// MARK: - RecordingPreviewPanelDelegate
|
|
|
|
@MainActor
|
|
protocol RecordingPreviewPanelDelegate: AnyObject {
|
|
func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>)
|
|
func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel)
|
|
func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel)
|
|
}
|
|
|
|
// MARK: - RecordingPreviewPanel
|
|
|
|
/// Preview panel shown after `lock -> stop`, before sending voice message.
|
|
/// Includes waveform scrubbing + trim handles + send/delete/record-more controls.
|
|
final class RecordingPreviewPanel: UIView {
|
|
|
|
private enum PanMode {
|
|
case scrub
|
|
case trimLeft
|
|
case trimRight
|
|
}
|
|
|
|
weak var delegate: RecordingPreviewPanelDelegate?
|
|
|
|
// MARK: - Subviews
|
|
|
|
private let glassBackground = TelegramGlassUIView(frame: .zero)
|
|
private let deleteButton = UIButton(type: .system)
|
|
private let playButton = UIButton(type: .system)
|
|
private let waveformContainer = UIView()
|
|
private let waveformView = WaveformView()
|
|
private let leftTrimMask = UIView()
|
|
private let rightTrimMask = UIView()
|
|
private let leftTrimHandle = UIView()
|
|
private let rightTrimHandle = UIView()
|
|
private let durationLabel = UILabel()
|
|
private let recordMoreButton = UIButton(type: .system)
|
|
private let sendButton = UIButton(type: .system)
|
|
|
|
// MARK: - Audio Playback
|
|
|
|
private var audioPlayer: AVAudioPlayer?
|
|
private var displayLink: CADisplayLink?
|
|
private var isPlaying = false
|
|
private let fileURL: URL
|
|
private let duration: TimeInterval
|
|
private let waveformSamples: [Float]
|
|
|
|
// MARK: - Trim / Scrub
|
|
|
|
private var trimStart: TimeInterval = 0
|
|
private var trimEnd: TimeInterval = 0
|
|
private var minTrimDuration: TimeInterval = 1
|
|
private var activePanMode: PanMode?
|
|
|
|
var selectedTrimRange: ClosedRange<TimeInterval> {
|
|
trimStart...trimEnd
|
|
}
|
|
|
|
// MARK: - Init
|
|
|
|
init(frame: CGRect, fileURL: URL, duration: TimeInterval, waveform: [Float]) {
|
|
self.fileURL = fileURL
|
|
self.duration = max(0, duration)
|
|
self.waveformSamples = waveform
|
|
super.init(frame: frame)
|
|
self.trimEnd = self.duration
|
|
clipsToBounds = true
|
|
layer.cornerRadius = 21
|
|
layer.cornerCurve = .continuous
|
|
setupSubviews()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupSubviews() {
|
|
glassBackground.fixedCornerRadius = 21
|
|
glassBackground.isUserInteractionEnabled = false
|
|
addSubview(glassBackground)
|
|
|
|
let trashConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
|
|
deleteButton.setImage(UIImage(systemName: "trash", withConfiguration: trashConfig), for: .normal)
|
|
deleteButton.tintColor = UIColor(red: 1, green: 45/255.0, blue: 85/255.0, alpha: 1)
|
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
|
deleteButton.isAccessibilityElement = true
|
|
deleteButton.accessibilityLabel = "Delete recording"
|
|
deleteButton.accessibilityHint = "Deletes the current voice draft."
|
|
addSubview(deleteButton)
|
|
|
|
configurePlayButton(playing: false)
|
|
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
|
playButton.isAccessibilityElement = true
|
|
playButton.accessibilityLabel = "Play recording"
|
|
playButton.accessibilityHint = "Plays or pauses voice preview."
|
|
addSubview(playButton)
|
|
|
|
waveformContainer.clipsToBounds = true
|
|
waveformContainer.layer.cornerRadius = 6
|
|
addSubview(waveformContainer)
|
|
|
|
waveformView.setSamples(waveformSamples)
|
|
waveformView.progress = 0
|
|
waveformContainer.addSubview(waveformView)
|
|
|
|
leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
|
rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
|
waveformContainer.addSubview(leftTrimMask)
|
|
waveformContainer.addSubview(rightTrimMask)
|
|
|
|
leftTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
|
leftTrimHandle.layer.cornerRadius = 2
|
|
waveformContainer.addSubview(leftTrimHandle)
|
|
|
|
rightTrimHandle.backgroundColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
|
rightTrimHandle.layer.cornerRadius = 2
|
|
waveformContainer.addSubview(rightTrimHandle)
|
|
|
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:)))
|
|
waveformContainer.addGestureRecognizer(pan)
|
|
waveformContainer.isAccessibilityElement = true
|
|
waveformContainer.accessibilityLabel = "Waveform trim area"
|
|
waveformContainer.accessibilityHint = "Drag to scrub, or drag edges to trim."
|
|
|
|
durationLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
|
|
durationLabel.textColor = .white.withAlphaComponent(0.72)
|
|
durationLabel.textAlignment = .right
|
|
addSubview(durationLabel)
|
|
|
|
let recordMoreConfig = UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)
|
|
recordMoreButton.setImage(UIImage(systemName: "plus.circle", withConfiguration: recordMoreConfig), for: .normal)
|
|
recordMoreButton.tintColor = .white.withAlphaComponent(0.85)
|
|
recordMoreButton.addTarget(self, action: #selector(recordMoreTapped), for: .touchUpInside)
|
|
recordMoreButton.isAccessibilityElement = true
|
|
recordMoreButton.accessibilityLabel = "Record more"
|
|
recordMoreButton.accessibilityHint = "Resume recording and append more audio."
|
|
addSubview(recordMoreButton)
|
|
|
|
let sendConfig = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold)
|
|
sendButton.setImage(UIImage(systemName: "arrow.up.circle.fill", withConfiguration: sendConfig), for: .normal)
|
|
sendButton.tintColor = UIColor(red: 0, green: 136/255.0, blue: 1, alpha: 1)
|
|
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
|
sendButton.isAccessibilityElement = true
|
|
sendButton.accessibilityLabel = "Send recording"
|
|
sendButton.accessibilityHint = "Sends current trimmed voice message."
|
|
addSubview(sendButton)
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
let h = bounds.height
|
|
let w = bounds.width
|
|
|
|
glassBackground.frame = bounds
|
|
glassBackground.applyCornerRadius()
|
|
|
|
deleteButton.frame = CGRect(x: 4, y: (h - 40) / 2, width: 40, height: 40)
|
|
playButton.frame = CGRect(x: 44, y: (h - 30) / 2, width: 30, height: 30)
|
|
|
|
sendButton.frame = CGRect(x: w - 40, y: (h - 36) / 2, width: 36, height: 36)
|
|
recordMoreButton.frame = CGRect(x: sendButton.frame.minX - 34, y: (h - 30) / 2, width: 30, height: 30)
|
|
|
|
let durationW: CGFloat = 44
|
|
durationLabel.frame = CGRect(
|
|
x: recordMoreButton.frame.minX - durationW - 6,
|
|
y: (h - 20) / 2,
|
|
width: durationW,
|
|
height: 20
|
|
)
|
|
|
|
let waveX = playButton.frame.maxX + 8
|
|
let waveW = durationLabel.frame.minX - 8 - waveX
|
|
waveformContainer.frame = CGRect(x: waveX, y: 4, width: max(0, waveW), height: h - 8)
|
|
waveformView.frame = waveformContainer.bounds
|
|
|
|
minTrimDuration = max(1.0, 56.0 * duration / max(waveformContainer.bounds.width, 1))
|
|
trimEnd = max(trimEnd, min(duration, trimStart + minTrimDuration))
|
|
updateTrimVisuals()
|
|
updateDurationLabel(isPlaying ? remainingFromPlayer() : (trimEnd - trimStart))
|
|
}
|
|
|
|
// MARK: - Play/Pause
|
|
|
|
@objc private func playTapped() {
|
|
if isPlaying {
|
|
pausePlayback()
|
|
} else {
|
|
startPlayback()
|
|
}
|
|
}
|
|
|
|
private func startPlayback() {
|
|
if audioPlayer == nil {
|
|
audioPlayer = try? AVAudioPlayer(contentsOf: fileURL)
|
|
audioPlayer?.prepareToPlay()
|
|
}
|
|
guard let player = audioPlayer else { return }
|
|
if player.currentTime < trimStart || player.currentTime > trimEnd {
|
|
player.currentTime = trimStart
|
|
}
|
|
player.play()
|
|
isPlaying = true
|
|
configurePlayButton(playing: true)
|
|
startDisplayLink()
|
|
}
|
|
|
|
private func pausePlayback() {
|
|
audioPlayer?.pause()
|
|
isPlaying = false
|
|
configurePlayButton(playing: false)
|
|
stopDisplayLink()
|
|
}
|
|
|
|
private func stopPlayback(resetToTrimStart: Bool = true) {
|
|
audioPlayer?.stop()
|
|
if resetToTrimStart {
|
|
audioPlayer?.currentTime = trimStart
|
|
waveformView.progress = CGFloat((duration > 0 ? trimStart / duration : 0))
|
|
} else {
|
|
waveformView.progress = 0
|
|
}
|
|
isPlaying = false
|
|
configurePlayButton(playing: false)
|
|
updateDurationLabel(trimEnd - trimStart)
|
|
stopDisplayLink()
|
|
}
|
|
|
|
private func configurePlayButton(playing: Bool) {
|
|
let config = UIImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
|
|
let name = playing ? "pause.fill" : "play.fill"
|
|
playButton.setImage(UIImage(systemName: name, withConfiguration: config), for: .normal)
|
|
playButton.tintColor = .white
|
|
}
|
|
|
|
// MARK: - Display Link
|
|
|
|
private func startDisplayLink() {
|
|
guard displayLink == nil else { return }
|
|
let link = CADisplayLink(target: self, selector: #selector(displayLinkTick))
|
|
link.add(to: .main, forMode: .common)
|
|
displayLink = link
|
|
}
|
|
|
|
private func stopDisplayLink() {
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
}
|
|
|
|
@objc private func displayLinkTick() {
|
|
guard let player = audioPlayer else { return }
|
|
if !player.isPlaying && isPlaying {
|
|
stopPlayback()
|
|
return
|
|
}
|
|
if player.currentTime >= trimEnd {
|
|
stopPlayback()
|
|
return
|
|
}
|
|
let progress = duration > 0 ? player.currentTime / duration : 0
|
|
waveformView.progress = CGFloat(progress)
|
|
updateDurationLabel(remainingFromPlayer())
|
|
}
|
|
|
|
// MARK: - Trim / Scrub
|
|
|
|
@objc private func handleWaveformPan(_ gesture: UIPanGestureRecognizer) {
|
|
guard duration > 0, waveformContainer.bounds.width > 1 else { return }
|
|
let location = gesture.location(in: waveformContainer)
|
|
let normalized = min(1, max(0, location.x / waveformContainer.bounds.width))
|
|
let targetTime = TimeInterval(normalized) * duration
|
|
|
|
switch gesture.state {
|
|
case .began:
|
|
let leftX = xForTime(trimStart)
|
|
let rightX = xForTime(trimEnd)
|
|
if abs(location.x - leftX) <= 14 {
|
|
activePanMode = .trimLeft
|
|
} else if abs(location.x - rightX) <= 14 {
|
|
activePanMode = .trimRight
|
|
} else {
|
|
activePanMode = .scrub
|
|
}
|
|
if activePanMode != .scrub {
|
|
pausePlayback()
|
|
}
|
|
case .changed:
|
|
switch activePanMode {
|
|
case .trimLeft:
|
|
trimStart = min(max(0, targetTime), trimEnd - minTrimDuration)
|
|
if let player = audioPlayer, player.currentTime < trimStart {
|
|
player.currentTime = trimStart
|
|
}
|
|
case .trimRight:
|
|
trimEnd = max(min(duration, targetTime), trimStart + minTrimDuration)
|
|
if let player = audioPlayer, player.currentTime > trimEnd {
|
|
player.currentTime = trimEnd
|
|
}
|
|
case .scrub:
|
|
let clamped = min(trimEnd, max(trimStart, targetTime))
|
|
if audioPlayer == nil {
|
|
audioPlayer = try? AVAudioPlayer(contentsOf: fileURL)
|
|
audioPlayer?.prepareToPlay()
|
|
}
|
|
audioPlayer?.currentTime = clamped
|
|
waveformView.progress = CGFloat(clamped / duration)
|
|
case .none:
|
|
break
|
|
}
|
|
updateTrimVisuals()
|
|
if activePanMode == .scrub {
|
|
updateDurationLabel(max(0, trimEnd - (audioPlayer?.currentTime ?? trimStart)))
|
|
} else {
|
|
updateDurationLabel(trimEnd - trimStart)
|
|
}
|
|
default:
|
|
activePanMode = nil
|
|
}
|
|
}
|
|
|
|
private func updateTrimVisuals() {
|
|
let h = waveformContainer.bounds.height
|
|
let w = waveformContainer.bounds.width
|
|
guard w > 0 else { return }
|
|
|
|
let startX = xForTime(trimStart)
|
|
let endX = xForTime(trimEnd)
|
|
leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: h)
|
|
rightTrimMask.frame = CGRect(x: min(w, endX), y: 0, width: max(0, w - endX), height: h)
|
|
|
|
let handleW: CGFloat = 4
|
|
leftTrimHandle.frame = CGRect(x: max(0, startX - handleW / 2), y: 0, width: handleW, height: h)
|
|
rightTrimHandle.frame = CGRect(x: min(w - handleW, endX - handleW / 2), y: 0, width: handleW, height: h)
|
|
}
|
|
|
|
private func xForTime(_ time: TimeInterval) -> CGFloat {
|
|
guard duration > 0 else { return 0 }
|
|
let normalized = min(1, max(0, time / duration))
|
|
return CGFloat(normalized) * waveformContainer.bounds.width
|
|
}
|
|
|
|
private func remainingFromPlayer() -> TimeInterval {
|
|
guard let player = audioPlayer else { return trimEnd - trimStart }
|
|
return max(0, trimEnd - player.currentTime)
|
|
}
|
|
|
|
// MARK: - Duration Formatting
|
|
|
|
private func updateDurationLabel(_ time: TimeInterval) {
|
|
let totalSeconds = Int(max(0, time))
|
|
let minutes = totalSeconds / 60
|
|
let seconds = totalSeconds % 60
|
|
durationLabel.text = String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc private func deleteTapped() {
|
|
stopPlayback()
|
|
delegate?.previewPanelDidTapDelete(self)
|
|
}
|
|
|
|
@objc private func recordMoreTapped() {
|
|
stopPlayback(resetToTrimStart: false)
|
|
delegate?.previewPanelDidTapRecordMore(self)
|
|
}
|
|
|
|
@objc private func sendTapped() {
|
|
stopPlayback(resetToTrimStart: false)
|
|
delegate?.previewPanelDidTapSend(self, trimRange: selectedTrimRange)
|
|
}
|
|
|
|
// MARK: - Animate In
|
|
|
|
func animateIn() {
|
|
alpha = 0
|
|
transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
|
UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) {
|
|
self.alpha = 1
|
|
self.transform = .identity
|
|
}
|
|
}
|
|
|
|
// MARK: - Animate Out
|
|
|
|
func animateOut(completion: (() -> Void)? = nil) {
|
|
stopPlayback()
|
|
UIView.animate(withDuration: 0.15, animations: {
|
|
self.alpha = 0
|
|
self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
|
}, completion: { _ in
|
|
self.removeFromSuperview()
|
|
completion?()
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
stopDisplayLink()
|
|
}
|
|
}
|