Голосовые сообщения — фикс аудио, layout preview panel, склейка сегментов
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
import os
|
||||||
@@ -33,6 +34,18 @@ final class AudioRecorder: NSObject {
|
|||||||
private var lastSampleTime: TimeInterval = 0
|
private var lastSampleTime: TimeInterval = 0
|
||||||
private let sampleInterval: TimeInterval = 1.0 / 30.0
|
private let sampleInterval: TimeInterval = 1.0 / 30.0
|
||||||
|
|
||||||
|
// Multi-segment support for "record more" after preview
|
||||||
|
private var previousSegments: [(url: URL, duration: TimeInterval)] = []
|
||||||
|
private var accumulatedDuration: TimeInterval = 0
|
||||||
|
|
||||||
|
private static let recordingSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||||
|
AVSampleRateKey: 48000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
||||||
|
AVEncoderBitRateKey: 64000
|
||||||
|
]
|
||||||
|
|
||||||
private var fileURL: URL {
|
private var fileURL: URL {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager.default.temporaryDirectory
|
||||||
return tmp.appendingPathComponent("rosetta_voice_\(UUID().uuidString).m4a")
|
return tmp.appendingPathComponent("rosetta_voice_\(UUID().uuidString).m4a")
|
||||||
@@ -55,16 +68,8 @@ final class AudioRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = fileURL
|
let url = fileURL
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
|
||||||
AVSampleRateKey: 48000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
|
||||||
AVEncoderBitRateKey: 64000
|
|
||||||
]
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let rec = try AVAudioRecorder(url: url, settings: settings)
|
let rec = try AVAudioRecorder(url: url, settings: Self.recordingSettings)
|
||||||
rec.isMeteringEnabled = true
|
rec.isMeteringEnabled = true
|
||||||
rec.delegate = self
|
rec.delegate = self
|
||||||
rec.prepareToRecord()
|
rec.prepareToRecord()
|
||||||
@@ -76,6 +81,8 @@ final class AudioRecorder: NSObject {
|
|||||||
waveformSamples = []
|
waveformSamples = []
|
||||||
lastSampleTime = 0
|
lastSampleTime = 0
|
||||||
micLevel = 0
|
micLevel = 0
|
||||||
|
previousSegments = []
|
||||||
|
accumulatedDuration = 0
|
||||||
state = .recording(duration: 0, micLevel: 0)
|
state = .recording(duration: 0, micLevel: 0)
|
||||||
startDisplayLink()
|
startDisplayLink()
|
||||||
logger.info("[AudioRecorder] Started: \(url.lastPathComponent)")
|
logger.info("[AudioRecorder] Started: \(url.lastPathComponent)")
|
||||||
@@ -87,42 +94,183 @@ final class AudioRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
guard let rec = recorder else { return }
|
if let rec = recorder {
|
||||||
let duration = rec.currentTime
|
let currentDuration = rec.currentTime
|
||||||
rec.stop()
|
rec.stop()
|
||||||
stopDisplayLink()
|
stopDisplayLink()
|
||||||
let url = rec.url
|
let currentURL = rec.url
|
||||||
state = .finished(url: url, duration: duration, waveform: waveformSamples)
|
recorder = nil
|
||||||
onFinished?(url, duration, waveformSamples)
|
finishWithFile(url: currentURL, fileDuration: currentDuration)
|
||||||
logger.info("[AudioRecorder] Stopped: \(String(format: "%.1f", duration))s")
|
} else if case .paused(let url, let duration, _) = state, !previousSegments.isEmpty {
|
||||||
recorder = nil
|
// Recorder already stopped (preview after record more) — concatenate segments
|
||||||
|
finishWithFile(url: url, fileDuration: duration)
|
||||||
|
} else if case .paused(let url, let duration, _) = state {
|
||||||
|
// Single segment, recorder stopped — just finish
|
||||||
|
state = .finished(url: url, duration: duration, waveform: waveformSamples)
|
||||||
|
onFinished?(url, duration, waveformSamples)
|
||||||
|
logger.info("[AudioRecorder] Stopped (from preview): \(String(format: "%.1f", duration))s")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pauses recording without losing the current file/waveform.
|
private func finishWithFile(url: URL, fileDuration: TimeInterval) {
|
||||||
/// Used by preview flow (`lock -> stop -> preview -> record more`).
|
if previousSegments.isEmpty {
|
||||||
|
state = .finished(url: url, duration: fileDuration, waveform: waveformSamples)
|
||||||
|
onFinished?(url, fileDuration, waveformSamples)
|
||||||
|
logger.info("[AudioRecorder] Stopped: \(String(format: "%.1f", fileDuration))s")
|
||||||
|
} else {
|
||||||
|
var allURLs = previousSegments.map(\.url)
|
||||||
|
allURLs.append(url)
|
||||||
|
let totalDuration = accumulatedDuration + fileDuration
|
||||||
|
|
||||||
|
concatenateAudioFiles(urls: allURLs) { [weak self] resultURL in
|
||||||
|
guard let self else { return }
|
||||||
|
if let resultURL {
|
||||||
|
self.state = .finished(url: resultURL, duration: totalDuration, waveform: self.waveformSamples)
|
||||||
|
self.onFinished?(resultURL, totalDuration, self.waveformSamples)
|
||||||
|
self.logger.info("[AudioRecorder] Stopped (merged): \(String(format: "%.1f", totalDuration))s")
|
||||||
|
} else {
|
||||||
|
self.state = .finished(url: url, duration: fileDuration, waveform: self.waveformSamples)
|
||||||
|
self.onFinished?(url, fileDuration, self.waveformSamples)
|
||||||
|
self.logger.warning("[AudioRecorder] Merge failed, using last segment")
|
||||||
|
}
|
||||||
|
for segment in self.previousSegments {
|
||||||
|
try? FileManager.default.removeItem(at: segment.url)
|
||||||
|
}
|
||||||
|
self.previousSegments = []
|
||||||
|
self.accumulatedDuration = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops recording and finalizes the M4A file for preview playback.
|
||||||
|
/// The file is fully written (moov atom included) so AVAudioPlayer can load it.
|
||||||
|
/// For single-segment: returns immediately with the file URL.
|
||||||
|
/// For multi-segment (after "record more"): merges all segments and calls completion with merged URL.
|
||||||
|
func pauseRecordingForPreview(completion: @escaping ((url: URL, duration: TimeInterval, waveform: [Float])?) -> Void) {
|
||||||
|
guard let rec = recorder, rec.isRecording else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let currentDuration = rec.currentTime
|
||||||
|
let currentURL = rec.url
|
||||||
|
rec.stop() // Finalize M4A — writes moov atom so file is playable
|
||||||
|
stopDisplayLink()
|
||||||
|
recorder = nil
|
||||||
|
|
||||||
|
if previousSegments.isEmpty {
|
||||||
|
// Single segment — return immediately
|
||||||
|
let totalDuration = currentDuration
|
||||||
|
state = .paused(url: currentURL, duration: totalDuration, waveform: waveformSamples)
|
||||||
|
logger.info("[AudioRecorder] Paused for preview: \(String(format: "%.1f", totalDuration))s")
|
||||||
|
completion((url: currentURL, duration: totalDuration, waveform: waveformSamples))
|
||||||
|
} else {
|
||||||
|
// Multi-segment — merge all segments + current into one file for preview
|
||||||
|
var allURLs = previousSegments.map(\.url)
|
||||||
|
allURLs.append(currentURL)
|
||||||
|
let totalDuration = accumulatedDuration + currentDuration
|
||||||
|
let waveform = waveformSamples
|
||||||
|
|
||||||
|
concatenateAudioFiles(urls: allURLs) { [weak self] mergedURL in
|
||||||
|
guard let self else { return }
|
||||||
|
if let mergedURL {
|
||||||
|
// Clean up individual segment files
|
||||||
|
for segment in self.previousSegments {
|
||||||
|
try? FileManager.default.removeItem(at: segment.url)
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
|
self.previousSegments = []
|
||||||
|
self.accumulatedDuration = 0
|
||||||
|
self.pendingTrimRange = nil
|
||||||
|
self.state = .paused(url: mergedURL, duration: totalDuration, waveform: waveform)
|
||||||
|
self.logger.info("[AudioRecorder] Paused for preview (merged): \(String(format: "%.1f", totalDuration))s")
|
||||||
|
completion((url: mergedURL, duration: totalDuration, waveform: waveform))
|
||||||
|
} else {
|
||||||
|
// Merge failed — show only current segment
|
||||||
|
self.state = .paused(url: currentURL, duration: currentDuration, waveform: waveform)
|
||||||
|
self.logger.warning("[AudioRecorder] Merge for preview failed, using last segment")
|
||||||
|
completion((url: currentURL, duration: currentDuration, waveform: waveform))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous version for backward compatibility (single-segment only).
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func pauseRecordingForPreview() -> (url: URL, duration: TimeInterval, waveform: [Float])? {
|
func pauseRecordingForPreview() -> (url: URL, duration: TimeInterval, waveform: [Float])? {
|
||||||
guard let rec = recorder, rec.isRecording else { return nil }
|
guard let rec = recorder, rec.isRecording else { return nil }
|
||||||
rec.pause()
|
guard previousSegments.isEmpty else {
|
||||||
|
// Multi-segment requires async version
|
||||||
|
logger.warning("[AudioRecorder] pauseRecordingForPreview sync called with segments — use async version")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let duration = rec.currentTime
|
||||||
|
let url = rec.url
|
||||||
|
rec.stop()
|
||||||
stopDisplayLink()
|
stopDisplayLink()
|
||||||
let snapshot = (url: rec.url, duration: rec.currentTime, waveform: waveformSamples)
|
let snapshot = (url: url, duration: duration, waveform: waveformSamples)
|
||||||
state = .paused(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
|
state = .paused(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
|
||||||
|
recorder = nil
|
||||||
|
logger.info("[AudioRecorder] Paused for preview: \(String(format: "%.1f", duration))s")
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether there are previous segments from "record more" that need merging.
|
||||||
|
var hasPreviousSegments: Bool { !previousSegments.isEmpty }
|
||||||
|
|
||||||
|
/// Starts a new recording segment for "record more" after preview.
|
||||||
|
/// If `trimRange` is provided, the previous segment is trimmed before saving.
|
||||||
|
/// The new recording will be concatenated with the trimmed segment on send.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func resumeRecording() -> Bool {
|
func resumeRecording(trimRange: ClosedRange<TimeInterval>? = nil) -> Bool {
|
||||||
guard let rec = recorder else { return false }
|
guard case .paused(let prevURL, let prevDuration, _) = state else { return false }
|
||||||
guard case .paused = state else { return false }
|
|
||||||
guard rec.record() else { return false }
|
// If trimmed, export only the trim range; otherwise keep full file
|
||||||
state = .recording(duration: rec.currentTime, micLevel: micLevel)
|
if let trim = trimRange, trim.lowerBound > 0.1 || trim.upperBound < prevDuration - 0.1 {
|
||||||
startDisplayLink()
|
let trimmedDuration = trim.upperBound - trim.lowerBound
|
||||||
return true
|
previousSegments.append((url: prevURL, duration: trimmedDuration))
|
||||||
|
pendingTrimRange = trim
|
||||||
|
} else {
|
||||||
|
previousSegments.append((url: prevURL, duration: prevDuration))
|
||||||
|
pendingTrimRange = nil
|
||||||
|
}
|
||||||
|
accumulatedDuration = previousSegments.reduce(0) { $0 + $1.duration }
|
||||||
|
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||||
|
try session.setActive(true)
|
||||||
|
} catch {
|
||||||
|
logger.error("[AudioRecorder] Resume session failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = fileURL
|
||||||
|
do {
|
||||||
|
let rec = try AVAudioRecorder(url: url, settings: Self.recordingSettings)
|
||||||
|
rec.isMeteringEnabled = true
|
||||||
|
rec.delegate = self
|
||||||
|
rec.prepareToRecord()
|
||||||
|
guard rec.record() else {
|
||||||
|
logger.error("[AudioRecorder] Resume record() failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
recorder = rec
|
||||||
|
state = .recording(duration: accumulatedDuration, micLevel: micLevel)
|
||||||
|
startDisplayLink()
|
||||||
|
logger.info("[AudioRecorder] Resumed recording (segment \(self.previousSegments.count + 1), trimmed: \(self.pendingTrimRange != nil))")
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
logger.error("[AudioRecorder] Resume init failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trim range to apply to the first segment during concatenation
|
||||||
|
private var pendingTrimRange: ClosedRange<TimeInterval>?
|
||||||
|
|
||||||
func currentRecordingSnapshot() -> (url: URL, duration: TimeInterval, waveform: [Float])? {
|
func currentRecordingSnapshot() -> (url: URL, duration: TimeInterval, waveform: [Float])? {
|
||||||
if let rec = recorder {
|
if let rec = recorder {
|
||||||
return (url: rec.url, duration: rec.currentTime, waveform: waveformSamples)
|
return (url: rec.url, duration: accumulatedDuration + rec.currentTime, waveform: waveformSamples)
|
||||||
}
|
}
|
||||||
switch state {
|
switch state {
|
||||||
case .paused(let url, let duration, let waveform):
|
case .paused(let url, let duration, let waveform):
|
||||||
@@ -135,11 +283,16 @@ final class AudioRecorder: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelRecording() {
|
func cancelRecording() {
|
||||||
guard let rec = recorder else { reset(); return }
|
if let rec = recorder {
|
||||||
let url = rec.url
|
let url = rec.url
|
||||||
rec.stop()
|
rec.stop()
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
stopDisplayLink()
|
stopDisplayLink()
|
||||||
try? FileManager.default.removeItem(at: url)
|
// Clean up previous segment files
|
||||||
|
for segment in previousSegments {
|
||||||
|
try? FileManager.default.removeItem(at: segment.url)
|
||||||
|
}
|
||||||
logger.info("[AudioRecorder] Cancelled")
|
logger.info("[AudioRecorder] Cancelled")
|
||||||
recorder = nil
|
recorder = nil
|
||||||
reset()
|
reset()
|
||||||
@@ -150,6 +303,9 @@ final class AudioRecorder: NSObject {
|
|||||||
recorder = nil
|
recorder = nil
|
||||||
micLevel = 0
|
micLevel = 0
|
||||||
waveformSamples = []
|
waveformSamples = []
|
||||||
|
previousSegments = []
|
||||||
|
accumulatedDuration = 0
|
||||||
|
pendingTrimRange = nil
|
||||||
state = .idle
|
state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +333,11 @@ final class AudioRecorder: NSObject {
|
|||||||
let power = rec.averagePower(forChannel: 0)
|
let power = rec.averagePower(forChannel: 0)
|
||||||
let normalized = Self.normalizeMicLevel(power)
|
let normalized = Self.normalizeMicLevel(power)
|
||||||
micLevel = normalized
|
micLevel = normalized
|
||||||
let duration = rec.currentTime
|
let duration = accumulatedDuration + rec.currentTime
|
||||||
state = .recording(duration: duration, micLevel: normalized)
|
state = .recording(duration: duration, micLevel: normalized)
|
||||||
if duration - lastSampleTime >= sampleInterval {
|
if rec.currentTime - lastSampleTime >= sampleInterval {
|
||||||
waveformSamples.append(normalized)
|
waveformSamples.append(normalized)
|
||||||
lastSampleTime = duration
|
lastSampleTime = rec.currentTime
|
||||||
}
|
}
|
||||||
onLevelUpdate?(duration, normalized)
|
onLevelUpdate?(duration, normalized)
|
||||||
}
|
}
|
||||||
@@ -207,6 +363,56 @@ final class AudioRecorder: NSObject {
|
|||||||
@unknown default: return false
|
@unknown default: return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Concatenation
|
||||||
|
|
||||||
|
private func concatenateAudioFiles(urls: [URL], completion: @escaping @MainActor (URL?) -> Void) {
|
||||||
|
let trimRange = pendingTrimRange
|
||||||
|
let composition = AVMutableComposition()
|
||||||
|
guard let track = composition.addMutableTrack(
|
||||||
|
withMediaType: .audio,
|
||||||
|
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||||
|
) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var insertTime = CMTime.zero
|
||||||
|
for (index, url) in urls.enumerated() {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
guard let audioTrack = asset.tracks(withMediaType: .audio).first else { continue }
|
||||||
|
do {
|
||||||
|
// Apply trim range to the first segment (the original recording)
|
||||||
|
let sourceRange: CMTimeRange
|
||||||
|
if index == 0, let trim = trimRange {
|
||||||
|
let start = CMTime(seconds: trim.lowerBound, preferredTimescale: 44100)
|
||||||
|
let end = CMTime(seconds: trim.upperBound, preferredTimescale: 44100)
|
||||||
|
sourceRange = CMTimeRange(start: start, end: end)
|
||||||
|
} else {
|
||||||
|
sourceRange = CMTimeRange(start: .zero, duration: asset.duration)
|
||||||
|
}
|
||||||
|
try track.insertTimeRange(sourceRange, of: audioTrack, at: insertTime)
|
||||||
|
insertTime = CMTimeAdd(insertTime, sourceRange.duration)
|
||||||
|
} catch {
|
||||||
|
logger.error("[AudioRecorder] Concat insert failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("rosetta_voice_merged_\(UUID().uuidString).m4a")
|
||||||
|
|
||||||
|
guard let exporter = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporter.outputURL = outputURL
|
||||||
|
exporter.outputFileType = .m4a
|
||||||
|
exporter.exportAsynchronously {
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(exporter.status == .completed ? outputURL : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AudioRecorder: AVAudioRecorderDelegate {
|
extension AudioRecorder: AVAudioRecorderDelegate {
|
||||||
|
|||||||
@@ -460,6 +460,20 @@ final class ComposerView: UIView, UITextViewDelegate {
|
|||||||
recordingFlowState = state
|
recordingFlowState = state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Hit Testing (RecordMore floats above inputContainer)
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
if let preview = recordingPreviewPanel {
|
||||||
|
let previewPoint = convert(point, to: preview)
|
||||||
|
if preview.point(inside: previewPoint, with: event) {
|
||||||
|
if let hit = preview.hitTest(previewPoint, with: event) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
@@ -1133,20 +1147,8 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
setRecordingFlowState(.waitingForPreview)
|
setRecordingFlowState(.waitingForPreview)
|
||||||
|
|
||||||
audioRecorder.onLevelUpdate = nil
|
audioRecorder.onLevelUpdate = nil
|
||||||
let paused = audioRecorder.pauseRecordingForPreview() ?? audioRecorder.currentRecordingSnapshot()
|
|
||||||
guard let snapshot = paused else {
|
|
||||||
dismissOverlayAndRestore()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastRecordedURL = snapshot.url
|
|
||||||
lastRecordedDuration = snapshot.duration
|
|
||||||
lastRecordedWaveform = snapshot.waveform
|
|
||||||
|
|
||||||
if VoiceRecordingParityMath.shouldDiscard(duration: snapshot.duration) {
|
|
||||||
dismissOverlayAndRestore()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Dismiss recording UI immediately
|
||||||
recordingOverlay?.dismiss()
|
recordingOverlay?.dismiss()
|
||||||
recordingOverlay = nil
|
recordingOverlay = nil
|
||||||
recordingLockView?.dismiss()
|
recordingLockView?.dismiss()
|
||||||
@@ -1156,17 +1158,43 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
}
|
}
|
||||||
updateRecordingSendAccessibilityArea(isEnabled: false)
|
updateRecordingSendAccessibilityArea(isEnabled: false)
|
||||||
|
|
||||||
guard let url = lastRecordedURL else {
|
if audioRecorder.hasPreviousSegments {
|
||||||
dismissOverlayAndRestore(skipAudioCleanup: true)
|
// Multi-segment (after "record more") — async merge then show preview
|
||||||
|
audioRecorder.pauseRecordingForPreview { [weak self] snapshot in
|
||||||
|
guard let self, let snapshot else {
|
||||||
|
self?.dismissOverlayAndRestore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.presentPreviewPanel(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single segment — sync path
|
||||||
|
let paused = audioRecorder.pauseRecordingForPreview() ?? audioRecorder.currentRecordingSnapshot()
|
||||||
|
guard let snapshot = paused else {
|
||||||
|
dismissOverlayAndRestore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
presentPreviewPanel(url: snapshot.url, duration: snapshot.duration, waveform: snapshot.waveform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPreviewPanel(url: URL, duration: TimeInterval, waveform: [Float]) {
|
||||||
|
lastRecordedURL = url
|
||||||
|
lastRecordedDuration = duration
|
||||||
|
lastRecordedWaveform = waveform
|
||||||
|
|
||||||
|
if VoiceRecordingParityMath.shouldDiscard(duration: duration) {
|
||||||
|
dismissOverlayAndRestore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setPreviewRowReplacement(true)
|
setPreviewRowReplacement(true)
|
||||||
micButton.resetState()
|
micButton.resetState()
|
||||||
let preview = RecordingPreviewPanel(
|
let preview = RecordingPreviewPanel(
|
||||||
frame: inputContainer.bounds,
|
frame: inputContainer.bounds,
|
||||||
fileURL: url,
|
fileURL: url,
|
||||||
duration: lastRecordedDuration,
|
duration: duration,
|
||||||
waveform: lastRecordedWaveform
|
waveform: waveform
|
||||||
)
|
)
|
||||||
preview.delegate = self
|
preview.delegate = self
|
||||||
inputContainer.addSubview(preview)
|
inputContainer.addSubview(preview)
|
||||||
@@ -1389,16 +1417,21 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
restoreComposerChrome()
|
restoreComposerChrome()
|
||||||
|
|
||||||
// For cancel: play bin animation inside attach button, then restore icon
|
// For cancel: play bin animation inside attach button, then restore icon
|
||||||
|
// Slide-to-cancel (discardRecording) = red bin, Preview delete (preserveRecordedDraft) = white bin
|
||||||
if dismissStyle == .cancel {
|
if dismissStyle == .cancel {
|
||||||
playBinAnimationInAttachButton()
|
let useRedBin = cleanup == .discardRecording
|
||||||
|
playBinAnimationInAttachButton(useRedBin: useRedBin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playBinAnimationInAttachButton() {
|
private func playBinAnimationInAttachButton(useRedBin: Bool = true) {
|
||||||
// Hide paperclip icon, play bin Lottie inside attach button, then restore
|
// Hide paperclip icon, play bin Lottie inside attach button, then restore
|
||||||
attachIconLayer?.opacity = 0
|
attachIconLayer?.opacity = 0
|
||||||
|
|
||||||
guard let animation = LottieAnimation.named(VoiceRecordingLottieAsset.binRed.rawValue) else {
|
// Slide-to-cancel: BinRed (red animation, no tint)
|
||||||
|
// Preview delete: BinBlue with panelControlColor tint (white in dark theme)
|
||||||
|
let assetName = useRedBin ? VoiceRecordingLottieAsset.binRed.rawValue : VoiceRecordingLottieAsset.binBlue.rawValue
|
||||||
|
guard let animation = LottieAnimation.named(assetName) else {
|
||||||
// No Lottie asset — just fade icon back
|
// No Lottie asset — just fade icon back
|
||||||
CATransaction.begin()
|
CATransaction.begin()
|
||||||
CATransaction.setAnimationDuration(0.25)
|
CATransaction.setAnimationDuration(0.25)
|
||||||
@@ -1407,11 +1440,24 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let binView = LottieAnimationView(animation: animation)
|
let config = LottieConfiguration(renderingEngine: .mainThread)
|
||||||
|
let binView = LottieAnimationView(animation: animation, configuration: config)
|
||||||
binView.frame = attachButton.bounds
|
binView.frame = attachButton.bounds
|
||||||
binView.contentMode = .scaleAspectFit
|
binView.contentMode = .scaleAspectFit
|
||||||
binView.backgroundBehavior = .pauseAndRestore
|
binView.backgroundBehavior = .pauseAndRestore
|
||||||
binView.loopMode = .playOnce
|
binView.loopMode = .playOnce
|
||||||
|
|
||||||
|
// Apply theme tint only for BinBlue (preview delete — white in dark, black in light)
|
||||||
|
// BinRed (slide-to-cancel) uses its original red color, no tint needed.
|
||||||
|
if !useRedBin {
|
||||||
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||||
|
UIColor.label.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||||
|
binView.setValueProvider(
|
||||||
|
ColorValueProvider(LottieColor(r: Double(r), g: Double(g), b: Double(b), a: Double(a))),
|
||||||
|
keypath: AnimationKeypath(keypath: "**.Color")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
attachButton.addSubview(binView)
|
attachButton.addSubview(binView)
|
||||||
|
|
||||||
binView.play { [weak self] _ in
|
binView.play { [weak self] _ in
|
||||||
@@ -1465,9 +1511,10 @@ extension ComposerView: RecordingMicButtonDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resumeRecordingFromPreview() {
|
private func resumeRecordingFromPreview() {
|
||||||
|
let trimRange = recordingPreviewPanel?.selectedTrimRange
|
||||||
setPreviewRowReplacement(false)
|
setPreviewRowReplacement(false)
|
||||||
micButton.resetState()
|
micButton.resetState()
|
||||||
guard audioRecorder.resumeRecording() else {
|
guard audioRecorder.resumeRecording(trimRange: trimRange) else {
|
||||||
dismissOverlayAndRestore()
|
dismissOverlayAndRestore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1607,12 +1654,40 @@ extension ComposerView: RecordingPreviewPanelDelegate {
|
|||||||
func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) {
|
func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel) {
|
||||||
audioRecorder.cancelRecording()
|
audioRecorder.cancelRecording()
|
||||||
clearLastRecordedDraftFile()
|
clearLastRecordedDraftFile()
|
||||||
dismissOverlayAndRestore(skipAudioCleanup: true)
|
finalizeVoiceSession(cleanup: .preserveRecordedDraft, dismissStyle: .cancel)
|
||||||
delegate?.composerDidCancelRecording(self)
|
delegate?.composerDidCancelRecording(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel) {
|
private static let trimWarningShownKey = "voice_trim_resume_warning_shown"
|
||||||
resumeRecordingFromPreview()
|
|
||||||
|
func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>, isTrimmed: Bool) {
|
||||||
|
let alreadyShown = UserDefaults.standard.bool(forKey: Self.trimWarningShownKey)
|
||||||
|
if isTrimmed, !alreadyShown {
|
||||||
|
let alert = UIAlertController(
|
||||||
|
title: "Trim to selected range?",
|
||||||
|
message: "Audio outside that range will be discarded, and recording will start immediately.",
|
||||||
|
preferredStyle: .alert
|
||||||
|
)
|
||||||
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
|
alert.addAction(UIAlertAction(title: "Proceed", style: .default) { [weak self] _ in
|
||||||
|
UserDefaults.standard.set(true, forKey: Self.trimWarningShownKey)
|
||||||
|
self?.resumeRecordingFromPreview()
|
||||||
|
})
|
||||||
|
if let vc = presentingViewController() {
|
||||||
|
vc.present(alert, animated: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resumeRecordingFromPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentingViewController() -> UIViewController? {
|
||||||
|
var responder: UIResponder? = self
|
||||||
|
while let next = responder?.next {
|
||||||
|
if let vc = next as? UIViewController { return vc }
|
||||||
|
responder = next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import UIKit
|
|||||||
protocol RecordingPreviewPanelDelegate: AnyObject {
|
protocol RecordingPreviewPanelDelegate: AnyObject {
|
||||||
func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>)
|
func previewPanelDidTapSend(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>)
|
||||||
func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel)
|
func previewPanelDidTapDelete(_ panel: RecordingPreviewPanel)
|
||||||
func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel)
|
func previewPanelDidTapRecordMore(_ panel: RecordingPreviewPanel, trimRange: ClosedRange<TimeInterval>, isTrimmed: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RecordingPreviewPanel
|
// MARK: - RecordingPreviewPanel
|
||||||
|
|
||||||
/// Telegram-parity recording preview: glass delete circle + dark glass panel + blue send circle.
|
/// Telegram-parity recording preview.
|
||||||
/// Blue accent fill covers only the trim range. Play button floats inside waveform.
|
/// Layout: [delete glass circle] [dark glass panel with waveform + accent fill + trim + play pill + send]
|
||||||
final class RecordingPreviewPanel: UIView {
|
/// RecordMore floats directly above the send button.
|
||||||
|
final class RecordingPreviewPanel: UIView, UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
private enum PanMode {
|
private enum PanMode {
|
||||||
case scrub
|
case scrub
|
||||||
@@ -31,7 +32,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
|
|
||||||
weak var delegate: RecordingPreviewPanelDelegate?
|
weak var delegate: RecordingPreviewPanelDelegate?
|
||||||
|
|
||||||
// MARK: - Background elements (3 separate visual blocks)
|
// MARK: - Background elements
|
||||||
|
|
||||||
private let deleteGlassCircle = TelegramGlassUIView(frame: .zero)
|
private let deleteGlassCircle = TelegramGlassUIView(frame: .zero)
|
||||||
private let centerGlassBackground = TelegramGlassUIView(frame: .zero)
|
private let centerGlassBackground = TelegramGlassUIView(frame: .zero)
|
||||||
@@ -48,14 +49,15 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
|
|
||||||
private let waveformContainer = UIView()
|
private let waveformContainer = UIView()
|
||||||
private let waveformView = WaveformView()
|
private let waveformView = WaveformView()
|
||||||
private let leftTrimMask = UIView()
|
|
||||||
private let rightTrimMask = UIView()
|
// MARK: - Trim handles (at panel level)
|
||||||
|
|
||||||
private let leftTrimHandle = UIView()
|
private let leftTrimHandle = UIView()
|
||||||
private let rightTrimHandle = UIView()
|
private let rightTrimHandle = UIView()
|
||||||
private let leftCapsuleView = UIView()
|
private let leftCapsuleView = UIView()
|
||||||
private let rightCapsuleView = UIView()
|
private let rightCapsuleView = UIView()
|
||||||
|
|
||||||
// MARK: - Play button pill (floats inside waveform)
|
// MARK: - Play button pill (at panel level)
|
||||||
|
|
||||||
private let playButtonPill = UIButton(type: .custom)
|
private let playButtonPill = UIButton(type: .custom)
|
||||||
private let playPillBackground = UIImageView()
|
private let playPillBackground = UIImageView()
|
||||||
@@ -85,6 +87,8 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
// MARK: - Layout cache
|
// MARK: - Layout cache
|
||||||
|
|
||||||
private var centerPanelFrame: CGRect = .zero
|
private var centerPanelFrame: CGRect = .zero
|
||||||
|
private var waveformOriginX: CGFloat = 0
|
||||||
|
private var waveformWidth: CGFloat = 0
|
||||||
|
|
||||||
// MARK: - Colors
|
// MARK: - Colors
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var panelControlAccentColor: UIColor {
|
private var panelControlAccentColor: UIColor {
|
||||||
UIColor(red: 0, green: 136 / 255.0, blue: 1.0, alpha: 1.0)
|
UIColor(red: 0.2, green: 0.565, blue: 0.925, alpha: 1) // #3390EC — same as outgoing message bubble
|
||||||
}
|
}
|
||||||
|
|
||||||
private var panelSecondaryTextColor: UIColor {
|
private var panelSecondaryTextColor: UIColor {
|
||||||
@@ -130,6 +134,40 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) { fatalError() }
|
required init?(coder: NSCoder) { fatalError() }
|
||||||
|
|
||||||
|
// MARK: - Hit testing (RecordMore floats above bounds)
|
||||||
|
|
||||||
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||||
|
if recordMoreButton.frame.contains(point) { return true }
|
||||||
|
return super.point(inside: point, with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
// RecordMore floating button
|
||||||
|
if !recordMoreButton.isHidden, recordMoreButton.frame.contains(point) {
|
||||||
|
return recordMoreButton
|
||||||
|
}
|
||||||
|
// Play pill (ensure it receives taps over pan gesture)
|
||||||
|
if !playButtonPill.isHidden, playButtonPill.alpha > 0.1 {
|
||||||
|
let pillPoint = convert(point, to: playButtonPill)
|
||||||
|
if playButtonPill.bounds.contains(pillPoint) {
|
||||||
|
return playButtonPill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gesture delegate (don't intercept button taps)
|
||||||
|
|
||||||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
let loc = gestureRecognizer.location(in: self)
|
||||||
|
// Don't start pan on buttons
|
||||||
|
if deleteButton.frame.contains(loc) { return false }
|
||||||
|
if sendButton.frame.contains(loc) { return false }
|
||||||
|
if recordMoreButton.frame.contains(loc) { return false }
|
||||||
|
if playButtonPill.alpha > 0.1, playButtonPill.frame.contains(loc) { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Setup
|
// MARK: - Setup
|
||||||
|
|
||||||
private func setupSubviews() {
|
private func setupSubviews() {
|
||||||
@@ -145,19 +183,15 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
deleteButton.accessibilityIdentifier = "voice.preview.delete"
|
deleteButton.accessibilityIdentifier = "voice.preview.delete"
|
||||||
addSubview(deleteButton)
|
addSubview(deleteButton)
|
||||||
|
|
||||||
// B) Central dark glass panel
|
// B) Central dark glass panel (includes send button area)
|
||||||
centerGlassBackground.isUserInteractionEnabled = false
|
centerGlassBackground.isUserInteractionEnabled = false
|
||||||
addSubview(centerGlassBackground)
|
addSubview(centerGlassBackground)
|
||||||
|
|
||||||
// Blue accent fill (dynamic trim range)
|
// Blue accent fill (dynamic trim range, BELOW waveform in z-order)
|
||||||
accentFillView.image = Self.makeStretchablePill(
|
|
||||||
diameter: 34,
|
|
||||||
color: panelControlAccentColor
|
|
||||||
)
|
|
||||||
accentFillView.isUserInteractionEnabled = false
|
accentFillView.isUserInteractionEnabled = false
|
||||||
addSubview(accentFillView)
|
addSubview(accentFillView)
|
||||||
|
|
||||||
// Waveform container
|
// Waveform container (ABOVE accent fill — bars visible on top of blue)
|
||||||
waveformContainer.clipsToBounds = true
|
waveformContainer.clipsToBounds = true
|
||||||
addSubview(waveformContainer)
|
addSubview(waveformContainer)
|
||||||
|
|
||||||
@@ -165,19 +199,12 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
waveformView.progress = 0
|
waveformView.progress = 0
|
||||||
waveformContainer.addSubview(waveformView)
|
waveformContainer.addSubview(waveformView)
|
||||||
|
|
||||||
// Trim masks
|
// Trim handles at panel level
|
||||||
leftTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
|
||||||
rightTrimMask.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
|
||||||
waveformContainer.addSubview(leftTrimMask)
|
|
||||||
waveformContainer.addSubview(rightTrimMask)
|
|
||||||
|
|
||||||
// Trim handles (transparent, 16pt wide)
|
|
||||||
leftTrimHandle.backgroundColor = .clear
|
leftTrimHandle.backgroundColor = .clear
|
||||||
rightTrimHandle.backgroundColor = .clear
|
rightTrimHandle.backgroundColor = .clear
|
||||||
addSubview(leftTrimHandle)
|
addSubview(leftTrimHandle)
|
||||||
addSubview(rightTrimHandle)
|
addSubview(rightTrimHandle)
|
||||||
|
|
||||||
// White capsule indicators inside handles
|
|
||||||
leftCapsuleView.backgroundColor = .white
|
leftCapsuleView.backgroundColor = .white
|
||||||
leftCapsuleView.layer.cornerRadius = 1.5
|
leftCapsuleView.layer.cornerRadius = 1.5
|
||||||
leftTrimHandle.addSubview(leftCapsuleView)
|
leftTrimHandle.addSubview(leftCapsuleView)
|
||||||
@@ -186,13 +213,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
rightCapsuleView.layer.cornerRadius = 1.5
|
rightCapsuleView.layer.cornerRadius = 1.5
|
||||||
rightTrimHandle.addSubview(rightCapsuleView)
|
rightTrimHandle.addSubview(rightCapsuleView)
|
||||||
|
|
||||||
// Pan gesture for waveform
|
// Play button pill at panel level
|
||||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:)))
|
|
||||||
waveformContainer.addGestureRecognizer(pan)
|
|
||||||
waveformContainer.accessibilityLabel = "Waveform trim area"
|
|
||||||
waveformContainer.accessibilityIdentifier = "voice.preview.waveform"
|
|
||||||
|
|
||||||
// Play button pill (inside waveform)
|
|
||||||
playPillBackground.isUserInteractionEnabled = false
|
playPillBackground.isUserInteractionEnabled = false
|
||||||
playButtonPill.addSubview(playPillBackground)
|
playButtonPill.addSubview(playPillBackground)
|
||||||
|
|
||||||
@@ -211,10 +232,10 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
playButtonPill.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
playButtonPill.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||||
playButtonPill.accessibilityLabel = "Play recording"
|
playButtonPill.accessibilityLabel = "Play recording"
|
||||||
playButtonPill.accessibilityIdentifier = "voice.preview.playPause"
|
playButtonPill.accessibilityIdentifier = "voice.preview.playPause"
|
||||||
waveformContainer.addSubview(playButtonPill)
|
addSubview(playButtonPill)
|
||||||
configurePlayButton(playing: false, animated: false)
|
configurePlayButton(playing: false, animated: false)
|
||||||
|
|
||||||
// C) Send button (solid blue circle)
|
// Send button INSIDE center panel (right edge)
|
||||||
sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal)
|
sendButton.setImage(VoiceRecordingAssets.image(.send, templated: true), for: .normal)
|
||||||
sendButton.backgroundColor = panelControlAccentColor
|
sendButton.backgroundColor = panelControlAccentColor
|
||||||
sendButton.layer.cornerRadius = 18
|
sendButton.layer.cornerRadius = 18
|
||||||
@@ -225,7 +246,7 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
sendButton.accessibilityIdentifier = "voice.preview.send"
|
sendButton.accessibilityIdentifier = "voice.preview.send"
|
||||||
addSubview(sendButton)
|
addSubview(sendButton)
|
||||||
|
|
||||||
// D) Record More glass circle + button (floating above)
|
// RecordMore glass circle floating above send button
|
||||||
recordMoreGlassCircle.fixedCornerRadius = 20
|
recordMoreGlassCircle.fixedCornerRadius = 20
|
||||||
recordMoreGlassCircle.isUserInteractionEnabled = false
|
recordMoreGlassCircle.isUserInteractionEnabled = false
|
||||||
addSubview(recordMoreGlassCircle)
|
addSubview(recordMoreGlassCircle)
|
||||||
@@ -237,6 +258,11 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore"
|
recordMoreButton.accessibilityIdentifier = "voice.preview.recordMore"
|
||||||
addSubview(recordMoreButton)
|
addSubview(recordMoreButton)
|
||||||
|
|
||||||
|
// Pan gesture on self for waveform scrubbing/trimming
|
||||||
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleWaveformPan(_:)))
|
||||||
|
pan.delegate = self
|
||||||
|
addGestureRecognizer(pan)
|
||||||
|
|
||||||
updateThemeColors()
|
updateThemeColors()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,39 +280,45 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
deleteGlassCircle.applyCornerRadius()
|
deleteGlassCircle.applyCornerRadius()
|
||||||
deleteButton.frame = deleteFrame
|
deleteButton.frame = deleteFrame
|
||||||
|
|
||||||
// Send — solid blue circle, right edge
|
// Send button — inside center panel, at right edge with inset
|
||||||
let sendSize: CGFloat = 36
|
let sendSize: CGFloat = 36
|
||||||
sendButton.frame = CGRect(x: w - sendSize, y: (h - sendSize) / 2, width: sendSize, height: sendSize)
|
let sendInset: CGFloat = 3
|
||||||
|
let sendX = w - sendInset - sendSize
|
||||||
|
sendButton.frame = CGRect(x: sendX, y: (h - sendSize) / 2, width: sendSize, height: sendSize)
|
||||||
|
|
||||||
// Central dark glass panel — between delete and send
|
// Central dark glass panel — from after delete gap to right edge (includes send)
|
||||||
let panelGap: CGFloat = 6
|
let panelGap: CGFloat = 6
|
||||||
let panelX = deleteSize + panelGap
|
let panelX = deleteSize + panelGap
|
||||||
let panelW = w - panelX - sendSize - panelGap
|
let panelW = w - panelX
|
||||||
let panelH = h - 6
|
let panelH = deleteSize // Same height as delete circle (40pt) — Telegram parity
|
||||||
let panelY: CGFloat = 3
|
let panelY = (h - panelH) / 2
|
||||||
let panelCornerRadius = panelH / 2
|
let panelCornerRadius = panelH / 2
|
||||||
centerGlassBackground.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
centerGlassBackground.frame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
||||||
centerGlassBackground.fixedCornerRadius = panelCornerRadius
|
centerGlassBackground.fixedCornerRadius = panelCornerRadius
|
||||||
centerGlassBackground.applyCornerRadius()
|
centerGlassBackground.applyCornerRadius()
|
||||||
centerPanelFrame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
centerPanelFrame = CGRect(x: panelX, y: panelY, width: panelW, height: panelH)
|
||||||
|
|
||||||
// Waveform — inside central panel, 18pt insets from panel edges
|
// Waveform — symmetric 21pt inset from panel edges (Telegram: x=21, width=panelW-42)
|
||||||
let wfInset: CGFloat = 18
|
let wfLeftInset: CGFloat = 21
|
||||||
let wfX = panelX + wfInset
|
let wfX = panelX + wfLeftInset
|
||||||
let wfW = panelW - wfInset * 2
|
let wfRightEdge = sendButton.frame.minX - 21 // Symmetric with left
|
||||||
|
let wfW = max(0, wfRightEdge - wfX)
|
||||||
let wfH: CGFloat = 13
|
let wfH: CGFloat = 13
|
||||||
let wfY = floor((h - wfH) / 2)
|
let wfY = floor((h - wfH) / 2)
|
||||||
waveformContainer.frame = CGRect(x: wfX, y: wfY, width: max(0, wfW), height: wfH)
|
waveformContainer.frame = CGRect(x: wfX, y: wfY, width: wfW, height: wfH)
|
||||||
waveformView.frame = waveformContainer.bounds
|
waveformView.frame = waveformContainer.bounds
|
||||||
|
waveformOriginX = wfX
|
||||||
|
waveformWidth = wfW
|
||||||
|
|
||||||
// RecordMore — floating above, right side
|
// RecordMore — floating directly above send button
|
||||||
let rmSize: CGFloat = 40
|
let rmSize: CGFloat = 40
|
||||||
let rmFrame = CGRect(x: w - rmSize - 10, y: -52, width: rmSize, height: rmSize)
|
let rmX = sendButton.frame.midX - rmSize / 2
|
||||||
|
let rmFrame = CGRect(x: rmX, y: -52, width: rmSize, height: rmSize)
|
||||||
recordMoreGlassCircle.frame = rmFrame
|
recordMoreGlassCircle.frame = rmFrame
|
||||||
recordMoreGlassCircle.applyCornerRadius()
|
recordMoreGlassCircle.applyCornerRadius()
|
||||||
recordMoreButton.frame = rmFrame
|
recordMoreButton.frame = rmFrame
|
||||||
|
|
||||||
// Trim computation
|
// Trim
|
||||||
minTrimDuration = VoiceRecordingParityConstants.minTrimDuration(
|
minTrimDuration = VoiceRecordingParityConstants.minTrimDuration(
|
||||||
duration: duration,
|
duration: duration,
|
||||||
waveformWidth: waveformContainer.bounds.width
|
waveformWidth: waveformContainer.bounds.width
|
||||||
@@ -313,15 +345,30 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startPlayback() {
|
private func startPlayback() {
|
||||||
|
// After recording, audio route may be earpiece. Switch to speaker for preview.
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker])
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
try? AVAudioSession.sharedInstance().overrideOutputAudioPort(.speaker)
|
||||||
|
|
||||||
if audioPlayer == nil {
|
if audioPlayer == nil {
|
||||||
audioPlayer = try? AVAudioPlayer(contentsOf: fileURL)
|
do {
|
||||||
audioPlayer?.prepareToPlay()
|
let player = try AVAudioPlayer(contentsOf: fileURL)
|
||||||
|
player.volume = 1.0
|
||||||
|
player.prepareToPlay()
|
||||||
|
audioPlayer = player
|
||||||
|
} catch {
|
||||||
|
print("[VoicePreview] AVAudioPlayer init failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
guard let player = audioPlayer else { return }
|
guard let player = audioPlayer else { return }
|
||||||
if player.currentTime < trimStart || player.currentTime > trimEnd {
|
if player.currentTime < trimStart || player.currentTime > trimEnd {
|
||||||
player.currentTime = trimStart
|
player.currentTime = trimStart
|
||||||
}
|
}
|
||||||
player.play()
|
guard player.play() else {
|
||||||
|
print("[VoicePreview] player.play() returned false")
|
||||||
|
return
|
||||||
|
}
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
configurePlayButton(playing: true, animated: true)
|
configurePlayButton(playing: true, animated: true)
|
||||||
startDisplayLink()
|
startDisplayLink()
|
||||||
@@ -409,26 +456,28 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
// MARK: - Trim / Scrub
|
// MARK: - Trim / Scrub
|
||||||
|
|
||||||
@objc private func handleWaveformPan(_ gesture: UIPanGestureRecognizer) {
|
@objc private func handleWaveformPan(_ gesture: UIPanGestureRecognizer) {
|
||||||
guard duration > 0, waveformContainer.bounds.width > 1 else { return }
|
guard duration > 0, waveformWidth > 1 else { return }
|
||||||
let location = gesture.location(in: waveformContainer)
|
let location = gesture.location(in: self)
|
||||||
let normalized = min(1, max(0, location.x / waveformContainer.bounds.width))
|
let wfRelativeX = location.x - waveformOriginX
|
||||||
let targetTime = TimeInterval(normalized) * duration
|
let fraction = min(1, max(0, wfRelativeX / waveformWidth))
|
||||||
|
let targetTime = TimeInterval(fraction) * duration
|
||||||
|
|
||||||
switch gesture.state {
|
switch gesture.state {
|
||||||
case .began:
|
case .began:
|
||||||
let leftX = xForTime(trimStart)
|
let leftX = xForTime(trimStart)
|
||||||
let rightX = xForTime(trimEnd)
|
let rightX = xForTime(trimEnd)
|
||||||
if abs(location.x - leftX) <= 17 {
|
if abs(wfRelativeX - leftX) <= 17 {
|
||||||
activePanMode = .trimLeft
|
activePanMode = .trimLeft
|
||||||
} else if abs(location.x - rightX) <= 17 {
|
} else if abs(wfRelativeX - rightX) <= 17 {
|
||||||
activePanMode = .trimRight
|
activePanMode = .trimRight
|
||||||
} else {
|
} else if wfRelativeX >= -10, wfRelativeX <= waveformWidth + 10 {
|
||||||
activePanMode = .scrub
|
activePanMode = .scrub
|
||||||
|
} else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if activePanMode != .scrub {
|
if activePanMode != .scrub {
|
||||||
pausePlayback()
|
pausePlayback()
|
||||||
}
|
}
|
||||||
// Hide play pill during scrub
|
|
||||||
if activePanMode == .scrub {
|
if activePanMode == .scrub {
|
||||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||||
self.playButtonPill.alpha = 0
|
self.playButtonPill.alpha = 0
|
||||||
@@ -465,7 +514,6 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
activePanMode = nil
|
activePanMode = nil
|
||||||
// Show play pill again
|
|
||||||
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) {
|
||||||
self.playButtonPill.alpha = 1
|
self.playButtonPill.alpha = 1
|
||||||
}
|
}
|
||||||
@@ -474,89 +522,84 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
|
|
||||||
private func updateTrimVisuals() {
|
private func updateTrimVisuals() {
|
||||||
let wfW = waveformContainer.bounds.width
|
let wfW = waveformContainer.bounds.width
|
||||||
let wfH = waveformContainer.bounds.height
|
|
||||||
guard wfW > 0 else { return }
|
guard wfW > 0 else { return }
|
||||||
|
|
||||||
let startX = xForTime(trimStart)
|
let startX = xForTime(trimStart)
|
||||||
let endX = xForTime(trimEnd)
|
let endX = xForTime(trimEnd)
|
||||||
|
|
||||||
// Trim masks (dim areas outside trim range)
|
// Trim handles at panel level, clamped to not overlap send button
|
||||||
leftTrimMask.frame = CGRect(x: 0, y: 0, width: max(0, startX), height: wfH)
|
|
||||||
rightTrimMask.frame = CGRect(x: min(wfW, endX), y: 0, width: max(0, wfW - endX), height: wfH)
|
|
||||||
|
|
||||||
// Trim handles (16pt wide, positioned at panel level)
|
|
||||||
let handleW: CGFloat = 16
|
let handleW: CGFloat = 16
|
||||||
let handleH = centerPanelFrame.height
|
let handleH = centerPanelFrame.height
|
||||||
let handleY = centerPanelFrame.minY
|
let handleY = centerPanelFrame.minY
|
||||||
let wfOriginX = waveformContainer.frame.minX
|
let maxRightHandleX = sendButton.frame.minX - 4 - handleW
|
||||||
|
|
||||||
leftTrimHandle.frame = CGRect(
|
leftTrimHandle.frame = CGRect(
|
||||||
x: wfOriginX + startX - handleW,
|
x: max(centerPanelFrame.minX, waveformOriginX + startX - handleW),
|
||||||
y: handleY,
|
y: handleY,
|
||||||
width: handleW,
|
width: handleW,
|
||||||
height: handleH
|
height: handleH
|
||||||
)
|
)
|
||||||
rightTrimHandle.frame = CGRect(
|
rightTrimHandle.frame = CGRect(
|
||||||
x: wfOriginX + endX,
|
x: min(maxRightHandleX, waveformOriginX + endX),
|
||||||
y: handleY,
|
y: handleY,
|
||||||
width: handleW,
|
width: handleW,
|
||||||
height: handleH
|
height: handleH
|
||||||
)
|
)
|
||||||
|
|
||||||
// Capsule indicators (3×12, centered vertically, 8pt from outer edge)
|
// Capsule indicators
|
||||||
let capsuleW: CGFloat = 3
|
let capsuleW: CGFloat = 3
|
||||||
let capsuleH: CGFloat = 12
|
let capsuleH: CGFloat = 12
|
||||||
let capsuleY = (handleH - capsuleH) / 2
|
let capsuleY = (handleH - capsuleH) / 2
|
||||||
// Left capsule: 8pt from right edge of left handle (outer edge faces left, capsule near waveform)
|
|
||||||
leftCapsuleView.frame = CGRect(x: handleW - 8 - capsuleW, y: capsuleY, width: capsuleW, height: capsuleH)
|
leftCapsuleView.frame = CGRect(x: handleW - 8 - capsuleW, y: capsuleY, width: capsuleW, height: capsuleH)
|
||||||
// Right capsule: 8pt from left edge of right handle
|
|
||||||
rightCapsuleView.frame = CGRect(x: 8, y: capsuleY, width: capsuleW, height: capsuleH)
|
rightCapsuleView.frame = CGRect(x: 8, y: capsuleY, width: capsuleW, height: capsuleH)
|
||||||
|
|
||||||
// Hide trim handles when duration < 2s
|
|
||||||
let showTrim = duration >= 2.0
|
let showTrim = duration >= 2.0
|
||||||
leftTrimHandle.isHidden = !showTrim
|
leftTrimHandle.isHidden = !showTrim
|
||||||
rightTrimHandle.isHidden = !showTrim
|
rightTrimHandle.isHidden = !showTrim
|
||||||
|
|
||||||
// Blue accent fill — dynamic between trim handles (covers panel area)
|
// Blue accent fill — spans from left trim handle to right trim handle (Telegram parity)
|
||||||
let fillX: CGFloat
|
// When trimming, the blue area moves with the handles.
|
||||||
let fillW: CGFloat
|
let accentInsetH: CGFloat = 0 // horizontal (left) — flush with panel edge
|
||||||
|
let accentInsetV: CGFloat = 3 // vertical (top/bottom) — proportional gap
|
||||||
|
let accentY = centerPanelFrame.minY + accentInsetV
|
||||||
|
let accentH = centerPanelFrame.height - accentInsetV * 2
|
||||||
if showTrim {
|
if showTrim {
|
||||||
fillX = wfOriginX + startX - 18 // extend to panel-edge inset
|
let fillX = leftTrimHandle.frame.minX
|
||||||
let fillEndX = wfOriginX + endX + 18
|
let fillEndX = rightTrimHandle.frame.maxX
|
||||||
let clampedX = max(centerPanelFrame.minX, fillX)
|
let clampedX = max(centerPanelFrame.minX + accentInsetH, fillX)
|
||||||
let clampedEndX = min(centerPanelFrame.maxX, fillEndX)
|
let clampedEnd = min(sendButton.frame.minX - 3, fillEndX)
|
||||||
fillW = clampedEndX - clampedX
|
accentFillView.frame = CGRect(x: clampedX, y: accentY, width: max(0, clampedEnd - clampedX), height: accentH)
|
||||||
accentFillView.frame = CGRect(x: clampedX, y: centerPanelFrame.minY, width: max(0, fillW), height: centerPanelFrame.height)
|
|
||||||
} else {
|
} else {
|
||||||
// No trim — fill entire central panel
|
let accentX = centerPanelFrame.minX + accentInsetH
|
||||||
accentFillView.frame = centerPanelFrame
|
let accentW = max(0, sendButton.frame.minX - 3 - accentX)
|
||||||
|
accentFillView.frame = CGRect(x: accentX, y: accentY, width: accentW, height: accentH)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play button pill — centered between trim handles
|
// Play button pill — centered between trim handles, at panel level
|
||||||
let space = endX - startX
|
let space = endX - startX
|
||||||
let pillW: CGFloat = space >= 70 ? 63 : 27
|
let pillW: CGFloat = space >= 70 ? 63 : 27
|
||||||
let pillH: CGFloat = 22
|
let pillH: CGFloat = 22
|
||||||
let pillX = startX + (space - pillW) / 2
|
let pillX = waveformOriginX + startX + (space - pillW) / 2
|
||||||
let pillY = (wfH - pillH) / 2
|
let pillY = (bounds.height - pillH) / 2
|
||||||
playButtonPill.frame = CGRect(x: pillX, y: pillY, width: pillW, height: pillH)
|
playButtonPill.frame = CGRect(x: pillX, y: pillY, width: pillW, height: pillH)
|
||||||
|
|
||||||
// Pill background
|
// Pill background
|
||||||
let pillCornerRadius = pillH / 2
|
if playPillBackground.image == nil {
|
||||||
if playPillBackground.image == nil || playPillBackground.frame.size != playButtonPill.bounds.size {
|
playPillBackground.image = Self.makeStretchablePill(
|
||||||
playPillBackground.image = Self.makeStretchablePill(diameter: pillH, color: .white)?.withRenderingMode(.alwaysTemplate)
|
diameter: pillH, color: .white
|
||||||
|
)?.withRenderingMode(.alwaysTemplate)
|
||||||
playPillBackground.tintColor = playPillBackgroundColor
|
playPillBackground.tintColor = playPillBackgroundColor
|
||||||
}
|
}
|
||||||
playPillBackground.frame = playButtonPill.bounds
|
playPillBackground.frame = playButtonPill.bounds
|
||||||
playPillBackground.layer.cornerRadius = pillCornerRadius
|
playPillBackground.layer.cornerRadius = pillH / 2
|
||||||
playPillBackground.clipsToBounds = true
|
playPillBackground.clipsToBounds = true
|
||||||
|
|
||||||
// Lottie icon inside pill
|
// Lottie icon inside pill
|
||||||
playPauseAnimationView.frame = CGRect(x: 3, y: 1, width: 21, height: 21)
|
playPauseAnimationView.frame = CGRect(x: 3, y: 0.5, width: 21, height: 21)
|
||||||
|
|
||||||
// Duration label inside pill
|
// Duration label inside pill
|
||||||
let showDuration = pillW > 27
|
durationLabel.isHidden = pillW <= 27
|
||||||
durationLabel.isHidden = !showDuration
|
durationLabel.frame = CGRect(x: 18, y: 1, width: 38, height: 20)
|
||||||
durationLabel.frame = CGRect(x: 18, y: 3, width: 35, height: 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func xForTime(_ time: TimeInterval) -> CGFloat {
|
private func xForTime(_ time: TimeInterval) -> CGFloat {
|
||||||
@@ -580,25 +623,16 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateThemeColors() {
|
private func updateThemeColors() {
|
||||||
// Waveform colors: white bars on blue accent background
|
|
||||||
waveformView.backgroundColor_ = UIColor.white.withAlphaComponent(0.4)
|
waveformView.backgroundColor_ = UIColor.white.withAlphaComponent(0.4)
|
||||||
waveformView.foregroundColor_ = UIColor.white
|
waveformView.foregroundColor_ = UIColor.white
|
||||||
waveformView.setNeedsDisplay()
|
waveformView.setNeedsDisplay()
|
||||||
|
|
||||||
// Delete button tint
|
|
||||||
deleteButton.tintColor = panelControlColor
|
deleteButton.tintColor = panelControlColor
|
||||||
|
|
||||||
// Record more tint
|
|
||||||
recordMoreButton.tintColor = panelControlColor
|
recordMoreButton.tintColor = panelControlColor
|
||||||
|
|
||||||
// Play pill text + icon
|
|
||||||
durationLabel.textColor = panelSecondaryTextColor
|
durationLabel.textColor = panelSecondaryTextColor
|
||||||
applyPlayPauseTintColor(panelSecondaryTextColor)
|
applyPlayPauseTintColor(panelSecondaryTextColor)
|
||||||
|
|
||||||
// Pill background
|
|
||||||
playPillBackground.tintColor = playPillBackgroundColor
|
playPillBackground.tintColor = playPillBackgroundColor
|
||||||
|
|
||||||
// Accent fill
|
|
||||||
accentFillView.image = Self.makeStretchablePill(
|
accentFillView.image = Self.makeStretchablePill(
|
||||||
diameter: 34,
|
diameter: 34,
|
||||||
color: panelControlAccentColor
|
color: panelControlAccentColor
|
||||||
@@ -626,7 +660,8 @@ final class RecordingPreviewPanel: UIView {
|
|||||||
|
|
||||||
@objc private func recordMoreTapped() {
|
@objc private func recordMoreTapped() {
|
||||||
stopPlayback(resetToTrimStart: false)
|
stopPlayback(resetToTrimStart: false)
|
||||||
delegate?.previewPanelDidTapRecordMore(self)
|
let isTrimmed = trimStart > 0.1 || trimEnd < duration - 0.1
|
||||||
|
delegate?.previewPanelDidTapRecordMore(self, trimRange: selectedTrimRange, isTrimmed: isTrimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sendTapped() {
|
@objc private func sendTapped() {
|
||||||
|
|||||||
Reference in New Issue
Block a user