409 lines
15 KiB
Swift
409 lines
15 KiB
Swift
import Foundation
|
||
import SwiftUI
|
||
import Combine
|
||
import PostHog
|
||
|
||
// Add notification name for meeting saved events
|
||
extension Notification.Name {
|
||
static let meetingSaved = Notification.Name("MeetingSaved")
|
||
static let meetingDeleted = Notification.Name("MeetingDeleted")
|
||
}
|
||
|
||
enum MeetingViewTab: String, CaseIterable {
|
||
case myNotes = "My Notes"
|
||
case transcript = "Transcript"
|
||
case enhancedNotes = "Meeting Notes"
|
||
}
|
||
|
||
|
||
|
||
@MainActor
|
||
class MeetingViewModel: ObservableObject {
|
||
@Published var meeting: Meeting
|
||
@Published var isGeneratingNotes = false
|
||
@Published var errorMessage: String?
|
||
@Published private var recordingStateChanged = false // Trigger SwiftUI updates
|
||
@Published var isValidatingKey = false // Indicates API key validation in progress
|
||
@Published var isStartingRecording = false // Indicates recording start in progress
|
||
@Published var isRetryingTranscription = false
|
||
@Published private(set) var recoveryAudioFolderURL: URL?
|
||
|
||
// Computed property to determine if Generate button should animate
|
||
var shouldAnimateGenerateButton: Bool {
|
||
let generateButtonEnabled = !meeting.transcript.isEmpty && !isGeneratingNotes && !isRecording && !isProcessing && !isStartingRecording
|
||
let noEnhancedNotesYet = meeting.generatedNotes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
return generateButtonEnabled && noEnhancedNotesYet
|
||
}
|
||
|
||
// Computed property to determine if the record button should animate
|
||
var shouldAnimateTranscribeButton: Bool {
|
||
return !isRecording && !isProcessing && meeting.transcriptChunks.isEmpty && !isStartingRecording
|
||
}
|
||
|
||
// Computed property that always uses the direct RecordingSessionManager check
|
||
var isRecording: Bool {
|
||
return recordingSessionManager.isRecordingMeeting(meeting.id)
|
||
}
|
||
|
||
var isProcessing: Bool {
|
||
return isRetryingTranscription ||
|
||
(recordingSessionManager.isProcessing && recordingSessionManager.activeMeetingId == meeting.id)
|
||
}
|
||
|
||
var canRetryTranscription: Bool {
|
||
recoveryAudioFolderURL != nil &&
|
||
!isRecording && !isProcessing && !isStartingRecording && !isValidatingKey
|
||
}
|
||
@Published var selectedTab: MeetingViewTab = .transcript // Default to transcript tab
|
||
|
||
@Published var isDeleted = false
|
||
@Published var templates: [NoteTemplate] = []
|
||
@Published var selectedTemplateId: UUID?
|
||
|
||
private let recordingSessionManager = RecordingSessionManager.shared
|
||
private var cancellables = Set<AnyCancellable>()
|
||
|
||
init(meeting: Meeting = Meeting()) {
|
||
// Load the latest version of the meeting from storage if it exists
|
||
if let savedMeeting = LocalStorageManager.shared.loadMeetings().first(where: { $0.id == meeting.id }) {
|
||
print("🔄 Loading latest version of meeting: \(meeting.id)")
|
||
self.meeting = savedMeeting
|
||
} else {
|
||
print("🆕 Using provided meeting: \(meeting.id)")
|
||
self.meeting = meeting
|
||
}
|
||
|
||
|
||
|
||
// Set initial tab based on notes existence
|
||
if !self.meeting.generatedNotes.isEmpty {
|
||
selectedTab = .enhancedNotes
|
||
} else {
|
||
selectedTab = .transcript
|
||
}
|
||
|
||
// Load templates and selected template
|
||
loadTemplates()
|
||
refreshRecoveryAudioFolder()
|
||
// Observe template selection: save to meeting and regenerate notes on changes (skip initial)
|
||
$selectedTemplateId
|
||
.dropFirst()
|
||
.sink { [weak self] newTemplateId in
|
||
guard let self = self else { return }
|
||
self.meeting.templateId = newTemplateId
|
||
Task {
|
||
await self.generateNotes()
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// 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 }
|
||
|
||
// If recording started for this meeting, end starting state
|
||
if isRecording && activeMeetingId == self.meeting.id {
|
||
self.isStartingRecording = false
|
||
}
|
||
// Toggle the dummy property to trigger SwiftUI re-render
|
||
self.recordingStateChanged.toggle()
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
recordingSessionManager.$isProcessing
|
||
.sink { [weak self] _ in
|
||
self?.recordingStateChanged.toggle()
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
// Update error message when recording session manager encounters errors
|
||
recordingSessionManager.$errorMessage
|
||
.compactMap { $0 }
|
||
.sink { [weak self] errorMessage in
|
||
// Suppress non-critical, self-healing errors that should not distract the user
|
||
let lowercased = errorMessage.lowercased()
|
||
if errorMessage == ErrorMessage.sessionExpired || lowercased.contains("socket is not connected") {
|
||
print("ℹ️ Suppressed non-critical error: \(errorMessage)")
|
||
return
|
||
}
|
||
self?.errorMessage = 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 for final transcript updates for this meeting.
|
||
recordingSessionManager.$activeRecordingTranscriptChunksUpdated
|
||
.dropFirst()
|
||
.sink { [weak self] updatedChunks in
|
||
guard let self = self else { return }
|
||
// Stop publishes the final chunks after capture has ended but
|
||
// before the active meeting is cleared.
|
||
if recordingSessionManager.activeMeetingId == self.meeting.id {
|
||
self.meeting.transcriptChunks = updatedChunks
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
NotificationCenter.default.publisher(for: .meetingSaved)
|
||
.compactMap { $0.object as? Meeting }
|
||
.filter { [weak self] in $0.id == self?.meeting.id }
|
||
.sink { [weak self] savedMeeting in
|
||
guard let self, !self.isDeleted, self.meeting != savedMeeting else { return }
|
||
self.meeting = savedMeeting
|
||
self.refreshRecoveryAudioFolder()
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
|
||
|
||
// Auto-save when meeting properties change
|
||
$meeting
|
||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||
.sink { [weak self] meeting in
|
||
print("🔄 Auto-saving meeting: \(meeting.id) - title: '\(meeting.title)', notes: '\(meeting.userNotes.prefix(50))...'")
|
||
self?.saveMeeting()
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
|
||
}
|
||
|
||
|
||
var recordingButtonText: String {
|
||
if isProcessing {
|
||
return "Processing"
|
||
}
|
||
if isRecording {
|
||
return "Stop"
|
||
} else {
|
||
// Check if there's existing transcript content
|
||
return meeting.transcriptChunks.isEmpty ? "Record" : "Resume"
|
||
}
|
||
}
|
||
|
||
func toggleRecording() {
|
||
// Prevent duplicate actions while validating API key or starting recording
|
||
if isValidatingKey || isStartingRecording || isProcessing { return }
|
||
// Use the same computed isRecording property for perfect consistency
|
||
if isRecording {
|
||
stopRecording()
|
||
} else {
|
||
startRecording()
|
||
}
|
||
}
|
||
|
||
func startRecording() {
|
||
// Validate API key before starting recording
|
||
isValidatingKey = true
|
||
isStartingRecording = true
|
||
Task {
|
||
let validationResult = await CoderAPIValidator.shared.validateCurrentAPIKey()
|
||
defer { isValidatingKey = false }
|
||
|
||
switch validationResult {
|
||
case .success():
|
||
// Key is valid, proceed with recording
|
||
recordingSessionManager.startRecording(for: meeting.id)
|
||
case .failure(let error):
|
||
// Show error message
|
||
errorMessage = error.localizedDescription
|
||
// Cancel starting if validation failed
|
||
isStartingRecording = false
|
||
print("❌ API key validation failed: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
|
||
func stopRecording() {
|
||
isStartingRecording = true
|
||
Task {
|
||
let chunks = await recordingSessionManager.stopRecording()
|
||
meeting.transcriptChunks = chunks
|
||
meeting.recoveryAudioFolderName = recordingSessionManager.lastRecoveryAudioFolderName
|
||
meeting.transcriptionError = recordingSessionManager.errorMessage
|
||
refreshRecoveryAudioFolder()
|
||
saveMeeting()
|
||
if !meeting.formattedTranscript.isEmpty {
|
||
await generateNotes()
|
||
}
|
||
isStartingRecording = false
|
||
}
|
||
}
|
||
|
||
func retryTranscription() {
|
||
guard let recoveryAudioFolderURL, canRetryTranscription else { return }
|
||
|
||
isRetryingTranscription = true
|
||
errorMessage = nil
|
||
Task {
|
||
defer { isRetryingTranscription = false }
|
||
do {
|
||
let chunks = try await AudioManager.shared.transcribeRecoveryAudio(
|
||
in: recoveryAudioFolderURL,
|
||
captureStartedAt: meeting.date
|
||
)
|
||
meeting.transcriptChunks = chunks
|
||
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
|
||
meeting.transcriptionError = nil
|
||
selectedTab = .transcript
|
||
|
||
guard saveMeeting() else {
|
||
throw CocoaError(.fileWriteUnknown)
|
||
}
|
||
|
||
await generateNotes()
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
meeting.transcriptionError = error.localizedDescription
|
||
_ = saveMeeting()
|
||
print("Retry transcription failed: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func refreshRecoveryAudioFolder() {
|
||
recoveryAudioFolderURL = LocalStorageManager.shared.findRecoveryAudioFolder(for: meeting)
|
||
meeting.recoveryAudioFolderName = recoveryAudioFolderURL?.lastPathComponent
|
||
}
|
||
|
||
func showAudioInFinder() {
|
||
guard let recoveryAudioFolderURL else { return }
|
||
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
|
||
}
|
||
|
||
func loadTemplates() {
|
||
templates = LocalStorageManager.shared.loadTemplates()
|
||
|
||
// Keep an existing meeting's template, otherwise use the configured default.
|
||
if let meetingTemplateId = meeting.templateId,
|
||
templates.contains(where: { $0.id == meetingTemplateId }) {
|
||
selectedTemplateId = meetingTemplateId
|
||
} else {
|
||
selectedTemplateId = LocalStorageManager.shared.preferredTemplateID(in: templates)
|
||
meeting.templateId = selectedTemplateId
|
||
}
|
||
}
|
||
|
||
func generateNotes() async {
|
||
isGeneratingNotes = true
|
||
errorMessage = nil
|
||
|
||
// Clear existing notes for streaming
|
||
meeting.generatedNotes = ""
|
||
|
||
// Load settings for generation
|
||
let userBlurb = UserDefaultsManager.shared.userBlurb
|
||
let systemPrompt = UserDefaultsManager.shared.systemPrompt
|
||
|
||
// Use streaming generation
|
||
let stream = NotesGenerator.shared.generateNotesStream(
|
||
meeting: meeting,
|
||
userBlurb: userBlurb,
|
||
systemPrompt: systemPrompt,
|
||
templateId: selectedTemplateId
|
||
)
|
||
|
||
var hasError = false
|
||
for await result in stream {
|
||
switch result {
|
||
case .content(let chunk):
|
||
meeting.generatedNotes += chunk
|
||
case .error(let error):
|
||
errorMessage = error
|
||
hasError = true
|
||
print("🚨 Note Generation Error: \(error)")
|
||
break
|
||
}
|
||
}
|
||
|
||
// Only save if there was no error
|
||
if !hasError {
|
||
await generateTitleIfNeeded()
|
||
saveMeeting()
|
||
}
|
||
|
||
isGeneratingNotes = false
|
||
}
|
||
|
||
private func generateTitleIfNeeded() async {
|
||
guard meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||
|
||
do {
|
||
if let title = try await NotesGenerator.shared.generateMeetingTitle(
|
||
meeting: meeting,
|
||
generatedNotes: meeting.generatedNotes,
|
||
templateId: selectedTemplateId
|
||
) {
|
||
meeting.title = title
|
||
}
|
||
} catch {
|
||
// A title is helpful but should never prevent completed notes from being saved.
|
||
print("Meeting title generation failed: \(error)")
|
||
}
|
||
}
|
||
|
||
@discardableResult
|
||
func saveMeeting() -> Bool {
|
||
if isDeleted { return false }
|
||
print("💾 Saving meeting: \(meeting.id)")
|
||
let success = LocalStorageManager.shared.saveMeeting(meeting)
|
||
print("💾 Save result: \(success ? "SUCCESS" : "FAILED")")
|
||
if success {
|
||
NotificationCenter.default.post(name: .meetingSaved, object: meeting)
|
||
}
|
||
return success
|
||
}
|
||
|
||
func copyCurrentTabContent() {
|
||
NSPasteboard.general.clearContents()
|
||
|
||
let content: String
|
||
switch selectedTab {
|
||
case .myNotes:
|
||
content = meeting.userNotes
|
||
case .transcript:
|
||
content = meeting.formattedTranscript
|
||
case .enhancedNotes:
|
||
var enhancedContent = ""
|
||
|
||
// Add title as h1 header if title is set
|
||
if !meeting.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
enhancedContent += "# \(meeting.title)\n\n"
|
||
}
|
||
|
||
// Add the generated notes
|
||
enhancedContent += meeting.generatedNotes
|
||
|
||
// Add attribution footer
|
||
if !enhancedContent.isEmpty {
|
||
enhancedContent += "\n\n---\n\nNotes generated using [Meetingnotes](https://meetingnotes.owengretzinger.com), the free, open source AI notetaker."
|
||
}
|
||
|
||
content = enhancedContent
|
||
}
|
||
|
||
NSPasteboard.general.setString(content, forType: .string)
|
||
}
|
||
|
||
func deleteMeeting() {
|
||
// If this meeting is currently being recorded, stop the recording first
|
||
if recordingSessionManager.isRecordingMeeting(meeting.id) {
|
||
print("🛑 Stopping recording for meeting being deleted: \(meeting.id)")
|
||
recordingSessionManager.cancelRecording()
|
||
}
|
||
|
||
let success = LocalStorageManager.shared.deleteMeeting(meeting)
|
||
if success {
|
||
isDeleted = true
|
||
NotificationCenter.default.post(name: .meetingDeleted, object: meeting)
|
||
}
|
||
}
|
||
|
||
}
|