mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:05:20 +00:00
* 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>
110 lines
3.7 KiB
Swift
110 lines
3.7 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
|
|
struct VoiceNoteView: View {
|
|
private let url: URL
|
|
private let isSending: Bool
|
|
private let sendProgress: Double?
|
|
private let onCancel: (() -> Void)?
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@StateObject private var playback: VoiceNotePlaybackController
|
|
@State private var waveform: [Float] = []
|
|
|
|
init(url: URL, isSending: Bool, sendProgress: Double?, onCancel: (() -> Void)?) {
|
|
self.url = url
|
|
self.isSending = isSending
|
|
self.sendProgress = sendProgress
|
|
self.onCancel = onCancel
|
|
_playback = StateObject(wrappedValue: VoiceNotePlaybackController(url: url))
|
|
}
|
|
|
|
private var samples: [Float] {
|
|
if waveform.isEmpty {
|
|
return Array(repeating: 0.25, count: 64)
|
|
}
|
|
return waveform
|
|
}
|
|
|
|
private var backgroundColor: Color {
|
|
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
|
|
}
|
|
|
|
private var playbackLabel: String {
|
|
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 {
|
|
HStack(spacing: 12) {
|
|
Button(action: playback.togglePlayback) {
|
|
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(Circle().fill(Color.green))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
WaveformView(
|
|
samples: samples,
|
|
playbackProgress: playback.progress,
|
|
sendProgress: sendProgress,
|
|
onSeek: { fraction in
|
|
playback.seek(to: fraction)
|
|
},
|
|
isInteractive: playback.isPlaying
|
|
)
|
|
|
|
Text(playbackLabel)
|
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
|
.foregroundColor(Color.secondary)
|
|
|
|
if let onCancel = onCancel, isSending {
|
|
Button(action: onCancel) {
|
|
Image(systemName: "xmark")
|
|
.font(.bitchatSystem(size: 12, weight: .bold))
|
|
.frame(width: 28, height: 28)
|
|
.background(Circle().fill(Color.red.opacity(0.9)))
|
|
.foregroundColor(.white)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.fill(backgroundColor)
|
|
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.3 : 0.1), radius: 6, x: 0, y: 2)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.stroke(borderColor, lineWidth: 1)
|
|
)
|
|
.task {
|
|
// Defer loading to let UI settle after view appears
|
|
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
|
playback.loadDuration()
|
|
await withCheckedContinuation { continuation in
|
|
WaveformCache.shared.waveform(for: url, completion: { bins in
|
|
waveform = bins
|
|
continuation.resume()
|
|
})
|
|
}
|
|
}
|
|
.onChange(of: url) { newValue in
|
|
WaveformCache.shared.waveform(for: newValue, completion: { bins in
|
|
self.waveform = bins
|
|
})
|
|
playback.replaceURL(newValue)
|
|
}
|
|
.onDisappear {
|
|
playback.stop()
|
|
}
|
|
}
|
|
}
|