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