Explorar el Código

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 hace 1 mes
padre
commit
7cec0d41b6
Se han modificado 3 ficheros con 336 adiciones y 29 borrados
  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 @@
26 26
 	<string>Main</string>
27 27
 	<key>NSPrincipalClass</key>
28 28
 	<string>NSApplication</string>
29
+	<key>NSMicrophoneUsageDescription</key>
30
+	<string>This app records meeting audio locally with your consent so it can be shown in AI Companion after the meeting.</string>
29 31
 	<key>AppLaunchPlaceholderURL</key>
30 32
 	<string>https://example.com/app-link-coming-soon</string>
31 33
 	<key>AppShareURL</key>

+ 328 - 29
meetings_app/ViewController.swift

@@ -245,6 +245,23 @@ final class ViewController: NSViewController {
245 245
         let pictureURL: URL?
246 246
     }
247 247
 
248
+    private struct MeetingRecordingSummary: Codable {
249
+        let id: String
250
+        let title: String
251
+        let meetURLString: String
252
+        let startedAt: Date
253
+        let endedAt: Date
254
+        let audioFilePath: String
255
+    }
256
+
257
+    private struct ActiveMeetingRecordingSession {
258
+        let id: String
259
+        let title: String
260
+        let meetURL: URL
261
+        let startedAt: Date
262
+        let audioFileURL: URL
263
+    }
264
+
248 265
     private var palette = Palette(isDarkMode: true)
249 266
     private let typography = Typography()
250 267
     private let launchContentSize = NSSize(width: 920, height: 690)
@@ -277,6 +294,7 @@ final class ViewController: NSViewController {
277 294
     private var aiCompanionTranscriptWindow: NSWindow?
278 295
     private weak var aiCompanionTranscriptTextView: NSTextView?
279 296
     private var aiCompanionAudioPlayer: AVPlayer?
297
+    private var aiCompanionLocalAudioPlayer: AVAudioPlayer?
280 298
     private var aiCompanionCurrentlyPlayingURL: URL?
281 299
     private weak var aiCompanionCurrentlyPlayingButton: NSButton?
282 300
     private var aiCompanionPlaybackEndObserver: NSObjectProtocol?
@@ -379,6 +397,10 @@ final class ViewController: NSViewController {
379 397
     private var scheduleProfileImageTask: Task<Void, Never>?
380 398
     private var googleAccountPopover: NSPopover?
381 399
     private var scheduleCachedMeetings: [ScheduledMeeting] = []
400
+    private var aiCompanionLocalRecordings: [MeetingRecordingSummary] = []
401
+    private var activeMeetingRecordingSession: ActiveMeetingRecordingSession?
402
+    private var activeMeetingAudioRecorder: AVAudioRecorder?
403
+    private weak var aiCompanionStopRecordingButton: NSButton?
382 404
     private let widgetSnapshotLimit: Int = 3
383 405
 
384 406
     private var schedulePageFilter: SchedulePageFilter = .all
@@ -429,6 +451,7 @@ final class ViewController: NSViewController {
429 451
     private let userHasRatedDefaultsKey = "rating.userHasRated"
430 452
     private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
431 453
     private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
454
+    private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
432 455
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
433 456
     private var darkModeEnabled: Bool {
434 457
         get {
@@ -474,6 +497,7 @@ final class ViewController: NSViewController {
474 497
     override func viewDidLoad() {
475 498
         super.viewDidLoad()
476 499
         aiCompanionSpeechSynthesizer.delegate = self
500
+        loadAiCompanionLocalRecordings()
477 501
         // Sync toggle + palette with current macOS appearance on launch.
478 502
         darkModeEnabled = systemPrefersDarkMode()
479 503
         palette = Palette(isDarkMode: darkModeEnabled)
@@ -489,6 +513,7 @@ final class ViewController: NSViewController {
489 513
         migrateLegacyRatingStateIfNeeded()
490 514
         beginUsageTrackingSessionIfNeeded()
491 515
         observeAppLifecycleForUsageTrackingIfNeeded()
516
+        observeMeetingRecordingLifecycle()
492 517
         registerWidgetNotificationObservers()
493 518
         setupRootView()
494 519
         buildMainLayout()
@@ -783,6 +808,7 @@ private extension ViewController {
783 808
             return
784 809
         }
785 810
 
811
+        beginMeetingRecordingIfConsented(meetingTitle: "Quick Join Meeting", meetingURL: url)
786 812
         openInDefaultBrowser(url: url)
787 813
         consumeNonPremiumJoinTrialIfNeeded()
788 814
     }
@@ -1225,6 +1251,156 @@ private extension ViewController {
1225 1251
         alert.runModal()
1226 1252
     }
1227 1253
 
1254
+    private func observeMeetingRecordingLifecycle() {
1255
+        NotificationCenter.default.addObserver(
1256
+            self,
1257
+            selector: #selector(handleWorkspaceAppActivation(_:)),
1258
+            name: NSWorkspace.didActivateApplicationNotification,
1259
+            object: nil
1260
+        )
1261
+    }
1262
+
1263
+    @objc private func handleWorkspaceAppActivation(_ notification: Notification) {
1264
+        guard activeMeetingRecordingSession != nil else { return }
1265
+        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
1266
+        guard app.bundleIdentifier == Bundle.main.bundleIdentifier else { return }
1267
+        promptStopRecordingAfterReturningToApp()
1268
+    }
1269
+
1270
+    private func promptStopRecordingAfterReturningToApp() {
1271
+        guard let session = activeMeetingRecordingSession else { return }
1272
+        let alert = NSAlert()
1273
+        alert.messageText = "Stop meeting recording?"
1274
+        alert.informativeText = "You returned to the app for \"\(session.title)\". Stop local recording now?"
1275
+        alert.addButton(withTitle: "Stop Recording")
1276
+        alert.addButton(withTitle: "Keep Recording")
1277
+        if alert.runModal() == .alertFirstButtonReturn {
1278
+            finishActiveMeetingRecording()
1279
+        }
1280
+    }
1281
+
1282
+    private func requestMeetingRecordingConsent(title: String) -> Bool {
1283
+        let alert = NSAlert()
1284
+        alert.messageText = "Record this meeting locally?"
1285
+        alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
1286
+        alert.addButton(withTitle: "Allow and Continue")
1287
+        alert.addButton(withTitle: "Continue Without Recording")
1288
+        let response = alert.runModal()
1289
+        if response == .alertFirstButtonReturn { return true }
1290
+        return false
1291
+    }
1292
+
1293
+    private func loadAiCompanionLocalRecordings() {
1294
+        guard let raw = UserDefaults.standard.data(forKey: aiCompanionLocalRecordingsDefaultsKey) else {
1295
+            aiCompanionLocalRecordings = []
1296
+            return
1297
+        }
1298
+        let decoder = JSONDecoder()
1299
+        if let decoded = try? decoder.decode([MeetingRecordingSummary].self, from: raw) {
1300
+            aiCompanionLocalRecordings = decoded.sorted(by: { $0.endedAt > $1.endedAt })
1301
+        } else {
1302
+            aiCompanionLocalRecordings = []
1303
+        }
1304
+    }
1305
+
1306
+    private func persistAiCompanionLocalRecordings() {
1307
+        let encoder = JSONEncoder()
1308
+        guard let encoded = try? encoder.encode(aiCompanionLocalRecordings) else { return }
1309
+        UserDefaults.standard.set(encoded, forKey: aiCompanionLocalRecordingsDefaultsKey)
1310
+    }
1311
+
1312
+    private func localRecordingDirectoryURL() -> URL {
1313
+        let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
1314
+            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
1315
+        let bundleID = Bundle.main.bundleIdentifier ?? "meetings_app"
1316
+        let directory = base.appendingPathComponent(bundleID, isDirectory: true).appendingPathComponent("MeetingRecordings", isDirectory: true)
1317
+        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
1318
+        return directory
1319
+    }
1320
+
1321
+    private func beginMeetingRecordingIfConsented(meetingTitle: String, meetingURL: URL) {
1322
+        if activeMeetingRecordingSession != nil {
1323
+            finishActiveMeetingRecording()
1324
+        }
1325
+        guard requestMeetingRecordingConsent(title: meetingTitle) else { return }
1326
+        let permission = AVCaptureDevice.authorizationStatus(for: .audio)
1327
+        switch permission {
1328
+        case .authorized:
1329
+            startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1330
+        case .notDetermined:
1331
+            AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in
1332
+                DispatchQueue.main.async {
1333
+                    guard let self else { return }
1334
+                    if granted {
1335
+                        self.startMeetingRecording(meetingTitle: meetingTitle, meetingURL: meetingURL)
1336
+                    } else {
1337
+                        self.showSimpleAlert(title: "Microphone permission denied", message: "Enable microphone access in System Settings to record meetings locally.")
1338
+                    }
1339
+                }
1340
+            }
1341
+        case .denied, .restricted:
1342
+            showSimpleAlert(title: "Microphone permission required", message: "Enable microphone access in System Settings to record meetings locally.")
1343
+        @unknown default:
1344
+            break
1345
+        }
1346
+    }
1347
+
1348
+    private func startMeetingRecording(meetingTitle: String, meetingURL: URL) {
1349
+        let recordingID = UUID().uuidString
1350
+        let outputURL = localRecordingDirectoryURL().appendingPathComponent("\(recordingID).m4a")
1351
+        let settings: [String: Any] = [
1352
+            AVFormatIDKey: kAudioFormatMPEG4AAC,
1353
+            AVSampleRateKey: 44_100,
1354
+            AVNumberOfChannelsKey: 1,
1355
+            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
1356
+        ]
1357
+
1358
+        do {
1359
+            let recorder = try AVAudioRecorder(url: outputURL, settings: settings)
1360
+            recorder.prepareToRecord()
1361
+            recorder.record()
1362
+            activeMeetingAudioRecorder = recorder
1363
+            activeMeetingRecordingSession = ActiveMeetingRecordingSession(
1364
+                id: recordingID,
1365
+                title: meetingTitle,
1366
+                meetURL: meetingURL,
1367
+                startedAt: Date(),
1368
+                audioFileURL: outputURL
1369
+            )
1370
+            showTopToast(message: "Meeting recording started locally", isError: false)
1371
+            pageCache[.aiCompanion] = nil
1372
+            if selectedSidebarPage == .aiCompanion {
1373
+                showSidebarPage(.aiCompanion)
1374
+            }
1375
+        } catch {
1376
+            showSimpleAlert(title: "Could not start recording", message: error.localizedDescription)
1377
+        }
1378
+    }
1379
+
1380
+    private func finishActiveMeetingRecording() {
1381
+        guard let session = activeMeetingRecordingSession else { return }
1382
+        activeMeetingAudioRecorder?.stop()
1383
+        activeMeetingAudioRecorder = nil
1384
+        activeMeetingRecordingSession = nil
1385
+
1386
+        let summary = MeetingRecordingSummary(
1387
+            id: session.id,
1388
+            title: session.title,
1389
+            meetURLString: session.meetURL.absoluteString,
1390
+            startedAt: session.startedAt,
1391
+            endedAt: Date(),
1392
+            audioFilePath: session.audioFileURL.path
1393
+        )
1394
+        aiCompanionLocalRecordings.insert(summary, at: 0)
1395
+        aiCompanionLocalRecordings.sort(by: { $0.endedAt > $1.endedAt })
1396
+        persistAiCompanionLocalRecordings()
1397
+        pageCache[.aiCompanion] = nil
1398
+        showTopToast(message: "Meeting recording saved", isError: false)
1399
+        if selectedSidebarPage == .aiCompanion {
1400
+            showSidebarPage(.aiCompanion)
1401
+        }
1402
+    }
1403
+
1228 1404
     private func showTopToast(message: String, isError: Bool) {
1229 1405
         topToastHideWorkItem?.cancel()
1230 1406
         topToastHideWorkItem = nil
@@ -1968,6 +2144,8 @@ private extension ViewController {
1968 2144
         // Reset per-card mappings so stale buttons/labels from previous page builds don't linger.
1969 2145
         aiCompanionAudioPlayer?.pause()
1970 2146
         aiCompanionAudioPlayer = nil
2147
+        aiCompanionLocalAudioPlayer?.stop()
2148
+        aiCompanionLocalAudioPlayer = nil
1971 2149
         aiCompanionCurrentlyPlayingURL = nil
1972 2150
         aiCompanionCurrentlyPlayingButton = nil
1973 2151
         aiCompanionTimeControlObserver = nil
@@ -2026,7 +2204,7 @@ private extension ViewController {
2026 2204
         titleLabel.alignment = .left
2027 2205
         contentStack.addArrangedSubview(titleLabel)
2028 2206
 
2029
-        let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
2207
+        let subtitle = textLabel("Ended meetings with local recordings", font: typography.fieldLabel, color: palette.textSecondary)
2030 2208
         subtitle.alignment = .left
2031 2209
         contentStack.addArrangedSubview(subtitle)
2032 2210
         contentStack.setCustomSpacing(14, after: subtitle)
@@ -2034,13 +2212,15 @@ private extension ViewController {
2034 2212
         titleLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2035 2213
         subtitle.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2036 2214
 
2037
-        let endedMeetings = scheduleCachedMeetings
2038
-            .filter { $0.endDate < Date() }
2039
-            .sorted { $0.endDate > $1.endDate }
2215
+        if let session = activeMeetingRecordingSession {
2216
+            let activeCard = aiCompanionActiveRecordingCard(session: session)
2217
+            contentStack.addArrangedSubview(activeCard)
2218
+            activeCard.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2219
+        }
2040 2220
 
2041
-        if endedMeetings.isEmpty {
2221
+        if aiCompanionLocalRecordings.isEmpty {
2042 2222
             let emptyLabel = textLabel(
2043
-                "No ended meetings yet. Transcript items will appear here after meetings end.",
2223
+                "No saved recordings yet. Start a meeting from this app and allow consent to store local audio here.",
2044 2224
                 font: typography.fieldLabel,
2045 2225
                 color: palette.textMuted
2046 2226
             )
@@ -2050,8 +2230,8 @@ private extension ViewController {
2050 2230
             contentStack.addArrangedSubview(emptyLabel)
2051 2231
             emptyLabel.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2052 2232
         } else {
2053
-            for meeting in endedMeetings {
2054
-                let card = aiCompanionMeetingCard(meeting)
2233
+            for recording in aiCompanionLocalRecordings {
2234
+                let card = aiCompanionMeetingCard(recording)
2055 2235
                 contentStack.addArrangedSubview(card)
2056 2236
                 card.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
2057 2237
             }
@@ -2072,34 +2252,75 @@ private extension ViewController {
2072 2252
 
2073 2253
             contentStack.leftAnchor.constraint(equalTo: content.leftAnchor, constant: 28),
2074 2254
             contentStack.rightAnchor.constraint(equalTo: content.rightAnchor, constant: -28),
2075
-            contentStack.topAnchor.constraint(equalTo: content.topAnchor),
2076
-            contentStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16)
2255
+            contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
2256
+            content.bottomAnchor.constraint(greaterThanOrEqualTo: contentStack.bottomAnchor, constant: 16)
2077 2257
         ])
2078 2258
 
2079 2259
         return panel
2080 2260
     }
2081 2261
 
2082
-    private func aiCompanionMeetingCard(_ meeting: ScheduledMeeting) -> NSView {
2262
+    private func aiCompanionActiveRecordingCard(session: ActiveMeetingRecordingSession) -> NSView {
2083 2263
         let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
2084 2264
         card.translatesAutoresizingMaskIntoConstraints = false
2085 2265
         styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2086 2266
 
2087 2267
         let stack = NSStackView()
2088 2268
         stack.translatesAutoresizingMaskIntoConstraints = false
2269
+        stack.userInterfaceLayoutDirection = .leftToRight
2089 2270
         stack.orientation = .vertical
2090 2271
         stack.alignment = .leading
2091 2272
         stack.spacing = 8
2092 2273
 
2093
-        let title = textLabel(meeting.title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
2274
+        let title = textLabel("Recording in progress: \(session.title)", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
2275
+        let started = DateFormatter.localizedString(from: session.startedAt, dateStyle: .medium, timeStyle: .short)
2276
+        let status = textLabel("Started: \(started)", font: typography.fieldLabel, color: palette.textSecondary)
2277
+        let stopButton = NSButton(title: "Stop Recording", target: self, action: #selector(aiCompanionStopRecordingTapped(_:)))
2278
+        stopButton.translatesAutoresizingMaskIntoConstraints = false
2279
+        stopButton.isBordered = false
2280
+        stopButton.bezelStyle = .inline
2281
+        stopButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
2282
+        stopButton.contentTintColor = NSColor.systemRed
2283
+        stopButton.alignment = .left
2284
+        stopButton.setButtonType(.momentaryPushIn)
2285
+        aiCompanionStopRecordingButton = stopButton
2286
+
2287
+        stack.addArrangedSubview(title)
2288
+        stack.addArrangedSubview(status)
2289
+        stack.addArrangedSubview(stopButton)
2290
+
2291
+        card.addSubview(stack)
2292
+        NSLayoutConstraint.activate([
2293
+            stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
2294
+            stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
2295
+            stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
2296
+            stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
2297
+        ])
2298
+        return card
2299
+    }
2300
+
2301
+    private func aiCompanionMeetingCard(_ recording: MeetingRecordingSummary) -> NSView {
2302
+        let card = roundedContainer(cornerRadius: 14, color: palette.sectionCard)
2303
+        card.translatesAutoresizingMaskIntoConstraints = false
2304
+        styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
2305
+
2306
+        let stack = NSStackView()
2307
+        stack.translatesAutoresizingMaskIntoConstraints = false
2308
+        stack.userInterfaceLayoutDirection = .leftToRight
2309
+        stack.orientation = .vertical
2310
+        stack.alignment = .leading
2311
+        stack.spacing = 8
2312
+
2313
+        let title = textLabel(recording.title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: palette.textPrimary)
2094 2314
         title.alignment = .left
2095 2315
         title.maximumNumberOfLines = 2
2096 2316
         title.lineBreakMode = .byTruncatingTail
2097 2317
 
2098
-        let dateText = DateFormatter.localizedString(from: meeting.startDate, dateStyle: .medium, timeStyle: .short)
2099
-        let dateLabel = textLabel("Date: \(dateText)", font: typography.fieldLabel, color: palette.textSecondary)
2318
+        let dateText = DateFormatter.localizedString(from: recording.endedAt, dateStyle: .medium, timeStyle: .short)
2319
+        let dateLabel = textLabel("Saved: \(dateText)", font: typography.fieldLabel, color: palette.textSecondary)
2100 2320
         dateLabel.alignment = .left
2101 2321
 
2102
-        let audioLink = mockAudioURLString(for: meeting)
2322
+        let audioURL = URL(fileURLWithPath: recording.audioFilePath)
2323
+        let audioLink = audioURL.lastPathComponent
2103 2324
         let audioButton = NSButton(title: "Play Audio", target: self, action: #selector(aiCompanionAudioTapped(_:)))
2104 2325
         audioButton.translatesAutoresizingMaskIntoConstraints = false
2105 2326
         audioButton.isBordered = false
@@ -2108,12 +2329,10 @@ private extension ViewController {
2108 2329
         audioButton.contentTintColor = palette.primaryBlue
2109 2330
         audioButton.alignment = .left
2110 2331
         audioButton.setButtonType(.momentaryPushIn)
2111
-        if let audioURL = URL(string: audioLink) {
2112
-            aiCompanionAudioURLByView[ObjectIdentifier(audioButton)] = audioURL
2113
-        }
2114
-        let trimmedSubtitle = meeting.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines)
2332
+        aiCompanionAudioURLByView[ObjectIdentifier(audioButton)] = audioURL
2333
+        let trimmedSubtitle: String? = nil
2115 2334
         let speechText = {
2116
-            let base = "Ended meeting: \(meeting.title)."
2335
+            let base = "Ended meeting: \(recording.title)."
2117 2336
             guard let trimmedSubtitle, trimmedSubtitle.isEmpty == false else { return base }
2118 2337
             return "\(base) \(trimmedSubtitle)."
2119 2338
         }()
@@ -2139,7 +2358,7 @@ private extension ViewController {
2139 2358
         transcriptButton.alignment = .left
2140 2359
         transcriptButton.setButtonType(.momentaryPushIn)
2141 2360
 
2142
-        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
2361
+        aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = recording.id
2143 2362
 
2144 2363
         let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
2145 2364
         transcriptStatusLabel.alignment = .left
@@ -2195,11 +2414,24 @@ private extension ViewController {
2195 2414
                     aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
2196 2415
                 }
2197 2416
                 return
2417
+            } else if let player = aiCompanionLocalAudioPlayer {
2418
+                if player.isPlaying {
2419
+                    player.pause()
2420
+                    sender.title = "Play Audio"
2421
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Paused"
2422
+                } else {
2423
+                    player.play()
2424
+                    sender.title = "Pause Audio"
2425
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
2426
+                }
2427
+                return
2198 2428
             }
2199 2429
         }
2200 2430
 
2201 2431
         // Stop any previous playback and remove observers.
2202 2432
         aiCompanionAudioPlayer?.pause()
2433
+        aiCompanionLocalAudioPlayer?.stop()
2434
+        aiCompanionLocalAudioPlayer = nil
2203 2435
         aiCompanionSpeechSynthesizer.stopSpeaking(at: .immediate)
2204 2436
         aiCompanionIsUsingSpeech = false
2205 2437
         aiCompanionNoProgressWorkItem?.cancel()
@@ -2229,6 +2461,32 @@ private extension ViewController {
2229 2461
         let urlToCheck = url
2230 2462
         let senderButton = sender
2231 2463
 
2464
+        if urlToCheck.isFileURL {
2465
+            guard FileManager.default.fileExists(atPath: urlToCheck.path) else {
2466
+                senderButton.title = "Play Audio"
2467
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Audio file missing"
2468
+                return
2469
+            }
2470
+            do {
2471
+                let player = try AVAudioPlayer(contentsOf: urlToCheck)
2472
+                player.volume = 1.0
2473
+                player.delegate = self
2474
+                player.prepareToPlay()
2475
+                let didPlay = player.play()
2476
+                guard didPlay else {
2477
+                    senderButton.title = "Play Audio"
2478
+                    aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Could not play recording"
2479
+                    return
2480
+                }
2481
+                aiCompanionLocalAudioPlayer = player
2482
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Playing..."
2483
+            } catch {
2484
+                senderButton.title = "Play Audio"
2485
+                aiCompanionAudioStatusLabelByView[senderId]?.stringValue = "Failed: \(error.localizedDescription)"
2486
+            }
2487
+            return
2488
+        }
2489
+
2232 2490
         var request = URLRequest(url: urlToCheck)
2233 2491
         request.setValue("bytes=0-0", forHTTPHeaderField: "Range") // lightweight probe
2234 2492
         request.timeoutInterval = 10
@@ -2341,7 +2599,11 @@ private extension ViewController {
2341 2599
     @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
2342 2600
         let senderId = ObjectIdentifier(sender)
2343 2601
         guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
2344
-        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
2602
+        guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else {
2603
+            aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
2604
+            showSimpleAlert(title: "Transcript unavailable", message: "Transcript fetch is available for Google Calendar meetings only.")
2605
+            return
2606
+        }
2345 2607
 
2346 2608
         if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
2347 2609
             aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
@@ -2380,9 +2642,15 @@ private extension ViewController {
2380 2642
         }
2381 2643
     }
2382 2644
 
2645
+    @objc private func aiCompanionStopRecordingTapped(_ sender: NSButton) {
2646
+        finishActiveMeetingRecording()
2647
+    }
2648
+
2383 2649
     private func aiCompanionAudioTimeControlObserverResetForFailure() {
2384 2650
         aiCompanionAudioPlayer?.pause()
2385 2651
         aiCompanionAudioPlayer = nil
2652
+        aiCompanionLocalAudioPlayer?.stop()
2653
+        aiCompanionLocalAudioPlayer = nil
2386 2654
         aiCompanionTimeControlObserver = nil
2387 2655
 
2388 2656
         if let token = aiCompanionPlaybackEndObserver { NotificationCenter.default.removeObserver(token) }
@@ -2435,6 +2703,8 @@ private extension ViewController {
2435 2703
 
2436 2704
         aiCompanionAudioPlayer?.pause()
2437 2705
         aiCompanionAudioPlayer = nil
2706
+        aiCompanionLocalAudioPlayer?.stop()
2707
+        aiCompanionLocalAudioPlayer = nil
2438 2708
         aiCompanionCurrentlyPlayingURL = nil
2439 2709
         aiCompanionCurrentlyPlayingButton = nil
2440 2710
 
@@ -2463,6 +2733,8 @@ private extension ViewController {
2463 2733
 
2464 2734
         aiCompanionAudioPlayer?.pause()
2465 2735
         aiCompanionAudioPlayer = nil
2736
+        aiCompanionLocalAudioPlayer?.stop()
2737
+        aiCompanionLocalAudioPlayer = nil
2466 2738
         aiCompanionCurrentlyPlayingURL = nil
2467 2739
         aiCompanionCurrentlyPlayingButton = nil
2468 2740
 
@@ -2473,11 +2745,6 @@ private extension ViewController {
2473 2745
         aiCompanionTimeControlObserver = nil
2474 2746
     }
2475 2747
 
2476
-    private func mockAudioURLString(for meeting: ScheduledMeeting) -> String {
2477
-        let slug = meeting.id.replacingOccurrences(of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression)
2478
-        return "https://mock-audio.local/\(slug).mp3"
2479
-    }
2480
-
2481 2748
     @MainActor
2482 2749
     private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
2483 2750
         if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
@@ -5352,6 +5619,35 @@ extension ViewController: AVSpeechSynthesizerDelegate {
5352 5619
     }
5353 5620
 }
5354 5621
 
5622
+extension ViewController: AVAudioPlayerDelegate {
5623
+    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
5624
+        DispatchQueue.main.async { [weak self] in
5625
+            guard let self else { return }
5626
+            guard self.aiCompanionLocalAudioPlayer === player else { return }
5627
+            self.aiCompanionAudioDidFinish()
5628
+        }
5629
+    }
5630
+
5631
+    func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
5632
+        DispatchQueue.main.async { [weak self] in
5633
+            guard let self else { return }
5634
+            guard self.aiCompanionLocalAudioPlayer === player else { return }
5635
+            if let button = self.aiCompanionCurrentlyPlayingButton {
5636
+                let buttonId = ObjectIdentifier(button)
5637
+                button.title = "Play Audio"
5638
+                self.aiCompanionAudioStatusLabelByView[buttonId]?.stringValue = "Failed: \(error?.localizedDescription ?? "decode error")"
5639
+            }
5640
+            self.aiCompanionAudioPlayer?.pause()
5641
+            self.aiCompanionAudioPlayer = nil
5642
+            self.aiCompanionLocalAudioPlayer?.stop()
5643
+            self.aiCompanionLocalAudioPlayer = nil
5644
+            self.aiCompanionCurrentlyPlayingURL = nil
5645
+            self.aiCompanionCurrentlyPlayingButton = nil
5646
+            self.aiCompanionTimeControlObserver = nil
5647
+        }
5648
+    }
5649
+}
5650
+
5355 5651
 extension ViewController: NSWindowDelegate {
5356 5652
     func windowWillClose(_ notification: Notification) {
5357 5653
         guard let closingWindow = notification.object as? NSWindow else { return }
@@ -7195,7 +7491,8 @@ private extension ViewController {
7195 7491
         }
7196 7492
         guard let raw = sender.identifier?.rawValue,
7197 7493
               let url = URL(string: raw) else { return }
7198
-        openMeetingURL(url)
7494
+        let meeting = scheduleCachedMeetings.first(where: { $0.meetURL.absoluteString == raw })
7495
+        openMeetingURL(url, meeting: meeting)
7199 7496
     }
7200 7497
 
7201 7498
     @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
@@ -7331,7 +7628,9 @@ private extension ViewController {
7331 7628
         return f.string(from: day)
7332 7629
     }
7333 7630
 
7334
-    private func openMeetingURL(_ url: URL) {
7631
+    private func openMeetingURL(_ url: URL, meeting: ScheduledMeeting? = nil) {
7632
+        let title = meeting?.title ?? "Scheduled Meeting"
7633
+        beginMeetingRecordingIfConsented(meetingTitle: title, meetingURL: url)
7335 7634
         NSWorkspace.shared.open(url)
7336 7635
     }
7337 7636
 

+ 6 - 0
meetings_app/meetings_app.entitlements

@@ -8,5 +8,11 @@
8 8
 	<true/>
9 9
 	<key>com.apple.security.network.server</key>
10 10
 	<true/>
11
+	<key>com.apple.security.device.audio-input</key>
12
+	<true/>
13
+	<key>com.apple.security.exception.mach-lookup.global-name</key>
14
+	<array>
15
+		<string>com.apple.audioanalyticsd</string>
16
+	</array>
11 17
 </dict>
12 18
 </plist>