|
@@ -8,6 +8,7 @@
|
|
|
import Cocoa
|
|
import Cocoa
|
|
|
import Combine
|
|
import Combine
|
|
|
import CryptoKit
|
|
import CryptoKit
|
|
|
|
|
+import AVFoundation
|
|
|
import StoreKit
|
|
import StoreKit
|
|
|
import SwiftUI
|
|
import SwiftUI
|
|
|
import UserNotifications
|
|
import UserNotifications
|
|
@@ -328,6 +329,13 @@ class ViewController: NSViewController {
|
|
|
private var aiCompanionIsLoading: Bool = false
|
|
private var aiCompanionIsLoading: Bool = false
|
|
|
private var aiCompanionLastLoadedAt: Date?
|
|
private var aiCompanionLastLoadedAt: Date?
|
|
|
private var aiCompanionSelectedRange: String = "Last 30 days"
|
|
private var aiCompanionSelectedRange: String = "Last 30 days"
|
|
|
|
|
+
|
|
|
|
|
+ // New AI Companion mode: local meeting recordings (audio)
|
|
|
|
|
+ private var recordedMeetings: [MeetingRecordingManager.RecordedMeeting] = []
|
|
|
|
|
+ private var recordedMeetingsPlayer: AVAudioPlayer?
|
|
|
|
|
+ private var recordedMeetingsPlayingID: String?
|
|
|
|
|
+ private weak var aiCompanionStopRecordingButton: NSButton?
|
|
|
|
|
+ private weak var aiCompanionOpenRecordingsFolderButton: NSButton?
|
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
@@ -641,6 +649,27 @@ class ViewController: NSViewController {
|
|
|
meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
|
|
meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
|
|
|
ensureUsageSessionStartDate()
|
|
ensureUsageSessionStartDate()
|
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
scheduleUsageBasedRatingPromptIfNeeded()
|
|
|
|
|
+ recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: MeetingRecordingManager.recordingsDidChangeNotification,
|
|
|
|
|
+ object: nil,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self] _ in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ self.renderAiCompanionList()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
|
|
+ forName: MeetingRecordingManager.recordingStateDidChangeNotification,
|
|
|
|
|
+ object: nil,
|
|
|
|
|
+ queue: .main
|
|
|
|
|
+ ) { [weak self] _ in
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ self?.updateAiCompanionRecordingUI()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
setupUI()
|
|
setupUI()
|
|
|
setupWidgetBridge()
|
|
setupWidgetBridge()
|
|
|
startStoreKit()
|
|
startStoreKit()
|
|
@@ -679,6 +708,9 @@ class ViewController: NSViewController {
|
|
|
if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
|
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
updateEmbeddedRightPanelWidthForOpenState()
|
|
|
}
|
|
}
|
|
|
|
|
+ if aiCompanionPageRootView?.isHidden == false {
|
|
|
|
|
+ refreshAiCompanionStatusPreferredMaxLayoutWidth()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func setupUI() {
|
|
private func setupUI() {
|
|
@@ -764,6 +796,8 @@ class ViewController: NSViewController {
|
|
|
aiCompanionDetailEmptyLabel = nil
|
|
aiCompanionDetailEmptyLabel = nil
|
|
|
aiCompanionOpenDocButton = nil
|
|
aiCompanionOpenDocButton = nil
|
|
|
aiCompanionCopyMarkdownButton = nil
|
|
aiCompanionCopyMarkdownButton = nil
|
|
|
|
|
+ aiCompanionStopRecordingButton = nil
|
|
|
|
|
+ aiCompanionOpenRecordingsFolderButton = nil
|
|
|
aiCompanionRowButtons.removeAll()
|
|
aiCompanionRowButtons.removeAll()
|
|
|
aiCompanionLastLoadedAt = nil
|
|
aiCompanionLastLoadedAt = nil
|
|
|
homeView?.removeFromSuperview()
|
|
homeView?.removeFromSuperview()
|
|
@@ -1666,6 +1700,15 @@ class ViewController: NSViewController {
|
|
|
let opened = openURLInDefaultBrowser(url)
|
|
let opened = openURLInDefaultBrowser(url)
|
|
|
if opened {
|
|
if opened {
|
|
|
joinMeetingWindow?.performClose(nil)
|
|
joinMeetingWindow?.performClose(nil)
|
|
|
|
|
+ // AI Companion mode requirement: automatically record every meeting we start/join.
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
|
}
|
|
}
|
|
@@ -1812,15 +1855,11 @@ class ViewController: NSViewController {
|
|
|
zoomOAuth.clearSavedTokens()
|
|
zoomOAuth.clearSavedTokens()
|
|
|
persistLoggedInState(false)
|
|
persistLoggedInState(false)
|
|
|
allScheduledMeetings = []
|
|
allScheduledMeetings = []
|
|
|
- aiCompanionSummaries = []
|
|
|
|
|
- aiCompanionSelectedIndex = nil
|
|
|
|
|
- aiCompanionLastLoadedAt = nil
|
|
|
|
|
applyAuthenticationUIState(triggerInitialRefresh: false)
|
|
applyAuthenticationUIState(triggerInitialRefresh: false)
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
self.applyMeetings([])
|
|
self.applyMeetings([])
|
|
|
self.renderAiCompanionList()
|
|
self.renderAiCompanionList()
|
|
|
- self.renderAiCompanionDetail()
|
|
|
|
|
- self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
|
|
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -6518,6 +6557,8 @@ class ViewController: NSViewController {
|
|
|
let root = NSView()
|
|
let root = NSView()
|
|
|
root.wantsLayer = true
|
|
root.wantsLayer = true
|
|
|
root.layer?.backgroundColor = NSColor.clear.cgColor
|
|
root.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ // Let the window dictate width; long status copy must not inflate the layout minimum.
|
|
|
|
|
+ root.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
|
|
|
let container = NSView()
|
|
let container = NSView()
|
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
@@ -6528,40 +6569,47 @@ class ViewController: NSViewController {
|
|
|
container.layer?.borderColor = palette.inputBorder.cgColor
|
|
container.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
root.addSubview(container)
|
|
root.addSubview(container)
|
|
|
|
|
|
|
|
- // Header row: title / subtitle / count + range selector + refresh.
|
|
|
|
|
|
|
+ // Header row: recordings list (AI Companion mode).
|
|
|
let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
|
|
let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
|
|
|
- let subtitle = makeLabel("AI-generated notes from your past Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
|
|
- let count = makeLabel("0 summaries", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
|
|
|
|
+ let subtitle = makeLabel("Recorded meetings (audio)", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
|
|
+ let count = makeLabel("0 recordings", size: 12, color: secondaryText, weight: .medium, centered: false)
|
|
|
let titleStack = NSStackView(views: [title, subtitle, count])
|
|
let titleStack = NSStackView(views: [title, subtitle, count])
|
|
|
titleStack.orientation = .vertical
|
|
titleStack.orientation = .vertical
|
|
|
titleStack.spacing = 4
|
|
titleStack.spacing = 4
|
|
|
titleStack.alignment = .leading
|
|
titleStack.alignment = .leading
|
|
|
titleStack.translatesAutoresizingMaskIntoConstraints = false
|
|
titleStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- let rangePopup = NSPopUpButton()
|
|
|
|
|
- rangePopup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- rangePopup.addItems(withTitles: ["Last 7 days", "Last 30 days", "Last 90 days", "All available"])
|
|
|
|
|
- rangePopup.selectItem(withTitle: aiCompanionSelectedRange)
|
|
|
|
|
- rangePopup.target = self
|
|
|
|
|
- rangePopup.action = #selector(aiCompanionRangeChanged(_:))
|
|
|
|
|
-
|
|
|
|
|
- let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
|
|
|
|
|
- refresh.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- refresh.isBordered = false
|
|
|
|
|
- refresh.wantsLayer = true
|
|
|
|
|
- refresh.layer?.backgroundColor = accentBlue.cgColor
|
|
|
|
|
- refresh.layer?.cornerRadius = 10
|
|
|
|
|
- refresh.contentTintColor = .white
|
|
|
|
|
- refresh.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ // Old Zoom AI summaries controls are intentionally hidden/unused.
|
|
|
let loadingIndicator = NSProgressIndicator()
|
|
let loadingIndicator = NSProgressIndicator()
|
|
|
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
|
|
loadingIndicator.style = .spinning
|
|
loadingIndicator.style = .spinning
|
|
|
loadingIndicator.controlSize = .small
|
|
loadingIndicator.controlSize = .small
|
|
|
loadingIndicator.isDisplayedWhenStopped = false
|
|
loadingIndicator.isDisplayedWhenStopped = false
|
|
|
loadingIndicator.isIndeterminate = true
|
|
loadingIndicator.isIndeterminate = true
|
|
|
-
|
|
|
|
|
- let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
|
|
|
|
|
|
|
+ loadingIndicator.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ let stopRecording = NSButton(title: "Stop & save", target: self, action: #selector(aiCompanionStopRecordingTapped))
|
|
|
|
|
+ stopRecording.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ stopRecording.isBordered = false
|
|
|
|
|
+ stopRecording.wantsLayer = true
|
|
|
|
|
+ stopRecording.layer?.backgroundColor = accentBlue.cgColor
|
|
|
|
|
+ stopRecording.layer?.cornerRadius = 10
|
|
|
|
|
+ stopRecording.contentTintColor = .white
|
|
|
|
|
+ stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
+ stopRecording.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ let openFolder = NSButton(title: "Show folder", target: self, action: #selector(aiCompanionOpenRecordingsFolderTapped))
|
|
|
|
|
+ openFolder.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ openFolder.isBordered = false
|
|
|
|
|
+ openFolder.wantsLayer = true
|
|
|
|
|
+ openFolder.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
|
|
|
|
|
+ openFolder.layer?.cornerRadius = 10
|
|
|
|
|
+ openFolder.layer?.borderWidth = 1
|
|
|
|
|
+ openFolder.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ openFolder.contentTintColor = primaryText
|
|
|
|
|
+ openFolder.font = .systemFont(ofSize: 13, weight: .semibold)
|
|
|
|
|
+
|
|
|
|
|
+ let toolbarRight = NSStackView(views: [openFolder, stopRecording, loadingIndicator])
|
|
|
toolbarRight.orientation = .horizontal
|
|
toolbarRight.orientation = .horizontal
|
|
|
toolbarRight.alignment = .centerY
|
|
toolbarRight.alignment = .centerY
|
|
|
toolbarRight.spacing = 10
|
|
toolbarRight.spacing = 10
|
|
@@ -6571,8 +6619,14 @@ class ViewController: NSViewController {
|
|
|
container.addSubview(toolbarRight)
|
|
container.addSubview(toolbarRight)
|
|
|
|
|
|
|
|
// Status label below header.
|
|
// Status label below header.
|
|
|
- let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
|
|
|
|
+ let status = makeLabel("", size: 12, color: secondaryText, weight: .regular, centered: false)
|
|
|
status.translatesAutoresizingMaskIntoConstraints = false
|
|
status.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
|
+ status.lineBreakMode = .byWordWrapping
|
|
|
|
|
+ status.maximumNumberOfLines = 0
|
|
|
|
|
+ if let cell = status.cell as? NSTextFieldCell {
|
|
|
|
|
+ cell.wraps = true
|
|
|
|
|
+ }
|
|
|
container.addSubview(status)
|
|
container.addSubview(status)
|
|
|
|
|
|
|
|
// List scroll view (left column).
|
|
// List scroll view (left column).
|
|
@@ -6598,47 +6652,19 @@ class ViewController: NSViewController {
|
|
|
listScroll.documentView = listStack
|
|
listScroll.documentView = listStack
|
|
|
container.addSubview(listScroll)
|
|
container.addSubview(listScroll)
|
|
|
|
|
|
|
|
- let listEmpty = makeLabel("No AI notes yet for the selected range.", size: 12, color: secondaryText, weight: .regular, centered: true)
|
|
|
|
|
|
|
+ let listEmpty = makeLabel("No recordings yet.", size: 12, color: secondaryText, weight: .regular, centered: true)
|
|
|
listEmpty.translatesAutoresizingMaskIntoConstraints = false
|
|
listEmpty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
listEmpty.maximumNumberOfLines = 3
|
|
listEmpty.maximumNumberOfLines = 3
|
|
|
listEmpty.lineBreakMode = .byWordWrapping
|
|
listEmpty.lineBreakMode = .byWordWrapping
|
|
|
listEmpty.isHidden = true
|
|
listEmpty.isHidden = true
|
|
|
container.addSubview(listEmpty)
|
|
container.addSubview(listEmpty)
|
|
|
|
|
|
|
|
- // Detail scroll view (right column).
|
|
|
|
|
- let detailScroll = NSScrollView()
|
|
|
|
|
- detailScroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- detailScroll.drawsBackground = false
|
|
|
|
|
- detailScroll.hasHorizontalScroller = false
|
|
|
|
|
- detailScroll.hasVerticalScroller = true
|
|
|
|
|
- detailScroll.autohidesScrollers = true
|
|
|
|
|
- detailScroll.borderType = .noBorder
|
|
|
|
|
- detailScroll.scrollerStyle = .overlay
|
|
|
|
|
- detailScroll.automaticallyAdjustsContentInsets = false
|
|
|
|
|
-
|
|
|
|
|
- let detailClip = TopAlignedClipView()
|
|
|
|
|
- detailClip.drawsBackground = false
|
|
|
|
|
- detailScroll.contentView = detailClip
|
|
|
|
|
-
|
|
|
|
|
- let detailStack = NSStackView()
|
|
|
|
|
- detailStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- detailStack.orientation = .vertical
|
|
|
|
|
- detailStack.alignment = .leading
|
|
|
|
|
- detailStack.spacing = 14
|
|
|
|
|
- detailStack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 20, right: 20)
|
|
|
|
|
- detailScroll.documentView = detailStack
|
|
|
|
|
- container.addSubview(detailScroll)
|
|
|
|
|
-
|
|
|
|
|
- let detailEmpty = makeLabel("Select a meeting on the left to view its AI notes.", size: 13, color: secondaryText, weight: .regular, centered: true)
|
|
|
|
|
- detailEmpty.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- detailEmpty.maximumNumberOfLines = 3
|
|
|
|
|
- detailEmpty.lineBreakMode = .byWordWrapping
|
|
|
|
|
- container.addSubview(detailEmpty)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ // Old detail pane (AI notes) removed.
|
|
|
let divider = NSView()
|
|
let divider = NSView()
|
|
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
divider.wantsLayer = true
|
|
divider.wantsLayer = true
|
|
|
divider.layer?.backgroundColor = palette.inputBorder.cgColor
|
|
divider.layer?.backgroundColor = palette.inputBorder.cgColor
|
|
|
|
|
+ divider.isHidden = true
|
|
|
container.addSubview(divider)
|
|
container.addSubview(divider)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
@@ -6654,10 +6680,10 @@ class ViewController: NSViewController {
|
|
|
toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
|
|
toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
|
|
|
toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
|
|
|
|
|
|
|
|
- refresh.heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
- refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
|
|
|
|
|
- rangePopup.heightAnchor.constraint(equalToConstant: 28),
|
|
|
|
|
- rangePopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
|
|
|
|
|
|
|
+ stopRecording.heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
+ stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
|
|
|
|
|
+ openFolder.heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
+ openFolder.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
|
|
|
|
|
|
|
|
status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
|
|
status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
|
|
|
status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
|
|
@@ -6666,12 +6692,13 @@ class ViewController: NSViewController {
|
|
|
listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
|
|
listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
|
|
|
listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
|
|
|
listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
|
|
listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
|
|
|
- listScroll.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.34),
|
|
|
|
|
|
|
+ listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
|
|
|
|
|
|
|
+ // Pin document view to the clip view without horizontal insets: when the clip is still
|
|
|
|
|
+ // width 0 during layout, leading/trailing + width - 16 over-constrains and breaks.
|
|
|
listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
|
|
listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
|
|
|
- listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor, constant: 8),
|
|
|
|
|
- listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor, constant: -8),
|
|
|
|
|
- listStack.widthAnchor.constraint(equalTo: listClip.widthAnchor, constant: -16),
|
|
|
|
|
|
|
+ listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor),
|
|
|
|
|
+ listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor),
|
|
|
|
|
|
|
|
listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
|
|
listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
|
|
|
listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
|
|
listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
|
|
@@ -6680,40 +6707,67 @@ class ViewController: NSViewController {
|
|
|
divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
|
|
divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
|
|
|
divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
|
|
divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
|
|
|
divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
|
|
divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
|
|
|
- divider.widthAnchor.constraint(equalToConstant: 1),
|
|
|
|
|
-
|
|
|
|
|
- detailScroll.topAnchor.constraint(equalTo: listScroll.topAnchor),
|
|
|
|
|
- detailScroll.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
|
|
|
|
|
- detailScroll.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10),
|
|
|
|
|
- detailScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
|
|
|
|
|
-
|
|
|
|
|
- detailStack.topAnchor.constraint(equalTo: detailClip.topAnchor),
|
|
|
|
|
- detailStack.leadingAnchor.constraint(equalTo: detailClip.leadingAnchor),
|
|
|
|
|
- detailStack.trailingAnchor.constraint(equalTo: detailClip.trailingAnchor),
|
|
|
|
|
- detailStack.widthAnchor.constraint(equalTo: detailClip.widthAnchor),
|
|
|
|
|
-
|
|
|
|
|
- detailEmpty.centerXAnchor.constraint(equalTo: detailScroll.centerXAnchor),
|
|
|
|
|
- detailEmpty.centerYAnchor.constraint(equalTo: detailScroll.centerYAnchor),
|
|
|
|
|
- detailEmpty.widthAnchor.constraint(lessThanOrEqualTo: detailScroll.widthAnchor, constant: -32)
|
|
|
|
|
|
|
+ divider.widthAnchor.constraint(equalToConstant: 1)
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
aiCompanionListScroll = listScroll
|
|
aiCompanionListScroll = listScroll
|
|
|
aiCompanionListStack = listStack
|
|
aiCompanionListStack = listStack
|
|
|
- aiCompanionDetailScroll = detailScroll
|
|
|
|
|
- aiCompanionDetailStack = detailStack
|
|
|
|
|
aiCompanionStatusLabel = status
|
|
aiCompanionStatusLabel = status
|
|
|
aiCompanionCountLabel = count
|
|
aiCompanionCountLabel = count
|
|
|
- aiCompanionRefreshButton = refresh
|
|
|
|
|
- aiCompanionRangePopup = rangePopup
|
|
|
|
|
aiCompanionEmptyLabel = listEmpty
|
|
aiCompanionEmptyLabel = listEmpty
|
|
|
aiCompanionLoadingIndicator = loadingIndicator
|
|
aiCompanionLoadingIndicator = loadingIndicator
|
|
|
- aiCompanionDetailEmptyLabel = detailEmpty
|
|
|
|
|
|
|
+ aiCompanionDetailEmptyLabel = nil
|
|
|
|
|
+ aiCompanionDetailScroll = nil
|
|
|
|
|
+ aiCompanionDetailStack = nil
|
|
|
|
|
+ aiCompanionStopRecordingButton = stopRecording
|
|
|
|
|
+ aiCompanionOpenRecordingsFolderButton = openFolder
|
|
|
|
|
|
|
|
renderAiCompanionList()
|
|
renderAiCompanionList()
|
|
|
- renderAiCompanionDetail()
|
|
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
return root
|
|
return root
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func updateAiCompanionRecordingUI() {
|
|
|
|
|
+ let mgr = MeetingRecordingManager.shared
|
|
|
|
|
+ aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
|
|
|
|
|
+ if mgr.isRecording {
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue =
|
|
|
|
|
+ "Recording… When you leave the meeting, click Stop & save (here or in the menu bar). Files are stored in the app sandbox—use Show folder to open the Recordings location in Finder."
|
|
|
|
|
+ } else {
|
|
|
|
|
+ aiCompanionStatusLabel?.stringValue =
|
|
|
|
|
+ "Start or join a meeting from this app to record. Saved audio is stored in the app sandbox; use Show folder to open the Recordings folder in Finder."
|
|
|
|
|
+ }
|
|
|
|
|
+ refreshAiCompanionStatusPreferredMaxLayoutWidth()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Keeps the status field wrapping inside the card so a long path or error string cannot widen the window.
|
|
|
|
|
+ @MainActor
|
|
|
|
|
+ private func refreshAiCompanionStatusPreferredMaxLayoutWidth() {
|
|
|
|
|
+ guard let status = aiCompanionStatusLabel, let container = status.superview else { return }
|
|
|
|
|
+ let w = container.bounds.width
|
|
|
|
|
+ guard w > 1 else { return }
|
|
|
|
|
+ let margin: CGFloat = 48
|
|
|
|
|
+ status.preferredMaxLayoutWidth = max(160, w - margin)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func aiCompanionStopRecordingTapped() {
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await MeetingRecordingManager.shared.stopRecording()
|
|
|
|
|
+ self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ self.renderAiCompanionList()
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.showSimpleAlert(title: "Could not save recording", message: error.localizedDescription)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func aiCompanionOpenRecordingsFolderTapped() {
|
|
|
|
|
+ MeetingRecordingManager.shared.revealRecordingsInFinder()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// MARK: - AI Companion list / detail rendering
|
|
// MARK: - AI Companion list / detail rendering
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -6725,85 +6779,102 @@ class ViewController: NSViewController {
|
|
|
view.removeFromSuperview()
|
|
view.removeFromSuperview()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let count = aiCompanionSummaries.count
|
|
|
|
|
- aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
|
|
|
|
|
|
|
+ let count = recordedMeetings.count
|
|
|
|
|
+ aiCompanionCountLabel?.stringValue = count == 1 ? "1 recording" : "\(count) recordings"
|
|
|
|
|
|
|
|
- let showEmpty: Bool
|
|
|
|
|
- if isUserLoggedIn() == false {
|
|
|
|
|
- showEmpty = true
|
|
|
|
|
- aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
|
|
|
|
|
- } else if count == 0 {
|
|
|
|
|
- showEmpty = true
|
|
|
|
|
- aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
|
|
|
|
|
- } else {
|
|
|
|
|
- showEmpty = false
|
|
|
|
|
|
|
+ let showEmpty = count == 0
|
|
|
|
|
+ if showEmpty {
|
|
|
|
|
+ aiCompanionEmptyLabel?.stringValue = "No recordings yet. Start or join a meeting to record it."
|
|
|
}
|
|
}
|
|
|
aiCompanionEmptyLabel?.isHidden = !showEmpty
|
|
aiCompanionEmptyLabel?.isHidden = !showEmpty
|
|
|
|
|
|
|
|
- for (idx, summary) in aiCompanionSummaries.enumerated() {
|
|
|
|
|
- let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
|
|
|
|
|
|
|
+ for (idx, recording) in recordedMeetings.enumerated() {
|
|
|
|
|
+ let row = makeRecordedMeetingCard(recording: recording, index: idx)
|
|
|
stack.addArrangedSubview(row)
|
|
stack.addArrangedSubview(row)
|
|
|
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
|
- private func makeAiCompanionListRow(summary: AICompanionSummary, index: Int, isSelected: Bool) -> NSButton {
|
|
|
|
|
- let button = NSButton(title: "", target: self, action: #selector(aiCompanionRowTapped(_:)))
|
|
|
|
|
- button.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- button.bezelStyle = .regularSquare
|
|
|
|
|
- button.isBordered = false
|
|
|
|
|
- button.setButtonType(.momentaryChange)
|
|
|
|
|
- button.tag = index
|
|
|
|
|
- button.wantsLayer = true
|
|
|
|
|
- button.layer?.cornerRadius = 12
|
|
|
|
|
- button.layer?.borderWidth = 1
|
|
|
|
|
-
|
|
|
|
|
- let baseBg: NSColor = palette.isDarkMode
|
|
|
|
|
- ? NSColor.white.withAlphaComponent(0.04)
|
|
|
|
|
- : NSColor.black.withAlphaComponent(0.03)
|
|
|
|
|
- let selectedBg: NSColor = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.22 : 0.14)
|
|
|
|
|
- let baseBorder: NSColor = palette.inputBorder
|
|
|
|
|
- let selectedBorder: NSColor = accentBlue
|
|
|
|
|
-
|
|
|
|
|
- button.layer?.backgroundColor = (isSelected ? selectedBg : baseBg).cgColor
|
|
|
|
|
- button.layer?.borderColor = (isSelected ? selectedBorder : baseBorder).cgColor
|
|
|
|
|
|
|
+ private func makeRecordedMeetingCard(recording: MeetingRecordingManager.RecordedMeeting, index: Int) -> NSView {
|
|
|
|
|
+ let card = NSView()
|
|
|
|
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ card.wantsLayer = true
|
|
|
|
|
+ card.layer?.cornerRadius = 12
|
|
|
|
|
+ card.layer?.borderWidth = 1
|
|
|
|
|
+ card.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
|
|
+ card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
|
|
|
|
|
|
|
|
- let topic = summary.summaryTitle?.isEmpty == false
|
|
|
|
|
- ? summary.summaryTitle!
|
|
|
|
|
- : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
|
|
|
|
|
- let titleLabel = makeLabel(topic, size: 14, color: primaryText, weight: .semibold, centered: false)
|
|
|
|
|
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- titleLabel.maximumNumberOfLines = 2
|
|
|
|
|
- titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
|
|
+ let title = makeLabel("Meeting recording", size: 14, color: primaryText, weight: .semibold, centered: false)
|
|
|
|
|
+ title.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ title.maximumNumberOfLines = 1
|
|
|
|
|
+ title.lineBreakMode = .byTruncatingTail
|
|
|
|
|
|
|
|
- let dateText = aiCompanionDateText(for: summary)
|
|
|
|
|
|
|
+ let dateFormatter = DateFormatter()
|
|
|
|
|
+ dateFormatter.dateStyle = .medium
|
|
|
|
|
+ dateFormatter.timeStyle = .short
|
|
|
|
|
+ let dateText = "\(dateFormatter.string(from: recording.startedAt))"
|
|
|
let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
|
|
let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
|
|
|
dateLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
dateLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- let badge = makeLabel("AI notes", size: 10, color: accentBlue, weight: .semibold, centered: false)
|
|
|
|
|
- badge.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
+ let play = NSButton(title: recordedMeetingsPlayingID == recording.id ? "Stop" : "Play", target: self, action: #selector(recordingPlayTapped(_:)))
|
|
|
|
|
+ play.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ play.isBordered = false
|
|
|
|
|
+ play.wantsLayer = true
|
|
|
|
|
+ play.layer?.cornerRadius = 10
|
|
|
|
|
+ play.layer?.backgroundColor = accentBlue.cgColor
|
|
|
|
|
+ play.contentTintColor = .white
|
|
|
|
|
+ play.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
|
|
+ play.tag = index
|
|
|
|
|
|
|
|
- button.addSubview(titleLabel)
|
|
|
|
|
- button.addSubview(dateLabel)
|
|
|
|
|
- button.addSubview(badge)
|
|
|
|
|
|
|
+ card.addSubview(title)
|
|
|
|
|
+ card.addSubview(dateLabel)
|
|
|
|
|
+ card.addSubview(play)
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- titleLabel.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
|
|
|
|
|
- titleLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
|
|
|
|
|
- titleLabel.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
|
|
|
|
|
|
|
+ title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
|
|
|
|
|
+ title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
|
|
|
|
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: play.leadingAnchor, constant: -10),
|
|
|
|
|
+
|
|
|
|
|
+ dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
|
|
|
|
|
+ dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
|
|
+ dateLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
|
|
|
|
|
+ dateLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10),
|
|
|
|
|
+
|
|
|
|
|
+ play.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
|
|
|
|
+ play.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
|
|
+ play.heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
+ play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72)
|
|
|
|
|
+ ])
|
|
|
|
|
|
|
|
- dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
|
|
|
- dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
|
|
|
- dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
|
|
|
|
|
|
|
+ return card
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
|
|
|
|
|
- badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
|
|
|
|
|
- badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
|
|
|
|
|
- ])
|
|
|
|
|
|
|
+ @objc
|
|
|
|
|
+ private func recordingPlayTapped(_ sender: NSButton) {
|
|
|
|
|
+ let idx = sender.tag
|
|
|
|
|
+ guard recordedMeetings.indices.contains(idx) else { return }
|
|
|
|
|
+ let recording = recordedMeetings[idx]
|
|
|
|
|
|
|
|
- aiCompanionRowButtons.append(button)
|
|
|
|
|
- return button
|
|
|
|
|
|
|
+ if recordedMeetingsPlayingID == recording.id {
|
|
|
|
|
+ recordedMeetingsPlayer?.stop()
|
|
|
|
|
+ recordedMeetingsPlayer = nil
|
|
|
|
|
+ recordedMeetingsPlayingID = nil
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ do {
|
|
|
|
|
+ let url = try MeetingRecordingManager.shared.audioURL(for: recording)
|
|
|
|
|
+ recordedMeetingsPlayer?.stop()
|
|
|
|
|
+ recordedMeetingsPlayer = try AVAudioPlayer(contentsOf: url)
|
|
|
|
|
+ recordedMeetingsPlayer?.prepareToPlay()
|
|
|
|
|
+ recordedMeetingsPlayer?.play()
|
|
|
|
|
+ recordedMeetingsPlayingID = recording.id
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ showSimpleAlert(title: "Unable to play", message: "Could not open the audio recording.")
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@MainActor
|
|
@MainActor
|
|
@@ -7060,38 +7131,23 @@ class ViewController: NSViewController {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRefreshTapped() {
|
|
@objc private func aiCompanionRefreshTapped() {
|
|
|
- Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
|
|
|
|
+ // Removed: Zoom AI summaries fetch (AI Companion now shows local recordings only).
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
|
|
@objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
|
|
|
- aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
|
|
|
|
|
- Task { await self.loadAICompanionSummaries(force: true) }
|
|
|
|
|
|
|
+ // Removed: Zoom AI summaries range filter (AI Companion now shows local recordings only).
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionRowTapped(_ sender: NSButton) {
|
|
@objc private func aiCompanionRowTapped(_ sender: NSButton) {
|
|
|
- let index = sender.tag
|
|
|
|
|
- guard aiCompanionSummaries.indices.contains(index) else { return }
|
|
|
|
|
- aiCompanionSelectedIndex = index
|
|
|
|
|
- renderAiCompanionList()
|
|
|
|
|
- renderAiCompanionDetail()
|
|
|
|
|
|
|
+ // Removed: Zoom AI summaries row selection (AI Companion now shows local recordings only).
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionOpenDocTapped() {
|
|
@objc private func aiCompanionOpenDocTapped() {
|
|
|
- guard let index = aiCompanionSelectedIndex,
|
|
|
|
|
- aiCompanionSummaries.indices.contains(index),
|
|
|
|
|
- let url = aiCompanionSummaries[index].summaryDocURL else { return }
|
|
|
|
|
- openWebURLPreferringInApp(url)
|
|
|
|
|
|
|
+ // Removed: Zoom Docs deep link (AI Companion now shows local recordings only).
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@objc private func aiCompanionCopyMarkdownTapped() {
|
|
@objc private func aiCompanionCopyMarkdownTapped() {
|
|
|
- guard let index = aiCompanionSelectedIndex,
|
|
|
|
|
- aiCompanionSummaries.indices.contains(index) else { return }
|
|
|
|
|
- let summary = aiCompanionSummaries[index]
|
|
|
|
|
- let markdown = aiCompanionMarkdown(for: summary)
|
|
|
|
|
- let pasteboard = NSPasteboard.general
|
|
|
|
|
- pasteboard.clearContents()
|
|
|
|
|
- pasteboard.setString(markdown, forType: .string)
|
|
|
|
|
- aiCompanionStatusLabel?.stringValue = "Copied AI notes as Markdown."
|
|
|
|
|
|
|
+ // Removed: Zoom AI summary markdown export (AI Companion now shows local recordings only).
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
|
|
private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
|
|
@@ -7680,6 +7736,14 @@ class ViewController: NSViewController {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
openWebURLPreferringInApp(url)
|
|
openWebURLPreferringInApp(url)
|
|
|
|
|
+ Task { @MainActor in
|
|
|
|
|
+ do {
|
|
|
|
|
+ try await MeetingRecordingManager.shared.startRecording(sourceURL: url)
|
|
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func menuBarTriggerJoinMeeting() {
|
|
func menuBarTriggerJoinMeeting() {
|
|
@@ -7771,6 +7835,9 @@ class ViewController: NSViewController {
|
|
|
applyMeetingsPageFilters(resetVisible: false)
|
|
applyMeetingsPageFilters(resetVisible: false)
|
|
|
}
|
|
}
|
|
|
if isAiCompanion {
|
|
if isAiCompanion {
|
|
|
|
|
+ recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
|
|
+ renderAiCompanionList()
|
|
|
|
|
+ updateAiCompanionRecordingUI()
|
|
|
aiCompanionRefreshIfStale()
|
|
aiCompanionRefreshIfStale()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|