Голосовые сообщения — фикс аудио, layout preview panel, склейка сегментов

This commit is contained in:
2026-04-13 03:57:22 +05:00
parent dd80c7d7e3
commit 472b9a23f5
3 changed files with 483 additions and 167 deletions

View File

@@ -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 {

View File

@@ -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
} }
} }

View File

@@ -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() {