mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:25:21 +00:00
live recording
This commit is contained in:
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
@@ -34,6 +35,27 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
completion()
|
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 {
|
enum VoiceRecorderPaths {
|
||||||
|
|||||||
+175
-97
@@ -9,12 +9,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#if os(iOS)
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -61,9 +58,19 @@ struct ContentView: View {
|
|||||||
// Window sizes for rendering (infinite scroll up)
|
// Window sizes for rendering (infinite scroll up)
|
||||||
@State private var windowCountPublic: Int = 300
|
@State private var windowCountPublic: Int = 300
|
||||||
@State private var windowCountPrivate: [String: Int] = [:]
|
@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)
|
#if os(iOS)
|
||||||
@State private var voiceRecorder = VoiceRecorder()
|
@State private var voiceRecorder = VoiceRecorder()
|
||||||
@State private var isRecordingVoice = false
|
@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
|
@State private var isShowingImagePicker = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -851,93 +858,123 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
HStack(alignment: .center, spacing: 4) {
|
HStack(alignment: .center, spacing: 4) {
|
||||||
TextField("type a message...", text: $messageText)
|
ZStack(alignment: .leading) {
|
||||||
.textFieldStyle(.plain)
|
// Always keep the TextField mounted so the keyboard state doesn't change
|
||||||
.font(.system(size: 14, design: .monospaced))
|
TextField("type a message...", text: $messageText)
|
||||||
.foregroundColor(textColor)
|
.textFieldStyle(.plain)
|
||||||
.focused($isTextFieldFocused)
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.padding(.leading, 12)
|
.foregroundColor(textColor)
|
||||||
// iOS keyboard autocomplete and capitalization enabled by default
|
.focused($isTextFieldFocused)
|
||||||
.onChange(of: messageText) { newValue in
|
.padding(.leading, 12)
|
||||||
// Cancel previous debounce timer
|
.opacity(isRecordingVoice ? 0.0 : 1.0)
|
||||||
autocompleteDebounceTimer?.invalidate()
|
// Measure the text field height so we can match recorder height
|
||||||
|
.background(GeometryReader { geo in
|
||||||
// Debounce autocomplete updates to reduce calls during rapid typing
|
Color.clear.preference(key: InputHeightPreferenceKey.self, value: geo.size.height)
|
||||||
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
})
|
||||||
// Get cursor position (approximate - end of text for now)
|
|
||||||
let cursorPosition = newValue.count
|
// Recording overlay: live waveform + elapsed/max time, same height as input field
|
||||||
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
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)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, 12)
|
||||||
// Check for command autocomplete (instant, no debounce needed)
|
.frame(height: max(28, inputFieldMeasuredHeight))
|
||||||
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
}
|
||||||
// Build context-aware command list
|
|
||||||
let isGeoPublic: Bool = {
|
}
|
||||||
if case .location = locationManager.selectedChannel { return true }
|
.onChange(of: messageText) { newValue in
|
||||||
return false
|
// Cancel previous debounce timer
|
||||||
}()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
|
||||||
var commandDescriptions = [
|
// Debounce autocomplete updates to reduce calls during rapid typing
|
||||||
("/block", "block or list blocked peers"),
|
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
|
||||||
("/clear", "clear chat messages"),
|
// Get cursor position (approximate - end of text for now)
|
||||||
("/hug", "send someone a warm hug"),
|
let cursorPosition = newValue.count
|
||||||
("/m", "send private message"),
|
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
|
||||||
("/slap", "slap someone with a trout"),
|
}
|
||||||
("/unblock", "unblock a peer"),
|
|
||||||
("/w", "see who's online")
|
// Check for command autocomplete (instant, no debounce needed)
|
||||||
]
|
if newValue.hasPrefix("/") && newValue.count >= 1 {
|
||||||
// Only show favorites commands when not in geohash context
|
// Build context-aware command list
|
||||||
if !(isGeoPublic || isGeoDM) {
|
let isGeoPublic: Bool = {
|
||||||
commandDescriptions.append(("/fav", "add to favorites"))
|
if case .location = locationManager.selectedChannel { return true }
|
||||||
commandDescriptions.append(("/unfav", "remove from favorites"))
|
return false
|
||||||
}
|
}()
|
||||||
|
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
|
||||||
let input = newValue.lowercased()
|
var commandDescriptions = [
|
||||||
|
("/block", "block or list blocked peers"),
|
||||||
// Map of aliases to primary commands
|
("/clear", "clear chat messages"),
|
||||||
let aliases: [String: String] = [
|
("/hug", "send someone a warm hug"),
|
||||||
"/join": "/j",
|
("/m", "send private message"),
|
||||||
"/msg": "/m"
|
("/slap", "slap someone with a trout"),
|
||||||
]
|
("/unblock", "unblock a peer"),
|
||||||
|
("/w", "see who's online")
|
||||||
// Filter commands, but convert aliases to primary
|
]
|
||||||
commandSuggestions = commandDescriptions
|
// Only show favorites commands when not in geohash context
|
||||||
.filter { $0.0.starts(with: input) }
|
if !(isGeoPublic || isGeoDM) {
|
||||||
.map { $0.0 }
|
commandDescriptions.append(("/fav", "add to favorites"))
|
||||||
|
commandDescriptions.append(("/unfav", "remove from favorites"))
|
||||||
// Also check if input matches an alias
|
}
|
||||||
for (alias, primary) in aliases {
|
|
||||||
if alias.starts(with: input) && !commandSuggestions.contains(primary) {
|
let input = newValue.lowercased()
|
||||||
if commandDescriptions.contains(where: { $0.0 == primary }) {
|
|
||||||
commandSuggestions.append(primary)
|
// 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 {
|
Group {
|
||||||
if messageText.isEmpty {
|
if messageText.isEmpty {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
// Plus button for image picker (to the left of mic)
|
// Plus button for image picker (hidden while recording)
|
||||||
Button(action: { showImagePicker() }) {
|
if !isRecordingVoice {
|
||||||
Image(systemName: "plus.circle")
|
Button(action: { showImagePicker() }) {
|
||||||
.font(.system(size: 22))
|
Image(systemName: "plus.circle")
|
||||||
.foregroundColor(viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor)
|
.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")
|
Image(systemName: isRecordingVoice ? "mic.circle.fill" : "mic.circle")
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22))
|
||||||
@@ -949,31 +986,17 @@ struct ContentView: View {
|
|||||||
do {
|
do {
|
||||||
let url = try VoiceRecorderPaths.outgoingURL()
|
let url = try VoiceRecorderPaths.outgoingURL()
|
||||||
try voiceRecorder.startRecording(to: url)
|
try voiceRecorder.startRecording(to: url)
|
||||||
|
currentRecordingURL = url
|
||||||
isRecordingVoice = true
|
isRecordingVoice = true
|
||||||
|
startRecordingMeters()
|
||||||
|
hapticStart()
|
||||||
} catch {
|
} catch {
|
||||||
isRecordingVoice = false
|
isRecordingVoice = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
voiceRecorder.stopRecording {
|
finishRecordingAndSend()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1020,9 +1043,64 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#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() {
|
private func showImagePicker() {
|
||||||
isShowingImagePicker = true
|
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
|
#endif
|
||||||
|
|
||||||
// MARK: - Sidebar View
|
// MARK: - Sidebar View
|
||||||
|
|||||||
Reference in New Issue
Block a user