|
|
@@ -337,6 +337,12 @@ class ViewController: NSViewController {
|
|
|
private var recordedMeetingsPlayingID: String?
|
|
|
private weak var aiCompanionStopRecordingButton: NSButton?
|
|
|
private weak var aiCompanionOpenRecordingsFolderButton: NSButton?
|
|
|
+ private weak var aiCompanionLocalTranscriptPanel: NSPanel?
|
|
|
+ private weak var aiCompanionLocalTranscriptTextView: NSTextView?
|
|
|
+ private weak var aiCompanionLocalTranscriptRootView: NSView?
|
|
|
+ private weak var aiCompanionLocalTranscriptHeaderView: NSView?
|
|
|
+ private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
|
|
|
+ private var aiCompanionLocalTranscriptPanelRecordingID: String?
|
|
|
private weak var meetingsPageCategoryFilter: NSPopUpButton?
|
|
|
private weak var meetingsPageFromDatePicker: NSDatePicker?
|
|
|
private weak var meetingsPageToDatePicker: NSDatePicker?
|
|
|
@@ -7158,6 +7164,7 @@ class ViewController: NSViewController {
|
|
|
stack.addArrangedSubview(row)
|
|
|
row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
}
|
|
|
+ refreshLocalRecordingTranscriptPanelContentIfNeeded()
|
|
|
}
|
|
|
|
|
|
@MainActor
|
|
|
@@ -7182,6 +7189,18 @@ class ViewController: NSViewController {
|
|
|
let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
|
|
|
dateLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
+ let transcriptBtn = NSButton(title: "Transcript", target: self, action: #selector(recordingTranscriptTapped(_:)))
|
|
|
+ transcriptBtn.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ transcriptBtn.isBordered = false
|
|
|
+ transcriptBtn.wantsLayer = true
|
|
|
+ transcriptBtn.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
|
|
|
+ transcriptBtn.layer?.cornerRadius = 10
|
|
|
+ transcriptBtn.layer?.borderWidth = 1
|
|
|
+ transcriptBtn.layer?.borderColor = palette.inputBorder.cgColor
|
|
|
+ transcriptBtn.contentTintColor = primaryText
|
|
|
+ transcriptBtn.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
+ transcriptBtn.tag = index
|
|
|
+
|
|
|
let play = NSButton(title: recordedMeetingsPlayingID == recording.id ? "Stop" : "Play", target: self, action: #selector(recordingPlayTapped(_:)))
|
|
|
play.translatesAutoresizingMaskIntoConstraints = false
|
|
|
play.isBordered = false
|
|
|
@@ -7192,12 +7211,17 @@ class ViewController: NSViewController {
|
|
|
play.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
|
play.tag = index
|
|
|
|
|
|
+ let buttonsRow = NSStackView(views: [transcriptBtn, play])
|
|
|
+ buttonsRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ buttonsRow.orientation = .horizontal
|
|
|
+ buttonsRow.alignment = .centerY
|
|
|
+ buttonsRow.spacing = 8
|
|
|
+
|
|
|
card.addSubview(title)
|
|
|
card.addSubview(dateLabel)
|
|
|
- card.addSubview(play)
|
|
|
+ card.addSubview(buttonsRow)
|
|
|
|
|
|
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)
|
|
|
@@ -7213,7 +7237,7 @@ class ViewController: NSViewController {
|
|
|
} 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.maximumNumberOfLines = 2
|
|
|
errLabel.lineBreakMode = .byWordWrapping
|
|
|
if let cell = errLabel.cell as? NSTextFieldCell {
|
|
|
cell.wraps = true
|
|
|
@@ -7227,67 +7251,202 @@ class ViewController: NSViewController {
|
|
|
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),
|
|
|
- title.trailingAnchor.constraint(lessThanOrEqualTo: play.leadingAnchor, constant: -10),
|
|
|
+ title.trailingAnchor.constraint(lessThanOrEqualTo: buttonsRow.leadingAnchor, constant: -10),
|
|
|
+
|
|
|
+ buttonsRow.centerYAnchor.constraint(equalTo: title.centerYAnchor),
|
|
|
+ buttonsRow.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
+
|
|
|
+ transcriptBtn.heightAnchor.constraint(equalToConstant: 30),
|
|
|
+ transcriptBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 92),
|
|
|
+ play.heightAnchor.constraint(equalToConstant: 30),
|
|
|
+ play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
|
|
|
|
|
|
dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
|
|
|
dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
|
|
|
- dateLabel.trailingAnchor.constraint(equalTo: title.trailingAnchor),
|
|
|
+ dateLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
|
|
|
- play.centerYAnchor.constraint(equalTo: title.centerYAnchor),
|
|
|
- play.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -12),
|
|
|
- play.heightAnchor.constraint(equalToConstant: 30),
|
|
|
- play.widthAnchor.constraint(greaterThanOrEqualToConstant: 72)
|
|
|
+ lastStackBottom.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
|
|
|
])
|
|
|
|
|
|
- if pinnedTranscriptScrollToCardBottom == false {
|
|
|
- lastStackBottom.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10).isActive = true
|
|
|
+ return card
|
|
|
+ }
|
|
|
+
|
|
|
+ private func bodyTextForLocalRecordingTranscript(recording: MeetingRecordingManager.RecordedMeeting) -> String {
|
|
|
+ if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
|
|
|
+ return "Generating transcript…"
|
|
|
}
|
|
|
+ if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
|
|
|
+ return "Transcript could not be generated.\n\n\(err)"
|
|
|
+ }
|
|
|
+ if let text = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false {
|
|
|
+ return text
|
|
|
+ }
|
|
|
+ return "Transcript will appear here after the audio is processed. You can leave this window open; it updates when the transcript is ready. If nothing appears, check Speech Recognition in System Settings → Privacy & Security."
|
|
|
+ }
|
|
|
|
|
|
- return card
|
|
|
+ @MainActor
|
|
|
+ private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
|
|
|
+ guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
|
|
|
+ let textView = aiCompanionLocalTranscriptTextView,
|
|
|
+ let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
|
|
|
+ textView.string = bodyTextForLocalRecordingTranscript(recording: recording)
|
|
|
+ }
|
|
|
+
|
|
|
+ @MainActor
|
|
|
+ private func presentLocalRecordingTranscriptPanel(recording: MeetingRecordingManager.RecordedMeeting) {
|
|
|
+ guard let hostWindow = view.window else { return }
|
|
|
+ let meetingTitle = recording.resolvedDisplayTitle
|
|
|
+ let body = bodyTextForLocalRecordingTranscript(recording: recording)
|
|
|
+
|
|
|
+ let hostIsDarkMode = darkModeEnabled
|
|
|
+ let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
|
|
|
+ let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
|
|
|
+ let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
|
|
|
+
|
|
|
+ if let panel = aiCompanionLocalTranscriptPanel, let textView = aiCompanionLocalTranscriptTextView {
|
|
|
+ panel.title = "Transcript - \(meetingTitle)"
|
|
|
+ panel.appearance = NSAppearance(named: panelAppearanceName)
|
|
|
+ panel.backgroundColor = outerBackgroundColor
|
|
|
+ textView.string = body
|
|
|
+ aiCompanionLocalTranscriptPanelRecordingID = recording.id
|
|
|
+ aiCompanionLocalTranscriptRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
|
|
|
+ aiCompanionLocalTranscriptHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
|
|
|
+ aiCompanionLocalTranscriptSubtitleLabel?.stringValue = meetingTitle
|
|
|
+ if hostWindow.attachedSheet !== panel {
|
|
|
+ hostWindow.beginSheet(panel, completionHandler: nil)
|
|
|
+ }
|
|
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ let panelWidth: CGFloat = 720
|
|
|
+ let panelHeight: CGFloat = 540
|
|
|
+ let panel = NSPanel(
|
|
|
+ contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
|
|
|
+ styleMask: [.titled, .closable],
|
|
|
+ backing: .buffered,
|
|
|
+ defer: false
|
|
|
+ )
|
|
|
+ panel.isReleasedWhenClosed = false
|
|
|
+ panel.title = "Transcript - \(meetingTitle)"
|
|
|
+ panel.isFloatingPanel = false
|
|
|
+ panel.hidesOnDeactivate = false
|
|
|
+ panel.delegate = self
|
|
|
+ panel.appearance = NSAppearance(named: panelAppearanceName)
|
|
|
+ panel.backgroundColor = outerBackgroundColor
|
|
|
+ panel.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
+ panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
|
+
|
|
|
+ let root = NSView()
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ root.wantsLayer = true
|
|
|
+ root.layer?.backgroundColor = outerBackgroundColor.cgColor
|
|
|
+
|
|
|
+ let header = NSView()
|
|
|
+ header.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ header.wantsLayer = true
|
|
|
+ header.layer?.backgroundColor = headerBackgroundColor.cgColor
|
|
|
+ header.layer?.cornerRadius = 10
|
|
|
+
|
|
|
+ let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
|
|
|
+ let titleLabel = textLabel("Transcript", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
|
|
|
+ subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ subtitleLabel.lineBreakMode = .byTruncatingTail
|
|
|
+
|
|
|
+ let scroll = NSScrollView()
|
|
|
+ scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ scroll.drawsBackground = true
|
|
|
+ scroll.backgroundColor = NSColor.textBackgroundColor
|
|
|
+ scroll.hasVerticalScroller = true
|
|
|
+ scroll.hasHorizontalScroller = false
|
|
|
+ scroll.borderType = .bezelBorder
|
|
|
+
|
|
|
+ let textView = NSTextView()
|
|
|
+ textView.isEditable = false
|
|
|
+ textView.isSelectable = true
|
|
|
+ textView.backgroundColor = NSColor.textBackgroundColor
|
|
|
+ textView.textColor = NSColor.labelColor
|
|
|
+ textView.font = .systemFont(ofSize: 13, weight: .regular)
|
|
|
+ textView.string = body
|
|
|
+ textView.textContainer?.widthTracksTextView = true
|
|
|
+ textView.textContainerInset = NSSize(width: 10, height: 10)
|
|
|
+
|
|
|
+ let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalTranscriptCopyTapped(_:)))
|
|
|
+ copyButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ copyButton.bezelStyle = .rounded
|
|
|
+
|
|
|
+ let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalTranscriptCloseTapped(_:)))
|
|
|
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ closeButton.bezelStyle = .rounded
|
|
|
+
|
|
|
+ scroll.documentView = textView
|
|
|
+ header.addSubview(titleLabel)
|
|
|
+ header.addSubview(subtitleLabel)
|
|
|
+ header.addSubview(copyButton)
|
|
|
+ header.addSubview(closeButton)
|
|
|
+ root.addSubview(header)
|
|
|
+ root.addSubview(scroll)
|
|
|
+
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
|
|
|
+ header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
|
|
|
+ header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
|
|
|
+
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
|
|
|
+ titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
|
|
|
+
|
|
|
+ subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
|
+ subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
|
|
|
+ subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
|
|
|
+ subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
|
|
|
+
|
|
|
+ copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
|
|
+ copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
|
|
|
+
|
|
|
+ closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
|
|
+ closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
|
|
|
+
|
|
|
+ scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
|
|
|
+ scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
|
|
|
+ scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
|
|
|
+ scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
|
|
|
+ ])
|
|
|
+
|
|
|
+ panel.contentView = root
|
|
|
+ aiCompanionLocalTranscriptPanel = panel
|
|
|
+ aiCompanionLocalTranscriptTextView = textView
|
|
|
+ aiCompanionLocalTranscriptRootView = root
|
|
|
+ aiCompanionLocalTranscriptHeaderView = header
|
|
|
+ aiCompanionLocalTranscriptSubtitleLabel = subtitleLabel
|
|
|
+ aiCompanionLocalTranscriptPanelRecordingID = recording.id
|
|
|
+
|
|
|
+ hostWindow.beginSheet(panel, completionHandler: nil)
|
|
|
+ panel.makeKeyAndOrderFront(nil)
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func recordingTranscriptTapped(_ sender: NSButton) {
|
|
|
+ let idx = sender.tag
|
|
|
+ guard recordedMeetings.indices.contains(idx) else { return }
|
|
|
+ presentLocalRecordingTranscriptPanel(recording: recordedMeetings[idx])
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionLocalTranscriptCopyTapped(_ sender: NSButton) {
|
|
|
+ let text = aiCompanionLocalTranscriptTextView?.string ?? ""
|
|
|
+ NSPasteboard.general.clearContents()
|
|
|
+ NSPasteboard.general.setString(text, forType: .string)
|
|
|
+ }
|
|
|
+
|
|
|
+ @objc private func aiCompanionLocalTranscriptCloseTapped(_ sender: NSButton) {
|
|
|
+ guard let panel = aiCompanionLocalTranscriptPanel, let host = view.window else { return }
|
|
|
+ host.endSheet(panel)
|
|
|
}
|
|
|
|
|
|
@objc
|
|
|
@@ -8620,6 +8779,14 @@ extension ViewController: WKNavigationDelegate, WKUIDelegate {
|
|
|
}
|
|
|
|
|
|
extension ViewController: NSWindowDelegate {
|
|
|
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
|
+ if let panel = aiCompanionLocalTranscriptPanel, sender === panel, let host = view.window {
|
|
|
+ host.endSheet(panel)
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
|
guard let window = notification.object as? NSWindow else { return }
|
|
|
if window === joinMeetingWindow {
|