Переглянути джерело

Automate meeting-timed recording and add system audio capture.

Start/stop recording based on actual Meet activity, reduce end-detection delay, and capture participant audio via ScreenCaptureKit with microphone fallback.

Made-with: Cursor
huzaifahayat12 2 місяців тому
батько
коміт
71974495a4
1 змінених файлів з 207 додано та 48 видалено
  1. 207 48
      meetings_app/ViewController.swift

+ 207 - 48
meetings_app/ViewController.swift

@@ -12,6 +12,7 @@ import AVKit
 import WebKit
 import AuthenticationServices
 import StoreKit
+import ScreenCaptureKit
 
 private enum SidebarPage: Int {
     case joinMeetings = 0
@@ -400,6 +401,8 @@ final class ViewController: NSViewController {
     private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
     private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
     private var activeMeetingAudioRecorder: AVAudioRecorder?
+    private var activeMeetingSystemAudioStopper: (() async -> Void)?
+    private var meetingRecordingMonitorTask: Task<Void, Never>?
     private weak var aiCompanionStopRecordingButton: NSButton?
     private let widgetSnapshotLimit: Int = 3
 
@@ -513,7 +516,6 @@ final class ViewController: NSViewController {
         migrateLegacyRatingStateIfNeeded()
         beginUsageTrackingSessionIfNeeded()
         observeAppLifecycleForUsageTrackingIfNeeded()
-        observeMeetingRecordingLifecycle()
         registerWidgetNotificationObservers()
         setupRootView()
         buildMainLayout()
@@ -571,6 +573,7 @@ final class ViewController: NSViewController {
         storeKitStartupTask?.cancel()
         paywallPurchaseTask?.cancel()
         launchPaywallWorkItem?.cancel()
+        meetingRecordingMonitorTask?.cancel()
     }
 }
 
@@ -1251,34 +1254,6 @@ private extension ViewController {
         alert.runModal()
     }
 
-    private func observeMeetingRecordingLifecycle() {
-        NotificationCenter.default.addObserver(
-            self,
-            selector: #selector(handleWorkspaceAppActivation(_:)),
-            name: NSWorkspace.didActivateApplicationNotification,
-            object: nil
-        )
-    }
-
-    @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
-        guard activeMeetingRecordingSession != nil else { return }
-        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
-        guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
-        promptStopRecordingAfterReturningToApp()
-    }
-
-    private func promptStopRecordingAfterReturningToApp() {
-        guard let session = activeMeetingRecordingSession else { return }
-        let alert = NSAlert()
-        alert.messageText = "Stop meeting recording?"
-        alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
-        alert.addButton(withTitle: "Stop Recording")
-        alert.addButton(withTitle: "Keep Recording")
-        if alert.runModal() == .alertFirstButtonReturn {
-            finishActiveMeetingRecording()
-        }
-    }
-
     private func requestMeetingRecordingConsent(title: String) -> Bool {
         let alert = NSAlert()
         alert.messageText = "Record this meeting locally?"
@@ -1319,20 +1294,20 @@ private extension ViewController {
     }
 
     private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
-        if activeMeetingRecordingSession != nil {
-            finishActiveMeetingRecording()
-        }
+        meetingRecordingMonitorTask?.cancel()
+        meetingRecordingMonitorTask = nil
+        if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
         guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
         let permission = AVCaptureDevice.authorizationStatus(for: .audio)
         switch permission {
         case .authorized:
-            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+            startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
         case .notDetermined:
             AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
                 DispatchQueue.main.async {
                     guard let self else { return }
                     if granted {
-                        self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+                        self.startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
                     } else {
                         self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
                     }
@@ -1345,40 +1320,129 @@ private extension ViewController {
         }
     }
 
+    private func startAutomaticRecordingFlow(meetingTitle: String, meetingURL: URL) {
+        guard let meetingCode = aiCompanionMeetMeetingCode(from: meetingURL), hasGoogleSessionAvailable() else {
+            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+            return
+        }
+
+        showTopToast(message: "Recording will start when the meeting starts", isError: false)
+        meetingRecordingMonitorTask = Task { [weak self] in
+            guard let self else { return }
+            defer { self.meetingRecordingMonitorTask = nil }
+
+            do {
+                let space = try await self.googleMeetClient.getSpace(
+                    accessToken: try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window),
+                    spaceNameOrMeetingCode: "spaces/\(meetingCode)"
+                )
+                let spaceName = space.name ?? "spaces/\(meetingCode)"
+                var hasStarted = false
+                var inactivePollCount = 0
+                let noStartDeadline = Date().addingTimeInterval(20 * 60)
+
+                while !Task.isCancelled {
+                    let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
+                    let isActive = try await self.aiCompanionIsMeetingActive(spaceResourceName: spaceName, accessToken: token)
+
+                    if isActive {
+                        inactivePollCount = 0
+                        if !hasStarted {
+                            self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+                            hasStarted = true
+                        }
+                    } else if hasStarted {
+                        inactivePollCount += 1
+                        if inactivePollCount >= 1 {
+                            self.finishActiveMeetingRecording(cancelMonitoring: false)
+                            break
+                        }
+                    } else if Date() > noStartDeadline {
+                        break
+                    }
+
+                    try await Task.sleep(nanoseconds: 2_000_000_000)
+                }
+            } catch {
+                if self.activeMeetingRecordingSession == nil {
+                    self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+                }
+            }
+        }
+    }
+
+    private func aiCompanionIsMeetingActive(spaceResourceName: String, accessToken: String) async throws -> Bool {
+        let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceResourceName)
+        return records.contains { $0.startTime != nil && $0.endTime == nil }
+    }
+
     private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
         let recordingID = UUID().uuidString
         let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
+
+        activeMeetingRecordingSession = ActiveMeetingRecordingSession(
+            id: recordingID,
+            title: meetingTitle,
+            meetURL: meetingURL,
+            startedAt: Date(),
+            audioFileURL: outputURL
+        )
+        pageCache[.aiCompanion] = nil
+        if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
+
+        if #available(macOS 13.0, *) {
+            let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
+            activeMeetingSystemAudioStopper = { [systemRecorder] in
+                try? await systemRecorder.stop()
+            }
+            Task { [weak self] in
+                guard let self else { return }
+                do {
+                    try await systemRecorder.start()
+                    await MainActor.run {
+                        self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
+                    }
+                } catch {
+                    await MainActor.run {
+                        self.activeMeetingSystemAudioStopper = nil
+                        self.startMicrophoneFallbackRecording(at: outputURL)
+                    }
+                }
+            }
+            return
+        }
+
+        startMicrophoneFallbackRecording(at: outputURL)
+    }
+
+    private func startMicrophoneFallbackRecording(at outputURL: URL) {
         let settings: [String: Any] = [
             AVFormatIDKey: kAudioFormatMPEG4AAC,
             AVSampleRateKey: 44_100,
             AVNumberOfChannelsKey: 1,
             AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
         ]
-
         do {
             let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
             recorder.prepareToRecord()
             recorder.record()
             activeMeetingAudioRecorder = recorder
-            activeMeetingRecordingSession = ActiveMeetingRecordingSession(
-                id: recordingID,
-                title: meetingTitle,
-                meetURL: meetingURL,
-                startedAt: Date(),
-                audioFileURL: outputURL
-            )
-            showTopToast(message: "Meeting recording started locally", isError: false)
-            pageCache[.aiCompanion] = nil
-            if selectedSidebarPage == .aiCompanion {
-                showSidebarPage(.aiCompanion)
-            }
+            showTopToast(message: "Meeting recording started (microphone only)", isError: false)
         } catch {
+            activeMeetingRecordingSession = nil
             showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
         }
     }
 
-    private func finishActiveMeetingRecording() {
+    private func finishActiveMeetingRecording(cancelMonitoring: Bool = true) {
+        if cancelMonitoring {
+            meetingRecordingMonitorTask?.cancel()
+            meetingRecordingMonitorTask = nil
+        }
         guard let session = activeMeetingRecordingSession else { return }
+        let stopSystemAudio = activeMeetingSystemAudioStopper
+        activeMeetingSystemAudioStopper = nil
+        if let stopSystemAudio { Task { await stopSystemAudio() } }
         activeMeetingAudioRecorder?.stop()
         activeMeetingAudioRecorder = nil
         activeMeetingRecordingSession = nil
@@ -5658,6 +5722,101 @@ extension ViewController: NSWindowDelegate {
     }
 }
 
+@available(macOS 13.0, *)
+private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
+    private let outputURL: URL
+    private var stream: SCStream?
+    private var writer: AVAssetWriter?
+    private var writerInput: AVAssetWriterInput?
+    private let outputQueue = DispatchQueue(label: "meeting.system.audio.capture")
+    private var didStartSession = false
+
+    init(outputURL: URL) {
+        self.outputURL = outputURL
+    }
+
+    func start() async throws {
+        try? FileManager.default.removeItem(at: outputURL)
+        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
+        guard let display = content.displays.first else {
+            throw NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
+        }
+
+        let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
+        let inputSettings: [String: Any] = [
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
+            AVSampleRateKey: 48_000,
+            AVNumberOfChannelsKey: 2,
+            AVEncoderBitRateKey: 128_000
+        ]
+        let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
+        input.expectsMediaDataInRealTime = true
+        guard writer.canAdd(input) else {
+            throw NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio writer input."])
+        }
+        writer.add(input)
+        self.writer = writer
+        self.writerInput = input
+        didStartSession = false
+
+        let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
+        let config = SCStreamConfiguration()
+        config.width = 2
+        config.height = 2
+        config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
+        config.queueDepth = 1
+        config.capturesAudio = true
+        if #available(macOS 13.0, *) {
+            config.excludesCurrentProcessAudio = true
+        }
+
+        let stream = SCStream(filter: filter, configuration: config, delegate: self)
+        self.stream = stream
+        try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
+        try await stream.startCapture()
+    }
+
+    func stop() async throws {
+        if let stream {
+            try? await stream.stopCapture()
+            self.stream = nil
+        }
+        writerInput?.markAsFinished()
+        guard let writer else { return }
+        await withCheckedContinuation { continuation in
+            writer.finishWriting {
+                continuation.resume()
+            }
+        }
+        self.writer = nil
+        self.writerInput = nil
+    }
+
+    func stream(_ stream: SCStream, didStopWithError error: Error) {
+        // Capture may stop on permission changes or display changes; caller handles lifecycle.
+    }
+
+    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
+        guard outputType == .audio else { return }
+        guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
+        guard let writer = writer, let input = writerInput else { return }
+
+        if writer.status == .unknown {
+            writer.startWriting()
+        }
+        if writer.status == .writing {
+            let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+            if !didStartSession {
+                writer.startSession(atSourceTime: pts)
+                didStartSession = true
+            }
+            if input.isReadyForMoreMediaData {
+                input.append(sampleBuffer)
+            }
+        }
+    }
+}
+
 /// Default `NSClipView` uses a non-flipped coordinate system, so a document shorter than the visible area is anchored to the **bottom** of the clip, leaving a large gap above (e.g. Schedule empty state). Flipped coordinates match Auto Layout’s top-leading anchors and keep content top-aligned.
 private final class TopAlignedClipView: NSClipView {
     override var isFlipped: Bool { true }