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

Fix meeting recording to capture all participants and respect Zoom mute.

Prioritize ScreenCaptureKit meeting audio (window/app/display probing) for
embedded Zoom, sync the local mic track with in-meeting mute state, and
improve export so participant audio is primary while the user mic is mixed
only when unmuted.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 2 місяців тому
батько
коміт
1cb17470d8
2 змінених файлів з 359 додано та 87 видалено
  1. 318 69
      zoom_app/MeetingRecordingManager.swift
  2. 41 18
      zoom_app/ViewController.swift

+ 318 - 69
zoom_app/MeetingRecordingManager.swift

@@ -91,6 +91,8 @@ final class MeetingRecordingManager: NSObject {
     private var micRecorder: AVAudioRecorder?
     /// True while `AVAudioRecorder` is actively encoding (unmuted in the meeting).
     private var micRecordingActive: Bool = false
+    /// True only after the user was unmuted in Zoom at least once this session (controls export mix).
+    private var localMicEverActivated: Bool = false
 
     /// System audio capture (ScreenCaptureKit + `AVAssetWriter` AAC, aligned with meetings_app).
     private var systemAudioRecorder: AnyObject?
@@ -207,42 +209,53 @@ final class MeetingRecordingManager: NSObject {
         pendingMeetingDisplayName = displayTitle
         outputAudioURL = outBase
 
-        do {
-            if micGranted {
-                try? prepareMicRecorder(to: micURL)
-            }
-            if #available(macOS 13.0, *) {
+        if micGranted {
+            try? prepareMicRecorder(to: micURL)
+        }
+
+        var systemCaptureStarted = false
+        if #available(macOS 13.0, *) {
+            do {
                 try await ensureScreenCaptureAccess()
                 let target = resolveSystemAudioCaptureTarget()
-                let recorder = ZoomMeetingSystemAudioRecorder(outputURL: sysURL, captureTarget: target)
+                let recorder = try await startSystemAudioRecorder(outputURL: sysURL, primaryTarget: target)
                 systemAudioRecorder = recorder
-                do {
-                    try await recorder.start()
-                } catch {
-                    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 {
+                systemCaptureStarted = true
+            } catch {
                 systemAudioRecorder = nil
+                NSLog("MeetingRecordingManager: system audio unavailable — \(error.localizedDescription)")
             }
-        } catch {
-            stopMicRecorder(discardFile: true)
-            if #available(macOS 13.0, *), let r = systemAudioRecorder as? ZoomMeetingSystemAudioRecorder {
-                try? await r.stop()
+        } else if captureEmbeddedZoomWebClient {
+            if micGranted == false {
+                recordingID = nil
+                recordingStartedAt = nil
+                recordingSourceURLString = nil
+                pendingMeetingDisplayName = nil
+                outputAudioURL = nil
+                throw RecordingError.systemAudioUnavailable("System audio capture requires macOS 13 or later.")
             }
-            systemAudioRecorder = nil
+        }
+
+        // Meeting recording is driven by system/meeting audio (all participants). Local mic is optional.
+        if systemCaptureStarted == false {
+            stopMicRecorder(discardFile: true)
             recordingID = nil
             recordingStartedAt = nil
             recordingSourceURLString = nil
             pendingMeetingDisplayName = nil
             outputAudioURL = nil
-            throw error
+            if micGranted == false {
+                throw RecordingError.microphonePermissionDenied
+            }
+            throw RecordingError.systemAudioUnavailable(
+                "Meeting audio could not be captured. Enable Screen Recording for this app in System Settings, then try again."
+            )
         }
 
+        localMicEverActivated = false
         isRecording = true
+        // Keep the Mac mic paused until Zoom reports the user is unmuted.
+        pauseMicRecording()
         NotificationCenter.default.post(name: Self.recordingStateDidChangeNotification, object: self)
         await MainActor.run {
             self.startAutomaticStopMonitoringOnMain()
@@ -308,6 +321,9 @@ final class MeetingRecordingManager: NSObject {
 
         // Let ScreenCaptureKit / AVAssetWriter finish flushing to disk.
         try? await Task.sleep(nanoseconds: 1_000_000_000)
+        if systemSampleCount > 0, hasUsableAudio(at: sysURL) == false {
+            try? await Task.sleep(nanoseconds: 1_500_000_000)
+        }
 
         if systemSampleCount == 0 {
             NSLog("MeetingRecordingManager: warning — system audio capture received 0 samples.")
@@ -316,7 +332,8 @@ final class MeetingRecordingManager: NSObject {
         let exportResult = await exportMeetingAudio(
             systemURL: sysURL,
             microphoneURL: micURL,
-            outputURL: outURL
+            outputURL: outURL,
+            systemSamplesCaptured: systemSampleCount
         )
 
         // Cleanup intermediates (ignore failures).
@@ -324,20 +341,38 @@ final class MeetingRecordingManager: NSObject {
         try? FileManager.default.removeItem(at: sysURL)
 
         guard let savedURL = exportResult.url else {
+            let sysBytes = fileSize(at: sysURL)
+            let micBytes = fileSize(at: micURL)
             NSLog(
                 "MeetingRecordingManager: export failed — system %lld bytes, mic %lld bytes, out %lld bytes",
-                fileSize(at: sysURL),
-                fileSize(at: micURL),
-                fileSize(at: outURL)
+                sysBytes, micBytes, fileSize(at: outURL)
             )
-            throw RecordingError.exportFailed
+            if sysBytes < 128, micBytes < 128 {
+                throw RecordingError.exportFailed
+            }
+            // Keep a minimal entry so the session is not silently lost.
+            let rec = RecordedMeeting(
+                id: id,
+                startedAt: startedAt,
+                endedAt: endedAt,
+                sourceURLString: sourceURLString,
+                meetingDisplayName: displayName,
+                audioFilename: outURL.lastPathComponent,
+                transcriptText: nil,
+                transcriptError: "Recording could not be exported. Check Screen Recording and Microphone permissions, then try again.",
+                notesStatusRaw: nil,
+                notesText: nil,
+                notesError: nil
+            )
+            recordings.insert(rec, at: 0)
+            saveRecordingsToDisk()
+            NotificationCenter.default.post(name: Self.recordingsDidChangeNotification, object: self)
+            return
         }
 
         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."
+        if systemSampleCount == 0 {
+            transcriptWarning = "Meeting audio may be incomplete. Keep Screen Recording enabled and stay in the meeting until it ends."
         }
 
         let rec = RecordedMeeting(
@@ -364,9 +399,19 @@ final class MeetingRecordingManager: NSObject {
         let transcriptWarning: String?
     }
 
-    private func exportMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async -> MeetingAudioExportResult {
+    private func exportMeetingAudio(
+        systemURL: URL,
+        microphoneURL: URL,
+        outputURL: URL,
+        systemSamplesCaptured: Int = 0
+    ) async -> MeetingAudioExportResult {
         do {
-            let url = try await finalizeMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL)
+            let url = try await finalizeMeetingAudio(
+                systemURL: systemURL,
+                microphoneURL: microphoneURL,
+                outputURL: outputURL,
+                systemSamplesCaptured: systemSamplesCaptured
+            )
             return MeetingAudioExportResult(url: url, transcriptWarning: nil)
         } catch {
             if let salvaged = salvageMeetingAudio(systemURL: systemURL, microphoneURL: microphoneURL, outputURL: outputURL) {
@@ -408,6 +453,7 @@ final class MeetingRecordingManager: NSObject {
         outputAudioURL = nil
         micRecorder = nil
         micRecordingActive = false
+        localMicEverActivated = false
         systemAudioRecorder = nil
         captureEmbeddedZoomWebClient = false
         let wasRecording = isRecording
@@ -423,7 +469,6 @@ final class MeetingRecordingManager: NSObject {
         stopAutomaticStopMonitoringOnMain()
         automaticStopConsecutiveMisses = 0
         automaticStopHaveSeenCallUI = false
-        embeddedWebClientCallUIVisible = false
 
         automaticStopWorkspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
             forName: NSWorkspace.didTerminateApplicationNotification,
@@ -592,17 +637,122 @@ 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 {
+            if #available(macOS 14.0, *) {
+                return .hostMeetingWindow
+            }
+            return .hostApplication
+        }
+        return .display(excludesCurrentProcessAudio: true)
+    }
+
+    @available(macOS 13.0, *)
+    private func embeddedSystemAudioCaptureTargets() -> [ZoomMeetingSystemAudioRecorder.CaptureTarget] {
+        var targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget] = [
+            .hostApplication,
+            .display(excludesCurrentProcessAudio: false),
+            .display(excludesCurrentProcessAudio: true)
+        ]
+        if #available(macOS 14.0, *) {
+            targets.insert(.hostMeetingWindow, at: 0)
+        }
+        return targets
+    }
+
+    @available(macOS 13.0, *)
+    private func fallbackSystemAudioCaptureTarget(after primary: ZoomMeetingSystemAudioRecorder.CaptureTarget)
+        -> ZoomMeetingSystemAudioRecorder.CaptureTarget?
+    {
+        switch primary {
+        case .hostMeetingWindow:
+            return .hostApplication
+        case .hostApplication:
             return .display(excludesCurrentProcessAudio: false)
+        case .display(excludesCurrentProcessAudio: false):
+            return .display(excludesCurrentProcessAudio: true)
+        case .display(excludesCurrentProcessAudio: true), .zoomDesktop:
+            return nil
         }
-        if zoomRunning {
-            return .zoomDesktop
+    }
+
+    /// Briefly tries each capture mode and picks the one that receives the most meeting audio samples.
+    @available(macOS 13.0, *)
+    private func probeBestSystemAudioCaptureTarget(
+        in directory: URL,
+        targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget]
+    ) async -> ZoomMeetingSystemAudioRecorder.CaptureTarget? {
+        var bestTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget?
+        var bestSamples = 0
+
+        for target in targets {
+            let probeURL = directory.appendingPathComponent("probe-\(UUID().uuidString).m4a")
+            let recorder = ZoomMeetingSystemAudioRecorder(outputURL: probeURL, captureTarget: target)
+            do {
+                try await recorder.start()
+                try await Task.sleep(nanoseconds: 2_500_000_000)
+                let samples = recorder.capturedSampleCount
+                try await recorder.stop()
+                try? FileManager.default.removeItem(at: probeURL)
+                if samples > bestSamples {
+                    bestSamples = samples
+                    bestTarget = target
+                }
+            } catch {
+                try? FileManager.default.removeItem(at: probeURL)
+                NSLog("MeetingRecordingManager: probe failed for \(target) — \(error.localizedDescription)")
+            }
         }
-        return .display(excludesCurrentProcessAudio: false)
+
+        if let bestTarget {
+            NSLog("MeetingRecordingManager: probed meeting audio — \(bestSamples) samples with \(bestTarget).")
+        }
+        return bestTarget
+    }
+
+    @available(macOS 13.0, *)
+    private func startSystemAudioRecorder(
+        outputURL: URL,
+        primaryTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget
+    ) async throws -> ZoomMeetingSystemAudioRecorder {
+        let targets: [ZoomMeetingSystemAudioRecorder.CaptureTarget]
+        if captureEmbeddedZoomWebClient {
+            targets = embeddedSystemAudioCaptureTargets()
+        } else {
+            var chain: [ZoomMeetingSystemAudioRecorder.CaptureTarget] = [primaryTarget]
+            var cursor = primaryTarget
+            while let next = fallbackSystemAudioCaptureTarget(after: cursor) {
+                chain.append(next)
+                cursor = next
+            }
+            targets = chain
+        }
+
+        let chosenTarget: ZoomMeetingSystemAudioRecorder.CaptureTarget
+        if captureEmbeddedZoomWebClient, targets.count > 1 {
+            let directory = outputURL.deletingLastPathComponent()
+            chosenTarget = await probeBestSystemAudioCaptureTarget(in: directory, targets: targets)
+                ?? targets[0]
+        } else {
+            chosenTarget = targets[0]
+        }
+
+        var target = chosenTarget
+        var lastError: Error?
+        while true {
+            let recorder = ZoomMeetingSystemAudioRecorder(outputURL: outputURL, captureTarget: target)
+            do {
+                try await recorder.start()
+                return recorder
+            } catch {
+                lastError = error
+                guard let next = fallbackSystemAudioCaptureTarget(after: target) else { break }
+                NSLog("MeetingRecordingManager: system audio start failed for \(target); trying \(next).")
+                target = next
+            }
+        }
+        throw lastError ?? RecordingError.systemAudioUnavailable(
+            "Could not start system audio capture. Allow Screen Recording for this app in System Settings."
+        )
     }
 
     @available(macOS 13.0, *)
@@ -611,6 +761,11 @@ final class MeetingRecordingManager: NSObject {
             CGRequestScreenCaptureAccess()
             try? await Task.sleep(nanoseconds: 800_000_000)
         }
+        guard CGPreflightScreenCaptureAccess() else {
+            throw RecordingError.systemAudioUnavailable(
+                "Allow Screen Recording for this app in System Settings → Privacy & Security → Screen Recording."
+            )
+        }
         do {
             _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
         } catch {
@@ -645,6 +800,7 @@ final class MeetingRecordingManager: NSObject {
             return
         }
         micRecordingActive = true
+        localMicEverActivated = true
     }
 
     private func pauseMicRecording() {
@@ -661,7 +817,8 @@ final class MeetingRecordingManager: NSObject {
         if shouldFinalize {
             recorder.stop()
         }
-        if discardFile || fileSize(at: url) < 256 {
+        let dropMicTrack = discardFile || localMicEverActivated == false || fileSize(at: url) < 256
+        if dropMicTrack {
             try? FileManager.default.removeItem(at: url)
         }
         micRecorder = nil
@@ -687,21 +844,30 @@ final class MeetingRecordingManager: NSObject {
         return size >= 512
     }
 
-    private func finalizeMeetingAudio(systemURL: URL, microphoneURL: URL, outputURL: URL) async throws -> URL {
+    private func finalizeMeetingAudio(
+        systemURL: URL,
+        microphoneURL: URL,
+        outputURL: URL,
+        systemSamplesCaptured: Int = 0
+    ) async throws -> URL {
         let fm = FileManager.default
         if fm.fileExists(atPath: outputURL.path) {
             try? fm.removeItem(at: outputURL)
         }
 
-        let hasSystem = hasUsableAudio(at: systemURL)
-        let hasMic = hasUsableAudio(at: microphoneURL)
+        var hasSystem = hasUsableAudio(at: systemURL)
+        if hasSystem == false, systemSamplesCaptured > 0, fileSize(at: systemURL) >= 64 {
+            hasSystem = true
+        }
+        // Meeting mix is primary; local mic is merged only if the user spoke while unmuted in Zoom.
+        let hasMic = localMicEverActivated && hasUsableAudio(at: microphoneURL)
 
         if hasSystem && hasMic {
             do {
                 try await mixAudioFiles(systemURL: systemURL, microphoneURL: microphoneURL, destinationURL: outputURL)
                 return outputURL
             } catch {
-                // Fall through to single-track handling.
+                NSLog("MeetingRecordingManager: mix failed — \(error.localizedDescription); exporting meeting audio only.")
             }
         }
 
@@ -732,38 +898,47 @@ final class MeetingRecordingManager: NSObject {
     private func mixAudioFiles(systemURL: URL, microphoneURL: URL, destinationURL: URL) async throws {
         try? FileManager.default.removeItem(at: destinationURL)
 
-        let composition = AVMutableComposition()
-        guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid),
-              let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
-            throw RecordingError.exportFailed
-        }
-
         let systemAsset = AVURLAsset(url: systemURL)
         let micAsset = AVURLAsset(url: microphoneURL)
 
-        if let src = systemAsset.tracks(withMediaType: .audio).first {
-            let duration = systemAsset.duration
-            if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
-                try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
-            }
+        let systemSourceTracks = try await systemAsset.loadTracks(withMediaType: .audio)
+        guard let systemSource = systemSourceTracks.first else {
+            throw RecordingError.exportFailed
         }
-        if let src = micAsset.tracks(withMediaType: .audio).first {
-            let duration = micAsset.duration
-            if duration.isValid && !duration.isIndefinite && duration.seconds > 0 {
-                try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: duration), of: src, at: .zero)
-            }
+        let systemDuration = try await systemAsset.load(.duration)
+        guard systemDuration.isValid, !systemDuration.isIndefinite, systemDuration.seconds > 0 else {
+            throw RecordingError.exportFailed
         }
 
-        guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
+        let composition = AVMutableComposition()
+        guard let systemTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) else {
             throw RecordingError.exportFailed
         }
+        try systemTrack.insertTimeRange(CMTimeRange(start: .zero, duration: systemDuration), of: systemSource, at: .zero)
 
+        var mixParameters: [AVMutableAudioMixInputParameters] = []
         let systemMix = AVMutableAudioMixInputParameters(track: systemTrack)
         systemMix.setVolume(1.0, at: .zero)
-        let micMix = AVMutableAudioMixInputParameters(track: micTrack)
-        micMix.setVolume(0.9, at: .zero)
+        mixParameters.append(systemMix)
+
+        let micSourceTracks = try await micAsset.loadTracks(withMediaType: .audio)
+        if let micSource = micSourceTracks.first {
+            let micDuration = try await micAsset.load(.duration)
+            if micDuration.isValid, !micDuration.isIndefinite, micDuration.seconds > 0,
+               let micTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
+                try micTrack.insertTimeRange(CMTimeRange(start: .zero, duration: micDuration), of: micSource, at: .zero)
+                let micMix = AVMutableAudioMixInputParameters(track: micTrack)
+                micMix.setVolume(0.55, at: .zero)
+                mixParameters.append(micMix)
+            }
+        }
+
+        guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
+            throw RecordingError.exportFailed
+        }
+
         let audioMix = AVMutableAudioMix()
-        audioMix.inputParameters = [systemMix, micMix]
+        audioMix.inputParameters = mixParameters
 
         export.outputURL = destinationURL
         export.outputFileType = .m4a
@@ -846,6 +1021,8 @@ final class MeetingRecordingManager: NSObject {
 @available(macOS 13.0, *)
 private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
     enum CaptureTarget {
+        /// Embedded Zoom `WKWebView` window (macOS 14+).
+        case hostMeetingWindow
         case hostApplication
         case zoomDesktop
         case display(excludesCurrentProcessAudio: Bool)
@@ -887,41 +1064,113 @@ private final class ZoomMeetingSystemAudioRecorder: NSObject, SCStreamOutput, SC
         config.capturesAudio = true
         config.sampleRate = 48_000
         config.channelCount = 2
+        config.queueDepth = 16
         switch captureTarget {
-        case .hostApplication, .display(excludesCurrentProcessAudio: false):
+        case .hostMeetingWindow, .hostApplication, .display(excludesCurrentProcessAudio: false):
             config.excludesCurrentProcessAudio = false
         case .zoomDesktop:
             config.excludesCurrentProcessAudio = true
         case .display(excludesCurrentProcessAudio: true):
             config.excludesCurrentProcessAudio = true
         }
+        if #available(macOS 15.0, *) {
+            config.captureMicrophone = false
+        }
 
         let stream = SCStream(filter: filter, configuration: config, delegate: self)
         self.stream = stream
         try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
+        // Some macOS versions only deliver meeting audio when a screen output is also attached.
+        try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: outputQueue)
         try await stream.startCapture()
     }
 
     private func makeContentFilter(from content: SCShareableContent) throws -> SCContentFilter {
-        guard let display = content.displays.first else {
+        guard let display = Self.preferredDisplay(from: content, captureTarget: captureTarget) else {
             throw MeetingRecordingManager.RecordingError.noShareableContent
         }
         let emptyWindows: [SCWindow] = []
+        let hostBundleID = Bundle.main.bundleIdentifier ?? ""
         switch captureTarget {
+        case .hostMeetingWindow:
+            if #available(macOS 14.0, *), let window = Self.largestHostWindow(from: content) {
+                return SCContentFilter(desktopIndependentWindow: window)
+            }
+            NSLog("ZoomMeetingSystemAudioRecorder: no host meeting window; falling back to app audio.")
+            fallthrough
         case .hostApplication:
-            if let app = content.applications.first(where: { $0.bundleIdentifier == Bundle.main.bundleIdentifier }) {
+            if let app = content.applications.first(where: { $0.bundleIdentifier == hostBundleID }) {
                 return SCContentFilter(display: display, including: [app], exceptingWindows: emptyWindows)
             }
+            NSLog("ZoomMeetingSystemAudioRecorder: host app not listed in shareable content; using full display audio.")
         case .zoomDesktop:
             if let zoom = content.applications.first(where: { $0.bundleIdentifier == Self.zoomDesktopBundleID }) {
                 return SCContentFilter(display: display, including: [zoom], exceptingWindows: emptyWindows)
             }
+            NSLog("ZoomMeetingSystemAudioRecorder: Zoom desktop not listed; using full display audio.")
         case .display:
             break
         }
         return SCContentFilter(display: display, excludingApplications: [], exceptingWindows: emptyWindows)
     }
 
+    /// Prefer the display showing Zoom or this app instead of an arbitrary `displays.first` (often wrong on multi-monitor setups).
+    private static func preferredDisplay(
+        from content: SCShareableContent,
+        captureTarget: CaptureTarget
+    ) -> SCDisplay? {
+        let displays = content.displays
+        guard displays.isEmpty == false else { return nil }
+
+        let mainDisplayID = CGMainDisplayID()
+        if let main = displays.first(where: { $0.displayID == mainDisplayID }) {
+            return main
+        }
+
+        let bundleIDsToMatch: Set<String>
+        switch captureTarget {
+        case .hostMeetingWindow, .hostApplication:
+            bundleIDsToMatch = [Bundle.main.bundleIdentifier ?? ""]
+        case .zoomDesktop:
+            bundleIDsToMatch = [zoomDesktopBundleID]
+        case .display:
+            bundleIDsToMatch = [zoomDesktopBundleID, Bundle.main.bundleIdentifier ?? ""]
+        }
+
+        for window in content.windows where window.isOnScreen {
+            guard let ownerBundleID = window.owningApplication?.bundleIdentifier,
+                  bundleIDsToMatch.contains(ownerBundleID) else { continue }
+            let center = CGPoint(x: window.frame.midX, y: window.frame.midY)
+            if let match = displayContaining(point: center, in: displays) {
+                return match
+            }
+        }
+
+        return displays.first
+    }
+
+    private static func displayContaining(point: CGPoint, in displays: [SCDisplay]) -> SCDisplay? {
+        for display in displays {
+            let bounds = CGDisplayBounds(display.displayID)
+            if bounds.contains(point) {
+                return display
+            }
+        }
+        return nil
+    }
+
+    /// Largest on-screen window owned by this app (embedded Zoom web client).
+    private static func largestHostWindow(from content: SCShareableContent) -> SCWindow? {
+        let hostBundleID = Bundle.main.bundleIdentifier ?? ""
+        return content.windows
+            .filter { $0.isOnScreen && $0.owningApplication?.bundleIdentifier == hostBundleID }
+            .max { lhs, rhs in
+                let lArea = lhs.frame.width * lhs.frame.height
+                let rArea = rhs.frame.width * rhs.frame.height
+                return lArea < rArea
+            }
+    }
+
     func stop() async throws {
         if let stream {
             try? await stream.stopCapture()

+ 41 - 18
zoom_app/ViewController.swift

@@ -74,14 +74,17 @@ class ViewController: NSViewController {
           || label === 'mute' || label === 'unmute';
       }
       function readMicMuted() {
-        var nodes = document.querySelectorAll('button,[role="button"]');
+        var nodes = document.querySelectorAll('button,[role="button"],[role="switch"]');
         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();
+          var label = (el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('data-tooltip') || '').toLowerCase().trim();
           if (!isMicrophoneControl(label)) continue;
           if (label.indexOf('unmute') !== -1) return true;
           if (label.indexOf('mute') !== -1) return false;
+          var pressed = (el.getAttribute('aria-pressed') || '').toLowerCase();
+          if (pressed === 'true') return true;
+          if (pressed === 'false') return false;
         }
         return null;
       }
@@ -123,14 +126,17 @@ class ViewController: NSViewController {
         return label.indexOf('mic') !== -1 || label.indexOf('audio') !== -1
           || label === 'mute' || label === 'unmute';
       }
-      var nodes = document.querySelectorAll('button,[role="button"]');
+      var nodes = document.querySelectorAll('button,[role="button"],[role="switch"]');
       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();
+        var label = (el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('data-tooltip') || '').toLowerCase().trim();
         if (!isMicrophoneControl(label)) continue;
         if (label.indexOf('unmute') !== -1) return { muted: true };
         if (label.indexOf('mute') !== -1) return { muted: false };
+        var pressed = (el.getAttribute('aria-pressed') || '').toLowerCase();
+        if (pressed === 'true') return { muted: true };
+        if (pressed === 'false') return { muted: false };
       }
       return null;
     })();
@@ -2195,15 +2201,24 @@ class ViewController: NSViewController {
     /// 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 }
-        }
+        guard let url, isZoomMeetingLeaveOrEndURL(url) == false else { return false }
+        guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
+        let path = url.path.lowercased()
+        let absolute = url.absoluteString.lowercased()
+        if path.contains("/wc/inmeeting") || path.contains("/wc/video") { return true }
+        if path.contains("/wc/join") { return true }
+        if path.contains("/j/") || path.contains("/join/") || path.contains("/w/") || path.contains("/s/") { return true }
+        if path.contains("/my/") && absolute.contains("meeting") { return true }
+        if path.contains("videomeeting") || path.contains("/start") { return true }
         return false
     }
 
+    /// Any Zoom meeting join/start page loaded in the embedded browser (used to begin capture promptly).
+    private func embeddedWebViewShowsMeetingFlow(url: URL?) -> Bool {
+        guard let url else { return false }
+        return isZoomMeetingJoinOrStartURL(url) || isEmbeddedZoomActiveMeeting(url: url, webViewTitle: nil)
+    }
+
     private func embeddedWebViewTitleIndicatesActiveMeeting(_ title: String) -> Bool {
         let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
         guard t.isEmpty == false else { return false }
@@ -2224,7 +2239,7 @@ class ViewController: NSViewController {
 
         let workplaceHubMarkers = [
             "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
-            "/wc/whiteboard", "/wc/meeting", "/wc/recordings", "/wc/notes",
+            "/wc/whiteboard", "/wc/recordings", "/wc/notes",
             "/wc/contacts", "/wc/scheduler", "/wc/documents", "/wc/apps"
         ]
         if workplaceHubMarkers.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { return true }
@@ -2272,7 +2287,7 @@ class ViewController: NSViewController {
         }
         MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inActiveMeeting)
 
-        let shouldStopNow = leftByURL || leftByTitle
+        let shouldStopNow = (leftByURL || leftByTitle) && embeddedMeetingReachedInCallPage
         if shouldStopNow {
             pendingEmbeddedRecording = nil
             if MeetingRecordingManager.shared.isRecording {
@@ -2299,6 +2314,8 @@ class ViewController: NSViewController {
                     sourceURL: pending.sourceURL,
                     meetingDisplayName: pending.meetingDisplayName
                 )
+                MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(false)
+                self.pollEmbeddedZoomMicMuteState()
                 self.updateAiCompanionRecordingUI()
             } catch {
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
@@ -2338,7 +2355,7 @@ class ViewController: NSViewController {
         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 }
+            let muted = Self.parseEmbeddedZoomMicMuted(from: result) ?? true
             Task { @MainActor in
                 MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
             }
@@ -2358,7 +2375,7 @@ class ViewController: NSViewController {
     @MainActor
     private func applyEmbeddedZoomMicStateMessage(_ body: Any) {
         guard MeetingRecordingManager.shared.isRecording else { return }
-        guard let muted = Self.parseEmbeddedZoomMicMuted(from: body) else { return }
+        let muted = Self.parseEmbeddedZoomMicMuted(from: body) ?? true
         MeetingRecordingManager.shared.setMeetingMicrophoneTransmitting(!muted)
     }
 
@@ -2389,12 +2406,13 @@ class ViewController: NSViewController {
     private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
         guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
         guard MeetingRecordingManager.shared.isRecording == false else { return }
+        // Meeting audio for the embedded Zoom web client is played by this app (WKWebView), not the desktop Zoom app.
         let usesEmbeddedWebClient = embeddedBrowserWebView != nil
-            && (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
+            && isZoomMeetingJoinOrStartURL(sourceURL)
 
         Task { @MainActor in
+            MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: usesEmbeddedWebClient)
             do {
-                MeetingRecordingManager.shared.configureCapture(embeddedZoomWebClient: usesEmbeddedWebClient)
                 try await MeetingRecordingManager.shared.startRecording(
                     sourceURL: sourceURL,
                     meetingDisplayName: meetingDisplayName
@@ -2403,11 +2421,16 @@ class ViewController: NSViewController {
                 self.pendingEmbeddedRecording = nil
                 self.updateAiCompanionRecordingUI()
                 if usesEmbeddedWebClient {
+                    self.embeddedMeetingReachedInCallPage = self.embeddedWebViewShowsMeetingFlow(
+                        url: self.embeddedBrowserWebView?.url
+                    )
                     self.startEmbeddedMeetingRecordingMonitorIfNeeded()
+                    self.pollEmbeddedZoomMicMuteState()
                 }
             } catch {
                 self.pendingEmbeddedRecording = nil
                 self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
+                self.updateAiCompanionRecordingUI()
             }
         }
     }
@@ -7802,12 +7825,12 @@ class ViewController: NSViewController {
         aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
         if mgr.isRecording {
             aiCompanionStatusLabel?.stringValue =
-                "Recording… Stops automatically when you leave or the meeting ends."
+                "Recording meeting audio (all participants). Your mic is added only while unmuted in Zoom."
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
         } else if pendingEmbeddedRecording != nil {
             aiCompanionStatusLabel?.stringValue =
-                "Waiting to join… Recording starts when you enter the meeting."
+                "Starting meeting recording…"
             aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
             aiCompanionStatusLabel?.textColor = secondaryText
         } else {