|
@@ -6,6 +6,7 @@
|
|
|
//
|
|
//
|
|
|
|
|
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
|
|
+import AVFoundation
|
|
|
import Combine
|
|
import Combine
|
|
|
import CryptoKit
|
|
import CryptoKit
|
|
|
import StoreKit
|
|
import StoreKit
|
|
@@ -15,7 +16,7 @@ import WebKit
|
|
|
|
|
|
|
|
/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
|
|
/// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
|
|
|
private enum AppWindowMetrics {
|
|
private enum AppWindowMetrics {
|
|
|
- static let defaultContentWidth: CGFloat = 1020
|
|
|
|
|
|
|
+ static let defaultContentWidth: CGFloat = 940
|
|
|
static let defaultContentHeight: CGFloat = 690
|
|
static let defaultContentHeight: CGFloat = 690
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -41,6 +42,15 @@ class ViewController: NSViewController {
|
|
|
case settings = "Settings"
|
|
case settings = "Settings"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private struct ActiveMeetingRecordingSession {
|
|
|
|
|
+ let id: String
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let meetingURL: URL?
|
|
|
|
|
+ let startedAt: Date
|
|
|
|
|
+ let systemAudioFileURL: URL
|
|
|
|
|
+ let microphoneAudioFileURL: URL
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private let googleOAuth = GoogleOAuthService.shared
|
|
private let googleOAuth = GoogleOAuthService.shared
|
|
|
private let zoomOAuth = ZoomOAuthService.shared
|
|
private let zoomOAuth = ZoomOAuthService.shared
|
|
|
private let loginStateKey = "zoom_app.isLoggedIn"
|
|
private let loginStateKey = "zoom_app.isLoggedIn"
|
|
@@ -55,6 +65,10 @@ class ViewController: NSViewController {
|
|
|
private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
|
|
private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
|
|
|
private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
|
|
private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
|
|
|
|
|
|
|
|
|
|
+ private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
|
|
|
|
|
+ private let aiCompanionPreferredLanguage1DefaultsKey = "aiCompanion.preferredLanguage1"
|
|
|
|
|
+ private let aiCompanionPreferredLanguage2DefaultsKey = "aiCompanion.preferredLanguage2"
|
|
|
|
|
+
|
|
|
private struct Palette {
|
|
private struct Palette {
|
|
|
let isDarkMode: Bool
|
|
let isDarkMode: Bool
|
|
|
|
|
|
|
@@ -328,6 +342,14 @@ class ViewController: NSViewController {
|
|
|
private var aiCompanionIsLoading: Bool = false
|
|
private var aiCompanionIsLoading: Bool = false
|
|
|
private var aiCompanionLastLoadedAt: Date?
|
|
private var aiCompanionLastLoadedAt: Date?
|
|
|
private var aiCompanionSelectedRange: String = "Last 30 days"
|
|
private var aiCompanionSelectedRange: String = "Last 30 days"
|
|
|
|
|
+ private var aiCompanionLocalRecordings: [LocalMeetingRecording] = []
|
|
|
|
|
+ private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
|
|
|
|
|
+ private var activeMeetingAudioRecorder: AVAudioRecorder?
|
|
|
|
|
+ private var activeMeetingSystemAudioStopper: (() async -> Void)?
|
|
|
|
|
+ private weak var aiCompanionStopRecordingButton: NSButton?
|
|
|
|
|
+
|
|
|
|
|
+ private let meetingTranscriptionService = MeetingTranscriptionService()
|
|
|
|
|
+ private let meetingNotesService = MeetingNotesService()
|
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
@@ -639,6 +661,7 @@ class ViewController: NSViewController {
|
|
|
NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
|
|
|
meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
|
|
meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
|
|
|
|
|
+ loadAiCompanionLocalRecordings()
|
|
|
ensureUsageSessionStartDate()
|
|
ensureUsageSessionStartDate()
|
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
|
setupUI()
|
|
setupUI()
|
|
@@ -649,9 +672,7 @@ class ViewController: NSViewController {
|
|
|
override func viewDidAppear() {
|
|
override func viewDidAppear() {
|
|
|
super.viewDidAppear()
|
|
super.viewDidAppear()
|
|
|
if let window = view.window {
|
|
if let window = view.window {
|
|
|
- window.setContentSize(
|
|
|
|
|
- NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ enforceDefaultWindowFrame(window)
|
|
|
applyWindowBackgroundForCurrentTheme(window)
|
|
applyWindowBackgroundForCurrentTheme(window)
|
|
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
// Use full-size content view so custom top chrome sits in the titlebar region.
|
|
|
window.titleVisibility = .hidden
|
|
window.titleVisibility = .hidden
|
|
@@ -695,6 +716,31 @@ class ViewController: NSViewController {
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func enforceDefaultWindowFrame(_ window: NSWindow) {
|
|
|
|
|
+ // Avoid fighting full-screen.
|
|
|
|
|
+ if window.styleMask.contains(.fullScreen) { return }
|
|
|
|
|
+ // If the window is zoomed, setContentSize may be ignored.
|
|
|
|
|
+ if window.isZoomed {
|
|
|
|
|
+ window.zoom(nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let targetContentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
|
|
|
|
|
+ // Keep the main window at a consistent size (matches the reference app behavior).
|
|
|
|
|
+ window.contentMinSize = targetContentSize
|
|
|
|
|
+ window.contentMaxSize = targetContentSize
|
|
|
|
|
+
|
|
|
|
|
+ let contentRect = NSRect(origin: .zero, size: targetContentSize)
|
|
|
|
|
+ let frameRect = window.frameRect(forContentRect: contentRect)
|
|
|
|
|
+ var newFrame = window.frame
|
|
|
|
|
+ newFrame.size = frameRect.size
|
|
|
|
|
+
|
|
|
|
|
+ if let screenFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame {
|
|
|
|
|
+ newFrame.origin.x = screenFrame.midX - (newFrame.size.width / 2)
|
|
|
|
|
+ newFrame.origin.y = screenFrame.midY - (newFrame.size.height / 2)
|
|
|
|
|
+ }
|
|
|
|
|
+ window.setFrame(newFrame, display: true)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func installWindowFullScreenObservers(for window: NSWindow) {
|
|
private func installWindowFullScreenObservers(for window: NSWindow) {
|
|
|
guard windowFullScreenObservers.isEmpty else { return }
|
|
guard windowFullScreenObservers.isEmpty else { return }
|
|
|
|
|
|
|
@@ -1666,11 +1712,298 @@ class ViewController: NSViewController {
|
|
|
let opened = openURLInDefaultBrowser(url)
|
|
let opened = openURLInDefaultBrowser(url)
|
|
|
if opened {
|
|
if opened {
|
|
|
joinMeetingWindow?.performClose(nil)
|
|
joinMeetingWindow?.performClose(nil)
|
|
|
|
|
+ maybeStartLocalMeetingRecording(meetingTitle: "Zoom meeting", meetingURL: url)
|
|
|
} else {
|
|
} else {
|
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // MARK: - Local meeting recording (AI Companion)
|
|
|
|
|
+
|
|
|
|
|
+ private func maybeStartLocalMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
|
|
|
+ // Avoid double-start if user clicks multiple times.
|
|
|
|
|
+ if activeMeetingRecordingSession != nil { return }
|
|
|
|
|
+
|
|
|
|
|
+ let alert = NSAlert()
|
|
|
|
|
+ alert.messageText = "Record this meeting locally?"
|
|
|
|
|
+ alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
|
|
|
|
|
+ alert.addButton(withTitle: "Allow and Start Recording")
|
|
|
|
|
+ alert.addButton(withTitle: "Continue Without Recording")
|
|
|
|
|
+ alert.window.title = meetingTitle
|
|
|
|
|
+ let response = alert.runModal()
|
|
|
|
|
+ if response != .alertFirstButtonReturn { return }
|
|
|
|
|
+
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ await startLocalMeetingRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func startLocalMeetingRecordingFlow(meetingTitle: String, meetingURL: URL) async {
|
|
|
|
|
+ // Microphone permission
|
|
|
|
|
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
|
|
|
+ case .authorized:
|
|
|
|
|
+ startLocalMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ case .notDetermined:
|
|
|
|
|
+ let granted = await AVCaptureDevice.requestAccess(for: .audio)
|
|
|
|
|
+ if granted {
|
|
|
|
|
+ startLocalMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
|
|
|
+ }
|
|
|
|
|
+ case .denied, .restricted:
|
|
|
|
|
+ showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
|
|
|
+ @unknown default:
|
|
|
|
|
+ showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func startLocalMeetingRecording(meetingTitle: String, meetingURL: URL) {
|
|
|
|
|
+ let recordingID = UUID().uuidString
|
|
|
|
|
+ let systemURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-system.m4a")
|
|
|
|
|
+ let microphoneURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID)-mic.m4a")
|
|
|
|
|
+
|
|
|
|
|
+ activeMeetingRecordingSession = ActiveMeetingRecordingSession(
|
|
|
|
|
+ id: recordingID,
|
|
|
|
|
+ title: meetingTitle,
|
|
|
|
|
+ meetingURL: meetingURL,
|
|
|
|
|
+ startedAt: Date(),
|
|
|
|
|
+ systemAudioFileURL: systemURL,
|
|
|
|
|
+ microphoneAudioFileURL: microphoneURL
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ startMicrophoneRecording(at: microphoneURL)
|
|
|
|
|
+
|
|
|
|
|
+ if #available(macOS 13.0, *) {
|
|
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
|
|
+ let recorder = MeetingSystemAudioRecorder(outputURL: systemURL)
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await recorder.start()
|
|
|
|
|
+ self?.activeMeetingSystemAudioStopper = {
|
|
|
|
|
+ await recorder.stop()
|
|
|
|
|
+ }
|
|
|
|
|
+ self?.showTopToast(message: "Meeting recording started (meeting + microphone)", isError: false)
|
|
|
|
|
+ self?.updateAiCompanionRecordingControls()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self?.activeMeetingSystemAudioStopper = nil
|
|
|
|
|
+ self?.showTopToast(message: "System audio unavailable. Recording microphone only.", isError: true)
|
|
|
|
|
+ self?.updateAiCompanionRecordingControls()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showTopToast(message: "Meeting recording started (microphone only)", isError: false)
|
|
|
|
|
+ updateAiCompanionRecordingControls()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func startMicrophoneRecording(at outputURL: URL) {
|
|
|
|
|
+ let settings: [String: Any] = [
|
|
|
|
|
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
|
|
|
+ AVSampleRateKey: 44_100,
|
|
|
|
|
+ AVNumberOfChannelsKey: 1,
|
|
|
|
|
+ AVEncoderBitRateKey: 96_000
|
|
|
|
|
+ ]
|
|
|
|
|
+ do {
|
|
|
|
|
+ let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
|
|
|
+ recorder.prepareToRecord()
|
|
|
|
|
+ recorder.record()
|
|
|
|
|
+ activeMeetingAudioRecorder = recorder
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showTopToast(message: "Microphone recording failed: \(error.localizedDescription)", isError: true)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func aiCompanionStopRecordingTapped() {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ await stopLocalMeetingRecording()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func stopLocalMeetingRecording() async {
|
|
|
|
|
+ guard let session = activeMeetingRecordingSession else { return }
|
|
|
|
|
+ activeMeetingRecordingSession = nil
|
|
|
|
|
+
|
|
|
|
|
+ activeMeetingAudioRecorder?.stop()
|
|
|
|
|
+ activeMeetingAudioRecorder = nil
|
|
|
|
|
+
|
|
|
|
|
+ if let stopSystem = activeMeetingSystemAudioStopper {
|
|
|
|
|
+ await stopSystem()
|
|
|
|
|
+ }
|
|
|
|
|
+ activeMeetingSystemAudioStopper = nil
|
|
|
|
|
+
|
|
|
|
|
+ updateAiCompanionRecordingControls()
|
|
|
|
|
+
|
|
|
|
|
+ let destinationURL = localRecordingDirectoryURL().appendingPathComponent("\(session.id).m4a")
|
|
|
|
|
+ let finalized = await MeetingAudioMixer.finalize(
|
|
|
|
|
+ systemURL: session.systemAudioFileURL,
|
|
|
|
|
+ microphoneURL: session.microphoneAudioFileURL,
|
|
|
|
|
+ destinationURL: destinationURL
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ let recording = LocalMeetingRecording(
|
|
|
|
|
+ id: session.id,
|
|
|
|
|
+ title: session.title,
|
|
|
|
|
+ meetingURLString: session.meetingURL?.absoluteString,
|
|
|
|
|
+ startedAt: session.startedAt,
|
|
|
|
|
+ endedAt: Date(),
|
|
|
|
|
+ mixedAudioFilePath: finalized.mixedURL.path,
|
|
|
|
|
+ microphoneAudioFilePath: finalized.microphoneURL?.path,
|
|
|
|
|
+ systemAudioFilePath: finalized.systemURL?.path,
|
|
|
|
|
+ transcriptStatusRaw: LocalMeetingTranscriptStatus.notRequested.rawValue,
|
|
|
|
|
+ transcriptText: nil,
|
|
|
|
|
+ transcriptSegmentsJSON: nil,
|
|
|
|
|
+ transcriptErrorMessage: nil,
|
|
|
|
|
+ notesStatusRaw: LocalMeetingNotesStatus.notRequested.rawValue,
|
|
|
|
|
+ notesText: nil,
|
|
|
|
|
+ notesErrorMessage: nil
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ aiCompanionLocalRecordings.insert(recording, at: 0)
|
|
|
|
|
+ aiCompanionLocalRecordings.sort { $0.endedAt > $1.endedAt }
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+
|
|
|
|
|
+ // Surface it immediately in the UI list.
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
|
|
+
|
|
|
|
|
+ showTopToast(message: "Recording saved. Generating transcript + notes…", isError: false)
|
|
|
|
|
+ Task.detached { [weak self] in
|
|
|
|
|
+ await self?.generateTranscriptAndNotesForRecording(meetingId: session.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func localRecordingDirectoryURL() -> URL {
|
|
|
|
|
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
|
|
|
+ let bundleID = Bundle.main.bundleIdentifier ?? "zoom_app"
|
|
|
|
|
+ let dir = base.appendingPathComponent(bundleID).appendingPathComponent("AICompanionRecordings")
|
|
|
|
|
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
|
+ return dir
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionPreferredLocales() -> [Locale] {
|
|
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
|
|
+ let selectedIdentifiers = [
|
|
|
|
|
+ defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
|
|
|
|
|
+ defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
|
|
|
|
|
+ ]
|
|
|
|
|
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
|
|
+ .filter { $0.isEmpty == false }
|
|
|
|
|
+
|
|
|
|
|
+ if selectedIdentifiers.isEmpty { return [] }
|
|
|
|
|
+ return selectedIdentifiers.map { Locale(identifier: $0) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func generateTranscriptAndNotesForRecording(meetingId: String) async {
|
|
|
|
|
+ func updateRecording(_ mutate: (inout LocalMeetingRecording) -> Void) {
|
|
|
|
|
+ if let idx = aiCompanionLocalRecordings.firstIndex(where: { $0.id == meetingId }) {
|
|
|
|
|
+ var copy = aiCompanionLocalRecordings[idx]
|
|
|
|
|
+ mutate(©)
|
|
|
|
|
+ aiCompanionLocalRecordings[idx] = copy
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ updateRecording { r in
|
|
|
|
|
+ r.transcriptStatusRaw = LocalMeetingTranscriptStatus.processing.rawValue
|
|
|
|
|
+ r.transcriptErrorMessage = nil
|
|
|
|
|
+ r.notesStatusRaw = LocalMeetingNotesStatus.notRequested.rawValue
|
|
|
|
|
+ r.notesErrorMessage = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let recording = await MainActor.run { aiCompanionLocalRecordings.first(where: { $0.id == meetingId }) }
|
|
|
|
|
+ guard let recording else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let micURL: URL? = recording.microphoneAudioFilePath
|
|
|
|
|
+ .map { URL(fileURLWithPath: $0) }
|
|
|
|
|
+ .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
|
|
|
|
|
+ let systemURL: URL? = recording.systemAudioFilePath
|
|
|
|
|
+ .map { URL(fileURLWithPath: $0) }
|
|
|
|
|
+ .flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : nil }
|
|
|
|
|
+ let mixedURL = URL(fileURLWithPath: recording.mixedAudioFilePath)
|
|
|
|
|
+ let hasPerChannel = (micURL != nil || systemURL != nil)
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ let locales = await MainActor.run { aiCompanionPreferredLocales() }
|
|
|
|
|
+ let segments: [TranscriptSegment]
|
|
|
|
|
+ if hasPerChannel {
|
|
|
|
|
+ segments = try await meetingTranscriptionService.transcribeMeeting(
|
|
|
|
|
+ micURL: micURL,
|
|
|
|
|
+ systemURL: systemURL,
|
|
|
|
|
+ locales: locales
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ segments = try await meetingTranscriptionService.transcribeMeeting(
|
|
|
|
|
+ micURL: nil,
|
|
|
|
|
+ systemURL: (FileManager.default.fileExists(atPath: mixedURL.path) ? mixedURL : nil),
|
|
|
|
|
+ locales: locales
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let transcriptText = segments.renderedTimelineText()
|
|
|
|
|
+ let segmentsJSON = String(data: try JSONEncoder().encode(segments), encoding: .utf8)
|
|
|
|
|
+
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ updateRecording { r in
|
|
|
|
|
+ r.transcriptStatusRaw = LocalMeetingTranscriptStatus.ready.rawValue
|
|
|
|
|
+ r.transcriptText = transcriptText
|
|
|
|
|
+ r.transcriptSegmentsJSON = segmentsJSON
|
|
|
|
|
+ r.transcriptErrorMessage = nil
|
|
|
|
|
+ r.notesStatusRaw = LocalMeetingNotesStatus.processing.rawValue
|
|
|
|
|
+ r.notesErrorMessage = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
|
|
+ let preferred1 = defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
|
|
|
|
|
+ let preferred2 = defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
|
|
|
|
|
+ let notes = try await meetingNotesService.generateNotes(
|
|
|
|
|
+ from: transcriptText,
|
|
|
|
|
+ preferredLanguageIdentifier1: preferred1,
|
|
|
|
|
+ preferredLanguageIdentifier2: preferred2
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ updateRecording { r in
|
|
|
|
|
+ r.notesStatusRaw = LocalMeetingNotesStatus.ready.rawValue
|
|
|
|
|
+ r.notesText = notes
|
|
|
|
|
+ r.notesErrorMessage = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue = "Notes ready."
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ updateRecording { r in
|
|
|
|
|
+ r.transcriptStatusRaw = LocalMeetingTranscriptStatus.failed.rawValue
|
|
|
|
|
+ r.transcriptErrorMessage = error.localizedDescription
|
|
|
|
|
+ r.notesStatusRaw = LocalMeetingNotesStatus.failed.rawValue
|
|
|
|
|
+ r.notesErrorMessage = error.localizedDescription
|
|
|
|
|
+ }
|
|
|
|
|
+ persistAiCompanionLocalRecordings()
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue = "Failed to generate notes."
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@discardableResult
|
|
@discardableResult
|
|
|
private func openURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
private func openURLInDefaultBrowser(_ url: URL) -> Bool {
|
|
|
NSWorkspace.shared.open(url)
|
|
NSWorkspace.shared.open(url)
|
|
@@ -2455,6 +2788,15 @@ class ViewController: NSViewController {
|
|
|
alert.runModal()
|
|
alert.runModal()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func showTopToast(message: String, isError: Bool) {
|
|
|
|
|
+ // Lightweight status surface (avoids interrupting the user with alerts).
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue = message
|
|
|
|
|
+ if isError {
|
|
|
|
|
+ NSSound.beep()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private struct ScheduledMeeting {
|
|
private struct ScheduledMeeting {
|
|
|
let title: String
|
|
let title: String
|
|
|
let start: Date
|
|
let start: Date
|
|
@@ -6545,6 +6887,16 @@ class ViewController: NSViewController {
|
|
|
rangePopup.target = self
|
|
rangePopup.target = self
|
|
|
rangePopup.action = #selector(aiCompanionRangeChanged(_:))
|
|
rangePopup.action = #selector(aiCompanionRangeChanged(_:))
|
|
|
|
|
|
|
|
|
|
+ let stopRecording = NSButton(title: "Stop recording", target: self, action: #selector(aiCompanionStopRecordingTapped))
|
|
|
|
|
+ stopRecording.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stopRecording.isBordered = false
|
|
|
|
|
+ stopRecording.wantsLayer = true
|
|
|
|
|
+ stopRecording.layer?.backgroundColor = NSColor.systemRed.withAlphaComponent(0.95).cgColor
|
|
|
|
|
+ stopRecording.layer?.cornerRadius = 10
|
|
|
|
|
+ stopRecording.contentTintColor = .white
|
|
|
|
|
+ stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
+ stopRecording.isHidden = true
|
|
|
|
|
+
|
|
|
let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
|
|
let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
|
|
|
refresh.translatesAutoresizingMaskIntoConstraints = false
|
|
refresh.translatesAutoresizingMaskIntoConstraints = false
|
|
|
refresh.isBordered = false
|
|
refresh.isBordered = false
|
|
@@ -6561,7 +6913,7 @@ class ViewController: NSViewController {
|
|
|
loadingIndicator.isDisplayedWhenStopped = false
|
|
loadingIndicator.isDisplayedWhenStopped = false
|
|
|
loadingIndicator.isIndeterminate = true
|
|
loadingIndicator.isIndeterminate = true
|
|
|
|
|
|
|
|
- let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
|
|
|
|
|
|
|
+ let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, stopRecording, refresh])
|
|
|
toolbarRight.orientation = .horizontal
|
|
toolbarRight.orientation = .horizontal
|
|
|
toolbarRight.alignment = .centerY
|
|
toolbarRight.alignment = .centerY
|
|
|
toolbarRight.spacing = 10
|
|
toolbarRight.spacing = 10
|
|
@@ -6571,7 +6923,7 @@ class ViewController: NSViewController {
|
|
|
container.addSubview(toolbarRight)
|
|
container.addSubview(toolbarRight)
|
|
|
|
|
|
|
|
// Status label below header.
|
|
// Status label below header.
|
|
|
- let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
|
|
|
|
+ let status = makeLabel("Local recordings and AI notes live on this Mac.", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
status.translatesAutoresizingMaskIntoConstraints = false
|
|
status.translatesAutoresizingMaskIntoConstraints = false
|
|
|
container.addSubview(status)
|
|
container.addSubview(status)
|
|
|
|
|
|
|
@@ -6643,9 +6995,11 @@ class ViewController: NSViewController {
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
|
|
container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
|
|
|
- container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
|
|
|
|
|
- container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
|
|
|
|
|
container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
|
|
container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
|
|
|
|
|
+ container.centerXAnchor.constraint(equalTo: root.centerXAnchor),
|
|
|
|
|
+ container.widthAnchor.constraint(lessThanOrEqualToConstant: 980),
|
|
|
|
|
+ container.leadingAnchor.constraint(greaterThanOrEqualTo: root.leadingAnchor, constant: 12),
|
|
|
|
|
+ container.trailingAnchor.constraint(lessThanOrEqualTo: root.trailingAnchor, constant: -12),
|
|
|
|
|
|
|
|
titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
|
|
titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
|
|
|
titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
@@ -6654,6 +7008,8 @@ class ViewController: NSViewController {
|
|
|
toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
|
|
toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
|
|
|
toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
|
|
|
|
|
|
|
|
+ stopRecording.heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
+ stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
|
|
|
refresh.heightAnchor.constraint(equalToConstant: 30),
|
|
refresh.heightAnchor.constraint(equalToConstant: 30),
|
|
|
refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
|
|
refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
|
|
|
rangePopup.heightAnchor.constraint(equalToConstant: 28),
|
|
rangePopup.heightAnchor.constraint(equalToConstant: 28),
|
|
@@ -6705,17 +7061,103 @@ class ViewController: NSViewController {
|
|
|
aiCompanionCountLabel = count
|
|
aiCompanionCountLabel = count
|
|
|
aiCompanionRefreshButton = refresh
|
|
aiCompanionRefreshButton = refresh
|
|
|
aiCompanionRangePopup = rangePopup
|
|
aiCompanionRangePopup = rangePopup
|
|
|
|
|
+ aiCompanionStopRecordingButton = stopRecording
|
|
|
aiCompanionEmptyLabel = listEmpty
|
|
aiCompanionEmptyLabel = listEmpty
|
|
|
aiCompanionLoadingIndicator = loadingIndicator
|
|
aiCompanionLoadingIndicator = loadingIndicator
|
|
|
aiCompanionDetailEmptyLabel = detailEmpty
|
|
aiCompanionDetailEmptyLabel = detailEmpty
|
|
|
|
|
|
|
|
renderAiCompanionList()
|
|
renderAiCompanionList()
|
|
|
renderAiCompanionDetail()
|
|
renderAiCompanionDetail()
|
|
|
|
|
+ updateAiCompanionRecordingControls()
|
|
|
return root
|
|
return root
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - AI Companion list / detail rendering
|
|
// MARK: - AI Companion list / detail rendering
|
|
|
|
|
|
|
|
|
|
+ private func updateAiCompanionRecordingControls() {
|
|
|
|
|
+ aiCompanionStopRecordingButton?.isHidden = (activeMeetingRecordingSession == nil)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func loadAiCompanionLocalRecordings() {
|
|
|
|
|
+ let defaults = UserDefaults.standard
|
|
|
|
|
+ guard let raw = defaults.data(forKey: aiCompanionLocalRecordingsDefaultsKey) else {
|
|
|
|
|
+ aiCompanionLocalRecordings = []
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if let decoded = try? JSONDecoder().decode([LocalMeetingRecording].self, from: raw) {
|
|
|
|
|
+ aiCompanionLocalRecordings = decoded.sorted(by: { $0.endedAt > $1.endedAt })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ aiCompanionLocalRecordings = []
|
|
|
|
|
+ }
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func persistAiCompanionLocalRecordings() {
|
|
|
|
|
+ guard let data = try? JSONEncoder().encode(aiCompanionLocalRecordings) else { return }
|
|
|
|
|
+ UserDefaults.standard.set(data, forKey: aiCompanionLocalRecordingsDefaultsKey)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func rebuildAiCompanionSummariesFromLocalRecordings() {
|
|
|
|
|
+ let recordings = filteredLocalRecordingsForSelectedRange(aiCompanionLocalRecordings)
|
|
|
|
|
+ aiCompanionSummaries = recordings.map { recording in
|
|
|
|
|
+ let content: String? = {
|
|
|
|
|
+ let notes = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if let notes, notes.isEmpty == false { return notes }
|
|
|
|
|
+ let status = LocalMeetingNotesStatus(rawValue: recording.notesStatusRaw ?? "") ?? .notRequested
|
|
|
|
|
+ switch status {
|
|
|
|
|
+ case .processing:
|
|
|
|
|
+ return "Notes are generating…"
|
|
|
|
|
+ case .failed:
|
|
|
|
|
+ return recording.notesErrorMessage ?? "Notes generation failed."
|
|
|
|
|
+ case .ready:
|
|
|
|
|
+ return notes
|
|
|
|
|
+ case .notRequested:
|
|
|
|
|
+ return "Notes not requested."
|
|
|
|
|
+ }
|
|
|
|
|
+ }()
|
|
|
|
|
+
|
|
|
|
|
+ return AICompanionSummary(
|
|
|
|
|
+ meetingUUID: recording.id,
|
|
|
|
|
+ meetingID: nil,
|
|
|
|
|
+ meetingTopic: recording.title,
|
|
|
|
|
+ meetingHostEmail: nil,
|
|
|
|
|
+ meetingStartTime: recording.startedAt,
|
|
|
|
|
+ meetingEndTime: recording.endedAt,
|
|
|
|
|
+ summaryTitle: recording.title,
|
|
|
|
|
+ summaryOverview: nil,
|
|
|
|
|
+ summaryDetails: [],
|
|
|
|
|
+ nextSteps: [],
|
|
|
|
|
+ summaryContent: content,
|
|
|
|
|
+ summaryDocURL: nil,
|
|
|
|
|
+ summaryCreatedTime: recording.endedAt,
|
|
|
|
|
+ summaryLastModifiedTime: recording.endedAt
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let idx = aiCompanionSelectedIndex, idx >= aiCompanionSummaries.count {
|
|
|
|
|
+ aiCompanionSelectedIndex = nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func filteredLocalRecordingsForSelectedRange(_ recordings: [LocalMeetingRecording]) -> [LocalMeetingRecording] {
|
|
|
|
|
+ let now = Date()
|
|
|
|
|
+ let calendar = Calendar.current
|
|
|
|
|
+ switch aiCompanionSelectedRange {
|
|
|
|
|
+ case "Last 7 days":
|
|
|
|
|
+ let from = calendar.date(byAdding: .day, value: -7, to: now) ?? now
|
|
|
|
|
+ return recordings.filter { $0.endedAt >= from }
|
|
|
|
|
+ case "Last 30 days":
|
|
|
|
|
+ let from = calendar.date(byAdding: .day, value: -30, to: now) ?? now
|
|
|
|
|
+ return recordings.filter { $0.endedAt >= from }
|
|
|
|
|
+ case "Last 90 days":
|
|
|
|
|
+ let from = calendar.date(byAdding: .day, value: -90, to: now) ?? now
|
|
|
|
|
+ return recordings.filter { $0.endedAt >= from }
|
|
|
|
|
+ default:
|
|
|
|
|
+ return recordings
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@MainActor
|
|
@MainActor
|
|
|
private func renderAiCompanionList() {
|
|
private func renderAiCompanionList() {
|
|
|
guard let stack = aiCompanionListStack else { return }
|
|
guard let stack = aiCompanionListStack else { return }
|
|
@@ -6729,12 +7171,9 @@ class ViewController: NSViewController {
|
|
|
aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
|
|
aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
|
|
|
|
|
|
|
|
let showEmpty: Bool
|
|
let showEmpty: Bool
|
|
|
- if isUserLoggedIn() == false {
|
|
|
|
|
- showEmpty = true
|
|
|
|
|
- aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
|
|
|
|
|
- } else if count == 0 {
|
|
|
|
|
|
|
+ if count == 0 {
|
|
|
showEmpty = true
|
|
showEmpty = true
|
|
|
- aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
|
|
|
|
|
|
|
+ aiCompanionEmptyLabel?.stringValue = "No recordings yet. Start or join a meeting and allow recording to generate notes."
|
|
|
} else {
|
|
} else {
|
|
|
showEmpty = false
|
|
showEmpty = false
|
|
|
}
|
|
}
|
|
@@ -6814,15 +7253,10 @@ class ViewController: NSViewController {
|
|
|
view.removeFromSuperview()
|
|
view.removeFromSuperview()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- guard isUserLoggedIn() else {
|
|
|
|
|
- aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
|
|
|
|
|
- aiCompanionDetailEmptyLabel?.isHidden = false
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
guard let index = aiCompanionSelectedIndex,
|
|
guard let index = aiCompanionSelectedIndex,
|
|
|
aiCompanionSummaries.indices.contains(index) else {
|
|
aiCompanionSummaries.indices.contains(index) else {
|
|
|
aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
|
|
aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
|
|
|
- ? "Run an AI Companion-enabled meeting to see notes here."
|
|
|
|
|
|
|
+ ? "Record a meeting to see notes here."
|
|
|
: "Select a meeting on the left to view its AI notes."
|
|
: "Select a meeting on the left to view its AI notes."
|
|
|
aiCompanionDetailEmptyLabel?.isHidden = false
|
|
aiCompanionDetailEmptyLabel?.isHidden = false
|
|
|
return
|
|
return
|
|
@@ -7055,17 +7489,23 @@ class ViewController: NSViewController {
|
|
|
stale = true
|
|
stale = true
|
|
|
}
|
|
}
|
|
|
if stale {
|
|
if stale {
|
|
|
- Task { await self.loadAICompanionSummaries(force: false) }
|
|
|
|
|
|
|
+ loadAiCompanionLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRefreshTapped() {
|
|
@objc private func aiCompanionRefreshTapped() {
|
|
|
- Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
|
|
|
|
+ loadAiCompanionLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
|
|
@objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
|
|
|
aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
|
|
aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
|
|
|
- Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
|
|
|
|
+ rebuildAiCompanionSummariesFromLocalRecordings()
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ renderAiCompanionDetail()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRowTapped(_ sender: NSButton) {
|
|
@objc private func aiCompanionRowTapped(_ sender: NSButton) {
|