Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/RecordingPreviewPanel.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()
}
}