mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
live recording
This commit is contained in:
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
|
||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
private var recorder: AVAudioRecorder?
|
||||
@@ -34,6 +35,27 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live metrics for visualizer
|
||||
|
||||
/// Returns normalized amplitude [0,1] based on average power in dB.
|
||||
/// Call periodically while recording to drive a live waveform.
|
||||
func pollNormalizedAmplitude() -> CGFloat {
|
||||
guard let r = recorder, isRecording else { return 0 }
|
||||
r.updateMeters()
|
||||
// Use peak power for responsiveness, map dB [-160,0] -> linear [0,1]
|
||||
let db = r.peakPower(forChannel: 0)
|
||||
let linear = pow(10.0, db / 20.0) // 0..1
|
||||
if linear.isNaN || linear.isInfinite { return 0 }
|
||||
// Keep linear to avoid globally scaling up low amplitudes
|
||||
return CGFloat(max(0, min(1, linear)))
|
||||
}
|
||||
|
||||
/// Current elapsed recording time in milliseconds.
|
||||
func currentTimeMs() -> Int {
|
||||
guard let r = recorder, isRecording else { return 0 }
|
||||
return Int(r.currentTime * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
enum VoiceRecorderPaths {
|
||||
|
||||
+175
-97
@@ -9,12 +9,9 @@
|
||||
import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#if os(iOS)
|
||||
import PhotosUI
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
//
|
||||
@@ -61,9 +58,19 @@ struct ContentView: View {
|
||||
// Window sizes for rendering (infinite scroll up)
|
||||
@State private var windowCountPublic: Int = 300
|
||||
@State private var windowCountPrivate: [String: Int] = [:]
|
||||
// Measure input field height so recorder overlay matches it
|
||||
@State private var inputFieldMeasuredHeight: CGFloat = 0
|
||||
private struct InputHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }
|
||||
}
|
||||
#if os(iOS)
|
||||
@State private var voiceRecorder = VoiceRecorder()
|
||||
@State private var isRecordingVoice = false
|
||||
@State private var recordingTimer: Timer? = nil
|
||||
@State private var recordingElapsedMs: Int = 0
|
||||
@State private var recordingAmplitudeNorm: CGFloat = 0
|
||||
@State private var currentRecordingURL: URL? = nil
|
||||
@State private var isShowingImagePicker = false
|
||||
#endif
|
||||
|
||||
@@ -851,93 +858,123 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
// iOS keyboard autocomplete and capitalization enabled by default
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
ZStack(alignment: .leading) {
|
||||
// Always keep the TextField mounted so the keyboard state doesn't change
|
||||
TextField("type a message...", text: $messageText)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.focused($isTextFieldFocused)
|
||||
.padding(.leading, 12)
|
||||
.opacity(isRecordingVoice ? 0.0 : 1.0)
|
||||
// Measure the text field height so we can match recorder height
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
|
||||
})
|
||||
|
||||
// Recording overlay: live waveform + elapsed/max time, same height as input field
|
||||
if isRecordingVoice {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
RealtimeScrollingWaveform(
|
||||
amplitudeNorm: recordingAmplitudeNorm,
|
||||
bars: 240,
|
||||
barColor: (colorScheme == .dark ? Color.green : Color(red: 0, green: 0.6, blue: 0.3))
|
||||
)
|
||||
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||
let secs = max(0, recordingElapsedMs / 1000)
|
||||
let mm = secs / 60
|
||||
let ss = secs % 60
|
||||
Text(String(format: "%02d:%02d / 00:10", mm, ss))
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||
}
|
||||
|
||||
}
|
||||
.onChange(of: messageText) { newValue in
|
||||
// Cancel previous debounce timer
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
|
||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||
// Get cursor position (approximate - end of text for now)
|
||||
let cursorPosition = newValue.count
|
||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
// Check for command autocomplete (instant, no debounce needed)
|
||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||
// Build context-aware command list
|
||||
let isGeoPublic: Bool = {
|
||||
if case .location = locationManager.selectedChannel { return true }
|
||||
return false
|
||||
}()
|
||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||
var commandDescriptions = [
|
||||
("/block", "block or list blocked peers"),
|
||||
("/clear", "clear chat messages"),
|
||||
("/hug", "send someone a warm hug"),
|
||||
("/m", "send private message"),
|
||||
("/slap", "slap someone with a trout"),
|
||||
("/unblock", "unblock a peer"),
|
||||
("/w", "see who's online")
|
||||
]
|
||||
// Only show favorites commands when not in geohash context
|
||||
if !(isGeoPublic || isGeoDM) {
|
||||
commandDescriptions.append(("/fav", "add to favorites"))
|
||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||
}
|
||||
|
||||
let input = newValue.lowercased()
|
||||
|
||||
// Map of aliases to primary commands
|
||||
let aliases: [String: String] = [
|
||||
"/join": "/j",
|
||||
"/msg": "/m"
|
||||
]
|
||||
|
||||
// Filter commands, but convert aliases to primary
|
||||
commandSuggestions = commandDescriptions
|
||||
.filter { $0.0.starts(with: input) }
|
||||
.map { $0.0 }
|
||||
|
||||
// Also check if input matches an alias
|
||||
for (alias, primary) in aliases {
|
||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
||||
commandSuggestions.append(primary)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
|
||||
// Remove duplicates and sort
|
||||
commandSuggestions = Array(Set(commandSuggestions)).sorted()
|
||||
showCommandSuggestions = !commandSuggestions.isEmpty
|
||||
} else {
|
||||
showCommandSuggestions = false
|
||||
commandSuggestions = []
|
||||
}
|
||||
.onSubmit {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
.onSubmit { sendMessage() }
|
||||
.onPreferenceChange(InputHeightPreferenceKey.self) { inputFieldMeasuredHeight = $0 }
|
||||
|
||||
Group {
|
||||
if messageText.isEmpty {
|
||||
#if os(iOS)
|
||||
HStack(spacing: 8) {
|
||||
// Plus button for image picker (to the left of mic)
|
||||
Button(action: { showImagePicker() }) {
|
||||
Image(systemName: "plus.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
||||
// Plus button for image picker (hidden while recording)
|
||||
if !isRecordingVoice {
|
||||
Button(action: { showImagePicker() }) {
|
||||
Image(systemName: "plus.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add media")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add media")
|
||||
|
||||
Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
|
||||
.font(.system(size: 22))
|
||||
@@ -949,31 +986,17 @@ struct ContentView: View {
|
||||
do {
|
||||
let url = try VoiceRecorderPaths.outgoingURL()
|
||||
try voiceRecorder.startRecording(to: url)
|
||||
currentRecordingURL = url
|
||||
isRecordingVoice = true
|
||||
startRecordingMeters()
|
||||
hapticStart()
|
||||
} catch {
|
||||
isRecordingVoice = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
voiceRecorder.stopRecording {
|
||||
isRecordingVoice = false
|
||||
// Send the most recent recorded file
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
|
||||
let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
||||
let latest = files.sorted { (a, b) -> Bool in
|
||||
let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return ad > bd
|
||||
}.first
|
||||
if let u = latest { viewModel.sendVoiceNote(fileURL: u) }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
finishRecordingAndSend()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1020,9 +1043,64 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
private func startRecordingMeters() {
|
||||
recordingTimer?.invalidate()
|
||||
recordingElapsedMs = 0
|
||||
recordingAmplitudeNorm = 0
|
||||
// Poll meters ~12.5Hz for elapsed and ~50Hz for waveform via the view's ticker.
|
||||
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in
|
||||
recordingAmplitudeNorm = voiceRecorder.pollNormalizedAmplitude()
|
||||
recordingElapsedMs = voiceRecorder.currentTimeMs()
|
||||
if recordingElapsedMs >= 10_000 {
|
||||
// Auto-stop at 10s
|
||||
finishRecordingAndSend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRecordingAndSend() {
|
||||
guard isRecordingVoice else { return }
|
||||
voiceRecorder.stopRecording {
|
||||
isRecordingVoice = false
|
||||
recordingTimer?.invalidate(); recordingTimer = nil
|
||||
hapticStop()
|
||||
let url = currentRecordingURL
|
||||
currentRecordingURL = nil
|
||||
if let u = url {
|
||||
viewModel.sendVoiceNote(fileURL: u)
|
||||
} else {
|
||||
// Fallback to latest file in case we lost the URL
|
||||
do {
|
||||
let fm = FileManager.default
|
||||
let base = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let folder = base.appendingPathComponent("bitchat_voicenotes/outgoing", isDirectory: true)
|
||||
let files = try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles])
|
||||
let latest = files.sorted { (a, b) -> Bool in
|
||||
let ad = (try? a.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let bd = (try? b.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return ad > bd
|
||||
}.first
|
||||
if let u = latest { viewModel.sendVoiceNote(fileURL: u) }
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showImagePicker() {
|
||||
isShowingImagePicker = true
|
||||
}
|
||||
|
||||
private func hapticStart() {
|
||||
let gen = UIImpactFeedbackGenerator(style: .medium)
|
||||
gen.prepare(); gen.impactOccurred()
|
||||
}
|
||||
private func hapticStop() {
|
||||
let gen = UINotificationFeedbackGenerator()
|
||||
gen.prepare(); gen.notificationOccurred(.success)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Sidebar View
|
||||
|
||||
Reference in New Issue
Block a user