|
|
@@ -270,6 +270,12 @@ final class ViewController: NSViewController {
|
|
270
|
270
|
private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
|
|
271
|
271
|
private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
|
|
272
|
272
|
private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
273
|
+ private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
|
|
|
274
|
+ private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
275
|
+ private var aiCompanionTranscriptTextByMeetingId = [String: String]()
|
|
|
276
|
+ private var aiCompanionTranscriptCurrentRequestId: UUID?
|
|
|
277
|
+ private var aiCompanionTranscriptWindow: NSWindow?
|
|
|
278
|
+ private weak var aiCompanionTranscriptTextView: NSTextView?
|
|
273
|
279
|
private var aiCompanionAudioPlayer: AVPlayer?
|
|
274
|
280
|
private var aiCompanionCurrentlyPlayingURL: URL?
|
|
275
|
281
|
private weak var aiCompanionCurrentlyPlayingButton: NSButton?
|
|
|
@@ -314,6 +320,7 @@ final class ViewController: NSViewController {
|
|
314
|
320
|
private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
|
|
315
|
321
|
private let googleOAuth = GoogleOAuthService.shared
|
|
316
|
322
|
private let calendarClient = GoogleCalendarClient()
|
|
|
323
|
+ private let googleMeetClient = GoogleMeetClient()
|
|
317
|
324
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
318
|
325
|
private var storeKitStartupTask: Task<Void, Never>?
|
|
319
|
326
|
private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
@@ -1977,6 +1984,13 @@ private extension ViewController {
|
|
1977
|
1984
|
aiCompanionAudioURLByView.removeAll()
|
|
1978
|
1985
|
aiCompanionAudioStatusLabelByView.removeAll()
|
|
1979
|
1986
|
aiCompanionSpeechTextByView.removeAll()
|
|
|
1987
|
+ aiCompanionTranscriptMeetingIdByView.removeAll()
|
|
|
1988
|
+ aiCompanionTranscriptStatusLabelByView.removeAll()
|
|
|
1989
|
+ aiCompanionTranscriptTextByMeetingId.removeAll()
|
|
|
1990
|
+ aiCompanionTranscriptCurrentRequestId = nil
|
|
|
1991
|
+ aiCompanionTranscriptWindow?.close()
|
|
|
1992
|
+ aiCompanionTranscriptWindow = nil
|
|
|
1993
|
+ aiCompanionTranscriptTextView = nil
|
|
1980
|
1994
|
|
|
1981
|
1995
|
let panel = NSView()
|
|
1982
|
1996
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -2012,7 +2026,7 @@ private extension ViewController {
|
|
2012
|
2026
|
titleLabel.alignment = .left
|
|
2013
|
2027
|
contentStack.addArrangedSubview(titleLabel)
|
|
2014
|
2028
|
|
|
2015
|
|
- let subtitle = textLabel("Ended meetings with temporary audio links", font: typography.fieldLabel, color: palette.textSecondary)
|
|
|
2029
|
+ let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
|
|
2016
|
2030
|
subtitle.alignment = .left
|
|
2017
|
2031
|
contentStack.addArrangedSubview(subtitle)
|
|
2018
|
2032
|
contentStack.setCustomSpacing(14, after: subtitle)
|
|
|
@@ -2026,7 +2040,7 @@ private extension ViewController {
|
|
2026
|
2040
|
|
|
2027
|
2041
|
if endedMeetings.isEmpty {
|
|
2028
|
2042
|
let emptyLabel = textLabel(
|
|
2029
|
|
- "No ended meetings yet. Audio items will appear here after meetings end.",
|
|
|
2043
|
+ "No ended meetings yet. Transcript items will appear here after meetings end.",
|
|
2030
|
2044
|
font: typography.fieldLabel,
|
|
2031
|
2045
|
color: palette.textMuted
|
|
2032
|
2046
|
)
|
|
|
@@ -2116,11 +2130,30 @@ private extension ViewController {
|
|
2116
|
2130
|
audioStatusLabel.lineBreakMode = .byTruncatingTail
|
|
2117
|
2131
|
aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
|
|
2118
|
2132
|
|
|
|
2133
|
+ let transcriptButton = NSButton(title: "View transcript", target: self, action: #selector(aiCompanionTranscriptTapped(_:)))
|
|
|
2134
|
+ transcriptButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2135
|
+ transcriptButton.isBordered = false
|
|
|
2136
|
+ transcriptButton.bezelStyle = .inline
|
|
|
2137
|
+ transcriptButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
|
|
|
2138
|
+ transcriptButton.contentTintColor = palette.primaryBlue
|
|
|
2139
|
+ transcriptButton.alignment = .left
|
|
|
2140
|
+ transcriptButton.setButtonType(.momentaryPushIn)
|
|
|
2141
|
+
|
|
|
2142
|
+ aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
|
|
|
2143
|
+
|
|
|
2144
|
+ let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
|
|
|
2145
|
+ transcriptStatusLabel.alignment = .left
|
|
|
2146
|
+ transcriptStatusLabel.maximumNumberOfLines = 2
|
|
|
2147
|
+ transcriptStatusLabel.lineBreakMode = .byTruncatingTail
|
|
|
2148
|
+ aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
|
|
|
2149
|
+
|
|
2119
|
2150
|
stack.addArrangedSubview(title)
|
|
2120
|
2151
|
stack.addArrangedSubview(dateLabel)
|
|
2121
|
2152
|
stack.addArrangedSubview(audioButton)
|
|
2122
|
2153
|
stack.addArrangedSubview(audioURLLabel)
|
|
2123
|
2154
|
stack.addArrangedSubview(audioStatusLabel)
|
|
|
2155
|
+ stack.addArrangedSubview(transcriptButton)
|
|
|
2156
|
+ stack.addArrangedSubview(transcriptStatusLabel)
|
|
2124
|
2157
|
|
|
2125
|
2158
|
card.addSubview(stack)
|
|
2126
|
2159
|
NSLayoutConstraint.activate([
|
|
|
@@ -2305,6 +2338,48 @@ private extension ViewController {
|
|
2305
|
2338
|
}.resume()
|
|
2306
|
2339
|
}
|
|
2307
|
2340
|
|
|
|
2341
|
+ @objc private func aiCompanionTranscriptTapped(_ sender: NSButton) {
|
|
|
2342
|
+ let senderId = ObjectIdentifier(sender)
|
|
|
2343
|
+ guard let meetingId = aiCompanionTranscriptMeetingIdByView[senderId] else { return }
|
|
|
2344
|
+ guard let meeting = scheduleCachedMeetings.first(where: { $0.id == meetingId }) else { return }
|
|
|
2345
|
+
|
|
|
2346
|
+ if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
|
|
|
2347
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
|
|
|
2348
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
2349
|
+ return
|
|
|
2350
|
+ }
|
|
|
2351
|
+
|
|
|
2352
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
|
|
|
2353
|
+
|
|
|
2354
|
+ let requestId = UUID()
|
|
|
2355
|
+ aiCompanionTranscriptCurrentRequestId = requestId
|
|
|
2356
|
+
|
|
|
2357
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
|
|
|
2358
|
+
|
|
|
2359
|
+ Task { [weak self] in
|
|
|
2360
|
+ guard let self else { return }
|
|
|
2361
|
+
|
|
|
2362
|
+ do {
|
|
|
2363
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
2364
|
+ let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
|
|
|
2365
|
+
|
|
|
2366
|
+ await MainActor.run {
|
|
|
2367
|
+ guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
|
|
|
2368
|
+ self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
|
|
|
2369
|
+ self.aiCompanionTranscriptTextView?.string = text
|
|
|
2370
|
+ self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
2371
|
+ }
|
|
|
2372
|
+ } catch {
|
|
|
2373
|
+ await MainActor.run {
|
|
|
2374
|
+ guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
2375
|
+ let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
|
|
|
2376
|
+ self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
|
|
|
2377
|
+ self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
|
|
|
2378
|
+ }
|
|
|
2379
|
+ }
|
|
|
2380
|
+ }
|
|
|
2381
|
+ }
|
|
|
2382
|
+
|
|
2308
|
2383
|
private func aiCompanionAudioTimeControlObserverResetForFailure() {
|
|
2309
|
2384
|
aiCompanionAudioPlayer?.pause()
|
|
2310
|
2385
|
aiCompanionAudioPlayer = nil
|
|
|
@@ -2403,6 +2478,154 @@ private extension ViewController {
|
|
2403
|
2478
|
return "https://mock-audio.local/\(slug).mp3"
|
|
2404
|
2479
|
}
|
|
2405
|
2480
|
|
|
|
2481
|
+ @MainActor
|
|
|
2482
|
+ private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
|
|
|
2483
|
+ if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
|
|
|
2484
|
+ window.title = "Transcript - \(meetingTitle)"
|
|
|
2485
|
+ textView.string = initialText
|
|
|
2486
|
+ window.makeKeyAndOrderFront(nil)
|
|
|
2487
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
2488
|
+ return
|
|
|
2489
|
+ }
|
|
|
2490
|
+
|
|
|
2491
|
+ let windowWidth: CGFloat = 640
|
|
|
2492
|
+ let windowHeight: CGFloat = 560
|
|
|
2493
|
+ let window = NSWindow(
|
|
|
2494
|
+ contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
|
|
|
2495
|
+ styleMask: [.titled, .closable, .resizable],
|
|
|
2496
|
+ backing: .buffered,
|
|
|
2497
|
+ defer: false
|
|
|
2498
|
+ )
|
|
|
2499
|
+ window.isReleasedWhenClosed = false
|
|
|
2500
|
+ window.title = "Transcript - \(meetingTitle)"
|
|
|
2501
|
+ window.center()
|
|
|
2502
|
+
|
|
|
2503
|
+ let root = NSView()
|
|
|
2504
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2505
|
+
|
|
|
2506
|
+ let scroll = NSScrollView()
|
|
|
2507
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
2508
|
+ scroll.drawsBackground = false
|
|
|
2509
|
+ scroll.hasVerticalScroller = true
|
|
|
2510
|
+
|
|
|
2511
|
+ let textView = NSTextView()
|
|
|
2512
|
+ textView.isEditable = false
|
|
|
2513
|
+ textView.isSelectable = true
|
|
|
2514
|
+ textView.backgroundColor = .clear
|
|
|
2515
|
+ textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
|
|
|
2516
|
+ textView.string = initialText
|
|
|
2517
|
+ textView.textContainer?.widthTracksTextView = true
|
|
|
2518
|
+
|
|
|
2519
|
+ scroll.documentView = textView
|
|
|
2520
|
+ root.addSubview(scroll)
|
|
|
2521
|
+
|
|
|
2522
|
+ NSLayoutConstraint.activate([
|
|
|
2523
|
+ scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
|
|
|
2524
|
+ scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
|
|
|
2525
|
+ scroll.topAnchor.constraint(equalTo: root.topAnchor),
|
|
|
2526
|
+ scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
|
|
|
2527
|
+ ])
|
|
|
2528
|
+
|
|
|
2529
|
+ window.contentView = root
|
|
|
2530
|
+ window.makeKeyAndOrderFront(nil)
|
|
|
2531
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
2532
|
+
|
|
|
2533
|
+ aiCompanionTranscriptWindow = window
|
|
|
2534
|
+ aiCompanionTranscriptTextView = textView
|
|
|
2535
|
+ }
|
|
|
2536
|
+
|
|
|
2537
|
+ private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
|
|
|
2538
|
+ // Typical: https://meet.google.com/abc-defg-hij
|
|
|
2539
|
+ guard let host = meetURL.host?.lowercased(),
|
|
|
2540
|
+ host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
|
|
|
2541
|
+
|
|
|
2542
|
+ let codeCandidate = meetURL.pathComponents.filter { !$0.isEmpty }.last
|
|
|
2543
|
+ guard let codeCandidate else { return nil }
|
|
|
2544
|
+
|
|
|
2545
|
+ // Allow flexible token shapes; Meet codes are usually 3 hyphen-separated chunks.
|
|
|
2546
|
+ let cleaned = codeCandidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2547
|
+ let parts = cleaned.split(separator: "-")
|
|
|
2548
|
+ guard parts.count >= 3 else { return nil }
|
|
|
2549
|
+
|
|
|
2550
|
+ return cleaned
|
|
|
2551
|
+ }
|
|
|
2552
|
+
|
|
|
2553
|
+ private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
|
|
|
2554
|
+ // Prefer a record whose time window overlaps the calendar meeting.
|
|
|
2555
|
+ let overlapping = records.filter { r in
|
|
|
2556
|
+ guard let start = r.startTime, let end = r.endTime else { return false }
|
|
|
2557
|
+ return start <= meeting.endDate && end >= meeting.startDate
|
|
|
2558
|
+ }
|
|
|
2559
|
+ if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
|
|
|
2560
|
+ return best
|
|
|
2561
|
+ }
|
|
|
2562
|
+ // Fallback: choose the most recent one we can.
|
|
|
2563
|
+ return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
|
|
|
2564
|
+ }
|
|
|
2565
|
+
|
|
|
2566
|
+ private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
|
|
|
2567
|
+ let overlapping = transcripts.filter { t in
|
|
|
2568
|
+ guard let start = t.startTime, let end = t.endTime else { return false }
|
|
|
2569
|
+ return start <= meeting.endDate && end >= meeting.startDate
|
|
|
2570
|
+ }
|
|
|
2571
|
+ if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
|
|
|
2572
|
+ return best
|
|
|
2573
|
+ }
|
|
|
2574
|
+ return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
|
|
|
2575
|
+ }
|
|
|
2576
|
+
|
|
|
2577
|
+ private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
|
|
|
2578
|
+ let lines = entries.compactMap { entry -> String? in
|
|
|
2579
|
+ guard let raw = entry.text else { return nil }
|
|
|
2580
|
+ let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2581
|
+ guard t.isEmpty == false else { return nil }
|
|
|
2582
|
+
|
|
|
2583
|
+ let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
2584
|
+ guard let speaker, speaker.isEmpty == false else { return t }
|
|
|
2585
|
+ return "\(speaker): \(t)"
|
|
|
2586
|
+ }
|
|
|
2587
|
+
|
|
|
2588
|
+ if lines.isEmpty { return "(No transcript entries found.)" }
|
|
|
2589
|
+ return lines.joined(separator: "\n")
|
|
|
2590
|
+ }
|
|
|
2591
|
+
|
|
|
2592
|
+ private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
|
|
|
2593
|
+ guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
|
|
|
2594
|
+ throw NSError(
|
|
|
2595
|
+ domain: "AiCompanionTranscript",
|
|
|
2596
|
+ code: 1,
|
|
|
2597
|
+ userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
|
|
|
2598
|
+ )
|
|
|
2599
|
+ }
|
|
|
2600
|
+
|
|
|
2601
|
+ // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
|
|
|
2602
|
+ let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
|
|
|
2603
|
+ let spaceName = space.name ?? "spaces/\(meetingCode)"
|
|
|
2604
|
+
|
|
|
2605
|
+ let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
|
|
|
2606
|
+ guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
|
|
|
2607
|
+ let conferenceRecordName = conferenceRecord.name else {
|
|
|
2608
|
+ throw NSError(
|
|
|
2609
|
+ domain: "AiCompanionTranscript",
|
|
|
2610
|
+ code: 2,
|
|
|
2611
|
+ userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
|
|
|
2612
|
+ )
|
|
|
2613
|
+ }
|
|
|
2614
|
+
|
|
|
2615
|
+ let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
|
|
|
2616
|
+ guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
|
|
|
2617
|
+ let transcriptName = transcript.name else {
|
|
|
2618
|
+ throw NSError(
|
|
|
2619
|
+ domain: "AiCompanionTranscript",
|
|
|
2620
|
+ code: 3,
|
|
|
2621
|
+ userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
|
|
|
2622
|
+ )
|
|
|
2623
|
+ }
|
|
|
2624
|
+
|
|
|
2625
|
+ let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
|
|
|
2626
|
+ return aiCompanionFormatTranscriptText(entries: entries)
|
|
|
2627
|
+ }
|
|
|
2628
|
+
|
|
2406
|
2629
|
private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
|
|
2407
|
2630
|
let panel = NSView()
|
|
2408
|
2631
|
panel.translatesAutoresizingMaskIntoConstraints = false
|