فهرست منبع

Add local meeting recording support in Ai companion.

Capture consented meeting audio locally, surface saved recordings in Ai companion, and add required microphone/sandbox entitlements for stable playback.

Made-with: Cursor
huzaifahayat12 2 ماه پیش
والد
کامیت
7cec0d41b6
3فایلهای تغییر یافته به همراه336 افزوده شده و 29 حذف شده
  1. 2 0
      Info.plist
  2. 328 29
      meetings_app/ViewController.swift
  3. 6 0
      meetings_app/meetings_app.entitlements

+ 2 - 0
Info.plist

@@ -26,6 +26,8 @@
 	<string>Main</string>
 	<key>NSPrincipalClass</key>
 	<string>NSApplication</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>This app records meeting audio locally with your consent so it can be shown in AI Companion after the meeting.</string>
 	<key>AppLaunchPlaceholderURL</key>
 	<string>https://example.com/app-link-coming-soon</string>
 	<key>AppShareURL</key>

+ 328 - 29
meetings_app/ViewController.swift

@@ -245,6 +245,23 @@ final class ViewController: NSViewController {
         let pictureURL: URL?
     }
 
+    private struct MeetingRecordingSummary: Codable {
+        let id: String
+        let title: String
+        let meetURLString: String
+        let startedAt: Date
+        let endedAt: Date
+        let audioFilePath: String
+    }
+
+    private struct ActiveMeetingRecordingSession {
+        let id: String
+        let title: String
+        let meetURL: URL
+        let startedAt: Date
+        let audioFileURL: URL
+    }
+
     private var palette = Palette(isDarkMode: true)
     private let typography = Typography()
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -277,6 +294,7 @@ final class ViewController: NSViewController {
     private var aiCompanionTranscriptWindow: NSWindow?
     private weak var aiCompanionTranscriptTextView: NSTextView?
     private var aiCompanionAudioPlayer: AVPlayer?
+    private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
     private var aiCompanionCurrentlyPlayingURL: URL?
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
     private var aiCompanionPlaybackEndObserver: NSObjectProtocol?
@@ -379,6 +397,10 @@ final class ViewController: NSViewController {
     private var scheduleProfileImageTask: Task<Void, Never>?
     private var googleAccountPopover: NSPopover?
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
+    private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
+    private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
+    private var activeMeetingAudioRecorder: AVAudioRecorder?
+    private weak var aiCompanionStopRecordingButton: NSButton?
     private let widgetSnapshotLimit: Int = 3
 
     private var schedulePageFilter: SchedulePageFilter = .all
@@ -429,6 +451,7 @@ final class ViewController: NSViewController {
     private let userHasRatedDefaultsKey = "rating.userHasRated"
     private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
     private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
+    private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
     private var darkModeEnabled: Bool {
         get {
@@ -474,6 +497,7 @@ final class ViewController: NSViewController {
     override func viewDidLoad() {
         super.viewDidLoad()
         aiCompanionSpeechSynthesizer.delegate = self
+        loadAiCompanionLocalRecordings()
         // Sync toggle + palette with current macOS appearance on launch.
         darkModeEnabled = systemPrefersDarkMode()
         palette = Palette(isDarkMode: darkModeEnabled)
@@ -489,6 +513,7 @@ final class ViewController: NSViewController {
         migrateLegacyRatingStateIfNeeded()
         beginUsageTrackingSessionIfNeeded()
         observeAppLifecycleForUsageTrackingIfNeeded()
+        observeMeetingRecordingLifecycle()
         registerWidgetNotificationObservers()
         setupRootView()
         buildMainLayout()
@@ -783,6 +808,7 @@ private extension ViewController {
             return
         }
 
+        beginMeetingRecordingIfConsented(meetingTitle: "Quick Join Meeting", meetingURL: url)
         openInDefaultBrowser(url: url)
         consumeNonPremiumJoinTrialIfNeeded()
     }
@@ -1225,6 +1251,156 @@ private extension ViewController {
         alert.runModal()
     }
 
+    private func observeMeetingRecordingLifecycle() {
+        NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(handleWorkspaceAppActivation(_:)),
+            name: NSWorkspace.didActivateApplicationNotification,
+            object: nil
+        )
+    }
+
+    @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
+        guard activeMeetingRecordingSession != nil else { return }
+        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
+        guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
+        promptStopRecordingAfterReturningToApp()
+    }
+
+    private func promptStopRecordingAfterReturningToApp() {
+        guard let session = activeMeetingRecordingSession else { return }
+        let alert = NSAlert()
+        alert.messageText = "Stop meeting recording?"
+        alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
+        alert.addButton(withTitle: "Stop Recording")
+        alert.addButton(withTitle: "Keep Recording")
+        if alert.runModal() == .alertFirstButtonReturn {
+            finishActiveMeetingRecording()
+        }
+    }
+
+    private func requestMeetingRecordingConsent(title: String) -> Bool {
+        let alert = NSAlert()
+        alert.messageText = "Record this meeting locally?"
+        alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
+        alert.addButton(withTitle: "Allow and Continue")
+        alert.addButton(withTitle: "Continue Without Recording")
+        let response = alert.runModal()
+        if response == .alertFirstButtonReturn { return true }
+        return false
+    }
+
+    private func loadAiCompanionLocalRecordings() {
+        guard let raw = UserDefaults.standard.data(forKey: aiCompanionLocalRecordingsDefaultsKey) else {
+            aiCompanionLocalRecordings = []
+            return
+        }
+        let decoder = JSONDecoder()
+        if let decoded = try? decoder.decode([MeetingRecordingSummary].self, from: raw) {
+            aiCompanionLocalRecordings = decoded.sorted(by: { $0.endedAt > $1.endedAt })
+        } else {
+            aiCompanionLocalRecordings = []
+        }
+    }
+
+    private func persistAiCompanionLocalRecordings() {
+        let encoder = JSONEncoder()
+        guard let encoded = try? encoder.encode(aiCompanionLocalRecordings) else { return }
+        UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
+    }
+
+    private func localRecordingDirectoryURL() -> URL {
+        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+        let bundleID = Bundle.main.bundleIdentifier ?? "meetings_app"
+        let directory = base.appendingPathComponent(bundleID, isDirectory: true).appendingPathComponent("MeetingRecordings", isDirectory: true)
+        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
+        return directory
+    }
+
+    private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
+        if activeMeetingRecordingSession != nil {
+            finishActiveMeetingRecording()
+        }
+        guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
+        let permission = AVCaptureDevice.authorizationStatus(for: .audio)
+        switch permission {
+        case .authorized:
+            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+        case .notDetermined:
+            AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
+                DispatchQueue.main.async {
+                    guard let self else { return }
+                    if granted {
+                        self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
+                    } else {
+                        self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
+                    }
+                }
+            }
+        case .denied, .restricted:
+            showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
+        @unknown default:
+            break
+        }
+    }
+
+    private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
+        let recordingID = UUID().uuidString
+        let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
+        let settings: [String: Any] = [
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
+            AVSampleRateKey: 44_100,
+            AVNumberOfChannelsKey: 1,
+            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
+        ]
+
+        do {
+            let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
+            recorder.prepareToRecord()
+            recorder.record()
+            activeMeetingAudioRecorder = recorder
+            activeMeetingRecordingSession = ActiveMeetingRecordingSession(
+                id: recordingID,
+                title: meetingTitle,
+                meetURL: meetingURL,
+                startedAt: Date(),
+                audioFileURL: outputURL
+            )
+            showTopToast(message: "Meeting recording started locally", isError: false)
+            pageCache[.aiCompanion] = nil
+            if selectedSidebarPage == .aiCompanion {
+                showSidebarPage(.aiCompanion)
+            }
+        } catch {
+            showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
+        }
+    }
+
+    private func finishActiveMeetingRecording() {
+        guard let session = activeMeetingRecordingSession else { return }
+        activeMeetingAudioRecorder?.stop()
+        activeMeetingAudioRecorder = nil
+        activeMeetingRecordingSession = nil
+
+        let summary = MeetingRecordingSummary(
+            id: session.id,
+            title: session.title,
+            meetURLString: session.meetURL.absoluteString,
+            startedAt: session.startedAt,
+            endedAt: Date(),
+            audioFilePath: session.audioFileURL.path
+        )
+        aiCompanionLocalRecordings.insert(summary, at: 0)
+        aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
+        persistAiCompanionLocalRecordings()
+        pageCache[.aiCompanion] = nil
+        showTopToast(message: "Meeting recording saved", isError: false)
+        if selectedSidebarPage == .aiCompanion {
+            showSidebarPage(.aiCompanion)
+        }
+    }
+
     private func showTopToast(message: String, isError: Bool) {
         topToastHideWorkItem?.cancel()
         topToastHideWorkItem = nil
@@ -1968,6 +2144,8 @@ private extension ViewController {
         // Reset per-card mappings so stale buttons/labels from previous page builds don't linger.
         aiCompanionAudioPlayer?.pause()
         aiCompanionAudioPlayer = nil
+        aiCompanionLocalAudioPlayer?.stop()
+        aiCompanionLocalAudioPlayer = nil
         aiCompanionCurrentlyPlayingURL = nil
         aiCompanionCurrentlyPlayingButton = nil
         aiCompanionTimeControlObserver = nil
@@ -2026,7 +2204,7 @@ private extension ViewController {
         titleLabel.alignment = .left
         contentStack.addArrangedSubview(titleLabel)
 
-        let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
+        let subtitle = textLabel("Ended meetings with local recordings", font: typography.fieldLabel, color: palette.textSecondary)
         subtitle.alignment = .left
         contentStack.addArrangedSubview(subtitle)
         contentStack.setCustomSpacing(14, after: subtitle)
@@ -2034,13 +2212,15 @@ private extension ViewController {
         titleLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
         subtitle.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
 
-        let endedMeetings = scheduleCachedMeetings
-            .filter { $0.endDate < Date() }
-            .sorted { $0.endDate > $1.endDate }
+        if let session = activeMeetingRecordingSession {
+            let activeCard = aiCompanionActiveRecordingCard(session: session)
+            contentStack.addArrangedSubview(activeCard)
+            activeCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
+        }
 
-        if endedMeetings.isEmpty {
+        if aiCompanionLocalRecordings.isEmpty {
             let emptyLabel = textLabel(
-                "No ended meetings yet. Transcript items will appear here after meetings end.",
+                "No saved recordings yet. Start a meeting from this app and allow consent to store local audio here.",
                 font: typography.fieldLabel,
                 color: palette.textMuted
             )
@@ -2050,8 +2230,8 @@ private extension ViewController {
             contentStack.addArrangedSubview(emptyLabel)
             emptyLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
         } else {
-            for meeting in endedMeetings {
-                let card = aiCompanionMeetingCard(meeting)
+            for recording in aiCompanionLocalRecordings {
+                let card = aiCompanionMeetingCard(recording)
                 contentStack.addArrangedSubview(card)
                 card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
             }
@@ -2072,34 +2252,75 @@ private extension ViewController {
 
             contentStack.leftAnchor.constraint(equalTo: content.leftAnchor, constant: 28),
             contentStack.rightAnchor.constraint(equalTo: content.rightAnchor, constant: -28),
-            contentStack.topAnchor.constraint(equalTo: content.topAnchor),
-            contentStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16)
+            contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
+            content.bottomAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 16)
         ])
 
         return panel
     }
 
-    private func aiCompanionMeetingCard(_ meeting: ScheduledMeeting) -> NSView {
+    private func aiCompanionActiveRecordingCard(session: ActiveMeetingRecordingSession) -> NSView {
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
         card.translatesAutoresizingMaskIntoConstraints = false
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
 
         let stack = NSStackView()
         stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.userInterfaceLayoutDirection = .leftToRight
         stack.orientation = .vertical
         stack.alignment = .leading
         stack.spacing = 8
 
-        let title = textLabel(meeting.title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
+        let title = textLabel("Recording in progress: \(session.title)", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
+        let started = DateFormatter.localizedString(from: session.startedAt, dateStyle: .medium, timeStyle: .short)
+        let status = textLabel("Started: \(started)", font: typography.fieldLabel, color: palette.textSecondary)
+        let stopButton = NSButton(title: "Stop Recording", target: self, action: #selector(aiCompanionStopRecordingTapped(_:)))
+        stopButton.translatesAutoresizingMaskIntoConstraints = false
+        stopButton.isBordered = false
+        stopButton.bezelStyle = .inline
+        stopButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
+        stopButton.contentTintColor = NSColor.systemRed
+        stopButton.alignment = .left
+        stopButton.setButtonType(.momentaryPushIn)
+        aiCompanionStopRecordingButton = stopButton
+
+        stack.addArrangedSubview(title)
+        stack.addArrangedSubview(status)
+        stack.addArrangedSubview(stopButton)
+
+        card.addSubview(stack)
+        NSLayoutConstraint.activate([
+            stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
+            stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
+            stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
+            stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
+        ])
+        return card
+    }
+
+    private func aiCompanionMeetingCard(_ recording: MeetingRecordingSummary) -> NSView {
+        let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
+        card.translatesAutoresizingMaskIntoConstraints = false
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let stack = NSStackView()
+        stack.translatesAutoresizingMaskIntoConstraints = false
+        stack.userInterfaceLayoutDirection = .leftToRight
+        stack.orientation = .vertical
+        stack.alignment = .leading
+        stack.spacing = 8
+
+        let title = textLabel(recording.title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
         title.alignment = .left
         title.maximumNumberOfLines = 2
         title.lineBreakMode = .byTruncatingTail
 
-        let dateText = DateFormatter.localizedString(from: meeting.startDate, dateStyle: .medium, timeStyle: .short)
-        let dateLabel = textLabel("Date: \(dateText)", font: typography.fieldLabel, color: palette.textSecondary)
+        let dateText = DateFormatter.localizedString(from: recording.endedAt, dateStyle: .medium, timeStyle: .short)
+        let dateLabel = textLabel("Saved: \(dateText)", font: typography.fieldLabel, color: palette.textSecondary)
         dateLabel.alignment = .left
 
-        let audioLink = mockAudioURLString(for: meeting)
+        let audioURL = URL(fileURLWithPath: recording.audioFilePath)
+        let audioLink = audioURL.lastPathComponent
         let audioButton = NSButton(title: "Play Audio", target: self, action: #selector(aiCompanionAudioTapped(_:)))
         audioButton.translatesAutoresizingMaskIntoConstraints = false
         audioButton.isBordered = false
@@ -2108,12 +2329,10 @@ private extension ViewController {
         audioButton.contentTintColor = palette.primaryBlue
         audioButton.alignment = .left
         audioButton.setButtonType(.momentaryPushIn)
-        if let audioURL = URL(string: audioLink) {
-            aiCompanionAudioURLByView[ObjectIdentifier(audioButton)] = audioURL
-        }
-        let trimmedSubtitle = meeting.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines)
+        aiCompanionAudioURLByView[ObjectIdentifier(audioButton)] = audioURL
+        let trimmedSubtitle: String? = nil
         let speechText = {
-            let base = "Ended meeting: \(meeting.title)."
+            let base = "Ended meeting: \(recording.title)."
             guard let trimmedSubtitle, trimmedSubtitle.isEmpty == false else { return base }
             return "\(base) \(trimmedSubtitle)."
         }()
@@ -2139,7 +2358,7 @@ private extension ViewController {
         transcriptButton.alignment = .left
         transcriptButton.setButtonType(.momentaryPushIn)
 
-        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
+        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = recording.id
 
         let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
         transcriptStatusLabel.alignment = .left
@@ -2195,11 +2414,24 @@ private extension ViewController {
                     aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
                 }
                 return
+            } else if let player = aiCompanionLocalAudioPlayer {
+                if player.isPlaying {
+                    player.pause()
+                    sender.title = "Play Audio"
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
+                } else {
+                    player.play()
+                    sender.title = "Pause Audio"
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
+                }
+                return
             }
         }
 
         // Stop any previous playback and remove observers.
         aiCompanionAudioPlayer?.pause()
+        aiCompanionLocalAudioPlayer?.stop()
+        aiCompanionLocalAudioPlayer = nil
         aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
         aiCompanionIsUsingSpeech = false
         aiCompanionNoProgressWorkItem?.cancel()
@@ -2229,6 +2461,32 @@ private extension ViewController {
         let urlToCheck = url
         let senderButton = sender
 
+        if urlToCheck.isFileURL {
+            guard FileManager.default.fileExists(atPath: urlToCheck.path) else {
+                senderButton.title = "Play Audio"
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Audio file missing"
+                return
+            }
+            do {
+                let player = try AVAudioPlayer(contentsOf: urlToCheck)
+                player.volume = 1.0
+                player.delegate = self
+                player.prepareToPlay()
+                let didPlay = player.play()
+                guard didPlay else {
+                    senderButton.title = "Play Audio"
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Could not play recording"
+                    return
+                }
+                aiCompanionLocalAudioPlayer = player
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
+            } catch {
+                senderButton.title = "Play Audio"
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Failed: \(error.localizedDescription)"
+            }
+            return
+        }
+
         var request = URLRequest(url: urlToCheck)
         request.setValue("bytes=0-0", forHTTPHeaderField: "Range") // lightweight probe
         request.timeoutInterval = 10
@@ -2341,7 +2599,11 @@ private extension ViewController {
     @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
         let senderId = ObjectIdentifier(sender)
         guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
-        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
+        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else {
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
+            showSimpleAlert(title: "Transcript unavailable", message: "Transcript fetch is available for Google Calendar meetings only.")
+            return
+        }
 
         if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
             aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
@@ -2380,9 +2642,15 @@ private extension ViewController {
         }
     }
 
+    @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
+        finishActiveMeetingRecording()
+    }
+
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
         aiCompanionAudioPlayer?.pause()
         aiCompanionAudioPlayer = nil
+        aiCompanionLocalAudioPlayer?.stop()
+        aiCompanionLocalAudioPlayer = nil
         aiCompanionTimeControlObserver = nil
 
         if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
@@ -2435,6 +2703,8 @@ private extension ViewController {
 
         aiCompanionAudioPlayer?.pause()
         aiCompanionAudioPlayer = nil
+        aiCompanionLocalAudioPlayer?.stop()
+        aiCompanionLocalAudioPlayer = nil
         aiCompanionCurrentlyPlayingURL = nil
         aiCompanionCurrentlyPlayingButton = nil
 
@@ -2463,6 +2733,8 @@ private extension ViewController {
 
         aiCompanionAudioPlayer?.pause()
         aiCompanionAudioPlayer = nil
+        aiCompanionLocalAudioPlayer?.stop()
+        aiCompanionLocalAudioPlayer = nil
         aiCompanionCurrentlyPlayingURL = nil
         aiCompanionCurrentlyPlayingButton = nil
 
@@ -2473,11 +2745,6 @@ private extension ViewController {
         aiCompanionTimeControlObserver = nil
     }
 
-    private func mockAudioURLString(for meeting: ScheduledMeeting) -> String {
-        let slug = meeting.id.replacingOccurrences(of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression)
-        return "https://mock-audio.local/\(slug).mp3"
-    }
-
     @MainActor
     private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
         if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
@@ -5352,6 +5619,35 @@ extension ViewController: AVSpeechSynthesizerDelegate {
     }
 }
 
+extension ViewController: AVAudioPlayerDelegate {
+    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            guard self.aiCompanionLocalAudioPlayer === player else { return }
+            self.aiCompanionAudioDidFinish()
+        }
+    }
+
+    func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            guard self.aiCompanionLocalAudioPlayer === player else { return }
+            if let button = self.aiCompanionCurrentlyPlayingButton {
+                let buttonId = ObjectIdentifier(button)
+                button.title = "Play Audio"
+                self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Failed: \(error?.localizedDescription ?? "decode error")"
+            }
+            self.aiCompanionAudioPlayer?.pause()
+            self.aiCompanionAudioPlayer = nil
+            self.aiCompanionLocalAudioPlayer?.stop()
+            self.aiCompanionLocalAudioPlayer = nil
+            self.aiCompanionCurrentlyPlayingURL = nil
+            self.aiCompanionCurrentlyPlayingButton = nil
+            self.aiCompanionTimeControlObserver = nil
+        }
+    }
+}
+
 extension ViewController: NSWindowDelegate {
     func windowWillClose(_ notification: Notification) {
         guard let closingWindow = notification.object as? NSWindow else { return }
@@ -7195,7 +7491,8 @@ private extension ViewController {
         }
         guard let raw = sender.identifier?.rawValue,
               let url = URL(string: raw) else { return }
-        openMeetingURL(url)
+        let meeting = scheduleCachedMeetings.first(where: { $0.meetURL.absoluteString == raw })
+        openMeetingURL(url, meeting: meeting)
     }
 
     @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
@@ -7331,7 +7628,9 @@ private extension ViewController {
         return f.string(from: day)
     }
 
-    private func openMeetingURL(_ url: URL) {
+    private func openMeetingURL(_ url: URL, meeting: ScheduledMeeting? = nil) {
+        let title = meeting?.title ?? "Scheduled Meeting"
+        beginMeetingRecordingIfConsented(meetingTitle: title, meetingURL: url)
         NSWorkspace.shared.open(url)
     }
 

+ 6 - 0
meetings_app/meetings_app.entitlements

@@ -8,5 +8,11 @@
 	<true/>
 	<key>com.apple.security.network.server</key>
 	<true/>
+	<key>com.apple.security.device.audio-input</key>
+	<true/>
+	<key>com.apple.security.exception.mach-lookup.global-name</key>
+	<array>
+		<string>com.apple.audioanalyticsd</string>
+	</array>
 </dict>
 </plist>