|
|
@@ -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
|
|