mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +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 duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 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 player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
private var url: URL
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
actor VoiceRecorder {
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
case recorderInitializationFailed
|
||||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
static let minRecordingDuration: TimeInterval = 1
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var stopWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
@discardableResult
|
nonisolated
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
try queue.sync {
|
if recorder?.isRecording == true {
|
||||||
if recorder?.isRecording == true {
|
throw RecorderError.recordingInProgress
|
||||||
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 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) {
|
func stopRecording() async -> URL? {
|
||||||
queue.async { [weak self] in
|
guard let recorder, recorder.isRecording else {
|
||||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
return currentURL
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func cancelRecording() {
|
||||||
queue.async { [weak self] in
|
if let recorder, recorder.isRecording {
|
||||||
guard let self = self else { return }
|
recorder.stop()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
cleanupSession()
|
||||||
|
if let currentURL {
|
||||||
// MARK: - Metering
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
|
|
||||||
func currentAveragePower() -> Float {
|
|
||||||
queue.sync {
|
|
||||||
recorder?.updateMeters()
|
|
||||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
||||||
}
|
}
|
||||||
|
recorder = nil
|
||||||
|
currentURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// 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
|
// MARK: - Properties
|
||||||
|
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
|
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
@State private var messageText = ""
|
@State private var messageText = ""
|
||||||
@@ -73,13 +74,6 @@ struct ContentView: View {
|
|||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
@State private var imagePreviewURL: URL? = 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)
|
#if os(iOS)
|
||||||
@State private var showImagePicker = false
|
@State private var showImagePicker = false
|
||||||
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
@State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera
|
||||||
@@ -282,10 +276,15 @@ struct ContentView: View {
|
|||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Recording Error", isPresented: $showRecordingAlert, actions: {
|
.alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
|
||||||
Button("OK", role: .cancel) {}
|
Button("common.ok", role: .cancel) {}
|
||||||
|
if voiceRecordingVM.state == .permissionDenied {
|
||||||
|
Button("location_channels.action.open_settings") {
|
||||||
|
SystemSettings.microphone.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
}, message: {
|
}, message: {
|
||||||
Text(recordingAlertMessage)
|
Text(voiceRecordingVM.state.alertMessage)
|
||||||
})
|
})
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
|
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) {
|
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
||||||
Button("content.alert.bluetooth_required.settings") {
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
#if os(iOS)
|
SystemSettings.bluetooth.open()
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
Button("common.ok", role: .cancel) {}
|
Button("common.ok", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
@@ -629,8 +624,7 @@ struct ContentView: View {
|
|||||||
secondaryTextColor: secondaryTextColor
|
secondaryTextColor: secondaryTextColor
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recording indicator
|
if voiceRecordingVM.state.isActive {
|
||||||
if isPreparingVoiceNote || isRecordingVoiceNote {
|
|
||||||
recordingIndicator
|
recordingIndicator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1687,11 +1681,16 @@ private extension ContentView {
|
|||||||
Image(systemName: "waveform.circle.fill")
|
Image(systemName: "waveform.circle.fill")
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.bitchatSystem(size: 20))
|
.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))
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: cancelVoiceRecording) {
|
Button(action: voiceRecordingVM.cancel) {
|
||||||
Label("Cancel", systemImage: "xmark.circle")
|
Label("Cancel", systemImage: "xmark.circle")
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.bitchatSystem(size: 18))
|
.font(.bitchatSystem(size: 18))
|
||||||
@@ -1788,11 +1787,9 @@ private extension ContentView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var micButtonView: some View {
|
private var micButtonView: some View {
|
||||||
let tint = (isRecordingVoiceNote || isPreparingVoiceNote) ? Color.red : composerAccentColor
|
Image(systemName: "mic.circle.fill")
|
||||||
|
|
||||||
return Image(systemName: "mic.circle.fill")
|
|
||||||
.font(.bitchatSystem(size: 24))
|
.font(.bitchatSystem(size: 24))
|
||||||
.foregroundColor(tint)
|
.foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor)
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -1800,8 +1797,8 @@ private extension ContentView {
|
|||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
.onChanged { _ in startVoiceRecording() }
|
.onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) }
|
||||||
.onEnded { _ in finishVoiceRecording(send: true) }
|
.onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityLabel("Hold to record a voice note")
|
.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? {
|
func applicationFilesDirectory() -> URL? {
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
struct Cache {
|
struct Cache {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
|
|||||||
Text(Strings.permissionDenied)
|
Text(Strings.permissionDenied)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button(Strings.openSettings) { openSystemLocationSettings() }
|
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -246,9 +246,7 @@ struct LocationChannelsSheet: View {
|
|||||||
sectionDivider
|
sectionDivider
|
||||||
torToggleSection
|
torToggleSection
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
Button(action: {
|
Button(action: SystemSettings.location.open) {
|
||||||
openSystemLocationSettings()
|
|
||||||
}) {
|
|
||||||
Text(Strings.removeAccess)
|
Text(Strings.removeAccess)
|
||||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
.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)
|
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 {
|
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 {
|
var body: some View {
|
||||||
|
|||||||
@@ -436,13 +436,12 @@ struct ViewSmokeTests {
|
|||||||
playback.stop()
|
playback.stop()
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
VoiceNotePlaybackCoordinator.shared.deactivate(playback)
|
||||||
VoiceRecorder.shared.cancelRecording()
|
await VoiceRecorder.shared.cancelRecording()
|
||||||
|
|
||||||
#expect(bins.count == 16)
|
#expect(bins.count == 16)
|
||||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
||||||
#expect(playback.duration > 0)
|
#expect(playback.duration > 0)
|
||||||
#expect(playback.progress == 0)
|
#expect(playback.progress == 0)
|
||||||
#expect(VoiceRecorder.shared.currentAveragePower() <= 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
Reference in New Issue
Block a user