- Хаптик срабатывает ДО запуска AVAudioSession (иначе глушится) - Убрана двойная вибрация (AudioServicesPlaySystemSound + impactOccurred) - Прогрев Taptic Engine в didMoveToWindow + beginTracking - Recording chrome показывается синхронно до async Task
360 lines
12 KiB
Swift
360 lines
12 KiB
Swift
import QuartzCore
|
|
import UIKit
|
|
|
|
// MARK: - Recording State
|
|
|
|
enum VoiceRecordingState {
|
|
case idle
|
|
case waiting // finger down, waiting for threshold (0.19s)
|
|
case recording // actively recording, finger held
|
|
case locked // slid up past lock threshold, finger released
|
|
case cancelled // slid left past cancel threshold
|
|
case finished // finger released normally → send
|
|
}
|
|
|
|
// MARK: - RecordingMicButtonDelegate
|
|
|
|
@MainActor
|
|
protocol RecordingMicButtonDelegate: AnyObject {
|
|
/// Finger down, hold timer armed.
|
|
func micButtonRecordingArmed(_ button: RecordingMicButton)
|
|
|
|
/// Hold was cancelled before threshold (tap / move / system cancel).
|
|
func micButtonRecordingArmingCancelled(_ button: RecordingMicButton)
|
|
|
|
/// Recording threshold reached (0.19s hold). Start actual recording.
|
|
func micButtonRecordingBegan(_ button: RecordingMicButton)
|
|
|
|
/// Finger released normally → send the recording.
|
|
func micButtonRecordingFinished(_ button: RecordingMicButton)
|
|
|
|
/// Slid left past cancel threshold → discard recording.
|
|
func micButtonRecordingCancelled(_ button: RecordingMicButton)
|
|
|
|
/// Slid up past lock threshold → lock into hands-free recording.
|
|
func micButtonRecordingLocked(_ button: RecordingMicButton)
|
|
|
|
/// Raw drag distances for overlay transforms (Telegram: continueTrackingWithTouch).
|
|
/// distanceX: negative = left (cancel), distanceY: negative = up (lock)
|
|
func micButtonDragUpdate(_ button: RecordingMicButton, distanceX: CGFloat, distanceY: CGFloat)
|
|
}
|
|
|
|
// MARK: - RecordingMicButton
|
|
|
|
/// Custom UIControl that handles voice recording gestures.
|
|
/// Ported from Telegram's `TGModernConversationInputMicButton`.
|
|
///
|
|
/// Gesture mechanics:
|
|
/// - Long press (0.19s) → begin recording
|
|
/// - Slide left → cancel (threshold: -150px, haptic at -100px)
|
|
/// - Slide up → lock (threshold: -110px, haptic at -60px)
|
|
/// - Release velocity gate: <-400 px/s on X/Y commits cancel/lock
|
|
/// - Release → finish (send)
|
|
final class RecordingMicButton: UIControl {
|
|
|
|
weak var recordingDelegate: RecordingMicButtonDelegate?
|
|
|
|
private(set) var recordingState: VoiceRecordingState = .idle
|
|
|
|
// MARK: - Gesture Thresholds (Telegram parity)
|
|
|
|
private let holdThreshold: TimeInterval = VoiceRecordingParityConstants.holdThreshold
|
|
private let cancelDistanceThreshold: CGFloat = VoiceRecordingParityConstants.cancelDistanceThreshold
|
|
private let cancelHapticThreshold: CGFloat = VoiceRecordingParityConstants.cancelHapticThreshold
|
|
private let lockDistanceThreshold: CGFloat = VoiceRecordingParityConstants.lockDistanceThreshold
|
|
private let lockHapticThreshold: CGFloat = VoiceRecordingParityConstants.lockHapticThreshold
|
|
private let velocityGate: CGFloat = VoiceRecordingParityConstants.velocityGate
|
|
private let preHoldCancelDistance: CGFloat = VoiceRecordingParityConstants.preHoldCancelDistance
|
|
|
|
// MARK: - Tracking State
|
|
|
|
private var touchStartLocation: CGPoint = .zero
|
|
private var lastTouchLocation: CGPoint = .zero
|
|
private var lastTouchTimestamp: TimeInterval = 0
|
|
private var velocityX: CGFloat = 0
|
|
private var velocityY: CGFloat = 0
|
|
private var holdTimer: Timer?
|
|
private var displayLink: CADisplayLink?
|
|
|
|
// Raw target values (set by touch events)
|
|
private var targetCancelTranslation: CGFloat = 0
|
|
private var targetLockTranslation: CGFloat = 0
|
|
|
|
// Smoothed values (updated by display link)
|
|
private var currentCancelTranslation: CGFloat = 0
|
|
private var currentLockTranslation: CGFloat = 0
|
|
|
|
// Haptic tracking
|
|
private var didCancelHaptic = false
|
|
private var didLockHaptic = false
|
|
|
|
// Haptic generator — eager init so object is ready long before first use
|
|
private let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
|
|
// MARK: - Init
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
}
|
|
|
|
override func didMoveToWindow() {
|
|
super.didMoveToWindow()
|
|
if window != nil {
|
|
impactFeedback.prepare()
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
// MARK: - Touch Tracking
|
|
|
|
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
|
guard recordingState == .idle else { return false }
|
|
|
|
touchStartLocation = touch.location(in: window)
|
|
lastTouchLocation = touchStartLocation
|
|
lastTouchTimestamp = touch.timestamp
|
|
velocityX = 0
|
|
velocityY = 0
|
|
recordingState = .waiting
|
|
targetCancelTranslation = 0
|
|
targetLockTranslation = 0
|
|
currentCancelTranslation = 0
|
|
currentLockTranslation = 0
|
|
didCancelHaptic = false
|
|
didLockHaptic = false
|
|
|
|
impactFeedback.prepare()
|
|
recordingDelegate?.micButtonRecordingArmed(self)
|
|
|
|
// Start hold timer — after 0.19s we begin recording
|
|
holdTimer = Timer.scheduledTimer(withTimeInterval: holdThreshold, repeats: false) { [weak self] _ in
|
|
self?.beginRecording()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
|
guard recordingState == .waiting || recordingState == .recording else { return false }
|
|
|
|
let location = touch.location(in: window)
|
|
let distanceX = min(0, location.x - touchStartLocation.x)
|
|
let distanceY = min(0, location.y - touchStartLocation.y)
|
|
updateVelocity(with: touch, at: location)
|
|
|
|
// Check if we moved enough to cancel the hold timer (before recording started)
|
|
if recordingState == .waiting {
|
|
let totalDistance = sqrt(distanceX * distanceX + distanceY * distanceY)
|
|
if totalDistance > preHoldCancelDistance {
|
|
// Movement before threshold — cancel the timer, don't start recording
|
|
cancelHoldTimer()
|
|
recordingState = .idle
|
|
recordingDelegate?.micButtonRecordingArmingCancelled(self)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Recording state — track slide gestures
|
|
targetCancelTranslation = distanceX
|
|
targetLockTranslation = distanceY
|
|
|
|
// Cancel haptic
|
|
if distanceX < cancelHapticThreshold, !didCancelHaptic {
|
|
didCancelHaptic = true
|
|
fireHaptic()
|
|
}
|
|
|
|
// Lock haptic
|
|
if distanceY < lockHapticThreshold, !didLockHaptic {
|
|
didLockHaptic = true
|
|
fireHaptic()
|
|
}
|
|
|
|
// Check cancel threshold
|
|
if distanceX < cancelDistanceThreshold {
|
|
commitCancel()
|
|
return false
|
|
}
|
|
|
|
// Check lock threshold
|
|
if distanceY < lockDistanceThreshold {
|
|
commitLock()
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
|
if recordingState == .waiting {
|
|
// Released before hold threshold — just a tap
|
|
cancelHoldTimer()
|
|
recordingState = .idle
|
|
recordingDelegate?.micButtonRecordingArmingCancelled(self)
|
|
return
|
|
}
|
|
|
|
if recordingState == .recording {
|
|
// Fallback to distance thresholds on release.
|
|
if let touch {
|
|
let location = touch.location(in: window)
|
|
var distanceX = min(0, location.x - touchStartLocation.x)
|
|
var distanceY = min(0, location.y - touchStartLocation.y)
|
|
|
|
// Telegram parity: keep only dominant direction on release.
|
|
(distanceX, distanceY) = VoiceRecordingParityMath.dominantAxisDistances(
|
|
distanceX: distanceX,
|
|
distanceY: distanceY
|
|
)
|
|
|
|
switch VoiceRecordingParityMath.releaseDecision(
|
|
velocityX: velocityX,
|
|
velocityY: velocityY,
|
|
distanceX: distanceX,
|
|
distanceY: distanceY
|
|
) {
|
|
case .cancel:
|
|
commitCancel()
|
|
return
|
|
case .lock:
|
|
commitLock()
|
|
return
|
|
case .finish:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Normal release → finish recording (send)
|
|
commitFinish()
|
|
}
|
|
}
|
|
|
|
override func cancelTracking(with event: UIEvent?) {
|
|
if recordingState == .recording {
|
|
// Telegram parity: delayed lock after cancelTracking.
|
|
targetLockTranslation = 0
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
guard let self, self.recordingState == .recording else { return }
|
|
self.commitLock()
|
|
}
|
|
} else {
|
|
cancelHoldTimer()
|
|
recordingState = .idle
|
|
recordingDelegate?.micButtonRecordingArmingCancelled(self)
|
|
}
|
|
stopDisplayLink()
|
|
}
|
|
|
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
bounds.insetBy(dx: VoiceRecordingParityConstants.micHitInsetX, dy: 0).contains(point)
|
|
}
|
|
|
|
// MARK: - State Transitions
|
|
|
|
private func beginRecording() {
|
|
guard recordingState == .waiting else { return }
|
|
recordingState = .recording
|
|
holdTimer = nil
|
|
|
|
// Haptic fires BEFORE delegate — delegate starts AVAudioSession
|
|
// which suppresses Taptic Engine.
|
|
fireHaptic()
|
|
startDisplayLink()
|
|
recordingDelegate?.micButtonRecordingBegan(self)
|
|
}
|
|
|
|
private func commitCancel() {
|
|
guard recordingState == .recording else { return }
|
|
recordingState = .cancelled
|
|
stopDisplayLink()
|
|
recordingDelegate?.micButtonRecordingCancelled(self)
|
|
}
|
|
|
|
private func commitLock() {
|
|
guard recordingState == .recording else { return }
|
|
recordingState = .locked
|
|
stopDisplayLink()
|
|
fireHaptic()
|
|
recordingDelegate?.micButtonRecordingLocked(self)
|
|
}
|
|
|
|
private func commitFinish() {
|
|
guard recordingState == .recording else { return }
|
|
recordingState = .finished
|
|
stopDisplayLink()
|
|
recordingDelegate?.micButtonRecordingFinished(self)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Reset to idle state (call after processing send/cancel/lock).
|
|
func resetState() {
|
|
cancelHoldTimer()
|
|
stopDisplayLink()
|
|
recordingState = .idle
|
|
velocityX = 0
|
|
velocityY = 0
|
|
targetCancelTranslation = 0
|
|
targetLockTranslation = 0
|
|
currentCancelTranslation = 0
|
|
currentLockTranslation = 0
|
|
}
|
|
|
|
#if DEBUG
|
|
func debugSetRecordingState(_ state: VoiceRecordingState) {
|
|
recordingState = state
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Display Link
|
|
|
|
private func startDisplayLink() {
|
|
guard displayLink == nil else { return }
|
|
let link = CADisplayLink(target: self, selector: #selector(displayLinkUpdate))
|
|
link.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30)
|
|
link.add(to: .main, forMode: .common)
|
|
displayLink = link
|
|
}
|
|
|
|
private func stopDisplayLink() {
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
}
|
|
|
|
@objc private func displayLinkUpdate() {
|
|
// Telegram exact: 0.7/0.3 blend (TGModernConversationInputMicButton.m line 918-919)
|
|
currentCancelTranslation = min(0, currentCancelTranslation * 0.7 + targetCancelTranslation * 0.3)
|
|
currentLockTranslation = min(0, currentLockTranslation * 0.7 + targetLockTranslation * 0.3)
|
|
|
|
// Report raw smoothed distances for overlay transforms
|
|
recordingDelegate?.micButtonDragUpdate(self, distanceX: currentCancelTranslation, distanceY: currentLockTranslation)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Belt-and-suspenders haptic: UIImpactFeedbackGenerator + direct system call.
|
|
/// AudioServicesPlaySystemSound(1519) is the "peek" haptic — bypasses
|
|
/// UIFeedbackGenerator API and hits Taptic Engine directly.
|
|
/// Telegram uses this as fallback in HapticFeedback.swift.
|
|
private func fireHaptic() {
|
|
impactFeedback.impactOccurred()
|
|
impactFeedback.prepare()
|
|
}
|
|
|
|
private func cancelHoldTimer() {
|
|
holdTimer?.invalidate()
|
|
holdTimer = nil
|
|
}
|
|
|
|
private func updateVelocity(with touch: UITouch, at location: CGPoint) {
|
|
let dt = max(0.001, touch.timestamp - lastTouchTimestamp)
|
|
velocityX = (location.x - lastTouchLocation.x) / dt
|
|
velocityY = (location.y - lastTouchLocation.y) / dt
|
|
lastTouchLocation = location
|
|
lastTouchTimestamp = touch.timestamp
|
|
}
|
|
}
|