Jelajahi Sumber

Improve embedded Zoom recording with system audio and mic sync.

Capture web client audio via ScreenCaptureKit, defer recording until join, pause the mic track when muted, and harden export, meetings refresh, and notes flow.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 bulan lalu
induk
melakukan
0b28333ff5
2 mengubah file dengan 593 tambahan dan 115 penghapusan
  1. 288 64
      zoom_app/MeetingRecordingManager.swift
  2. 305 51
      zoom_app/ViewController.swift

+ 288 - 64
zoom_app/MeetingRecordingManager.swift

@@ -1,4 +1,5 @@
 import AppKit
 import AppKit
+import CoreGraphics
 import Foundation
 import Foundation
 import AVFoundation
 import AVFoundation
 import ScreenCaptureKit
 import ScreenCaptureKit
@@ -53,7 +54,7 @@ final class MeetingRecordingManager: NSObject {
             case .systemAudioUnavailable(let reason):
             case .systemAudioUnavailable(let reason):
                 return "System audio capture failed (\(reason)). Microphone-only recording may still work."
                 return "System audio capture failed (\(reason)). Microphone-only recording may still work."
             case .exportFailed:
             case .exportFailed:
-                return "Could not save the recording file. Check free disk space and try again."
+                return "No meeting audio was captured to save. Allow Screen Recording for system audio and Microphone if you plan to speak."
             case .micCaptureFailed(let reason):
             case .micCaptureFailed(let reason):
                 return "Microphone recording failed: \(reason)"
                 return "Microphone recording failed: \(reason)"
             }
             }
@@ -88,6 +89,8 @@ final class MeetingRecordingManager: NSObject {
 
 
     /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
     /// Microphone capture (same approach as meetings_app: `AVAudioRecorder` → AAC `.m4a`).
     private var micRecorder: AVAudioRecorder?
     private var micRecorder: AVAudioRecorder?
+    /// True while `AVAudioRecorder` is actively encoding (unmuted in the meeting).
+    private var micRecordingActive: Bool = false
 
 
     /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
     /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
     private var systemAudioRecorder: AnyObject?
     private var systemAudioRecorder: AnyObject?
@@ -114,6 +117,8 @@ final class MeetingRecordingManager: NSObject {
     /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
     /// Set by the embedded `WKWebView` when its URL indicates an active join/in-call web client page.
     private var embeddedWebClientCallUIVisible: Bool = false
     private var embeddedWebClientCallUIVisible: Bool = false
     private var stopRecordingInFlight: Bool = false
     private var stopRecordingInFlight: Bool = false
+    /// Zoom runs inside this app's `WKWebView`; system capture must include process audio.
+    private var captureEmbeddedZoomWebClient: Bool = false
 
 
     private override init() {
     private override init() {
         super.init()
         super.init()
@@ -177,11 +182,15 @@ final class MeetingRecordingManager: NSObject {
         NSWorkspace.shared.open(url)
         NSWorkspace.shared.open(url)
     }
     }
 
 
+    /// Call before `startRecording`. When `true`, meeting audio comes from the in-app Zoom web client via ScreenCaptureKit (not the physical mic).
+    func configureCapture(embeddedZoomWebClient: Bool) {
+        captureEmbeddedZoomWebClient = embeddedZoomWebClient
+    }
+
     func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
     func startRecording(sourceURL: URL?, meetingDisplayName: String? = nil) async throws {
         if isRecording { throw RecordingError.alreadyRecording }
         if isRecording { throw RecordingError.alreadyRecording }
 
 
         let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
         let micGranted = await AVCaptureDevice.requestAccess(for: .audio)
-        if micGranted == false { throw RecordingError.microphonePermissionDenied }
 
 
         let id = UUID().uuidString
         let id = UUID().uuidString
         let startedAt = Date()
         let startedAt = Date()
@@ -199,20 +208,28 @@ final class MeetingRecordingManager: NSObject {
         outputAudioURL = outBase
         outputAudioURL = outBase
 
 
         do {
         do {
-            try startMicRecorder(to: micURL)
+            if micGranted {
+                try? prepareMicRecorder(to: micURL)
+            }
             if #available(macOS 13.0, *) {
             if #available(macOS 13.0, *) {
-                let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL)
+                try await ensureScreenCaptureAccess()
+                let target = resolveSystemAudioCaptureTarget()
+                let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL, captureTarget: target)
                 systemAudioRecorder = recorder
                 systemAudioRecorder = recorder
                 do {
                 do {
                     try await recorder.start()
                     try await recorder.start()
                 } catch {
                 } catch {
                     systemAudioRecorder = nil
                     systemAudioRecorder = nil
+                    NSLog("MeetingRecordingManager: system audio start failed — \(error.localizedDescription)")
+                    throw RecordingError.systemAudioUnavailable(error.localizedDescription)
                 }
                 }
+            } else if captureEmbeddedZoomWebClient {
+                throw RecordingError.systemAudioUnavailable("System audio capture requires macOS 13 or later.")
             } else {
             } else {
                 systemAudioRecorder = nil
                 systemAudioRecorder = nil
             }
             }
         } catch {
         } catch {
-            stopMicRecorder()
+            stopMicRecorder(discardFile: true)
             if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
             if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
                 try? await r.stop()
                 try? await r.stop()
             }
             }
@@ -242,45 +259,96 @@ final class MeetingRecordingManager: NSObject {
         }
         }
     }
     }
 
 
+    /// When `false`, pauses the Mac microphone track; system audio capture keeps running.
+    func setMeetingMicrophoneTransmitting(_ transmitting: Bool) {
+        guard isRecording else { return }
+        if transmitting {
+            resumeMicRecordingIfPrepared()
+        } else {
+            pauseMicRecording()
+        }
+    }
+
     func stopRecording() async throws {
     func stopRecording() async throws {
         await MainActor.run {
         await MainActor.run {
             self.stopAutomaticStopMonitoringOnMain()
             self.stopAutomaticStopMonitoringOnMain()
         }
         }
-        guard stopRecordingInFlight == false else { return }
-        guard isRecording else { throw RecordingError.notRecording }
+        while stopRecordingInFlight {
+            try? await Task.sleep(nanoseconds: 100_000_000)
+        }
+        // Already saved (e.g. duplicate auto-stop) — treat as success.
+        guard isRecording else { return }
         stopRecordingInFlight = true
         stopRecordingInFlight = true
-        defer { stopRecordingInFlight = false }
+
         guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
         guard let id = recordingID, let startedAt = recordingStartedAt, let outURL = outputAudioURL else {
+            stopRecordingInFlight = false
+            clearRecordingSessionState()
             throw RecordingError.exportFailed
             throw RecordingError.exportFailed
         }
         }
 
 
         let endedAt = Date()
         let endedAt = Date()
+        let displayName = pendingMeetingDisplayName
+        let sourceURLString = recordingSourceURLString
         let dir = try recordingsDirectoryURL()
         let dir = try recordingsDirectoryURL()
         let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
         let micURL = dir.appendingPathComponent("\(id)-mic.m4a")
         let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
         let sysURL = dir.appendingPathComponent("\(id)-system.m4a")
 
 
+        defer {
+            stopRecordingInFlight = false
+            clearRecordingSessionState()
+        }
+
         stopMicRecorder()
         stopMicRecorder()
+        var systemSampleCount = 0
         if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
         if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
+            systemSampleCount = r.capturedSampleCount
             try? await r.stop()
             try? await r.stop()
         }
         }
         systemAudioRecorder = nil
         systemAudioRecorder = nil
 
 
-        let mixedURL = try await finalizeMeetingAudio(systemURL: sysURL, microphoneURL: micURL, outputURL: outURL)
+        // Let ScreenCaptureKit / AVAssetWriter finish flushing to disk.
+        try? await Task.sleep(nanoseconds: 1_000_000_000)
+
+        if systemSampleCount == 0 {
+            NSLog("MeetingRecordingManager: warning — system audio capture received 0 samples.")
+        }
+
+        let exportResult = await exportMeetingAudio(
+            systemURL: sysURL,
+            microphoneURL: micURL,
+            outputURL: outURL
+        )
 
 
         // Cleanup intermediates (ignore failures).
         // Cleanup intermediates (ignore failures).
         try? FileManager.default.removeItem(at: micURL)
         try? FileManager.default.removeItem(at: micURL)
         try? FileManager.default.removeItem(at: sysURL)
         try? FileManager.default.removeItem(at: sysURL)
 
 
-        let displayName = pendingMeetingDisplayName
+        guard let savedURL = exportResult.url else {
+            NSLog(
+                "MeetingRecordingManager: export failed — system %lld bytes, mic %lld bytes, out %lld bytes",
+                fileSize(at: sysURL),
+                fileSize(at: micURL),
+                fileSize(at: outURL)
+            )
+            throw RecordingError.exportFailed
+        }
+
+        var transcriptWarning = exportResult.transcriptWarning
+        let hasSystem = hasUsableAudio(at: sysURL)
+        let hasMic = hasUsableAudio(at: micURL)
+        if hasSystem == false, hasMic {
+            transcriptWarning = "Meeting audio (other participants) was not captured. Turn on Screen Recording for this app in System Settings, then record again."
+        }
+
         let rec = RecordedMeeting(
         let rec = RecordedMeeting(
             id: id,
             id: id,
             startedAt: startedAt,
             startedAt: startedAt,
             endedAt: endedAt,
             endedAt: endedAt,
-            sourceURLString: recordingSourceURLString,
+            sourceURLString: sourceURLString,
             meetingDisplayName: displayName,
             meetingDisplayName: displayName,
-            audioFilename: mixedURL.lastPathComponent,
+            audioFilename: savedURL.lastPathComponent,
             transcriptText: nil,
             transcriptText: nil,
-            transcriptError: nil,
+            transcriptError: transcriptWarning,
             notesStatusRaw: nil,
             notesStatusRaw: nil,
             notesText: nil,
             notesText: nil,
             notesError: nil
             notesError: nil
@@ -288,16 +356,65 @@ final class MeetingRecordingManager: NSObject {
         recordings.insert(rec, at: 0)
         recordings.insert(rec, at: 0)
         saveRecordingsToDisk()
         saveRecordingsToDisk()
         scheduleAppleTranscription(for: rec)
         scheduleAppleTranscription(for: rec)
+        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+    }
 
 
+    private struct MeetingAudioExportResult {
+        let url: URL?
+        let transcriptWarning: String?
+    }
+
+    private func exportMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async -> MeetingAudioExportResult {
+        do {
+            let url = try await finalizeMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL)
+            return MeetingAudioExportResult(url: url, transcriptWarning: nil)
+        } catch {
+            if let salvaged = salvageMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL) {
+                return MeetingAudioExportResult(
+                    url: salvaged,
+                    transcriptWarning: "Audio was recovered from a partial export (\(error.localizedDescription))."
+                )
+            }
+            return MeetingAudioExportResult(url: nil, transcriptWarning: nil)
+        }
+    }
+
+    private func salvageMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) -> URL? {
+        let fm = FileManager.default
+        let candidates = [systemURL, microphoneURL, outputURL]
+        for source in candidates {
+            guard fm.fileExists(atPath: source.path), fileSize(at: source) >= 128 else { continue }
+            if source.path == outputURL.path {
+                return outputURL
+            }
+            try? fm.removeItem(at: outputURL)
+            do {
+                try fm.copyItem(at: source, to: outputURL)
+                return outputURL
+            } catch {
+                return source
+            }
+        }
+        return nil
+    }
+
+    /// Ends an in-progress recording session and updates UI even when export fails.
+    private func clearRecordingSessionState() {
+        guard isRecording || recordingID != nil else { return }
         recordingID = nil
         recordingID = nil
         recordingStartedAt = nil
         recordingStartedAt = nil
         recordingSourceURLString = nil
         recordingSourceURLString = nil
         pendingMeetingDisplayName = nil
         pendingMeetingDisplayName = nil
         outputAudioURL = nil
         outputAudioURL = nil
-
+        micRecorder = nil
+        micRecordingActive = false
+        systemAudioRecorder = nil
+        captureEmbeddedZoomWebClient = false
+        let wasRecording = isRecording
         isRecording = false
         isRecording = false
-        NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
-        NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+        if wasRecording {
+            NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
+        }
     }
     }
 
 
     @MainActor
     @MainActor
@@ -473,9 +590,39 @@ final class MeetingRecordingManager: NSObject {
         }
         }
     }
     }
 
 
+    @available(macOS 13.0, *)
+    private func resolveSystemAudioCaptureTarget() -> ZoomMeetingSystemAudioRecorder.CaptureTarget {
+        let zoomRunning = NSWorkspace.shared.runningApplications.contains {
+            $0.bundleIdentifier == Self.zoomDesktopBundleID
+        }
+        // Embedded web client: capture the full display audio mix (all participants from Zoom output).
+        if captureEmbeddedZoomWebClient {
+            return .display(excludesCurrentProcessAudio: false)
+        }
+        if zoomRunning {
+            return .zoomDesktop
+        }
+        return .display(excludesCurrentProcessAudio: false)
+    }
+
+    @available(macOS 13.0, *)
+    private func ensureScreenCaptureAccess() async throws {
+        if CGPreflightScreenCaptureAccess() == false {
+            CGRequestScreenCaptureAccess()
+            try? await Task.sleep(nanoseconds: 800_000_000)
+        }
+        do {
+            _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
+        } catch {
+            throw RecordingError.systemAudioUnavailable(
+                "Allow Screen Recording for this app in System Settings → Privacy & Security → Screen Recording."
+            )
+        }
+    }
+
     // MARK: - Mic capture (meetings_app-style)
     // MARK: - Mic capture (meetings_app-style)
 
 
-    private func startMicRecorder(to outputURL: URL) throws {
+    private func prepareMicRecorder(to outputURL: URL) throws {
         try? FileManager.default.removeItem(at: outputURL)
         try? FileManager.default.removeItem(at: outputURL)
         let settings: [String: Any] = [
         let settings: [String: Any] = [
             AVFormatIDKey: kAudioFormatMPEG4AAC,
             AVFormatIDKey: kAudioFormatMPEG4AAC,
@@ -486,15 +633,39 @@ final class MeetingRecordingManager: NSObject {
         ]
         ]
         let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
         let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
         recorder.prepareToRecord()
         recorder.prepareToRecord()
+        micRecorder = recorder
+        micRecordingActive = false
+    }
+
+    private func resumeMicRecordingIfPrepared() {
+        guard let recorder = micRecorder else { return }
+        if micRecordingActive { return }
         guard recorder.record() else {
         guard recorder.record() else {
-            throw RecordingError.micCaptureFailed("AVAudioRecorder could not start.")
+            NSLog("MeetingRecordingManager: mic recorder could not resume.")
+            return
         }
         }
-        micRecorder = recorder
+        micRecordingActive = true
+    }
+
+    private func pauseMicRecording() {
+        guard let recorder = micRecorder, micRecordingActive else { return }
+        recorder.pause()
+        micRecordingActive = false
     }
     }
 
 
-    private func stopMicRecorder() {
-        micRecorder?.stop()
+    private func stopMicRecorder(discardFile: Bool = false) {
+        guard let recorder = micRecorder else { return }
+        let url = recorder.url
+        let bytesBeforeStop = fileSize(at: url)
+        let shouldFinalize = micRecordingActive || recorder.isRecording || bytesBeforeStop > 256
+        if shouldFinalize {
+            recorder.stop()
+        }
+        if discardFile || fileSize(at: url) < 256 {
+            try? FileManager.default.removeItem(at: url)
+        }
         micRecorder = nil
         micRecorder = nil
+        micRecordingActive = false
     }
     }
 
 
     // MARK: - Finalize / mix (meetings_app-style)
     // MARK: - Finalize / mix (meetings_app-style)
@@ -504,9 +675,16 @@ final class MeetingRecordingManager: NSObject {
         return attrs?[.size] as? Int64 ?? 0
         return attrs?[.size] as? Int64 ?? 0
     }
     }
 
 
-    private func hasAudioPayload(at url: URL, minBytes: Int64 = 10_000) -> Bool {
+    private func hasUsableAudio(at url: URL) -> Bool {
         guard FileManager.default.fileExists(atPath: url.path) else { return false }
         guard FileManager.default.fileExists(atPath: url.path) else { return false }
-        return fileSize(at: url) >= minBytes
+        let size = fileSize(at: url)
+        if size < 128 { return false }
+        // Freshly finalized AAC may not report duration immediately; trust file size.
+        if size >= 1_024 { return true }
+        let asset = AVURLAsset(url: url)
+        let seconds = CMTimeGetSeconds(asset.duration)
+        if seconds.isFinite && seconds > 0.05 { return true }
+        return size >= 512
     }
     }
 
 
     private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
     private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
@@ -515,8 +693,8 @@ final class MeetingRecordingManager: NSObject {
             try? fm.removeItem(at: outputURL)
             try? fm.removeItem(at: outputURL)
         }
         }
 
 
-        let hasSystem = hasAudioPayload(at: systemURL)
-        let hasMic = hasAudioPayload(at: microphoneURL)
+        let hasSystem = hasUsableAudio(at: systemURL)
+        let hasMic = hasUsableAudio(at: microphoneURL)
 
 
         if hasSystem && hasMic {
         if hasSystem && hasMic {
             do {
             do {
@@ -583,7 +761,7 @@ final class MeetingRecordingManager: NSObject {
         let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
         let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
         systemMix.setVolume(1.0, at: .zero)
         systemMix.setVolume(1.0, at: .zero)
         let micMix = AVMutableAudioMixInputParameters(track: micTrack)
         let micMix = AVMutableAudioMixInputParameters(track: micTrack)
-        micMix.setVolume(1.35, at: .zero)
+        micMix.setVolume(0.9, at: .zero)
         let audioMix = AVMutableAudioMix()
         let audioMix = AVMutableAudioMix()
         audioMix.inputParameters = [systemMix, micMix]
         audioMix.inputParameters = [systemMix, micMix]
 
 
@@ -663,55 +841,60 @@ final class MeetingRecordingManager: NSObject {
     }
     }
 }
 }
 
 
-// MARK: - System audio (meetings_app-style)
+// MARK: - System audio (ScreenCaptureKit)
 
 
 @available(macOS 13.0, *)
 @available(macOS 13.0, *)
 private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
 private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
+    enum CaptureTarget {
+        case hostApplication
+        case zoomDesktop
+        case display(excludesCurrentProcessAudio: Bool)
+    }
+
+    private static let zoomDesktopBundleID = "us.zoom.xos"
+
     private let outputURL: URL
     private let outputURL: URL
+    private let captureTarget: CaptureTarget
     private var stream: SCStream?
     private var stream: SCStream?
     private var writer: AVAssetWriter?
     private var writer: AVAssetWriter?
     private var writerInput: AVAssetWriterInput?
     private var writerInput: AVAssetWriterInput?
     private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
     private let outputQueue = DispatchQueue(label: "zoom_app.system.audio.capture")
     private var didStartSession = false
     private var didStartSession = false
+    private var appendedSampleCount: Int = 0
 
 
-    init(outputURL: URL) {
+    var capturedSampleCount: Int { appendedSampleCount }
+
+    init(outputURL: URL, captureTarget: CaptureTarget) {
         self.outputURL = outputURL
         self.outputURL = outputURL
+        self.captureTarget = captureTarget
     }
     }
 
 
     func start() async throws {
     func start() async throws {
         try? FileManager.default.removeItem(at: outputURL)
         try? FileManager.default.removeItem(at: outputURL)
-        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
-        guard let display = content.displays.first else {
-            throw MeetingRecordingManager.RecordingError.noShareableContent
-        }
-
+        let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
+        let filter = try makeContentFilter(from: content)
         let writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)
         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 MeetingRecordingManager.RecordingError.systemAudioUnavailable("Cannot add audio writer input.")
-        }
-        writer.add(input)
         self.writer = writer
         self.writer = writer
-        self.writerInput = input
+        writerInput = nil
         didStartSession = false
         didStartSession = false
+        appendedSampleCount = 0
 
 
-        let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
         let config = SCStreamConfiguration()
         let config = SCStreamConfiguration()
         config.width = 2
         config.width = 2
         config.height = 2
         config.height = 2
         config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
         config.minimumFrameInterval = CMTime(value: 1, timescale: 2)
-        config.queueDepth = 1
+        config.queueDepth = 8
         config.capturesAudio = true
         config.capturesAudio = true
         config.sampleRate = 48_000
         config.sampleRate = 48_000
         config.channelCount = 2
         config.channelCount = 2
-        config.excludesCurrentProcessAudio = true
+        switch captureTarget {
+        case .hostApplication, .display(excludesCurrentProcessAudio: false):
+            config.excludesCurrentProcessAudio = false
+        case .zoomDesktop:
+            config.excludesCurrentProcessAudio = true
+        case .display(excludesCurrentProcessAudio: true):
+            config.excludesCurrentProcessAudio = true
+        }
 
 
         let stream = SCStream(filter: filter, configuration: config, delegate: self)
         let stream = SCStream(filter: filter, configuration: config, delegate: self)
         self.stream = stream
         self.stream = stream
@@ -719,43 +902,84 @@ private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SC
         try await stream.startCapture()
         try await stream.startCapture()
     }
     }
 
 
+    private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
+        guard let display = content.displays.first else {
+            throw MeetingRecordingManager.RecordingError.noShareableContent
+        }
+        let emptyWindows: [SCWindow] = []
+        switch captureTarget {
+        case .hostApplication:
+            if let app = content.applications.first(where: { $0.bundleIdentifier == Bundle.main.bundleIdentifier }) {
+                return SCContentFilter(display: display, including: [app], exceptingWindows: emptyWindows)
+            }
+        case .zoomDesktop:
+            if let zoom = content.applications.first(where: { $0.bundleIdentifier == Self.zoomDesktopBundleID }) {
+                return SCContentFilter(display: display, including: [zoom], exceptingWindows: emptyWindows)
+            }
+        case .display:
+            break
+        }
+        return SCContentFilter(display: display, excludingApplications: [], exceptingWindows: emptyWindows)
+    }
+
     func stop() async throws {
     func stop() async throws {
         if let stream {
         if let stream {
             try? await stream.stopCapture()
             try? await stream.stopCapture()
             self.stream = nil
             self.stream = nil
         }
         }
-        writerInput?.markAsFinished()
         guard let writer else { return }
         guard let writer else { return }
-        await withCheckedContinuation { continuation in
-            writer.finishWriting {
-                continuation.resume()
+        if didStartSession, writer.status == .writing {
+            writerInput?.markAsFinished()
+            await withCheckedContinuation { continuation in
+                writer.finishWriting {
+                    continuation.resume()
+                }
             }
             }
+        } else {
+            try? FileManager.default.removeItem(at: outputURL)
         }
         }
         self.writer = nil
         self.writer = nil
         self.writerInput = nil
         self.writerInput = nil
+        didStartSession = false
+        appendedSampleCount = 0
     }
     }
 
 
     func stream(_ stream: SCStream, didStopWithError error: Error) {
     func stream(_ stream: SCStream, didStopWithError error: Error) {
-        // Capture may stop on permission changes or display changes; caller handles lifecycle.
+        NSLog("ZoomMeetingSystemAudioRecorder: capture stopped — \(error.localizedDescription)")
     }
     }
 
 
     func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
     func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
         guard outputType == .audio else { return }
         guard outputType == .audio else { return }
         guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
         guard CMSampleBufferDataIsReady(sampleBuffer) else { return }
-        guard let writer = writer, let input = writerInput else { return }
+        guard let writer else { return }
 
 
+        if writerInput == nil {
+            guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer) else { return }
+            let input = AVAssetWriterInput(mediaType: .audio, outputSettings: nil, sourceFormatHint: formatDescription)
+            input.expectsMediaDataInRealTime = true
+            guard writer.canAdd(input) else {
+                NSLog("ZoomMeetingSystemAudioRecorder: cannot add audio input for captured format.")
+                return
+            }
+            writer.add(input)
+            writerInput = input
+        }
+
+        guard let input = writerInput else { return }
         if writer.status == .unknown {
         if writer.status == .unknown {
             writer.startWriting()
             writer.startWriting()
         }
         }
-        if writer.status == .writing {
-            let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
-            if !didStartSession {
-                writer.startSession(atSourceTime: pts)
-                didStartSession = true
-            }
-            if input.isReadyForMoreMediaData {
-                input.append(sampleBuffer)
-            }
+        guard writer.status == .writing else { return }
+
+        let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+        if !didStartSession {
+            writer.startSession(atSourceTime: pts)
+            didStartSession = true
+        }
+
+        guard input.isReadyForMoreMediaData else { return }
+        if input.append(sampleBuffer) {
+            appendedSampleCount += 1
         }
         }
     }
     }
 }
 }

+ 305 - 51
zoom_app/ViewController.swift

@@ -39,6 +39,7 @@ protocol EmbeddedOAuthPresenting: AnyObject {
 
 
 class ViewController: NSViewController {
 class ViewController: NSViewController {
     private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
     private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
+    private static let embeddedZoomMicStateHandlerName = "zoomAppMicState"
     private static let embeddedZoomMeetingLifecycleJavaScript = """
     private static let embeddedZoomMeetingLifecycleJavaScript = """
     (function() {
     (function() {
       function notify() {
       function notify() {
@@ -55,6 +56,85 @@ class ViewController: NSViewController {
       setInterval(notify, 1500);
       setInterval(notify, 1500);
     })();
     })();
     """
     """
+    private static let embeddedZoomMicStateJavaScript = """
+    (function() {
+      function visible(el) {
+        if (!el || !el.getBoundingClientRect) return false;
+        var r = el.getBoundingClientRect();
+        return r.width > 0 && r.height > 0;
+      }
+      function isMicrophoneControl(label) {
+        if (!label) return false;
+        if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
+        if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
+        if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
+        if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
+        if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
+        return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
+          || label === 'mute' || label === 'unmute';
+      }
+      function readMicMuted() {
+        var nodes = document.querySelectorAll('button,[role="button"]');
+        for (var i = 0; i < nodes.length; i++) {
+          var el = nodes[i];
+          if (!visible(el)) continue;
+          var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
+          if (!isMicrophoneControl(label)) continue;
+          if (label.indexOf('unmute') !== -1) return true;
+          if (label.indexOf('mute') !== -1) return false;
+        }
+        return null;
+      }
+      var lastMuted = null;
+      function notify() {
+        var muted = readMicMuted();
+        if (muted === null || muted === lastMuted) return;
+        lastMuted = muted;
+        try {
+          window.webkit.messageHandlers.\(embeddedZoomMicStateHandlerName).postMessage({ muted: muted });
+        } catch (e) {}
+      }
+      var observer = new MutationObserver(function() { notify(); });
+      function startObserver() {
+        if (!document.body) return;
+        observer.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-label', 'aria-pressed', 'title', 'class'] });
+      }
+      if (document.body) startObserver();
+      else document.addEventListener('DOMContentLoaded', startObserver);
+      setInterval(notify, 500);
+      notify();
+    })();
+    """
+    /// Poll script for the embedded meeting monitor timer (returns `{ muted: boolean }` or `null`).
+    static let embeddedZoomMicStatePollJavaScript = """
+    (function() {
+      function visible(el) {
+        if (!el || !el.getBoundingClientRect) return false;
+        var r = el.getBoundingClientRect();
+        return r.width > 0 && r.height > 0;
+      }
+      function isMicrophoneControl(label) {
+        if (!label) return false;
+        if (label.indexOf('video') !== -1 || label.indexOf('camera') !== -1) return false;
+        if (label.indexOf('speaker') !== -1 || label.indexOf('headphone') !== -1) return false;
+        if (label.indexOf('share') !== -1 || label.indexOf('screen') !== -1) return false;
+        if (label.indexOf('record') !== -1 || label.indexOf('chat') !== -1) return false;
+        if (label.indexOf('participant') !== -1 || label.indexOf('reaction') !== -1) return false;
+        return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
+          || label === 'mute' || label === 'unmute';
+      }
+      var nodes = document.querySelectorAll('button,[role="button"]');
+      for (var i = 0; i < nodes.length; i++) {
+        var el = nodes[i];
+        if (!visible(el)) continue;
+        var label = (el.getAttribute('aria-label') || el.getAttribute('title') || '').toLowerCase().trim();
+        if (!isMicrophoneControl(label)) continue;
+        if (label.indexOf('unmute') !== -1) return { muted: true };
+        if (label.indexOf('mute') !== -1) return { muted: false };
+      }
+      return null;
+    })();
+    """
 
 
     enum HomeSection: String {
     enum HomeSection: String {
         case home = "Home"
         case home = "Home"
@@ -473,6 +553,9 @@ class ViewController: NSViewController {
     /// True after the embedded web client loaded a join/in-call URL during the current recording session.
     /// True after the embedded web client loaded a join/in-call URL during the current recording session.
     private var embeddedMeetingReachedInCallPage = false
     private var embeddedMeetingReachedInCallPage = false
     private var embeddedMeetingRecordingMonitorTimer: Timer?
     private var embeddedMeetingRecordingMonitorTimer: Timer?
+    private var endMeetingRecordingStopInFlight = false
+    /// Recording consent given; capture starts when the embedded client enters an active meeting (not the join lobby).
+    private var pendingEmbeddedRecording: (sourceURL: URL, meetingDisplayName: String?)?
 
 
     private enum LocalRecordingNotesStatus: String {
     private enum LocalRecordingNotesStatus: String {
         case notRequested
         case notRequested
@@ -524,8 +607,8 @@ class ViewController: NSViewController {
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastScrollEdgeRefreshAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     private var lastMeetingsPageAutoLoadAt = Date.distantPast
     // Keep this conservative to avoid Zoom API rate limits.
     // Keep this conservative to avoid Zoom API rate limits.
-    private let meetingsRefreshInterval: TimeInterval = 60
-    private let scrollRefreshCooldown: TimeInterval = 3
+    private let meetingsRefreshInterval: TimeInterval = 120
+    private let scrollRefreshCooldown: TimeInterval = 90
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
     private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
     private var meetingsRateLimitedUntil: Date?
     private var meetingsRateLimitedUntil: Date?
     private var zoomAccountTimeZone: TimeZone?
     private var zoomAccountTimeZone: TimeZone?
@@ -764,7 +847,9 @@ class ViewController: NSViewController {
             Task { @MainActor [weak self] in
             Task { @MainActor [weak self] in
                 guard let self else { return }
                 guard let self else { return }
                 self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
                 self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
+                self.recordedMeetings = MeetingRecordingManager.shared.recordings
                 self.renderAiCompanionList()
                 self.renderAiCompanionList()
+                self.autoGenerateMeetingNotesIfNeeded(forRecordingId: id)
             }
             }
         }
         }
         NotificationCenter.default.addObserver(
         NotificationCenter.default.addObserver(
@@ -2107,21 +2192,28 @@ class ViewController: NSViewController {
         return false
         return false
     }
     }
 
 
-    /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
-    private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
-        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
-        if isZoomMeetingLeaveOrEndURL(url) { return false }
-        let path = url.path.lowercased()
-        if path.contains("/wc/join") { return true }
-        if path.contains("/wc/inmeeting") { return true }
-        if path.contains("/wc/video") { return true }
-        // Non–web-client join links opened in the embedded panel (rare).
-        if path.contains("/wc/") == false {
-            return isZoomMeetingJoinOrStartURL(url)
+    /// True when the embedded web client is in a live meeting (not lobby, hub, or loading).
+    private func isEmbeddedZoomActiveMeeting(url: URL?, webViewTitle: String?) -> Bool {
+        if let title = webViewTitle, embeddedWebViewTitleIndicatesActiveMeeting(title) { return true }
+        if let url {
+            if isZoomMeetingLeaveOrEndURL(url) { return false }
+            let path = url.path.lowercased()
+            if path.contains("/wc/inmeeting") || path.contains("/wc/video") { return true }
+            if path.contains("/wc/join") { return true }
         }
         }
         return false
         return false
     }
     }
 
 
+    private func embeddedWebViewTitleIndicatesActiveMeeting(_ title: String) -> Bool {
+        let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+        guard t.isEmpty == false else { return false }
+        if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
+        if t.contains("sign in") || t.contains("sign-in") { return false }
+        if t.contains("zoom meeting") || t.contains("zoom webinar") { return true }
+        if t.contains("zoom workplace") && t.contains("|") == false { return true }
+        return false
+    }
+
     /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
     /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
     private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
     private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
         guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
@@ -2156,38 +2248,78 @@ class ViewController: NSViewController {
 
 
     @MainActor
     @MainActor
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
     private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
-        guard MeetingRecordingManager.shared.isRecording else {
+        let monitoring = MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil
+        guard monitoring else {
             stopEmbeddedMeetingRecordingMonitor()
             stopEmbeddedMeetingRecordingMonitor()
             return
             return
         }
         }
 
 
-        let inCallURL = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
         let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
         let leftByURL = url.map { isZoomMeetingLeaveOrEndURL($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
         let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
-        let inCall = inCallURL && !leftByURL
-
-        if inCall {
+        let detectedInMeeting = isEmbeddedZoomActiveMeeting(url: url, webViewTitle: webViewTitle)
+        if detectedInMeeting {
             embeddedMeetingReachedInCallPage = true
             embeddedMeetingReachedInCallPage = true
         }
         }
-        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
+        // Stay "in meeting" across mute UI changes until an explicit leave URL/title.
+        let inActiveMeeting = detectedInMeeting
+            || (embeddedMeetingReachedInCallPage && !leftByURL && !leftByTitle)
+
+        if inActiveMeeting {
+            startPendingEmbeddedRecordingIfNeeded()
+            if MeetingRecordingManager.shared.isRecording {
+                pollEmbeddedZoomMicMuteState()
+            }
+        }
+        MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inActiveMeeting)
 
 
         let shouldStopNow = leftByURL || leftByTitle
         let shouldStopNow = leftByURL || leftByTitle
-            || (embeddedMeetingReachedInCallPage && !inCall)
         if shouldStopNow {
         if shouldStopNow {
-            endZoomMeetingRecordingAutomatically()
+            pendingEmbeddedRecording = nil
+            if MeetingRecordingManager.shared.isRecording {
+                endZoomMeetingRecordingAutomatically()
+            } else {
+                stopEmbeddedMeetingRecordingMonitor()
+                updateAiCompanionRecordingUI()
+            }
+        }
+    }
+
+    @MainActor
+    private func startPendingEmbeddedRecordingIfNeeded() {
+        guard let pending = pendingEmbeddedRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording == false else {
+            pendingEmbeddedRecording = nil
+            return
+        }
+        pendingEmbeddedRecording = nil
+        Task { @MainActor in
+            do {
+                MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: true)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: pending.sourceURL,
+                    meetingDisplayName: pending.meetingDisplayName
+                )
+                self.updateAiCompanionRecordingUI()
+            } catch {
+                self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
+                self.updateAiCompanionRecordingUI()
+            }
         }
         }
     }
     }
 
 
     @MainActor
     @MainActor
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
     private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
         stopEmbeddedMeetingRecordingMonitor()
         stopEmbeddedMeetingRecordingMonitor()
-        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard MeetingRecordingManager.shared.isRecording || pendingEmbeddedRecording != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         guard embeddedBrowserWebView != nil else { return }
         embeddedMeetingReachedInCallPage = false
         embeddedMeetingReachedInCallPage = false
         let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
         let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
             guard let self else { return }
             guard let self else { return }
             let webView = self.embeddedBrowserWebView
             let webView = self.embeddedBrowserWebView
             self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
             self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
+            if MeetingRecordingManager.shared.isRecording {
+                self.pollEmbeddedZoomMicMuteState()
+            }
         }
         }
         RunLoop.main.add(timer, forMode: .common)
         RunLoop.main.add(timer, forMode: .common)
         embeddedMeetingRecordingMonitorTimer = timer
         embeddedMeetingRecordingMonitorTimer = timer
@@ -2198,22 +2330,58 @@ class ViewController: NSViewController {
         embeddedMeetingRecordingMonitorTimer?.invalidate()
         embeddedMeetingRecordingMonitorTimer?.invalidate()
         embeddedMeetingRecordingMonitorTimer = nil
         embeddedMeetingRecordingMonitorTimer = nil
         embeddedMeetingReachedInCallPage = false
         embeddedMeetingReachedInCallPage = false
+        pendingEmbeddedRecording = nil
+    }
+
+    @MainActor
+    private func pollEmbeddedZoomMicMuteState() {
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard let webView = embeddedBrowserWebView else { return }
+        webView.evaluateJavaScript(Self.embeddedZoomMicStatePollJavaScript) { result, _ in
+            guard let muted = Self.parseEmbeddedZoomMicMuted(from: result) else { return }
+            Task { @MainActor in
+                MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
+            }
+        }
+    }
+
+    private static func parseEmbeddedZoomMicMuted(from result: Any?) -> Bool? {
+        if let dict = result as? [String: Any], let muted = dict["muted"] as? Bool {
+            return muted
+        }
+        if let muted = result as? Bool {
+            return muted
+        }
+        return nil
+    }
+
+    @MainActor
+    private func applyEmbeddedZoomMicStateMessage(_ body: Any) {
+        guard MeetingRecordingManager.shared.isRecording else { return }
+        guard let muted = Self.parseEmbeddedZoomMicMuted(from: body) else { return }
+        MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
     }
     }
 
 
     @MainActor
     @MainActor
     private func endZoomMeetingRecordingAutomatically() {
     private func endZoomMeetingRecordingAutomatically() {
         guard MeetingRecordingManager.shared.isRecording else { return }
         guard MeetingRecordingManager.shared.isRecording else { return }
+        guard endMeetingRecordingStopInFlight == false else { return }
+        endMeetingRecordingStopInFlight = true
         stopEmbeddedMeetingRecordingMonitor()
         stopEmbeddedMeetingRecordingMonitor()
         Task { @MainActor in
         Task { @MainActor in
-            guard MeetingRecordingManager.shared.isRecording else { return }
+            defer { self.endMeetingRecordingStopInFlight = false }
             do {
             do {
                 try await MeetingRecordingManager.shared.stopRecording()
                 try await MeetingRecordingManager.shared.stopRecording()
-                self.recordedMeetings = MeetingRecordingManager.shared.recordings
-                self.renderAiCompanionList()
-                self.updateAiCompanionRecordingUI()
             } catch {
             } catch {
                 NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
                 NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
+                self.showSimpleAlert(
+                    title: "Recording could not be saved",
+                    message: error.localizedDescription
+                )
             }
             }
+            self.recordedMeetings = MeetingRecordingManager.shared.recordings
+            self.renderAiCompanionList()
+            self.updateAiCompanionRecordingUI()
         }
         }
     }
     }
 
 
@@ -2221,15 +2389,24 @@ class ViewController: NSViewController {
     private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
     private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard MeetingRecordingManager.shared.isRecording == false else { return }
         guard MeetingRecordingManager.shared.isRecording == false else { return }
+        let usesEmbeddedWebClient = embeddedBrowserWebView != nil
+            && (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
+
         Task { @MainActor in
         Task { @MainActor in
             do {
             do {
-                try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
+                MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: usesEmbeddedWebClient)
+                try await MeetingRecordingManager.shared.startRecording(
+                    sourceURL: sourceURL,
+                    meetingDisplayName: meetingDisplayName
+                )
+                MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(false)
+                self.pendingEmbeddedRecording = nil
                 self.updateAiCompanionRecordingUI()
                 self.updateAiCompanionRecordingUI()
-                if self.embeddedBrowserWebView != nil,
-                   (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
+                if usesEmbeddedWebClient {
                     self.startEmbeddedMeetingRecordingMonitorIfNeeded()
                     self.startEmbeddedMeetingRecordingMonitorIfNeeded()
                 }
                 }
             } catch {
             } catch {
+                self.pendingEmbeddedRecording = nil
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
             }
             }
         }
         }
@@ -2602,15 +2779,29 @@ class ViewController: NSViewController {
         guard storeKitCoordinator.hasPremiumAccess else { return }
         guard storeKitCoordinator.hasPremiumAccess else { return }
         let now = Date()
         let now = Date()
         if let until = meetingsRateLimitedUntil, now < until {
         if let until = meetingsRateLimitedUntil, now < until {
+            Task { @MainActor in
+                self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                self.applyFilteredMeetings()
+            }
             return
             return
         }
         }
-        if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
+        if now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
             return
             return
         }
         }
         lastMeetingsRefreshAt = now
         lastMeetingsRefreshAt = now
         Task { await self.loadScheduledMeetings() }
         Task { await self.loadScheduledMeetings() }
     }
     }
 
 
+    @MainActor
+    private func meetingsListStatusMessage() -> String {
+        if let until = meetingsRateLimitedUntil, Date() < until {
+            let minutes = max(1, Int(ceil(until.timeIntervalSince(Date()) / 60.0)))
+            return "Zoom rate limit reached. Please try again in \(minutes) min."
+        }
+        return "Upcoming meetings"
+    }
+
     private func clearMeetingsScrollObserver() {
     private func clearMeetingsScrollObserver() {
         if let meetingsScrollObserver {
         if let meetingsScrollObserver {
             NotificationCenter.default.removeObserver(meetingsScrollObserver)
             NotificationCenter.default.removeObserver(meetingsScrollObserver)
@@ -2891,7 +3082,7 @@ class ViewController: NSViewController {
             return
             return
         }
         }
         lastScrollEdgeRefreshAt = now
         lastScrollEdgeRefreshAt = now
-        triggerMeetingsRefresh(force: true)
+        triggerMeetingsRefresh(force: false)
     }
     }
 
 
     private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
     private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
@@ -3018,7 +3209,9 @@ class ViewController: NSViewController {
 
 
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer?.invalidate()
         meetingsRefreshTimer = nil
         meetingsRefreshTimer = nil
-        meetingsRateLimitedUntil = nil
+        if loggedIn == false {
+            meetingsRateLimitedUntil = nil
+        }
         if loggedIn {
         if loggedIn {
             if storeKitCoordinator.hasPremiumAccess {
             if storeKitCoordinator.hasPremiumAccess {
                 startMeetingsAutoRefresh()
                 startMeetingsAutoRefresh()
@@ -3147,10 +3340,10 @@ class ViewController: NSViewController {
         if ordered.isEmpty {
         if ordered.isEmpty {
             emptyMeetingLabel?.isHidden = showingHomeSection == false
             emptyMeetingLabel?.isHidden = showingHomeSection == false
             if source.isEmpty {
             if source.isEmpty {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
                 emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
                 emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
             } else {
             } else {
-                meetingsStatusLabel?.stringValue = "Upcoming meetings"
+                meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
                 emptyMeetingLabel?.stringValue = "No meetings match your search."
                 emptyMeetingLabel?.stringValue = "No meetings match your search."
             }
             }
             applySchedulerMeetingsForSelectedDate()
             applySchedulerMeetingsForSelectedDate()
@@ -3159,7 +3352,7 @@ class ViewController: NSViewController {
         }
         }
 
 
         emptyMeetingLabel?.isHidden = true
         emptyMeetingLabel?.isHidden = true
-        meetingsStatusLabel?.stringValue = "Upcoming meetings"
+        meetingsStatusLabel?.stringValue = meetingsListStatusMessage()
         for meeting in ordered {
         for meeting in ordered {
             let card = makeMeetingRowCard(meeting)
             let card = makeMeetingRowCard(meeting)
             stack.addArrangedSubview(card)
             stack.addArrangedSubview(card)
@@ -3667,7 +3860,25 @@ class ViewController: NSViewController {
             }
             }
         } catch {
         } catch {
             await MainActor.run {
             await MainActor.run {
-                self.applyMeetings([])
+                let preserveCachedMeetings: Bool
+                if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
+                    let seconds = max(retryAfterSeconds ?? 300, 30)
+                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
+                    preserveCachedMeetings = true
+                } else if case ZoomOAuthError.missingClientId = error {
+                    preserveCachedMeetings = false
+                } else if case ZoomOAuthError.missingClientSecret = error {
+                    preserveCachedMeetings = false
+                } else if case ZoomOAuthError.missingRequiredScope = error {
+                    preserveCachedMeetings = false
+                } else {
+                    preserveCachedMeetings = self.allScheduledMeetings.isEmpty == false
+                }
+
+                if preserveCachedMeetings == false {
+                    self.applyMeetings([])
+                }
+
                 if case ZoomOAuthError.missingClientId = error {
                 if case ZoomOAuthError.missingClientId = error {
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
@@ -3680,15 +3891,17 @@ class ViewController: NSViewController {
                     self.zoomOAuth.clearSavedTokens()
                     self.zoomOAuth.clearSavedTokens()
                     self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
                     self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
                     self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
-                } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
-                    let seconds = max(retryAfterSeconds ?? 300, 30)
-                    self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
-                    let minutes = Int(ceil(Double(seconds) / 60.0))
-                    self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
-                    self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
+                } else if case ZoomOAuthError.rateLimited = error {
+                    self.meetingsStatusLabel?.stringValue = self.meetingsListStatusMessage()
+                    self.meetingsPageStatusLabel?.stringValue = self.meetingsListStatusMessage()
                 } else {
                 } else {
-                    self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
-                    self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
+                    self.meetingsStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
+                    self.meetingsPageStatusLabel?.stringValue = "Unable to refresh meetings. Showing last loaded list."
+                }
+
+                if preserveCachedMeetings {
+                    self.applyFilteredMeetings()
+                    self.applyMeetingsPageFilters(resetVisible: false)
                 }
                 }
             }
             }
         }
         }
@@ -3941,7 +4154,7 @@ class ViewController: NSViewController {
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
             var items: [URLQueryItem] = [
             var items: [URLQueryItem] = [
                 URLQueryItem(name: "type", value: "scheduled"),
                 URLQueryItem(name: "type", value: "scheduled"),
-                URLQueryItem(name: "page_size", value: "30")
+                URLQueryItem(name: "page_size", value: "300")
             ]
             ]
             if let nextPageToken, nextPageToken.isEmpty == false {
             if let nextPageToken, nextPageToken.isEmpty == false {
                 items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
                 items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
@@ -6740,12 +6953,19 @@ class ViewController: NSViewController {
         embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
         embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
         let meetingLifecycleController = WKUserContentController()
         let meetingLifecycleController = WKUserContentController()
         meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
         meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
+        meetingLifecycleController.add(self, name: Self.embeddedZoomMicStateHandlerName)
         let lifecycleScript = WKUserScript(
         let lifecycleScript = WKUserScript(
             source: Self.embeddedZoomMeetingLifecycleJavaScript,
             source: Self.embeddedZoomMeetingLifecycleJavaScript,
             injectionTime: .atDocumentEnd,
             injectionTime: .atDocumentEnd,
             forMainFrameOnly: true
             forMainFrameOnly: true
         )
         )
         meetingLifecycleController.addUserScript(lifecycleScript)
         meetingLifecycleController.addUserScript(lifecycleScript)
+        let micStateScript = WKUserScript(
+            source: Self.embeddedZoomMicStateJavaScript,
+            injectionTime: .atDocumentEnd,
+            forMainFrameOnly: true
+        )
+        meetingLifecycleController.addUserScript(micStateScript)
         embeddedBrowserConfiguration.userContentController = meetingLifecycleController
         embeddedBrowserConfiguration.userContentController = meetingLifecycleController
         let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
         let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
         embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
         embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
@@ -7585,6 +7805,11 @@ class ViewController: NSViewController {
                 "Recording… Stops automatically when you leave or the meeting ends."
                 "Recording… Stops automatically when you leave or the meeting ends."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
             aiCompanionStatusLabel?.textColor = secondaryText
+        } else if pendingEmbeddedRecording != nil {
+            aiCompanionStatusLabel?.stringValue =
+                "Waiting to join… Recording starts when you enter the meeting."
+            aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
+            aiCompanionStatusLabel?.textColor = secondaryText
         } else {
         } else {
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.stringValue = "Previous meetings"
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
@@ -7754,7 +7979,12 @@ class ViewController: NSViewController {
             let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
             let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
             return (msg, secondaryText)
             return (msg, secondaryText)
         case .ready:
         case .ready:
-            return ("Notes ready", secondaryText)
+            if let notes = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), notes.isEmpty == false {
+                let preview = String(notes.prefix(120))
+                let suffix = notes.count > 120 ? "…" : ""
+                return ("Notes: \(preview)\(suffix)", secondaryText)
+            }
+            return ("Notes ready — tap View Notes", secondaryText)
         case .failed:
         case .failed:
             let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
             let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
             if err.isEmpty {
             if err.isEmpty {
@@ -8099,6 +8329,22 @@ class ViewController: NSViewController {
         localRecordingNotesTaskByRecordingId[recordingId] = task
         localRecordingNotesTaskByRecordingId[recordingId] = task
     }
     }
 
 
+    @MainActor
+    private func autoGenerateMeetingNotesIfNeeded(forRecordingId recordingId: String) {
+        recordedMeetings = MeetingRecordingManager.shared.recordings
+        guard let recording = recordedMeetings.first(where: { $0.id == recordingId }) else { return }
+        guard localRecordingNotesStatus(for: recording) == .notRequested else { return }
+        let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        guard transcript.isEmpty == false else { return }
+        let requestId = UUID()
+        startLocalRecordingNotesProcessing(
+            recordingId: recording.id,
+            transcript: transcript,
+            requestId: requestId,
+            autoPresentOnReady: false
+        )
+    }
+
     @objc private func recordingGetNotesTapped(_ sender: NSButton) {
     @objc private func recordingGetNotesTapped(_ sender: NSButton) {
         let idx = sender.tag
         let idx = sender.tag
         guard recordedMeetings.indices.contains(idx) else { return }
         guard recordedMeetings.indices.contains(idx) else { return }
@@ -9690,11 +9936,19 @@ extension ViewController: EmbeddedOAuthPresenting {}
 
 
 extension ViewController: WKScriptMessageHandler {
 extension ViewController: WKScriptMessageHandler {
     func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
     func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
-        guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
-        guard let href = message.body as? String, let url = URL(string: href) else { return }
-        Task { @MainActor in
-            let title = self.embeddedBrowserWebView?.title
-            self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
+        switch message.name {
+        case Self.embeddedZoomMeetingLifecycleHandlerName:
+            guard let href = message.body as? String, let url = URL(string: href) else { return }
+            Task { @MainActor in
+                let title = self.embeddedBrowserWebView?.title
+                self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
+            }
+        case Self.embeddedZoomMicStateHandlerName:
+            Task { @MainActor in
+                self.applyEmbeddedZoomMicStateMessage(message.body)
+            }
+        default:
+            break
         }
         }
     }
     }
 }
 }