|
@@ -270,6 +270,12 @@ final class ViewController: NSViewController {
|
|
|
private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
|
|
private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
|
|
|
private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
|
|
private var aiCompanionAudioURLByView = [ObjectIdentifier: URL]()
|
|
|
private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
private var aiCompanionAudioStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
|
|
+ private var aiCompanionTranscriptMeetingIdByView = [ObjectIdentifier: String]()
|
|
|
|
|
+ private var aiCompanionTranscriptStatusLabelByView = [ObjectIdentifier: NSTextField]()
|
|
|
|
|
+ private var aiCompanionTranscriptTextByMeetingId = [String: String]()
|
|
|
|
|
+ private var aiCompanionTranscriptCurrentRequestId: UUID?
|
|
|
|
|
+ private var aiCompanionTranscriptWindow: NSWindow?
|
|
|
|
|
+ private weak var aiCompanionTranscriptTextView: NSTextView?
|
|
|
private var aiCompanionAudioPlayer: AVPlayer?
|
|
private var aiCompanionAudioPlayer: AVPlayer?
|
|
|
private var aiCompanionCurrentlyPlayingURL: URL?
|
|
private var aiCompanionCurrentlyPlayingURL: URL?
|
|
|
private weak var aiCompanionCurrentlyPlayingButton: NSButton?
|
|
private weak var aiCompanionCurrentlyPlayingButton: NSButton?
|
|
@@ -314,6 +320,7 @@ final class ViewController: NSViewController {
|
|
|
private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
|
|
private var mainContentHostTopToPanelConstraint: NSLayoutConstraint?
|
|
|
private let googleOAuth = GoogleOAuthService.shared
|
|
private let googleOAuth = GoogleOAuthService.shared
|
|
|
private let calendarClient = GoogleCalendarClient()
|
|
private let calendarClient = GoogleCalendarClient()
|
|
|
|
|
+ private let googleMeetClient = GoogleMeetClient()
|
|
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
private let storeKitCoordinator = StoreKitCoordinator()
|
|
|
private var storeKitStartupTask: Task<Void, Never>?
|
|
private var storeKitStartupTask: Task<Void, Never>?
|
|
|
private var paywallPurchaseTask: Task<Void, Never>?
|
|
private var paywallPurchaseTask: Task<Void, Never>?
|
|
@@ -1977,6 +1984,13 @@ private extension ViewController {
|
|
|
aiCompanionAudioURLByView.removeAll()
|
|
aiCompanionAudioURLByView.removeAll()
|
|
|
aiCompanionAudioStatusLabelByView.removeAll()
|
|
aiCompanionAudioStatusLabelByView.removeAll()
|
|
|
aiCompanionSpeechTextByView.removeAll()
|
|
aiCompanionSpeechTextByView.removeAll()
|
|
|
|
|
+ aiCompanionTranscriptMeetingIdByView.removeAll()
|
|
|
|
|
+ aiCompanionTranscriptStatusLabelByView.removeAll()
|
|
|
|
|
+ aiCompanionTranscriptTextByMeetingId.removeAll()
|
|
|
|
|
+ aiCompanionTranscriptCurrentRequestId = nil
|
|
|
|
|
+ aiCompanionTranscriptWindow?.close()
|
|
|
|
|
+ aiCompanionTranscriptWindow = nil
|
|
|
|
|
+ aiCompanionTranscriptTextView = nil
|
|
|
|
|
|
|
|
let panel = NSView()
|
|
let panel = NSView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -2012,7 +2026,7 @@ private extension ViewController {
|
|
|
titleLabel.alignment = .left
|
|
titleLabel.alignment = .left
|
|
|
contentStack.addArrangedSubview(titleLabel)
|
|
contentStack.addArrangedSubview(titleLabel)
|
|
|
|
|
|
|
|
- let subtitle = textLabel("Ended meetings with temporary audio links", font: typography.fieldLabel, color: palette.textSecondary)
|
|
|
|
|
|
|
+ let subtitle = textLabel("Ended meetings with transcripts", font: typography.fieldLabel, color: palette.textSecondary)
|
|
|
subtitle.alignment = .left
|
|
subtitle.alignment = .left
|
|
|
contentStack.addArrangedSubview(subtitle)
|
|
contentStack.addArrangedSubview(subtitle)
|
|
|
contentStack.setCustomSpacing(14, after: subtitle)
|
|
contentStack.setCustomSpacing(14, after: subtitle)
|
|
@@ -2026,7 +2040,7 @@ private extension ViewController {
|
|
|
|
|
|
|
|
if endedMeetings.isEmpty {
|
|
if endedMeetings.isEmpty {
|
|
|
let emptyLabel = textLabel(
|
|
let emptyLabel = textLabel(
|
|
|
- "No ended meetings yet. Audio items will appear here after meetings end.",
|
|
|
|
|
|
|
+ "No ended meetings yet. Transcript items will appear here after meetings end.",
|
|
|
font: typography.fieldLabel,
|
|
font: typography.fieldLabel,
|
|
|
color: palette.textMuted
|
|
color: palette.textMuted
|
|
|
)
|
|
)
|
|
@@ -2116,11 +2130,30 @@ private extension ViewController {
|
|
|
audioStatusLabel.lineBreakMode = .byTruncatingTail
|
|
audioStatusLabel.lineBreakMode = .byTruncatingTail
|
|
|
aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
|
|
aiCompanionAudioStatusLabelByView[ObjectIdentifier(audioButton)] = audioStatusLabel
|
|
|
|
|
|
|
|
|
|
+ let transcriptButton = NSButton(title: "View transcript", target: self, action: #selector(aiCompanionTranscriptTapped(_:)))
|
|
|
|
|
+ transcriptButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ transcriptButton.isBordered = false
|
|
|
|
|
+ transcriptButton.bezelStyle = .inline
|
|
|
|
|
+ transcriptButton.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
+ transcriptButton.contentTintColor = palette.primaryBlue
|
|
|
|
|
+ transcriptButton.alignment = .left
|
|
|
|
|
+ transcriptButton.setButtonType(.momentaryPushIn)
|
|
|
|
|
+
|
|
|
|
|
+ aiCompanionTranscriptMeetingIdByView[ObjectIdentifier(transcriptButton)] = meeting.id
|
|
|
|
|
+
|
|
|
|
|
+ let transcriptStatusLabel = textLabel("Transcript not loaded", font: typography.fieldLabel, color: palette.textMuted)
|
|
|
|
|
+ transcriptStatusLabel.alignment = .left
|
|
|
|
|
+ transcriptStatusLabel.maximumNumberOfLines = 2
|
|
|
|
|
+ transcriptStatusLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ aiCompanionTranscriptStatusLabelByView[ObjectIdentifier(transcriptButton)] = transcriptStatusLabel
|
|
|
|
|
+
|
|
|
stack.addArrangedSubview(title)
|
|
stack.addArrangedSubview(title)
|
|
|
stack.addArrangedSubview(dateLabel)
|
|
stack.addArrangedSubview(dateLabel)
|
|
|
stack.addArrangedSubview(audioButton)
|
|
stack.addArrangedSubview(audioButton)
|
|
|
stack.addArrangedSubview(audioURLLabel)
|
|
stack.addArrangedSubview(audioURLLabel)
|
|
|
stack.addArrangedSubview(audioStatusLabel)
|
|
stack.addArrangedSubview(audioStatusLabel)
|
|
|
|
|
+ stack.addArrangedSubview(transcriptButton)
|
|
|
|
|
+ stack.addArrangedSubview(transcriptStatusLabel)
|
|
|
|
|
|
|
|
card.addSubview(stack)
|
|
card.addSubview(stack)
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
@@ -2305,6 +2338,48 @@ private extension ViewController {
|
|
|
}.resume()
|
|
}.resume()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @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 }
|
|
|
|
|
+
|
|
|
|
|
+ if let cached = aiCompanionTranscriptTextByMeetingId[meetingId] {
|
|
|
|
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: cached)
|
|
|
|
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Loading transcript..."
|
|
|
|
|
+
|
|
|
|
|
+ let requestId = UUID()
|
|
|
|
|
+ aiCompanionTranscriptCurrentRequestId = requestId
|
|
|
|
|
+
|
|
|
|
|
+ aiCompanionPresentTranscriptWindow(meetingTitle: meeting.title, initialText: "Loading transcript...")
|
|
|
|
|
+
|
|
|
|
|
+ Task { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
|
|
|
|
|
+ let text = try await self.aiCompanionFetchTranscriptText(for: meeting, accessToken: token)
|
|
|
|
|
+
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return } // stale request
|
|
|
|
|
+ self.aiCompanionTranscriptTextByMeetingId[meetingId] = text
|
|
|
|
|
+ self.aiCompanionTranscriptTextView?.string = text
|
|
|
|
|
+ self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript ready"
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ await MainActor.run {
|
|
|
|
|
+ guard self.aiCompanionTranscriptCurrentRequestId == requestId else { return }
|
|
|
|
|
+ let msg = error.localizedDescription.isEmpty ? "Failed to load transcript." : error.localizedDescription
|
|
|
|
|
+ self.aiCompanionTranscriptTextView?.string = "Transcript unavailable.\n\n\(msg)"
|
|
|
|
|
+ self.aiCompanionTranscriptStatusLabelByView[senderId]?.stringValue = "Transcript unavailable"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func aiCompanionAudioTimeControlObserverResetForFailure() {
|
|
private func aiCompanionAudioTimeControlObserverResetForFailure() {
|
|
|
aiCompanionAudioPlayer?.pause()
|
|
aiCompanionAudioPlayer?.pause()
|
|
|
aiCompanionAudioPlayer = nil
|
|
aiCompanionAudioPlayer = nil
|
|
@@ -2403,6 +2478,154 @@ private extension ViewController {
|
|
|
return "https://mock-audio.local/\(slug).mp3"
|
|
return "https://mock-audio.local/\(slug).mp3"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func aiCompanionPresentTranscriptWindow(meetingTitle: String, initialText: String) {
|
|
|
|
|
+ if let window = aiCompanionTranscriptWindow, let textView = aiCompanionTranscriptTextView {
|
|
|
|
|
+ window.title = "Transcript - \(meetingTitle)"
|
|
|
|
|
+ textView.string = initialText
|
|
|
|
|
+ window.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let windowWidth: CGFloat = 640
|
|
|
|
|
+ let windowHeight: CGFloat = 560
|
|
|
|
|
+ let window = NSWindow(
|
|
|
|
|
+ contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
|
|
|
|
|
+ styleMask: [.titled, .closable, .resizable],
|
|
|
|
|
+ backing: .buffered,
|
|
|
|
|
+ defer: false
|
|
|
|
|
+ )
|
|
|
|
|
+ window.isReleasedWhenClosed = false
|
|
|
|
|
+ window.title = "Transcript - \(meetingTitle)"
|
|
|
|
|
+ window.center()
|
|
|
|
|
+
|
|
|
|
|
+ let root = NSView()
|
|
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ let scroll = NSScrollView()
|
|
|
|
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scroll.drawsBackground = false
|
|
|
|
|
+ scroll.hasVerticalScroller = true
|
|
|
|
|
+
|
|
|
|
|
+ let textView = NSTextView()
|
|
|
|
|
+ textView.isEditable = false
|
|
|
|
|
+ textView.isSelectable = true
|
|
|
|
|
+ textView.backgroundColor = .clear
|
|
|
|
|
+ textView.font = NSFont.systemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
+ textView.string = initialText
|
|
|
|
|
+ textView.textContainer?.widthTracksTextView = true
|
|
|
|
|
+
|
|
|
|
|
+ scroll.documentView = textView
|
|
|
|
|
+ root.addSubview(scroll)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor),
|
|
|
|
|
+ scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor),
|
|
|
|
|
+ scroll.topAnchor.constraint(equalTo: root.topAnchor),
|
|
|
|
|
+ scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor)
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ window.contentView = root
|
|
|
|
|
+ window.makeKeyAndOrderFront(nil)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+
|
|
|
|
|
+ aiCompanionTranscriptWindow = window
|
|
|
|
|
+ aiCompanionTranscriptTextView = textView
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionMeetMeetingCode(from meetURL: URL) -> String? {
|
|
|
|
|
+ // Typical: https://meet.google.com/abc-defg-hij
|
|
|
|
|
+ guard let host = meetURL.host?.lowercased(),
|
|
|
|
|
+ host == "meet.google.com" || host.hasSuffix(".meet.google.com") else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ let codeCandidate = meetURL.pathComponents.filter { !$0.isEmpty }.last
|
|
|
|
|
+ guard let codeCandidate else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ // Allow flexible token shapes; Meet codes are usually 3 hyphen-separated chunks.
|
|
|
|
|
+ let cleaned = codeCandidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let parts = cleaned.split(separator: "-")
|
|
|
|
|
+ guard parts.count >= 3 else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ return cleaned
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionSelectConferenceRecord(for meeting: ScheduledMeeting, from records: [ConferenceRecord]) -> ConferenceRecord? {
|
|
|
|
|
+ // Prefer a record whose time window overlaps the calendar meeting.
|
|
|
|
|
+ let overlapping = records.filter { r in
|
|
|
|
|
+ guard let start = r.startTime, let end = r.endTime else { return false }
|
|
|
|
|
+ return start <= meeting.endDate && end >= meeting.startDate
|
|
|
|
|
+ }
|
|
|
|
|
+ if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
|
|
|
|
|
+ return best
|
|
|
|
|
+ }
|
|
|
|
|
+ // Fallback: choose the most recent one we can.
|
|
|
|
|
+ return records.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionSelectTranscript(for meeting: ScheduledMeeting, from transcripts: [Transcript]) -> Transcript? {
|
|
|
|
|
+ let overlapping = transcripts.filter { t in
|
|
|
|
|
+ guard let start = t.startTime, let end = t.endTime else { return false }
|
|
|
|
|
+ return start <= meeting.endDate && end >= meeting.startDate
|
|
|
|
|
+ }
|
|
|
|
|
+ if let best = overlapping.sorted(by: { ($0.endTime ?? .distantPast) > ($1.endTime ?? .distantPast) }).first {
|
|
|
|
|
+ return best
|
|
|
|
|
+ }
|
|
|
|
|
+ return transcripts.sorted(by: { ($0.startTime ?? .distantPast) > ($1.startTime ?? .distantPast) }).first
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionFormatTranscriptText(entries: [TranscriptEntry]) -> String {
|
|
|
|
|
+ let lines = entries.compactMap { entry -> String? in
|
|
|
|
|
+ guard let raw = entry.text else { return nil }
|
|
|
|
|
+ let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard t.isEmpty == false else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ let speaker = entry.participant?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard let speaker, speaker.isEmpty == false else { return t }
|
|
|
|
|
+ return "\(speaker): \(t)"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if lines.isEmpty { return "(No transcript entries found.)" }
|
|
|
|
|
+ return lines.joined(separator: "\n")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func aiCompanionFetchTranscriptText(for meeting: ScheduledMeeting, accessToken: String) async throws -> String {
|
|
|
|
|
+ guard let meetingCode = aiCompanionMeetMeetingCode(from: meeting.meetURL) else {
|
|
|
|
|
+ throw NSError(
|
|
|
|
|
+ domain: "AiCompanionTranscript",
|
|
|
|
|
+ code: 1,
|
|
|
|
|
+ userInfo: [NSLocalizedDescriptionKey: "Couldn't determine Meet meeting code from URL."]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Resolve `spaces/{meetingCode}` into a stable space resource name (`spaces/{spaceId}`).
|
|
|
|
|
+ let space = try await googleMeetClient.getSpace(accessToken: accessToken, spaceNameOrMeetingCode: "spaces/\(meetingCode)")
|
|
|
|
|
+ let spaceName = space.name ?? "spaces/\(meetingCode)"
|
|
|
|
|
+
|
|
|
|
|
+ let records = try await googleMeetClient.listConferenceRecords(accessToken: accessToken, spaceResourceName: spaceName)
|
|
|
|
|
+ guard let conferenceRecord = aiCompanionSelectConferenceRecord(for: meeting, from: records),
|
|
|
|
|
+ let conferenceRecordName = conferenceRecord.name else {
|
|
|
|
|
+ throw NSError(
|
|
|
|
|
+ domain: "AiCompanionTranscript",
|
|
|
|
|
+ code: 2,
|
|
|
|
|
+ userInfo: [NSLocalizedDescriptionKey: "No conference record found for this meeting."]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let transcripts = try await googleMeetClient.listTranscripts(accessToken: accessToken, conferenceRecordName: conferenceRecordName)
|
|
|
|
|
+ guard let transcript = aiCompanionSelectTranscript(for: meeting, from: transcripts),
|
|
|
|
|
+ let transcriptName = transcript.name else {
|
|
|
|
|
+ throw NSError(
|
|
|
|
|
+ domain: "AiCompanionTranscript",
|
|
|
|
|
+ code: 3,
|
|
|
|
|
+ userInfo: [NSLocalizedDescriptionKey: "No transcript found for this meeting."]
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let entries = try await googleMeetClient.listTranscriptEntries(accessToken: accessToken, transcriptName: transcriptName)
|
|
|
|
|
+ return aiCompanionFormatTranscriptText(entries: entries)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
|
|
private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
|
|
|
let panel = NSView()
|
|
let panel = NSView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|