mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:45:22 +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>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<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>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ extension BitchatMessage {
|
|||||||
var data = Data()
|
var data = Data()
|
||||||
|
|
||||||
// Message format:
|
// 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)
|
// - Timestamp: 8 bytes (seconds since epoch)
|
||||||
// - ID length: 1 byte
|
// - ID length: 1 byte
|
||||||
// - ID: variable
|
// - ID: variable
|
||||||
@@ -184,6 +184,8 @@ extension BitchatMessage {
|
|||||||
// - Original sender length + data
|
// - Original sender length + data
|
||||||
// - Recipient nickname length + data
|
// - Recipient nickname length + data
|
||||||
// - Sender peer ID length + data
|
// - Sender peer ID length + data
|
||||||
|
// - Mentions array
|
||||||
|
// - Voice note duration (8 bytes, double) + data length (4 bytes) + data
|
||||||
|
|
||||||
var flags: UInt8 = 0
|
var flags: UInt8 = 0
|
||||||
if isRelay { flags |= 0x01 }
|
if isRelay { flags |= 0x01 }
|
||||||
@@ -192,6 +194,7 @@ extension BitchatMessage {
|
|||||||
if recipientNickname != nil { flags |= 0x08 }
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
if senderPeerID != nil { flags |= 0x10 }
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
if voiceNoteData != nil { flags |= 0x40 }
|
||||||
|
|
||||||
data.append(flags)
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +304,7 @@ extension BitchatMessage {
|
|||||||
let hasRecipientNickname = (flags & 0x08) != 0
|
let hasRecipientNickname = (flags & 0x08) != 0
|
||||||
let hasSenderPeerID = (flags & 0x10) != 0
|
let hasSenderPeerID = (flags & 0x10) != 0
|
||||||
let hasMentions = (flags & 0x20) != 0
|
let hasMentions = (flags & 0x20) != 0
|
||||||
|
let hasVoiceNote = (flags & 0x40) != 0
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
guard offset + 8 <= dataCopy.count else {
|
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(
|
let message = BitchatMessage(
|
||||||
sender: sender,
|
sender: sender,
|
||||||
content: content,
|
content: content,
|
||||||
@@ -389,7 +436,9 @@ extension BitchatMessage {
|
|||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeerID: senderPeerID,
|
senderPeerID: senderPeerID,
|
||||||
mentions: mentions
|
mentions: mentions,
|
||||||
|
voiceNoteData: voiceNoteData,
|
||||||
|
voiceNoteDuration: voiceNoteDuration
|
||||||
)
|
)
|
||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ enum MessageType: UInt8 {
|
|||||||
case keyExchange = 0x06
|
case keyExchange = 0x06
|
||||||
case leave = 0x07
|
case leave = 0x07
|
||||||
case privateMessage = 0x08
|
case privateMessage = 0x08
|
||||||
|
case voiceNote = 0x09
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BitchatPacket: Codable {
|
struct BitchatPacket: Codable {
|
||||||
@@ -69,8 +70,10 @@ struct BitchatMessage: Codable, Equatable {
|
|||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeerID: String?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
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.id = UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -81,6 +84,8 @@ struct BitchatMessage: Codable, Equatable {
|
|||||||
self.recipientNickname = recipientNickname
|
self.recipientNickname = recipientNickname
|
||||||
self.senderPeerID = senderPeerID
|
self.senderPeerID = senderPeerID
|
||||||
self.mentions = mentions
|
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) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
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:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class ChatViewModel: ObservableObject {
|
|||||||
@Published var privateMessageNotification: (sender: String, message: String)? = nil
|
@Published var privateMessageNotification: (sender: String, message: String)? = nil
|
||||||
|
|
||||||
let meshService = BluetoothMeshService()
|
let meshService = BluetoothMeshService()
|
||||||
|
let audioRecorder = AudioRecordingService()
|
||||||
|
let audioPlayer = AudioPlaybackService()
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
private var nicknameSaveTimer: Timer?
|
private var nicknameSaveTimer: Timer?
|
||||||
@@ -471,4 +473,30 @@ extension ChatViewModel: BitchatDelegate {
|
|||||||
|
|
||||||
return Array(Set(mentions)) // Remove duplicates
|
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
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var showPeerList = false
|
@State private var showPeerList = false
|
||||||
|
@State private var isRecordingVoice = false
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
@@ -228,11 +229,32 @@ struct ContentView: View {
|
|||||||
// Check if current user is mentioned
|
// Check if current user is mentioned
|
||||||
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||||
|
|
||||||
Text(viewModel.formatMessage(message, colorScheme: colorScheme))
|
if message.voiceNoteData != nil {
|
||||||
.font(.system(size: 14, design: .monospaced))
|
// Voice note message
|
||||||
.fontWeight(isMentioned ? .bold : .regular)
|
HStack(spacing: 8) {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Button(action: {
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
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)
|
.padding(.horizontal, 12)
|
||||||
@@ -334,6 +356,27 @@ struct ContentView: View {
|
|||||||
sendMessage()
|
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) {
|
Button(action: sendMessage) {
|
||||||
Image(systemName: "arrow.right.circle.fill")
|
Image(systemName: "arrow.right.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
@@ -354,4 +397,37 @@ struct ContentView: View {
|
|||||||
viewModel.sendMessage(messageText)
|
viewModel.sendMessage(messageText)
|
||||||
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)
|
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||||
NSBluetoothAlwaysUsageDescription: bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.
|
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.
|
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:
|
UIBackgroundModes:
|
||||||
- bluetooth-central
|
- bluetooth-central
|
||||||
- bluetooth-peripheral
|
- bluetooth-peripheral
|
||||||
|
|||||||
Reference in New Issue
Block a user