Bladeren bron

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 1 maand geleden
bovenliggende
commit
71974495a4
1 gewijzigde bestanden met toevoegingen van 207 en 48 verwijderingen
  1. 207 48
      meetings_app/ViewController.swift

+ 207 - 48
meetings_app/ViewController.swift

@@ -12,6 +12,7 @@ import AVKit
12 12
 import WebKit
13 13
 import AuthenticationServices
14 14
 import StoreKit
15
+import ScreenCaptureKit
15 16
 
16 17
 private enum SidebarPage: Int {
17 18
     case joinMeetings = 0
@@ -400,6 +401,8 @@ final class ViewController: NSViewController {
400 401
     private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
401 402
     private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
402 403
     private var activeMeetingAudioRecorder: AVAudioRecorder?
404
+    private var activeMeetingSystemAudioStopper: (() async -> Void)?
405
+    private var meetingRecordingMonitorTask: Task<Void, Never>?
403 406
     private weak var aiCompanionStopRecordingButton: NSButton?
404 407
     private let widgetSnapshotLimit: Int = 3
405 408
 
@@ -513,7 +516,6 @@ final class ViewController: NSViewController {
513 516
         migrateLegacyRatingStateIfNeeded()
514 517
         beginUsageTrackingSessionIfNeeded()
515 518
         observeAppLifecycleForUsageTrackingIfNeeded()
516
-        observeMeetingRecordingLifecycle()
517 519
         registerWidgetNotificationObservers()
518 520
         setupRootView()
519 521
         buildMainLayout()
@@ -571,6 +573,7 @@ final class ViewController: NSViewController {
571 573
         storeKitStartupTask?.cancel()
572 574
         paywallPurchaseTask?.cancel()
573 575
         launchPaywallWorkItem?.cancel()
576
+        meetingRecordingMonitorTask?.cancel()
574 577
     }
575 578
 }
576 579
 
@@ -1251,34 +1254,6 @@ private extension ViewController {
1251 1254
         alert.runModal()
1252 1255
     }
1253 1256
 
1254
-    private func observeMeetingRecordingLifecycle() {
1255
-        NotificationCenter.default.addObserver(
1256
-            self,
1257
-            selector: #selector(handleWorkspaceAppActivation(_:)),
1258
-            name: NSWorkspace.didActivateApplicationNotification,
1259
-            object: nil
1260
-        )
1261
-    }
1262
-
1263
-    @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
1264
-        guard activeMeetingRecordingSession != nil else { return }
1265
-        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
1266
-        guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
1267
-        promptStopRecordingAfterReturningToApp()
1268
-    }
1269
-
1270
-    private func promptStopRecordingAfterReturningToApp() {
1271
-        guard let session = activeMeetingRecordingSession else { return }
1272
-        let alert = NSAlert()
1273
-        alert.messageText = "Stop meeting recording?"
1274
-        alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
1275
-        alert.addButton(withTitle: "Stop Recording")
1276
-        alert.addButton(withTitle: "Keep Recording")
1277
-        if alert.runModal() == .alertFirstButtonReturn {
1278
-            finishActiveMeetingRecording()
1279
-        }
1280
-    }
1281
-
1282 1257
     private func requestMeetingRecordingConsent(title: String) -> Bool {
1283 1258
         let alert = NSAlert()
1284 1259
         alert.messageText = "Record this meeting locally?"
@@ -1319,20 +1294,20 @@ private extension ViewController {
1319 1294
     }
1320 1295
 
1321 1296
     private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
1322
-        if activeMeetingRecordingSession != nil {
1323
-            finishActiveMeetingRecording()
1324
-        }
1297
+        meetingRecordingMonitorTask?.cancel()
1298
+        meetingRecordingMonitorTask = nil
1299
+        if activeMeetingRecordingSession != nil { finishActiveMeetingRecording(cancelMonitoring: false) }
1325 1300
         guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
1326 1301
         let permission = AVCaptureDevice.authorizationStatus(for: .audio)
1327 1302
         switch permission {
1328 1303
         case .authorized:
1329
-            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1304
+            startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
1330 1305
         case .notDetermined:
1331 1306
             AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
1332 1307
                 DispatchQueue.main.async {
1333 1308
                     guard let self else { return }
1334 1309
                     if granted {
1335
-                        self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1310
+                        self.startAutomaticRecordingFlow(meetingTitle: meetingTitle, meetingURL: meetingURL)
1336 1311
                     } else {
1337 1312
                         self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
1338 1313
                     }
@@ -1345,40 +1320,129 @@ private extension ViewController {
1345 1320
         }
1346 1321
     }
1347 1322
 
1323
+    private func startAutomaticRecordingFlow(meetingTitle: String, meetingURL: URL) {
1324
+        guard let meetingCode = aiCompanionMeetMeetingCode(from: meetingURL), hasGoogleSessionAvailable() else {
1325
+            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1326
+            return
1327
+        }
1328
+
1329
+        showTopToast(message: "Recording will start when the meeting starts", isError: false)
1330
+        meetingRecordingMonitorTask = Task { [weak self] in
1331
+            guard let self else { return }
1332
+            defer { self.meetingRecordingMonitorTask = nil }
1333
+
1334
+            do {
1335
+                let space = try await self.googleMeetClient.getSpace(
1336
+                    accessToken: try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window),
1337
+                    spaceNameOrMeetingCode: "spaces/\(meetingCode)"
1338
+                )
1339
+                let spaceName = space.name ?? "spaces/\(meetingCode)"
1340
+                var hasStarted = false
1341
+                var inactivePollCount = 0
1342
+                let noStartDeadline = Date().addingTimeInterval(20 * 60)
1343
+
1344
+                while !Task.isCancelled {
1345
+                    let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
1346
+                    let isActive = try await self.aiCompanionIsMeetingActive(spaceResourceName: spaceName, accessToken: token)
1347
+
1348
+                    if isActive {
1349
+                        inactivePollCount = 0
1350
+                        if !hasStarted {
1351
+                            self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1352
+                            hasStarted = true
1353
+                        }
1354
+                    } else if hasStarted {
1355
+                        inactivePollCount += 1
1356
+                        if inactivePollCount >= 1 {
1357
+                            self.finishActiveMeetingRecording(cancelMonitoring: false)
1358
+                            break
1359
+                        }
1360
+                    } else if Date() > noStartDeadline {
1361
+                        break
1362
+                    }
1363
+
1364
+                    try await Task.sleep(nanoseconds: 2_000_000_000)
1365
+                }
1366
+            } catch {
1367
+                if self.activeMeetingRecordingSession == nil {
1368
+                    self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1369
+                }
1370
+            }
1371
+        }
1372
+    }
1373
+
1374
+    private func aiCompanionIsMeetingActive(spaceResourceName: String, accessToken: String) async throws -> Bool {
1375
+        let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceResourceName)
1376
+        return records.contains { $0.startTime != nil && $0.endTime == nil }
1377
+    }
1378
+
1348 1379
     private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
1349 1380
         let recordingID = UUID().uuidString
1350 1381
         let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
1382
+
1383
+        activeMeetingRecordingSession = ActiveMeetingRecordingSession(
1384
+            id: recordingID,
1385
+            title: meetingTitle,
1386
+            meetURL: meetingURL,
1387
+            startedAt: Date(),
1388
+            audioFileURL: outputURL
1389
+        )
1390
+        pageCache[.aiCompanion] = nil
1391
+        if selectedSidebarPage == .aiCompanion { showSidebarPage(.aiCompanion) }
1392
+
1393
+        if #available(macOS 13.0, *) {
1394
+            let systemRecorder = MeetingSystemAudioRecorder(outputURL: outputURL)
1395
+            activeMeetingSystemAudioStopper = { [systemRecorder] in
1396
+                try? await systemRecorder.stop()
1397
+            }
1398
+            Task { [weak self] in
1399
+                guard let self else { return }
1400
+                do {
1401
+                    try await systemRecorder.start()
1402
+                    await MainActor.run {
1403
+                        self.showTopToast(message: "Meeting recording started (meeting audio)", isError: false)
1404
+                    }
1405
+                } catch {
1406
+                    await MainActor.run {
1407
+                        self.activeMeetingSystemAudioStopper = nil
1408
+                        self.startMicrophoneFallbackRecording(at: outputURL)
1409
+                    }
1410
+                }
1411
+            }
1412
+            return
1413
+        }
1414
+
1415
+        startMicrophoneFallbackRecording(at: outputURL)
1416
+    }
1417
+
1418
+    private func startMicrophoneFallbackRecording(at outputURL: URL) {
1351 1419
         let settings: [String: Any] = [
1352 1420
             AVFormatIDKey: kAudioFormatMPEG4AAC,
1353 1421
             AVSampleRateKey: 44_100,
1354 1422
             AVNumberOfChannelsKey: 1,
1355 1423
             AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
1356 1424
         ]
1357
-
1358 1425
         do {
1359 1426
             let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
1360 1427
             recorder.prepareToRecord()
1361 1428
             recorder.record()
1362 1429
             activeMeetingAudioRecorder = recorder
1363
-            activeMeetingRecordingSession = ActiveMeetingRecordingSession(
1364
-                id: recordingID,
1365
-                title: meetingTitle,
1366
-                meetURL: meetingURL,
1367
-                startedAt: Date(),
1368
-                audioFileURL: outputURL
1369
-            )
1370
-            showTopToast(message: "Meeting recording started locally", isError: false)
1371
-            pageCache[.aiCompanion] = nil
1372
-            if selectedSidebarPage == .aiCompanion {
1373
-                showSidebarPage(.aiCompanion)
1374
-            }
1430
+            showTopToast(message: "Meeting recording started (microphone only)", isError: false)
1375 1431
         } catch {
1432
+            activeMeetingRecordingSession = nil
1376 1433
             showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
1377 1434
         }
1378 1435
     }
1379 1436
 
1380
-    private func finishActiveMeetingRecording() {
1437
+    private func finishActiveMeetingRecording(cancelMonitoring: Bool = true) {
1438
+        if cancelMonitoring {
1439
+            meetingRecordingMonitorTask?.cancel()
1440
+            meetingRecordingMonitorTask = nil
1441
+        }
1381 1442
         guard let session = activeMeetingRecordingSession else { return }
1443
+        let stopSystemAudio = activeMeetingSystemAudioStopper
1444
+        activeMeetingSystemAudioStopper = nil
1445
+        if let stopSystemAudio { Task { await stopSystemAudio() } }
1382 1446
         activeMeetingAudioRecorder?.stop()
1383 1447
         activeMeetingAudioRecorder = nil
1384 1448
         activeMeetingRecordingSession = nil
@@ -5658,6 +5722,101 @@ extension ViewController: NSWindowDelegate {
5658 5722
     }
5659 5723
 }
5660 5724
 
5725
+@available(macOS 13.0, *)
5726
+private final class MeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
5727
+    private let outputURL: URL
5728
+    private var stream: SCStream?
5729
+    private var writer: AVAssetWriter?
5730
+    private var writerInput: AVAssetWriterInput?
5731
+    private let outputQueue = DispatchQueue(label: "meeting.system.audio.capture")
5732
+    private var didStartSession = false
5733
+
5734
+    init(outputURL: URL) {
5735
+        self.outputURL = outputURL
5736
+    }
5737
+
5738
+    func start() async throws {
5739
+        try? FileManager.default.removeItem(at: outputURL)
5740
+        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
5741
+        guard let display = content.displays.first else {
5742
+            throw NSError(domain: "MeetingSystemAudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available for system audio capture."])
5743
+        }
5744
+
5745
+        let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
5746
+        let inputSettings: [String: Any] = [
5747
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
5748
+            AVSampleRateKey: 48_000,
5749
+            AVNumberOfChannelsKey: 2,
5750
+            AVEncoderBitRateKey: 128_000
5751
+        ]
5752
+        let input = AVAssetWriterInput(mediaType: .audio, outputSettings: inputSettings)
5753
+        input.expectsMediaDataInRealTime = true
5754
+        guard writer.canAdd(input) else {
5755
+            throw NSError(domain: "MeetingSystemAudioRecorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio writer input."])
5756
+        }
5757
+        writer.add(input)
5758
+        self.writer = writer
5759
+        self.writerInput = input
5760
+        didStartSession = false
5761
+
5762
+        let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
5763
+        let config = SCStreamConfiguration()
5764
+        config.width = 2
5765
+        config.height = 2
5766
+        config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
5767
+        config.queueDepth = 1
5768
+        config.capturesAudio = true
5769
+        if #available(macOS 13.0, *) {
5770
+            config.excludesCurrentProcessAudio = true
5771
+        }
5772
+
5773
+        let stream = SCStream(filter: filter, configuration: config, delegate: self)
5774
+        self.stream = stream
5775
+        try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
5776
+        try await stream.startCapture()
5777
+    }
5778
+
5779
+    func stop() async throws {
5780
+        if let stream {
5781
+            try? await stream.stopCapture()
5782
+            self.stream = nil
5783
+        }
5784
+        writerInput?.markAsFinished()
5785
+        guard let writer else { return }
5786
+        await withCheckedContinuation { continuation in
5787
+            writer.finishWriting {
5788
+                continuation.resume()
5789
+            }
5790
+        }
5791
+        self.writer = nil
5792
+        self.writerInput = nil
5793
+    }
5794
+
5795
+    func stream(_ stream: SCStream, didStopWithError error: Error) {
5796
+        // Capture may stop on permission changes or display changes; caller handles lifecycle.
5797
+    }
5798
+
5799
+    func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
5800
+        guard outputType == .audio else { return }
5801
+        guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
5802
+        guard let writer = writer, let input = writerInput else { return }
5803
+
5804
+        if writer.status == .unknown {
5805
+            writer.startWriting()
5806
+        }
5807
+        if writer.status == .writing {
5808
+            let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
5809
+            if !didStartSession {
5810
+                writer.startSession(atSourceTime: pts)
5811
+                didStartSession = true
5812
+            }
5813
+            if input.isReadyForMoreMediaData {
5814
+                input.append(sampleBuffer)
5815
+            }
5816
+        }
5817
+    }
5818
+}
5819
+
5661 5820
 /// 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.
5662 5821
 private final class TopAlignedClipView: NSClipView {
5663 5822
     override var isFlipped: Bool { true }