mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Refactor Voice Messages (#1075)
* Extract voice-related code to a dedicated VM * Minor optimization of permission request + fix ui bug When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown * Remove dead code * Remove unnecessary delegate conformance * `actor VoiceRecorder` + other minor improvements * Centralized opening system settings + open for mic access * Better voice-recording state handling with Enum * Remove manual timer management * Simplify formatting + avoid jumping UI w/ countdown * Add `requestingPermission` state to avoid repetitive calls * Improve guarding of the states after awaits * Fix potential “actor reentrancy across awaits” issue * Remove short-circuiting on .idle + guard before error * Fix playbackLabel’s formatting for duration vs remainder --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var progress: Double = 0
|
||||
|
||||
/// rounded so 4.9s shows "00:05"
|
||||
var roundedDuration: Int {
|
||||
guard duration.isFinite else { return 0 }
|
||||
return Int(duration.rounded())
|
||||
}
|
||||
|
||||
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||
var remainingSeconds: Int {
|
||||
let remaining = max(0, duration - currentTime)
|
||||
return min(roundedDuration, Int(ceil(remaining)))
|
||||
}
|
||||
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
@@ -2,8 +2,7 @@ import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
private var stopWorkItem: DispatchWorkItem?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
@discardableResult
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
try queue.sync {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.delegate = self
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
stopWorkItem?.cancel()
|
||||
stopWorkItem = nil
|
||||
return outputURL
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
||||
completion(self?.currentURL)
|
||||
return
|
||||
}
|
||||
|
||||
let item = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
recorder.stop()
|
||||
self.cleanupSession()
|
||||
let url = self.currentURL
|
||||
self.recorder = nil
|
||||
self.currentURL = url
|
||||
completion(url)
|
||||
}
|
||||
self.stopWorkItem = item
|
||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
let sessionURL = currentURL
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||
|
||||
recorder.stop()
|
||||
|
||||
// A new session may have started during the sleep — don't touch its state
|
||||
if self.recorder === recorder {
|
||||
cleanupSession()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
return sessionURL
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopWorkItem?.cancel()
|
||||
self.stopWorkItem = nil
|
||||
if let recorder = self.recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
self.cleanupSession()
|
||||
if let url = self.currentURL {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
self.recorder = nil
|
||||
self.currentURL = nil
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metering
|
||||
|
||||
func currentAveragePower() -> Float {
|
||||
queue.sync {
|
||||
recorder?.updateMeters()
|
||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SystemSettings.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum SystemSettings {
|
||||
case bluetooth
|
||||
case location
|
||||
case microphone
|
||||
|
||||
#if os(macOS)
|
||||
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
|
||||
|
||||
private var macPrivacyAnchor: String {
|
||||
switch self {
|
||||
case .bluetooth: "Privacy_Bluetooth"
|
||||
case .location: "Privacy_LocationServices"
|
||||
case .microphone: "Privacy_Microphone"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func open() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// VoiceRecordingViewModel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class VoiceRecordingViewModel: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case requestingPermission
|
||||
case permissionDenied
|
||||
case preparing
|
||||
case recording(startDate: Date)
|
||||
case error(message: String)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .recording: true
|
||||
case .idle, .requestingPermission, .permissionDenied, .error: false
|
||||
}
|
||||
}
|
||||
|
||||
var alertMessage: String {
|
||||
switch self {
|
||||
case .error(let message): message
|
||||
case .permissionDenied: "Microphone access is required to record voice notes."
|
||||
case .idle, .requestingPermission, .preparing, .recording: ""
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func duration(for date: Date) -> TimeInterval {
|
||||
switch self {
|
||||
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
|
||||
case .recording(let startDate): date.timeIntervalSince(startDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var showAlert: Bool {
|
||||
get {
|
||||
switch state {
|
||||
case .permissionDenied, .error: true
|
||||
case .idle, .requestingPermission, .preparing, .recording: false
|
||||
}
|
||||
}
|
||||
set {
|
||||
if !newValue { state = .idle }
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var state = State.idle
|
||||
|
||||
func formattedDuration(for date: Date) -> String {
|
||||
let clamped = max(0, state.duration(for: date))
|
||||
let totalMilliseconds = Int(clamped * 1000)
|
||||
let minutes = totalMilliseconds / 60_000
|
||||
let seconds = (totalMilliseconds % 60_000) / 1_000
|
||||
let centiseconds = (totalMilliseconds % 1_000) / 10
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func start(shouldShow: Bool) {
|
||||
guard shouldShow, state == .idle else { return }
|
||||
state = .requestingPermission
|
||||
Task {
|
||||
let granted = await VoiceRecorder.shared.requestPermission()
|
||||
guard state == .requestingPermission else { return }
|
||||
guard granted else {
|
||||
state = .permissionDenied
|
||||
return
|
||||
}
|
||||
state = .preparing
|
||||
do {
|
||||
try await VoiceRecorder.shared.startRecording()
|
||||
guard state == .preparing else {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
state = .recording(startDate: Date())
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
guard state == .preparing else { return }
|
||||
state = .error(message: "Could not start recording.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish(completion: ((URL) -> Void)?) {
|
||||
let previousState = state
|
||||
|
||||
switch previousState {
|
||||
case .permissionDenied, .error:
|
||||
return
|
||||
case .idle, .requestingPermission, .preparing, .recording:
|
||||
break
|
||||
}
|
||||
|
||||
state = .idle
|
||||
|
||||
guard case .recording(let startDate) = previousState, let completion else {
|
||||
Task { await VoiceRecorder.shared.cancelRecording() }
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let finalDuration = Date().timeIntervalSince(startDate)
|
||||
if let url = await VoiceRecorder.shared.stopRecording(),
|
||||
isValidRecording(at: url, duration: finalDuration) {
|
||||
completion(url)
|
||||
} else {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finish(completion: nil)
|
||||
}
|
||||
|
||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 0,
|
||||
duration >= VoiceRecorder.minRecordingDuration {
|
||||
return true
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return false
|
||||
}
|
||||
}
|
||||
+22
-121
@@ -49,6 +49,7 @@ struct ContentView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@State private var messageText = ""
|
||||
@@ -73,13 +74,6 @@ struct ContentView: View {
|
||||
@State private var showLocationNotes = false
|
||||
@State private var notesGeohash: String? = nil
|
||||
@State private var imagePreviewURL: URL? = nil
|
||||
@State private var recordingAlertMessage: String = ""
|
||||
@State private var showRecordingAlert = false
|
||||
@State private var isRecordingVoiceNote = false
|
||||
@State private var isPreparingVoiceNote = false
|
||||
@State private var recordingDuration: TimeInterval = 0
|
||||
@State private var recordingTimer: Timer?
|
||||
@State private var recordingStartDate: Date?
|
||||
#if os(iOS)
|
||||
@State private var showImagePicker = false
|
||||
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
||||
@@ -282,10 +276,15 @@ struct ContentView: View {
|
||||
.environmentObject(viewModel)
|
||||
}
|
||||
}
|
||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
||||
Button("OK", role: .cancel) {}
|
||||
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
if voiceRecordingVM.state == .permissionDenied {
|
||||
Button("location_channels.action.open_settings") {
|
||||
SystemSettings.microphone.open()
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
Text(recordingAlertMessage)
|
||||
Text(voiceRecordingVM.state.alertMessage)
|
||||
})
|
||||
.confirmationDialog(
|
||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
||||
@@ -342,11 +341,7 @@ struct ContentView: View {
|
||||
}
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
@@ -629,8 +624,7 @@ struct ContentView: View {
|
||||
secondaryTextColor: secondaryTextColor
|
||||
)
|
||||
|
||||
// Recording indicator
|
||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||
if voiceRecordingVM.state.isActive {
|
||||
recordingIndicator
|
||||
}
|
||||
|
||||
@@ -1687,11 +1681,16 @@ private extension ContentView {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.bitchatSystem(size: 20))
|
||||
Text("recording \(formattedRecordingDuration())", comment: "Voice note recording duration indicator")
|
||||
TimelineView(.periodic(from: .now, by: 0.05)) { context in
|
||||
Text(
|
||||
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
|
||||
comment: "Voice note recording duration indicator"
|
||||
)
|
||||
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: cancelVoiceRecording) {
|
||||
Button(action: voiceRecordingVM.cancel) {
|
||||
Label("Cancel", systemImage: "xmark.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.bitchatSystem(size: 18))
|
||||
@@ -1788,11 +1787,9 @@ private extension ContentView {
|
||||
}
|
||||
|
||||
private var micButtonView: some View {
|
||||
let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor
|
||||
|
||||
return Image(systemName: "mic.circle.fill")
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.bitchatSystem(size: 24))
|
||||
.foregroundColor(tint)
|
||||
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.contentShape(Circle())
|
||||
.overlay(
|
||||
@@ -1800,8 +1797,8 @@ private extension ContentView {
|
||||
.contentShape(Circle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in startVoiceRecording() }
|
||||
.onEnded { _ in finishVoiceRecording(send: true) }
|
||||
.onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) }
|
||||
.onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) }
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Hold to record a voice note")
|
||||
@@ -1827,102 +1824,6 @@ private extension ContentView {
|
||||
)
|
||||
}
|
||||
|
||||
func formattedRecordingDuration() -> String {
|
||||
let clamped = max(0, recordingDuration)
|
||||
let totalMilliseconds = Int((clamped * 1000).rounded())
|
||||
let minutes = totalMilliseconds / 60_000
|
||||
let seconds = (totalMilliseconds % 60_000) / 1_000
|
||||
let centiseconds = (totalMilliseconds % 1_000) / 10
|
||||
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func startVoiceRecording() {
|
||||
guard shouldShowVoiceControl else { return }
|
||||
guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return }
|
||||
isPreparingVoiceNote = true
|
||||
Task { @MainActor in
|
||||
let granted = await VoiceRecorder.shared.requestPermission()
|
||||
guard granted else {
|
||||
isPreparingVoiceNote = false
|
||||
recordingAlertMessage = "Microphone access is required to record voice notes."
|
||||
showRecordingAlert = true
|
||||
return
|
||||
}
|
||||
do {
|
||||
_ = try VoiceRecorder.shared.startRecording()
|
||||
recordingDuration = 0
|
||||
recordingStartDate = Date()
|
||||
recordingTimer?.invalidate()
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
||||
if let start = recordingStartDate {
|
||||
recordingDuration = Date().timeIntervalSince(start)
|
||||
}
|
||||
}
|
||||
if let timer = recordingTimer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
isPreparingVoiceNote = false
|
||||
isRecordingVoiceNote = true
|
||||
} catch {
|
||||
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
|
||||
recordingAlertMessage = "Could not start recording."
|
||||
showRecordingAlert = true
|
||||
VoiceRecorder.shared.cancelRecording()
|
||||
isPreparingVoiceNote = false
|
||||
isRecordingVoiceNote = false
|
||||
recordingStartDate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishVoiceRecording(send: Bool) {
|
||||
if isPreparingVoiceNote {
|
||||
isPreparingVoiceNote = false
|
||||
VoiceRecorder.shared.cancelRecording()
|
||||
return
|
||||
}
|
||||
guard isRecordingVoiceNote else { return }
|
||||
isRecordingVoiceNote = false
|
||||
recordingTimer?.invalidate()
|
||||
recordingTimer = nil
|
||||
if let start = recordingStartDate {
|
||||
recordingDuration = Date().timeIntervalSince(start)
|
||||
}
|
||||
recordingStartDate = nil
|
||||
if send {
|
||||
let minimumDuration: TimeInterval = 1.0
|
||||
VoiceRecorder.shared.stopRecording { url in
|
||||
DispatchQueue.main.async {
|
||||
guard
|
||||
let url = url,
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let fileSize = attributes[.size] as? NSNumber,
|
||||
fileSize.intValue > 0,
|
||||
recordingDuration >= minimumDuration
|
||||
else {
|
||||
if let url = url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
recordingAlertMessage = recordingDuration < minimumDuration
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
showRecordingAlert = true
|
||||
return
|
||||
}
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VoiceRecorder.shared.cancelRecording()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelVoiceRecording() {
|
||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
||||
finishVoiceRecording(send: false)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationFilesDirectory() -> URL? {
|
||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||
struct Cache {
|
||||
|
||||
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
@@ -246,9 +246,7 @@ struct LocationChannelsSheet: View {
|
||||
sectionDivider
|
||||
torToggleSection
|
||||
.padding(.top, 12)
|
||||
Button(action: {
|
||||
openSystemLocationSettings()
|
||||
}) {
|
||||
Button(action: SystemSettings.location.open) {
|
||||
Text(Strings.removeAccess)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
@@ -622,18 +620,3 @@ extension LocationChannelsSheet {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open Settings helper
|
||||
private func openSystemLocationSettings() {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
|
||||
NSWorkspace.shared.open(url)
|
||||
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -34,24 +34,10 @@ struct VoiceNoteView: View {
|
||||
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let duration = playback.duration
|
||||
guard duration.isFinite, duration > 0 else { return "--:--" }
|
||||
let minutes = Int(duration) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var currentText: String {
|
||||
let current = playback.currentTime
|
||||
guard current.isFinite, current > 0 else { return "00:00" }
|
||||
let minutes = Int(current) / 60
|
||||
let seconds = Int(current) % 60
|
||||
return String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var playbackLabel: String {
|
||||
playback.isPlaying ? currentText + "/" + durationText : durationText
|
||||
guard playback.duration.isFinite else { return "--:--" }
|
||||
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
|
||||
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -436,13 +436,12 @@ struct ViewSmokeTests {
|
||||
playback.stop()
|
||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
||||
VoiceRecorder.shared.cancelRecording()
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
|
||||
#expect(bins.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
||||
#expect(playback.duration > 0)
|
||||
#expect(playback.progress == 0)
|
||||
#expect(VoiceRecorder.shared.currentAveragePower() <= 0)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
Reference in New Issue
Block a user