mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:05:19 +00:00
Add push-to-talk voice notes functionality
- Added voice note support to BitchatMessage with audio data and duration
- Updated binary protocol to encode/decode voice notes (flag 0x40)
- Created AudioRecordingService for recording up to 10s voice clips
- Created AudioPlaybackService for playing voice notes
- Added microphone permission to Info.plist and project.yml
- Implemented push-to-talk UI with mic button in ContentView
- Voice notes use AAC compression at 16kHz/32kbps for small file sizes
- Added play/pause buttons for voice notes in message list
- Voice notes display as '🎤 Voice note (X.Xs)' in chat
- Platform-specific audio session handling for iOS/macOS
This commit is contained in:
@@ -26,6 +26,8 @@
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat needs access to your microphone to record voice notes.</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
||||
@@ -172,7 +172,7 @@ extension BitchatMessage {
|
||||
var data = Data()
|
||||
|
||||
// Message format:
|
||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID)
|
||||
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions, bit 6: hasVoiceNote)
|
||||
// - Timestamp: 8 bytes (seconds since epoch)
|
||||
// - ID length: 1 byte
|
||||
// - ID: variable
|
||||
@@ -184,6 +184,8 @@ extension BitchatMessage {
|
||||
// - Original sender length + data
|
||||
// - Recipient nickname length + data
|
||||
// - Sender peer ID length + data
|
||||
// - Mentions array
|
||||
// - Voice note duration (8 bytes, double) + data length (4 bytes) + data
|
||||
|
||||
var flags: UInt8 = 0
|
||||
if isRelay { flags |= 0x01 }
|
||||
@@ -192,6 +194,7 @@ extension BitchatMessage {
|
||||
if recipientNickname != nil { flags |= 0x08 }
|
||||
if senderPeerID != nil { flags |= 0x10 }
|
||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||
if voiceNoteData != nil { flags |= 0x40 }
|
||||
|
||||
data.append(flags)
|
||||
|
||||
@@ -258,6 +261,24 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice note data
|
||||
if let voiceData = voiceNoteData, let duration = voiceNoteDuration {
|
||||
// Duration as 8 bytes (double)
|
||||
let durationBits = duration.bitPattern
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((durationBits >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Voice data length as 4 bytes (UInt32)
|
||||
let voiceLength = UInt32(min(voiceData.count, Int(UInt32.max)))
|
||||
for i in (0..<4).reversed() {
|
||||
data.append(UInt8((voiceLength >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Voice data
|
||||
data.append(voiceData.prefix(Int(voiceLength)))
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -283,6 +304,7 @@ extension BitchatMessage {
|
||||
let hasRecipientNickname = (flags & 0x08) != 0
|
||||
let hasSenderPeerID = (flags & 0x10) != 0
|
||||
let hasMentions = (flags & 0x20) != 0
|
||||
let hasVoiceNote = (flags & 0x40) != 0
|
||||
|
||||
// Timestamp
|
||||
guard offset + 8 <= dataCopy.count else {
|
||||
@@ -380,6 +402,31 @@ extension BitchatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice note data
|
||||
var voiceNoteData: Data?
|
||||
var voiceNoteDuration: TimeInterval?
|
||||
if hasVoiceNote && offset + 12 <= dataCopy.count {
|
||||
// Duration (8 bytes)
|
||||
let durationData = dataCopy[offset..<offset+8]
|
||||
let durationBits = durationData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt64(byte)
|
||||
}
|
||||
voiceNoteDuration = TimeInterval(bitPattern: durationBits)
|
||||
offset += 8
|
||||
|
||||
// Voice data length (4 bytes)
|
||||
let lengthData = dataCopy[offset..<offset+4]
|
||||
let voiceLength = lengthData.reduce(0) { result, byte in
|
||||
(result << 8) | UInt32(byte)
|
||||
}
|
||||
offset += 4
|
||||
|
||||
// Voice data
|
||||
if offset + Int(voiceLength) <= dataCopy.count {
|
||||
voiceNoteData = dataCopy[offset..<offset+Int(voiceLength)]
|
||||
}
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: sender,
|
||||
content: content,
|
||||
@@ -389,7 +436,9 @@ extension BitchatMessage {
|
||||
isPrivate: isPrivate,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: senderPeerID,
|
||||
mentions: mentions
|
||||
mentions: mentions,
|
||||
voiceNoteData: voiceNoteData,
|
||||
voiceNoteDuration: voiceNoteDuration
|
||||
)
|
||||
return message
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ enum MessageType: UInt8 {
|
||||
case keyExchange = 0x06
|
||||
case leave = 0x07
|
||||
case privateMessage = 0x08
|
||||
case voiceNote = 0x09
|
||||
}
|
||||
|
||||
struct BitchatPacket: Codable {
|
||||
@@ -69,8 +70,10 @@ struct BitchatMessage: Codable, Equatable {
|
||||
let recipientNickname: String?
|
||||
let senderPeerID: String?
|
||||
let mentions: [String]? // Array of mentioned nicknames
|
||||
let voiceNoteData: Data? // Audio data for voice notes
|
||||
let voiceNoteDuration: TimeInterval? // Duration in seconds
|
||||
|
||||
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil) {
|
||||
init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, voiceNoteData: Data? = nil, voiceNoteDuration: TimeInterval? = nil) {
|
||||
self.id = UUID().uuidString
|
||||
self.sender = sender
|
||||
self.content = content
|
||||
@@ -81,6 +84,8 @@ struct BitchatMessage: Codable, Equatable {
|
||||
self.recipientNickname = recipientNickname
|
||||
self.senderPeerID = senderPeerID
|
||||
self.mentions = mentions
|
||||
self.voiceNoteData = voiceNoteData
|
||||
self.voiceNoteDuration = voiceNoteDuration
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
class AudioPlaybackService: NSObject, ObservableObject {
|
||||
@Published var isPlaying = false
|
||||
@Published var currentPlayingMessageID: String?
|
||||
@Published var playbackProgress: Double = 0
|
||||
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
private var playbackTimer: Timer?
|
||||
private var audioFiles: [String: URL] = [:] // messageID -> temporary file URL
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupAudioSession()
|
||||
}
|
||||
|
||||
private func setupAudioSession() {
|
||||
#if os(iOS)
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(.playback, mode: .default)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("[AUDIO] Failed to set up audio session: \(error)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func playVoiceNote(messageID: String, audioData: Data, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
// Stop any current playback
|
||||
stopPlayback()
|
||||
|
||||
// Create temporary file for audio data
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("voice_note_\(messageID).m4a")
|
||||
|
||||
do {
|
||||
try audioData.write(to: tempURL)
|
||||
audioFiles[messageID] = tempURL
|
||||
|
||||
audioPlayer = try AVAudioPlayer(contentsOf: tempURL)
|
||||
audioPlayer?.delegate = self
|
||||
audioPlayer?.prepareToPlay()
|
||||
|
||||
if audioPlayer?.play() == true {
|
||||
isPlaying = true
|
||||
currentPlayingMessageID = messageID
|
||||
playbackProgress = 0
|
||||
|
||||
// Start progress timer
|
||||
playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
guard let self = self, let player = self.audioPlayer else { return }
|
||||
self.playbackProgress = player.currentTime / player.duration
|
||||
}
|
||||
|
||||
completion(.success(()))
|
||||
} else {
|
||||
completion(.failure(AudioError.playbackFailed))
|
||||
}
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func stopPlayback() {
|
||||
audioPlayer?.stop()
|
||||
isPlaying = false
|
||||
currentPlayingMessageID = nil
|
||||
playbackProgress = 0
|
||||
playbackTimer?.invalidate()
|
||||
playbackTimer = nil
|
||||
}
|
||||
|
||||
func togglePlayback(messageID: String, audioData: Data, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if isPlaying && currentPlayingMessageID == messageID {
|
||||
stopPlayback()
|
||||
completion(.success(()))
|
||||
} else {
|
||||
playVoiceNote(messageID: messageID, audioData: audioData, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupTemporaryFiles() {
|
||||
for (_, url) in audioFiles {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
audioFiles.removeAll()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanupTemporaryFiles()
|
||||
}
|
||||
|
||||
enum AudioError: LocalizedError {
|
||||
case playbackFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .playbackFailed:
|
||||
return "Failed to start audio playback"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioPlaybackService: AVAudioPlayerDelegate {
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
stopPlayback()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
class AudioRecordingService: NSObject, ObservableObject {
|
||||
@Published var isRecording = false
|
||||
@Published var recordingTime: TimeInterval = 0
|
||||
|
||||
private var audioRecorder: AVAudioRecorder?
|
||||
private var recordingTimer: Timer?
|
||||
private var recordingStartTime: Date?
|
||||
private let maxDuration: TimeInterval = 10.0
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setupAudioSession()
|
||||
}
|
||||
|
||||
private func setupAudioSession() {
|
||||
#if os(iOS)
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(.playAndRecord, mode: .default)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("[AUDIO] Failed to set up audio session: \(error)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func startRecording(completion: @escaping (Result<URL, Error>) -> Void) {
|
||||
guard !isRecording else { return }
|
||||
|
||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let audioFilename = documentsPath.appendingPathComponent("voice_note_\(Date().timeIntervalSince1970).m4a")
|
||||
|
||||
let settings = [
|
||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 16000, // Lower sample rate for smaller files
|
||||
AVNumberOfChannelsKey: 1, // Mono for voice
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue,
|
||||
AVEncoderBitRateKey: 32000 // 32 kbps for voice
|
||||
]
|
||||
|
||||
do {
|
||||
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.record()
|
||||
|
||||
isRecording = true
|
||||
recordingStartTime = Date()
|
||||
recordingTime = 0
|
||||
|
||||
// Start timer to update recording time and enforce max duration
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
guard let self = self, let startTime = self.recordingStartTime else { return }
|
||||
self.recordingTime = Date().timeIntervalSince(startTime)
|
||||
|
||||
if self.recordingTime >= self.maxDuration {
|
||||
self.stopRecording { result in
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(completion: @escaping (Result<URL, Error>) -> Void) {
|
||||
guard isRecording, let recorder = audioRecorder else {
|
||||
completion(.failure(AudioError.notRecording))
|
||||
return
|
||||
}
|
||||
|
||||
recorder.stop()
|
||||
isRecording = false
|
||||
recordingTimer?.invalidate()
|
||||
recordingTimer = nil
|
||||
|
||||
let url = recorder.url
|
||||
let _ = recordingTime
|
||||
|
||||
// Reset for next recording
|
||||
audioRecorder = nil
|
||||
recordingTime = 0
|
||||
recordingStartTime = nil
|
||||
|
||||
// Verify file exists and has content
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
if let fileSize = attributes[.size] as? Int, fileSize > 0 {
|
||||
completion(.success(url))
|
||||
} else {
|
||||
completion(.failure(AudioError.emptyRecording))
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
guard isRecording, let recorder = audioRecorder else { return }
|
||||
|
||||
recorder.stop()
|
||||
recorder.deleteRecording()
|
||||
|
||||
isRecording = false
|
||||
recordingTimer?.invalidate()
|
||||
recordingTimer = nil
|
||||
audioRecorder = nil
|
||||
recordingTime = 0
|
||||
recordingStartTime = nil
|
||||
}
|
||||
|
||||
enum AudioError: LocalizedError {
|
||||
case notRecording
|
||||
case emptyRecording
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notRecording:
|
||||
return "Not currently recording"
|
||||
case .emptyRecording:
|
||||
return "Recording produced no audio data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AudioRecordingService: AVAudioRecorderDelegate {
|
||||
func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
|
||||
if !flag {
|
||||
print("[AUDIO] Recording finished unsuccessfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,36 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let nickname = self.delegate as? ChatViewModel
|
||||
let senderNick = nickname?.nickname ?? self.myPeerID
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: senderNick,
|
||||
content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
voiceNoteData: audioData,
|
||||
voiceNoteDuration: duration
|
||||
)
|
||||
|
||||
if let messageData = message.toBinaryPayload() {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.voiceNote.rawValue,
|
||||
ttl: self.maxTTL,
|
||||
senderID: self.myPeerID,
|
||||
payload: messageData
|
||||
)
|
||||
|
||||
self.broadcastPacket(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
@@ -509,6 +539,29 @@ class BluetoothMeshService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
case .voiceNote:
|
||||
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
|
||||
// Ignore our own messages
|
||||
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), senderID == myPeerID {
|
||||
return
|
||||
}
|
||||
|
||||
// Store nickname mapping
|
||||
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
|
||||
peerNicknames[senderID] = message.sender
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
var relayPacket = packet
|
||||
relayPacket.ttl -= 1
|
||||
if relayPacket.ttl > 0 {
|
||||
self.broadcastPacket(relayPacket)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class ChatViewModel: ObservableObject {
|
||||
@Published var privateMessageNotification: (sender: String, message: String)? = nil
|
||||
|
||||
let meshService = BluetoothMeshService()
|
||||
let audioRecorder = AudioRecordingService()
|
||||
let audioPlayer = AudioPlaybackService()
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let nicknameKey = "bitchat.nickname"
|
||||
private var nicknameSaveTimer: Timer?
|
||||
@@ -471,4 +473,30 @@ extension ChatViewModel: BitchatDelegate {
|
||||
|
||||
return Array(Set(mentions)) // Remove duplicates
|
||||
}
|
||||
|
||||
func sendVoiceNote(_ audioData: Data, duration: TimeInterval) {
|
||||
let message = BitchatMessage(
|
||||
sender: nickname,
|
||||
content: "🎤 Voice note (\(String(format: "%.1f", duration))s)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
voiceNoteData: audioData,
|
||||
voiceNoteDuration: duration
|
||||
)
|
||||
messages.append(message)
|
||||
|
||||
// Send via mesh
|
||||
meshService.sendVoiceNote(audioData, duration: duration)
|
||||
}
|
||||
|
||||
func playVoiceNote(message: BitchatMessage) {
|
||||
guard let audioData = message.voiceNoteData else { return }
|
||||
|
||||
audioPlayer.togglePlayback(messageID: message.id, audioData: audioData) { result in
|
||||
if case .failure(let error) = result {
|
||||
print("[AUDIO] Failed to play voice note: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ struct ContentView: View {
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var showPeerList = false
|
||||
@State private var isRecordingVoice = false
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black : Color.white
|
||||
@@ -228,11 +229,32 @@ struct ContentView: View {
|
||||
// Check if current user is mentioned
|
||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||
|
||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if message.voiceNoteData != nil {
|
||||
// Voice note message
|
||||
HStack(spacing: 8) {
|
||||
Button(action: {
|
||||
viewModel.playVoiceNote(message: message)
|
||||
}) {
|
||||
Image(systemName: viewModel.audioPlayer.isPlaying && viewModel.audioPlayer.currentPlayingMessageID == message.id ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
} else {
|
||||
// Regular text message
|
||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
@@ -334,6 +356,27 @@ struct ContentView: View {
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
// Push to talk button
|
||||
Button(action: {}) {
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(isRecordingVoice ? Color.red : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !isRecordingVoice {
|
||||
startVoiceRecording()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if isRecordingVoice {
|
||||
stopVoiceRecording()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Button(action: sendMessage) {
|
||||
Image(systemName: "arrow.right.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
@@ -354,4 +397,37 @@ struct ContentView: View {
|
||||
viewModel.sendMessage(messageText)
|
||||
messageText = ""
|
||||
}
|
||||
|
||||
private func startVoiceRecording() {
|
||||
isRecordingVoice = true
|
||||
#if os(iOS)
|
||||
// Light haptic feedback
|
||||
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
||||
impactFeedback.impactOccurred()
|
||||
#endif
|
||||
|
||||
viewModel.audioRecorder.startRecording { result in
|
||||
// Will handle result in stopVoiceRecording
|
||||
}
|
||||
}
|
||||
|
||||
private func stopVoiceRecording() {
|
||||
isRecordingVoice = false
|
||||
|
||||
viewModel.audioRecorder.stopRecording { result in
|
||||
switch result {
|
||||
case .success(let audioURL):
|
||||
// Read audio file and send as voice note
|
||||
if let audioData = try? Data(contentsOf: audioURL) {
|
||||
let duration = viewModel.audioRecorder.recordingTime
|
||||
viewModel.sendVoiceNote(audioData, duration: duration)
|
||||
|
||||
// Clean up temporary file
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
}
|
||||
case .failure(let error):
|
||||
print("[AUDIO] Recording failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ targets:
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
||||
NSBluetoothPeripheralUsageDescription: bitchat uses Bluetooth to discover and connect with other bitchat users nearby.
|
||||
NSMicrophoneUsageDescription: bitchat needs access to your microphone to record voice notes.
|
||||
UIBackgroundModes:
|
||||
- bluetooth-central
|
||||
- bluetooth-peripheral
|
||||
|
||||
Reference in New Issue
Block a user