feat: ability to navigate around while still recording (#43)
This commit is contained in:
@@ -10,6 +10,8 @@ import Combine
|
||||
/// Manages audio capture from microphone and system audio and handles real-time transcription via OpenAI
|
||||
@MainActor
|
||||
class AudioManager: NSObject, ObservableObject {
|
||||
static let shared = AudioManager()
|
||||
|
||||
@Published var transcriptChunks: [TranscriptChunk] = []
|
||||
@Published var isRecording = false
|
||||
@Published var errorMessage: String?
|
||||
@@ -43,7 +45,7 @@ class AudioManager: NSObject, ObservableObject {
|
||||
private var pingTimers: [AudioSource: Timer] = [:]
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
override init() {
|
||||
private override init() {
|
||||
super.init()
|
||||
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange,
|
||||
object: audioEngine,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages recording sessions at the app level to persist across navigation
|
||||
@MainActor
|
||||
class RecordingSessionManager: ObservableObject {
|
||||
static let shared = RecordingSessionManager()
|
||||
|
||||
@Published var isRecording = false
|
||||
@Published var activeMeetingId: UUID?
|
||||
@Published var errorMessage: String?
|
||||
@Published var activeRecordingTranscriptChunksUpdated: [TranscriptChunk] = []
|
||||
|
||||
private let audioManager = AudioManager.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let transcriptUpdateSubject = PassthroughSubject<[TranscriptChunk], Never>()
|
||||
|
||||
// Store transcript chunks for the active recording session
|
||||
private var activeRecordingTranscriptChunks: [TranscriptChunk] = []
|
||||
|
||||
private init() {
|
||||
setupAudioManagerBindings()
|
||||
setupDebouncedSaving()
|
||||
}
|
||||
|
||||
private func setupAudioManagerBindings() {
|
||||
// Bind to audio manager state
|
||||
audioManager.$isRecording
|
||||
.sink { [weak self] isRecording in
|
||||
self?.isRecording = isRecording
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
audioManager.$errorMessage
|
||||
.sink { [weak self] errorMessage in
|
||||
self?.errorMessage = errorMessage
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// When transcript chunks change, store them for the active recording and send to debouncer
|
||||
audioManager.$transcriptChunks
|
||||
.sink { [weak self] newChunks in
|
||||
guard let self = self, self.isRecording, self.activeMeetingId != nil else { return }
|
||||
self.activeRecordingTranscriptChunks = newChunks
|
||||
self.activeRecordingTranscriptChunksUpdated = newChunks
|
||||
|
||||
self.transcriptUpdateSubject.send(newChunks)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func setupDebouncedSaving() {
|
||||
transcriptUpdateSubject
|
||||
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] chunks in
|
||||
guard let self = self, let activeMeetingId = self.activeMeetingId else { return }
|
||||
print("💾 Debounced save triggered for meeting: \(activeMeetingId.uuidString)")
|
||||
self.updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: chunks)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func startRecording(for meetingId: UUID) {
|
||||
print("🎙️ Starting recording for meeting: \(meetingId)")
|
||||
|
||||
// Load the meeting to get existing transcript chunks
|
||||
if let existingMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingId }) {
|
||||
activeRecordingTranscriptChunks = existingMeeting.transcriptChunks
|
||||
// Seed the audio manager with existing chunks
|
||||
audioManager.transcriptChunks = existingMeeting.transcriptChunks
|
||||
}
|
||||
|
||||
activeMeetingId = meetingId
|
||||
audioManager.startRecording()
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
print("🛑 Stopping recording for meeting: \(activeMeetingId?.uuidString ?? "unknown")")
|
||||
|
||||
audioManager.stopRecording()
|
||||
|
||||
// Perform a final, immediate save of transcript chunks to the meeting
|
||||
if let activeMeetingId = activeMeetingId {
|
||||
updateActiveMeetingTranscript(meetingId: activeMeetingId, chunks: activeRecordingTranscriptChunks)
|
||||
}
|
||||
|
||||
activeMeetingId = nil
|
||||
activeRecordingTranscriptChunks = []
|
||||
}
|
||||
|
||||
func isRecordingMeeting(_ meetingId: UUID) -> Bool {
|
||||
return isRecording && activeMeetingId == meetingId
|
||||
}
|
||||
|
||||
private func updateActiveMeetingTranscript(meetingId: UUID, chunks: [TranscriptChunk]) {
|
||||
// Load all meetings
|
||||
var meetings = LocalStorageManager.shared.loadMeetings()
|
||||
|
||||
// Find and update the active meeting
|
||||
if let index = meetings.firstIndex(where: { $0.id == meetingId }) {
|
||||
meetings[index].transcriptChunks = chunks
|
||||
|
||||
// Save the updated meeting
|
||||
let success = LocalStorageManager.shared.saveMeeting(meetings[index])
|
||||
if success {
|
||||
print("✅ Saved meeting transcript: \(meetingId.uuidString)")
|
||||
NotificationCenter.default.post(name: .meetingSaved, object: meetings[index])
|
||||
} else {
|
||||
print("❌ Failed to save meeting transcript: \(meetingId.uuidString)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getActiveRecordingTranscriptChunks() -> [TranscriptChunk] {
|
||||
return activeRecordingTranscriptChunks
|
||||
}
|
||||
|
||||
/// Get transcript chunks for a specific meeting, ensuring proper data separation
|
||||
func getTranscriptChunks(for meetingId: UUID) -> [TranscriptChunk] {
|
||||
if isRecording && activeMeetingId == meetingId {
|
||||
// Return live transcript chunks for the active recording
|
||||
return activeRecordingTranscriptChunks
|
||||
} else {
|
||||
// Load saved transcript chunks from storage for non-active meetings
|
||||
if let savedMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meetingId }) {
|
||||
return savedMeeting.transcriptChunks
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class MeetingListViewModel: ObservableObject {
|
||||
@Published var searchText: String = ""
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let recordingSessionManager = RecordingSessionManager.shared
|
||||
|
||||
// Computed property to filter meetings based on search text
|
||||
var filteredMeetings: [Meeting] {
|
||||
|
||||
@@ -15,24 +15,26 @@ enum MeetingViewTab: String, CaseIterable {
|
||||
case enhancedNotes = "Enhanced Notes"
|
||||
}
|
||||
|
||||
enum RecordingState {
|
||||
case idle // Not recording, shows "Transcribe" or "Resume" based on transcript content
|
||||
case recording // Recording, shows "Stop"
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
class MeetingViewModel: ObservableObject {
|
||||
@Published var meeting: Meeting
|
||||
@Published var isGeneratingNotes = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var isRecording = false
|
||||
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
|
||||
|
||||
// Computed property that always uses the direct RecordingSessionManager check
|
||||
var isRecording: Bool {
|
||||
return recordingSessionManager.isRecordingMeeting(meeting.id)
|
||||
}
|
||||
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
|
||||
@Published var recordingState: RecordingState = .idle
|
||||
|
||||
@Published var isDeleted = false
|
||||
@Published var templates: [NoteTemplate] = []
|
||||
@Published var selectedTemplateId: UUID?
|
||||
|
||||
private let audioManager = AudioManager()
|
||||
private let recordingSessionManager = RecordingSessionManager.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var isNewMeeting = false
|
||||
|
||||
@@ -54,6 +56,8 @@ class MeetingViewModel: ObservableObject {
|
||||
self.meeting = meeting
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Detect if this is a new meeting based on content, not storage existence
|
||||
isNewMeeting = isEmpty
|
||||
|
||||
@@ -78,31 +82,38 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// NEW: Seed the audio manager with any existing transcript chunks so the initial
|
||||
// published value doesn't overwrite the saved transcript with an empty array.
|
||||
audioManager.transcriptChunks = self.meeting.transcriptChunks
|
||||
|
||||
// Update meeting transcript chunks when audio manager transcript chunks change
|
||||
audioManager.$transcriptChunks
|
||||
.sink { [weak self] newChunks in
|
||||
self?.meeting.transcriptChunks = newChunks
|
||||
// Trigger SwiftUI updates when recording state changes
|
||||
Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId)
|
||||
.sink { [weak self] (isRecording, activeMeetingId) in
|
||||
guard let self = self else { return }
|
||||
// Toggle the dummy property to trigger SwiftUI re-render
|
||||
self.recordingStateChanged.toggle()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Update isRecording when audio manager recording state changes
|
||||
audioManager.$isRecording
|
||||
.sink { [weak self] isRecording in
|
||||
self?.isRecording = isRecording
|
||||
self?.updateRecordingState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Update error message when audio manager encounters errors
|
||||
audioManager.$errorMessage
|
||||
// Update error message when recording session manager encounters errors
|
||||
recordingSessionManager.$errorMessage
|
||||
.compactMap { $0 }
|
||||
.sink { [weak self] errorMessage in
|
||||
self?.errorMessage = errorMessage
|
||||
print("🚨 Audio Manager Error: \(errorMessage)")
|
||||
print("🚨 Recording Session Manager Error: \(errorMessage)")
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// If currently recording this meeting, load live transcript chunks
|
||||
if recordingSessionManager.isRecordingMeeting(meeting.id) {
|
||||
self.meeting.transcriptChunks = recordingSessionManager.getTranscriptChunks(for: meeting.id)
|
||||
}
|
||||
|
||||
// Listen to real-time transcript updates for this meeting if it's being recorded
|
||||
recordingSessionManager.$activeRecordingTranscriptChunksUpdated
|
||||
.dropFirst()
|
||||
.sink { [weak self] updatedChunks in
|
||||
guard let self = self else { return }
|
||||
// Only update if this meeting is the active recording
|
||||
if recordingSessionManager.isRecordingMeeting(self.meeting.id) {
|
||||
self.meeting.transcriptChunks = updatedChunks
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -117,37 +128,26 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Auto-start recording for empty meetings
|
||||
if isNewMeeting {
|
||||
print("🚀 Auto-starting recording for empty meeting")
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRecordingState() {
|
||||
if isRecording {
|
||||
recordingState = .recording
|
||||
} else {
|
||||
recordingState = .idle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
var recordingButtonText: String {
|
||||
switch recordingState {
|
||||
case .idle:
|
||||
// Use the same computed isRecording property for perfect consistency
|
||||
if isRecording {
|
||||
return "Stop"
|
||||
} else {
|
||||
// Check if there's existing transcript content
|
||||
return meeting.transcriptChunks.isEmpty ? "Transcribe" : "Resume"
|
||||
case .recording:
|
||||
return "Stop"
|
||||
}
|
||||
}
|
||||
|
||||
func toggleRecording() {
|
||||
switch recordingState {
|
||||
case .idle:
|
||||
startRecording()
|
||||
case .recording:
|
||||
// Use the same computed isRecording property for perfect consistency
|
||||
if isRecording {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class MeetingViewModel: ObservableObject {
|
||||
switch validationResult {
|
||||
case .success():
|
||||
// Key is valid, proceed with recording
|
||||
audioManager.startRecording()
|
||||
recordingSessionManager.startRecording(for: meeting.id)
|
||||
case .failure(let error):
|
||||
// Show error message
|
||||
errorMessage = error.localizedDescription
|
||||
@@ -169,7 +169,7 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
audioManager.stopRecording()
|
||||
recordingSessionManager.stopRecording()
|
||||
saveMeeting()
|
||||
|
||||
// Auto-generate notes if there's a transcript and no existing notes
|
||||
@@ -198,9 +198,6 @@ class MeetingViewModel: ObservableObject {
|
||||
isGeneratingNotes = true
|
||||
errorMessage = nil
|
||||
|
||||
// Clear any audio manager errors as well
|
||||
audioManager.errorMessage = nil
|
||||
|
||||
// Clear existing notes for streaming
|
||||
meeting.generatedNotes = ""
|
||||
|
||||
@@ -287,7 +284,7 @@ class MeetingViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteIfEmpty() {
|
||||
if isEmpty {
|
||||
if isEmpty && !isRecording {
|
||||
print("🗑️ Auto-deleting empty meeting")
|
||||
deleteMeeting()
|
||||
} else {
|
||||
|
||||
@@ -57,7 +57,7 @@ struct DancingAudioBars: View {
|
||||
|
||||
private func getDancingBarHeight(index: Int, level: Float) -> CGFloat {
|
||||
let baseHeight: CGFloat = 4 // Flat when no audio
|
||||
let maxHeight: CGFloat = 32 // Max dancing height
|
||||
let maxHeight: CGFloat = 24 // Max dancing height
|
||||
|
||||
if level > 0.03 { // Only dance when there's actual audio
|
||||
// Each bar responds to the same audio level but with different scaling
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct MeetingListView: View {
|
||||
@StateObject private var viewModel = MeetingListViewModel()
|
||||
@ObservedObject var settingsViewModel: SettingsViewModel
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
@State private var selectedMeeting: Meeting?
|
||||
@State private var navigationPath = NavigationPath()
|
||||
|
||||
@@ -113,7 +114,8 @@ struct MeetingListView: View {
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.help("New Meeting")
|
||||
.disabled(recordingSessionManager.isRecording)
|
||||
.help(recordingSessionManager.isRecording ? "Cannot create new meeting while recording is active" : "New Meeting")
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: String.self) { path in
|
||||
@@ -160,14 +162,21 @@ struct DayGroup {
|
||||
|
||||
struct MeetingRowView: View {
|
||||
let meeting: Meeting
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Title or default
|
||||
Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if recordingSessionManager.isRecordingMeeting(meeting.id) {
|
||||
Image(systemName: "record.circle")
|
||||
.foregroundColor(.red)
|
||||
.font(.headline)
|
||||
}
|
||||
Text(meeting.title.isEmpty ? "Untitled meeting" : meeting.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
// Date
|
||||
HStack {
|
||||
Text(meeting.date, style: .time)
|
||||
@@ -213,6 +222,7 @@ struct CollapsedTranscriptChunkView: View {
|
||||
|
||||
struct MeetingDetailContentView: View {
|
||||
@StateObject private var viewModel: MeetingViewModel
|
||||
@StateObject private var recordingSessionManager = RecordingSessionManager.shared
|
||||
@State private var showDeleteAlert = false
|
||||
@State private var isEditing = false
|
||||
@State private var showCopyConfirmation = false
|
||||
@@ -223,6 +233,12 @@ struct MeetingDetailContentView: View {
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
|
||||
// Computed property to determine if recording button should be disabled
|
||||
private var cannotStartRecording: Bool {
|
||||
// Disable if another meeting is recording (not this one)
|
||||
return recordingSessionManager.isRecording && !recordingSessionManager.isRecordingMeeting(viewModel.meeting.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Meeting Title with Menu
|
||||
@@ -272,15 +288,17 @@ struct MeetingDetailContentView: View {
|
||||
viewModel.toggleRecording()
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: viewModel.recordingState == .recording ? "stop.circle.fill" : "record.circle")
|
||||
.foregroundColor(viewModel.recordingState == .recording ? .red : .accentColor)
|
||||
Image(systemName: viewModel.isRecording ? "stop.circle.fill" : "record.circle")
|
||||
.foregroundColor(viewModel.isRecording ? .red : .accentColor)
|
||||
Text(viewModel.recordingButtonText)
|
||||
}
|
||||
.frame(minWidth: 110, minHeight: 36)
|
||||
.background(viewModel.recordingState == .recording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1))
|
||||
.background(viewModel.isRecording ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(cannotStartRecording)
|
||||
.help(cannotStartRecording ? "Another meeting is currently being recorded" : "Start or stop recording for this meeting")
|
||||
|
||||
Button(action: {
|
||||
viewModel.copyCurrentTabContent()
|
||||
@@ -336,12 +354,6 @@ struct MeetingDetailContentView: View {
|
||||
Text("Are you sure you want to delete this meeting? This action cannot be undone.")
|
||||
}
|
||||
.onDisappear {
|
||||
// Stop recording if it's in progress when leaving the page
|
||||
if viewModel.isRecording {
|
||||
print("🛑 Stopping recording because user is leaving the page")
|
||||
viewModel.stopRecording()
|
||||
}
|
||||
|
||||
// Auto-delete empty meetings when leaving, otherwise save
|
||||
viewModel.deleteIfEmpty()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user