|
|
@@ -9,6 +9,7 @@ import Cocoa
|
|
|
import Combine
|
|
|
import CryptoKit
|
|
|
import AVFoundation
|
|
|
+import Speech
|
|
|
import StoreKit
|
|
|
import SwiftUI
|
|
|
import UserNotifications
|
|
|
@@ -375,6 +376,8 @@ class ViewController: NSViewController {
|
|
|
private weak var settingsUpgradeButton: NSButton?
|
|
|
private weak var settingsUpgradeTitleLabel: NSTextField?
|
|
|
private weak var settingsNotificationsBlock: NSStackView?
|
|
|
+ private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
|
|
|
+ private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
|
|
|
private weak var settingsGoogleActionButton: NSButton?
|
|
|
private weak var topBarPremiumButton: NSButton?
|
|
|
private weak var topBarAuthButton: NSButton?
|
|
|
@@ -404,6 +407,13 @@ class ViewController: NSViewController {
|
|
|
private weak var joinURLFieldsContainer: NSView?
|
|
|
private weak var joinIDFieldsContainer: NSView?
|
|
|
private weak var joinModeSegment: NSSegmentedControl?
|
|
|
+
|
|
|
+ private var didTapInlineMeetingConsentClose = false
|
|
|
+ private weak var activeMeetingConsentWindow: NSWindow?
|
|
|
+ private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
|
|
|
+ private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
|
|
|
+
|
|
|
+ private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
|
|
|
private let paywallContentWidth: CGFloat = 520
|
|
|
private var selectedPremiumPlan: PremiumPlan = .yearly
|
|
|
private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
|
|
|
@@ -656,9 +666,36 @@ class ViewController: NSViewController {
|
|
|
object: nil,
|
|
|
queue: .main
|
|
|
) { [weak self] _ in
|
|
|
- guard let self else { return }
|
|
|
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
- Task { @MainActor in
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ self.recordedMeetings = MeetingRecordingManager.shared.recordings
|
|
|
+ for rec in self.recordedMeetings where rec.transcriptText != nil || rec.transcriptError != nil {
|
|
|
+ self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
|
|
|
+ }
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ forName: MeetingRecordingManager.transcriptionDidBeginNotification,
|
|
|
+ object: nil,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] note in
|
|
|
+ guard let id = note.userInfo?["recordingId"] as? String else { return }
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ self.aiCompanionTranscriptionActiveRecordingIDs.insert(id)
|
|
|
+ self.renderAiCompanionList()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ NotificationCenter.default.addObserver(
|
|
|
+ forName: MeetingRecordingManager.transcriptionDidEndNotification,
|
|
|
+ object: nil,
|
|
|
+ queue: .main
|
|
|
+ ) { [weak self] note in
|
|
|
+ guard let id = note.userInfo?["recordingId"] as? String else { return }
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
|
|
|
self.renderAiCompanionList()
|
|
|
}
|
|
|
}
|
|
|
@@ -667,8 +704,9 @@ class ViewController: NSViewController {
|
|
|
object: nil,
|
|
|
queue: .main
|
|
|
) { [weak self] _ in
|
|
|
- Task { @MainActor in
|
|
|
- self?.updateAiCompanionRecordingUI()
|
|
|
+ Task { @MainActor [weak self] in
|
|
|
+ guard let self else { return }
|
|
|
+ self.updateAiCompanionRecordingUI()
|
|
|
}
|
|
|
}
|
|
|
setupUI()
|
|
|
@@ -1648,13 +1686,15 @@ class ViewController: NSViewController {
|
|
|
|
|
|
@objc private func joinMeetingSubmitTapped() {
|
|
|
guard let segment = joinModeSegment else { return }
|
|
|
+ let meetingName = joinMeetingDisplayNameField?.stringValue
|
|
|
+ let zoomURL: URL?
|
|
|
if segment.selectedSegment == 0 {
|
|
|
let raw = joinURLField?.stringValue ?? ""
|
|
|
guard let url = parseZoomJoinURLFromUserInput(raw) else {
|
|
|
showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
|
|
|
return
|
|
|
}
|
|
|
- openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
|
|
|
+ zoomURL = url
|
|
|
} else {
|
|
|
let idRaw = joinMeetingIDField?.stringValue ?? ""
|
|
|
let digits = idRaw.filter(\.isNumber)
|
|
|
@@ -1667,7 +1707,188 @@ class ViewController: NSViewController {
|
|
|
showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
|
|
|
return
|
|
|
}
|
|
|
- openZoomMeetingInDefaultBrowser(url, meetingDisplayName: joinMeetingDisplayNameField?.stringValue)
|
|
|
+ zoomURL = url
|
|
|
+ }
|
|
|
+ guard let url = zoomURL else { return }
|
|
|
+
|
|
|
+ let consentChoice = requestMeetingRecordingConsent(title: "Join Meeting")
|
|
|
+ if consentChoice == .dismissed { return }
|
|
|
+ let shouldRecord = (consentChoice == .allowAndContinue)
|
|
|
+ openZoomMeetingInDefaultBrowser(url, meetingDisplayName: meetingName, recordLocally: shouldRecord)
|
|
|
+ }
|
|
|
+
|
|
|
+ private enum MeetingRecordingConsentChoice {
|
|
|
+ case allowAndContinue
|
|
|
+ case continueWithoutRecording
|
|
|
+ case dismissed
|
|
|
+ }
|
|
|
+
|
|
|
+ private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
|
|
|
+ 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")
|
|
|
+ alert.window.title = title
|
|
|
+
|
|
|
+ let speechOptions = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
|
|
|
+ let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
|
|
|
+ languageContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
|
|
|
+ languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
|
|
|
+
|
|
|
+ let languageTitle = NSTextField(labelWithString: "Transcription Languages")
|
|
|
+ languageTitle.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
+ languageTitle.textColor = NSColor.secondaryLabelColor
|
|
|
+
|
|
|
+ let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
|
+ language1Popup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ language1Popup.controlSize = .small
|
|
|
+ language1Popup.target = self
|
|
|
+ language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
|
|
|
+ for option in speechOptions {
|
|
|
+ language1Popup.addItem(withTitle: option.displayName)
|
|
|
+ language1Popup.lastItem?.representedObject = option.identifier
|
|
|
+ }
|
|
|
+
|
|
|
+ let selectedPrimary = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
|
|
|
+ ?? speechOptions.first?.identifier
|
|
|
+ ?? Locale.current.identifier
|
|
|
+ selectTranscriptionLanguage(identifier: selectedPrimary, in: language1Popup)
|
|
|
+
|
|
|
+ let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
|
+ language2Popup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ language2Popup.controlSize = .small
|
|
|
+ language2Popup.target = self
|
|
|
+ language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
|
|
|
+ language2Popup.addItem(withTitle: "None")
|
|
|
+ language2Popup.lastItem?.representedObject = ""
|
|
|
+ for option in speechOptions {
|
|
|
+ language2Popup.addItem(withTitle: option.displayName)
|
|
|
+ language2Popup.lastItem?.representedObject = option.identifier
|
|
|
+ }
|
|
|
+ if let selectedSecondary = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
|
|
|
+ selectTranscriptionLanguage(identifier: selectedSecondary, in: language2Popup)
|
|
|
+ } else {
|
|
|
+ language2Popup.selectItem(at: 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ let language1Label = NSTextField(labelWithString: "Preferred Language 1")
|
|
|
+ language1Label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
+ language1Label.textColor = NSColor.secondaryLabelColor
|
|
|
+ language1Label.alignment = .center
|
|
|
+
|
|
|
+ let language2Label = NSTextField(labelWithString: "Preferred Language 2")
|
|
|
+ language2Label.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
+ language2Label.textColor = NSColor.secondaryLabelColor
|
|
|
+ language2Label.alignment = .center
|
|
|
+
|
|
|
+ languageContainer.addSubview(languageTitle)
|
|
|
+ languageContainer.addSubview(language1Label)
|
|
|
+ languageContainer.addSubview(language1Popup)
|
|
|
+ languageContainer.addSubview(language2Label)
|
|
|
+ languageContainer.addSubview(language2Popup)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
|
|
|
+ languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
|
|
|
+
|
|
|
+ language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
|
|
|
+ language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
|
|
|
+ language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
|
|
|
+ language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
|
|
|
+ language1Popup.widthAnchor.constraint(equalToConstant: 240),
|
|
|
+
|
|
|
+ language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
|
|
|
+ language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
|
|
|
+ language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
|
|
|
+ language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
|
|
|
+ language2Popup.widthAnchor.constraint(equalToConstant: 240),
|
|
|
+ language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
|
|
|
+ ])
|
|
|
+
|
|
|
+ alert.accessoryView = languageContainer
|
|
|
+ didTapInlineMeetingConsentClose = false
|
|
|
+ activeMeetingConsentWindow = alert.window
|
|
|
+ meetingConsentPrimaryLanguagePopup = language1Popup
|
|
|
+ meetingConsentSecondaryLanguagePopup = language2Popup
|
|
|
+ if let contentView = alert.window.contentView {
|
|
|
+ let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
|
|
|
+ topCloseButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ topCloseButton.isBordered = false
|
|
|
+ topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
|
|
|
+ topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
|
|
|
+ topCloseButton.wantsLayer = true
|
|
|
+ topCloseButton.layer?.cornerRadius = 20
|
|
|
+ topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
|
|
|
+ contentView.addSubview(topCloseButton)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
|
|
+ topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
|
|
+ topCloseButton.widthAnchor.constraint(equalToConstant: 40),
|
|
|
+ topCloseButton.heightAnchor.constraint(equalToConstant: 40)
|
|
|
+ ])
|
|
|
+ }
|
|
|
+ let response = alert.runModal()
|
|
|
+ activeMeetingConsentWindow = nil
|
|
|
+ meetingConsentPrimaryLanguagePopup = nil
|
|
|
+ meetingConsentSecondaryLanguagePopup = nil
|
|
|
+
|
|
|
+ if didTapInlineMeetingConsentClose {
|
|
|
+ return .dismissed
|
|
|
+ }
|
|
|
+
|
|
|
+ if let primary = language1Popup.selectedItem?.representedObject as? String,
|
|
|
+ primary.isEmpty == false {
|
|
|
+ var secondary = language2Popup.selectedItem?.representedObject as? String
|
|
|
+ if secondary?.isEmpty == true {
|
|
|
+ secondary = nil
|
|
|
+ }
|
|
|
+ TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ switch response {
|
|
|
+ case .alertFirstButtonReturn:
|
|
|
+ return .allowAndContinue
|
|
|
+ case .alertSecondButtonReturn:
|
|
|
+ return .continueWithoutRecording
|
|
|
+ default:
|
|
|
+ return .dismissed
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
|
|
|
+ didTapInlineMeetingConsentClose = true
|
|
|
+ guard let consentWindow = activeMeetingConsentWindow else { return }
|
|
|
+ NSApp.stopModal(withCode: .abort)
|
|
|
+ consentWindow.orderOut(nil)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
|
|
|
+ guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
|
|
|
+ primary.isEmpty == false else { return }
|
|
|
+
|
|
|
+ var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
|
|
|
+ if secondary?.isEmpty == true {
|
|
|
+ secondary = nil
|
|
|
+ }
|
|
|
+ if let secondaryValue = secondary,
|
|
|
+ secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
|
|
|
+ secondary = nil
|
|
|
+ meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
|
|
|
+ }
|
|
|
+ TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func selectTranscriptionLanguage(identifier: String, in popup: NSPopUpButton) {
|
|
|
+ for item in popup.itemArray {
|
|
|
+ if let value = item.representedObject as? String, value == identifier {
|
|
|
+ popup.select(item)
|
|
|
+ return
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1740,11 +1961,13 @@ class ViewController: NSViewController {
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
- private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil) {
|
|
|
+ private func openZoomMeetingInDefaultBrowser(_ url: URL, meetingDisplayName: String? = nil, recordLocally: Bool = true) {
|
|
|
let opened = openURLInDefaultBrowser(url)
|
|
|
if opened {
|
|
|
joinMeetingWindow?.performClose(nil)
|
|
|
- beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: meetingDisplayName)
|
|
|
+ if recordLocally {
|
|
|
+ beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: meetingDisplayName)
|
|
|
+ }
|
|
|
} else {
|
|
|
showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
|
|
|
}
|
|
|
@@ -3865,6 +4088,31 @@ class ViewController: NSViewController {
|
|
|
settingsNotificationsBlock = notificationsBlock
|
|
|
stack.setCustomSpacing(24, after: notificationsBlock)
|
|
|
|
|
|
+ let transcriptionTitle = textLabel("Ai Companion transcription", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
|
|
|
+ let transcriptionSubtitle = textLabel(
|
|
|
+ "Preferred languages are used for Apple on-device speech recognition after each meeting recording.",
|
|
|
+ font: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ color: secondaryText
|
|
|
+ )
|
|
|
+ transcriptionSubtitle.maximumNumberOfLines = 0
|
|
|
+ transcriptionSubtitle.lineBreakMode = .byWordWrapping
|
|
|
+ let transcriptionBlock = NSStackView()
|
|
|
+ transcriptionBlock.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ transcriptionBlock.orientation = .vertical
|
|
|
+ transcriptionBlock.spacing = 12
|
|
|
+ transcriptionBlock.alignment = .leading
|
|
|
+ transcriptionBlock.addArrangedSubview(transcriptionTitle)
|
|
|
+ transcriptionBlock.addArrangedSubview(transcriptionSubtitle)
|
|
|
+ let lang1Row = makeSettingsSpeechLanguageRow(isPrimary: true)
|
|
|
+ let lang2Row = makeSettingsSpeechLanguageRow(isPrimary: false)
|
|
|
+ transcriptionBlock.addArrangedSubview(lang1Row)
|
|
|
+ transcriptionBlock.addArrangedSubview(lang2Row)
|
|
|
+ lang1Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
|
|
|
+ lang2Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
|
|
|
+ stack.addArrangedSubview(transcriptionBlock)
|
|
|
+ transcriptionBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
+ stack.setCustomSpacing(24, after: transcriptionBlock)
|
|
|
+
|
|
|
let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
|
|
|
stack.addArrangedSubview(legalTitle)
|
|
|
let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
|
|
|
@@ -3961,6 +4209,74 @@ class ViewController: NSViewController {
|
|
|
return row
|
|
|
}
|
|
|
|
|
|
+ private func makeSettingsSpeechLanguageRow(isPrimary: Bool) -> NSView {
|
|
|
+ let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
|
|
|
+ row.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ row.heightAnchor.constraint(equalToConstant: 72).isActive = true
|
|
|
+ styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
+
|
|
|
+ let titleText = isPrimary ? "Preferred Language 1" : "Preferred Language 2"
|
|
|
+ let titleLabel = textLabel(titleText, font: .systemFont(ofSize: 13, weight: .semibold), color: primaryText)
|
|
|
+ let popup = NSPopUpButton(frame: .zero, pullsDown: false)
|
|
|
+ popup.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ popup.target = self
|
|
|
+ popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
|
|
|
+
|
|
|
+ let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
|
|
|
+ if isPrimary == false {
|
|
|
+ popup.addItem(withTitle: "None")
|
|
|
+ popup.lastItem?.representedObject = ""
|
|
|
+ }
|
|
|
+ for option in options {
|
|
|
+ popup.addItem(withTitle: option.displayName)
|
|
|
+ popup.lastItem?.representedObject = option.identifier
|
|
|
+ }
|
|
|
+
|
|
|
+ if isPrimary {
|
|
|
+ let selected = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
|
|
|
+ ?? options.first?.identifier
|
|
|
+ ?? Locale.current.identifier
|
|
|
+ selectTranscriptionLanguage(identifier: selected, in: popup)
|
|
|
+ settingsPagePrimaryLanguagePopup = popup
|
|
|
+ } else {
|
|
|
+ if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
|
|
|
+ selectTranscriptionLanguage(identifier: selected, in: popup)
|
|
|
+ } else {
|
|
|
+ popup.selectItem(at: 0)
|
|
|
+ }
|
|
|
+ settingsPageSecondaryLanguagePopup = popup
|
|
|
+ }
|
|
|
+
|
|
|
+ row.addSubview(titleLabel)
|
|
|
+ row.addSubview(popup)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
|
|
|
+ titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
|
|
|
+
|
|
|
+ popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
|
|
|
+ popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
|
|
|
+ popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
|
|
|
+ popup.heightAnchor.constraint(equalToConstant: 28)
|
|
|
+ ])
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
|
|
|
+ guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
|
|
|
+ primary.isEmpty == false else { return }
|
|
|
+ var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
|
|
|
+ if secondary?.isEmpty == true {
|
|
|
+ secondary = nil
|
|
|
+ }
|
|
|
+ if let secondaryValue = secondary,
|
|
|
+ secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
|
|
|
+ secondary = nil
|
|
|
+ settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
|
|
|
+ }
|
|
|
+ TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
|
|
|
+ }
|
|
|
+
|
|
|
private func makeSettingsRemindersSection() -> NSView {
|
|
|
let container = NSStackView()
|
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -6608,7 +6924,7 @@ class ViewController: NSViewController {
|
|
|
|
|
|
// Header row: recordings list (AI Companion mode).
|
|
|
let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
|
|
|
- let subtitle = makeLabel("Recorded meetings (audio)", size: 13, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ let subtitle = makeLabel("Recorded meetings and Apple transcripts", 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])
|
|
|
titleStack.orientation = .vertical
|
|
|
@@ -6880,6 +7196,78 @@ class ViewController: NSViewController {
|
|
|
card.addSubview(dateLabel)
|
|
|
card.addSubview(play)
|
|
|
|
|
|
+ var lastStackBottom: NSView = dateLabel
|
|
|
+ var pinnedTranscriptScrollToCardBottom = false
|
|
|
+
|
|
|
+ if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
|
|
|
+ let busy = makeLabel("Generating transcript…", size: 11, color: secondaryText, weight: .regular, centered: false)
|
|
|
+ busy.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ busy.maximumNumberOfLines = 1
|
|
|
+ card.addSubview(busy)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ busy.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
|
|
|
+ busy.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
|
|
|
+ busy.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
|
|
|
+ ])
|
|
|
+ lastStackBottom = busy
|
|
|
+ } else if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
|
|
|
+ let errLabel = makeLabel("Transcript: \(err)", size: 11, color: NSColor.systemRed, weight: .regular, centered: false)
|
|
|
+ errLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ errLabel.maximumNumberOfLines = 3
|
|
|
+ errLabel.lineBreakMode = .byWordWrapping
|
|
|
+ if let cell = errLabel.cell as? NSTextFieldCell {
|
|
|
+ cell.wraps = true
|
|
|
+ }
|
|
|
+ card.addSubview(errLabel)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ errLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
|
|
|
+ errLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
|
|
|
+ errLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12)
|
|
|
+ ])
|
|
|
+ lastStackBottom = errLabel
|
|
|
+ }
|
|
|
+
|
|
|
+ if let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), transcript.isEmpty == false {
|
|
|
+ let header = makeLabel("Transcript", size: 11, color: secondaryText, weight: .semibold, centered: false)
|
|
|
+ header.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ card.addSubview(header)
|
|
|
+
|
|
|
+ let scroll = NSScrollView()
|
|
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ scroll.drawsBackground = false
|
|
|
+ scroll.hasVerticalScroller = true
|
|
|
+ scroll.borderType = .noBorder
|
|
|
+ scroll.autohidesScrollers = true
|
|
|
+ scroll.scrollerStyle = .overlay
|
|
|
+
|
|
|
+ let tv = NSTextView()
|
|
|
+ tv.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ tv.string = transcript
|
|
|
+ tv.isEditable = false
|
|
|
+ tv.isSelectable = true
|
|
|
+ tv.drawsBackground = false
|
|
|
+ tv.textColor = primaryText
|
|
|
+ tv.font = .systemFont(ofSize: 11)
|
|
|
+ tv.textContainerInset = NSSize(width: 4, height: 4)
|
|
|
+ tv.textContainer?.widthTracksTextView = true
|
|
|
+ scroll.documentView = tv
|
|
|
+
|
|
|
+ card.addSubview(scroll)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ header.topAnchor.constraint(equalTo: lastStackBottom.bottomAnchor, constant: 8),
|
|
|
+ header.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
|
|
|
+ header.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
+
|
|
|
+ scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 4),
|
|
|
+ scroll.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
|
|
|
+ scroll.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
|
|
|
+ scroll.heightAnchor.constraint(equalToConstant: 120),
|
|
|
+ scroll.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
|
|
|
+ ])
|
|
|
+ pinnedTranscriptScrollToCardBottom = true
|
|
|
+ }
|
|
|
+
|
|
|
NSLayoutConstraint.activate([
|
|
|
title.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
|
|
|
title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 12),
|
|
|
@@ -6888,14 +7276,17 @@ class ViewController: NSViewController {
|
|
|
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.centerYAnchor.constraint(equalTo: title.centerYAnchor),
|
|
|
play.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
play.heightAnchor.constraint(equalToConstant: 30),
|
|
|
play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72)
|
|
|
])
|
|
|
|
|
|
+ if pinnedTranscriptScrollToCardBottom == false {
|
|
|
+ lastStackBottom.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10).isActive = true
|
|
|
+ }
|
|
|
+
|
|
|
return card
|
|
|
}
|
|
|
|