Files
voice-cat/clients/apple/macOS/VoiceCatMac/Sheets/ScreenSharePickerSheet.swift

247 lines
10 KiB
Swift
Raw Permalink Normal View History

import AppKit
import ScreenCaptureKit
// ScreenSharePickerSheet chooses *what* the SCREEN_AUDIO stream captures before sharing
// starts. ScreenCaptureKit filters audio per application (not per window), so the user picks
// a mode (everything / only-these / all-except-these) plus a set of apps, and a dedicated
// toggle to drop their own screen-reader (VoiceOver) speech from the mix.
//
// Mirrors the modal-sheet pattern used by the rest of the macOS client (UserPickerSheet,
// MoveUserSheet, ): an NSViewController presented via MainWindowController.presentSheet, with
// an `onComplete` callback that returns the chosen `ScreenAudioSelection` (or nil on cancel).
//
// The app list comes from `SCShareableContent.current`, fetched asynchronously that first
// access is also what surfaces the Screen Recording (TCC) prompt, which is why the picker is
// the natural place for it to appear, before any capture begins.
final class ScreenSharePickerSheet: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
/// Called with the chosen selection, or `nil` if the user cancelled.
var onComplete: ((ScreenAudioSelection?) -> Void)?
private enum Mode: Int { case everything = 0, only = 1, except = 2 }
private struct AppEntry { let name: String; let bundleID: String; let icon: NSImage? }
private let initialSelection: ScreenAudioSelection
private var mode: Mode
private var excludeScreenReader: Bool
private var checked: Set<String> // bundle IDs ticked in the app table
private var apps: [AppEntry] = []
private let tableView = NSTableView()
private var modeControl: NSSegmentedControl?
private var screenReaderCheckbox: NSButton?
private var statusLabel: NSTextField?
init(selection: ScreenAudioSelection) {
self.initialSelection = selection
switch selection.scope {
case .entireDesktop: mode = .everything; checked = []
case .onlyApps(let ids): mode = .only; checked = Set(ids)
case .allExcept(let ids): mode = .except; checked = Set(ids)
}
self.excludeScreenReader = selection.excludeScreenReader
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 420))
}
override func viewDidLoad() {
super.viewDidLoad()
buildUI()
loadApps()
}
// MARK: - UI
private func buildUI() {
let titleLabel = NSTextField(labelWithString: "Choose what to share:")
titleLabel.font = .boldSystemFont(ofSize: 13)
titleLabel.setAccessibilityLabel("Choose what to share")
let modeControl = NSSegmentedControl(
labels: ["Everything", "Only selected", "All except selected"],
trackingMode: .selectOne, target: self, action: #selector(modeChanged))
modeControl.selectedSegment = mode.rawValue
modeControl.segmentDistribution = .fillEqually
modeControl.setAccessibilityLabel("Share mode")
self.modeControl = modeControl
let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("app"))
tableView.addTableColumn(col)
tableView.headerView = nil
tableView.dataSource = self
tableView.delegate = self
tableView.rowHeight = 22
tableView.setAccessibilityLabel("Application list")
let scroll = NSScrollView()
scroll.documentView = tableView
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.setContentHuggingPriority(.defaultLow, for: .vertical)
let statusLabel = NSTextField(labelWithString: "Loading apps…")
statusLabel.textColor = .secondaryLabelColor
statusLabel.font = .systemFont(ofSize: 11)
self.statusLabel = statusLabel
let screenReaderCheckbox = NSButton(checkboxWithTitle: "Exclude screen reader (VoiceOver) audio",
target: self, action: #selector(screenReaderToggled))
screenReaderCheckbox.state = excludeScreenReader ? .on : .off
screenReaderCheckbox.toolTip = "Keep your VoiceOver speech out of the shared audio."
self.screenReaderCheckbox = screenReaderCheckbox
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
cancelButton.bezelStyle = .rounded
cancelButton.keyEquivalent = "\u{1b}" // Esc
let shareButton = NSButton(title: "Share", target: self, action: #selector(shareClicked))
shareButton.bezelStyle = .rounded
shareButton.keyEquivalent = "\r"
let buttonRow = NSStackView(views: [NSView(), cancelButton, shareButton])
buttonRow.orientation = .horizontal
buttonRow.spacing = 8
let stack = NSStackView(views: [titleLabel, modeControl, scroll, statusLabel,
screenReaderCheckbox, buttonRow])
stack.orientation = .vertical
stack.spacing = 8
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updateEnabledStates()
}
/// Reflect the current mode: the app table only matters for only/except; the screen-reader
/// toggle is moot for `.only` (include-only already excludes the screen reader).
private func updateEnabledStates() {
let listActive = (mode != .everything)
tableView.isEnabled = listActive
tableView.alphaValue = listActive ? 1.0 : 0.45
screenReaderCheckbox?.isEnabled = (mode != .only)
}
private func loadApps() {
Task { @MainActor in
do {
let content = try await SCShareableContent.current
var seen = Set<String>()
var entries: [AppEntry] = []
for app in content.applications {
let bid = app.bundleIdentifier
guard !bid.isEmpty, !seen.contains(bid) else { continue }
// Hide our own app (its audio is already excluded) and the screen reader
// (handled by its own toggle).
if bid == Bundle.main.bundleIdentifier { continue }
if ScreenAudioCapture.screenReaderBundleIDs.contains(bid) { continue }
seen.insert(bid)
let name = app.applicationName.isEmpty ? bid : app.applicationName
let icon = NSRunningApplication
.runningApplications(withBundleIdentifier: bid).first?.icon
entries.append(AppEntry(name: name, bundleID: bid, icon: icon))
}
entries.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.apps = entries
self.statusLabel?.isHidden = true
self.tableView.reloadData()
} catch {
self.statusLabel?.stringValue = "Screen Recording permission needed to list apps — "
+ "grant it in System Settings ▸ Privacy & Security, then reopen this."
}
}
}
// MARK: - Actions
@objc private func modeChanged() {
mode = Mode(rawValue: modeControl?.selectedSegment ?? 0) ?? .everything
updateEnabledStates()
}
@objc private func screenReaderToggled() {
excludeScreenReader = (screenReaderCheckbox?.state == .on)
}
@objc private func appCheckboxToggled(_ sender: NSButton) {
let row = sender.tag
guard row >= 0, row < apps.count else { return }
let bid = apps[row].bundleID
if sender.state == .on { checked.insert(bid) } else { checked.remove(bid) }
}
@objc private func shareClicked() {
let scope: ScreenAudioScope
switch mode {
case .everything: scope = .entireDesktop
case .only: scope = .onlyApps(Array(checked))
case .except: scope = .allExcept(Array(checked))
}
dismiss(nil)
onComplete?(ScreenAudioSelection(scope: scope, excludeScreenReader: excludeScreenReader))
}
@objc private func cancelClicked() {
dismiss(nil)
onComplete?(nil)
}
// MARK: - NSTableViewDataSource / Delegate
func numberOfRows(in tableView: NSTableView) -> Int { apps.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let app = apps[row]
let cell = AppCheckboxCell()
cell.checkbox.title = app.name
cell.checkbox.state = checked.contains(app.bundleID) ? .on : .off
cell.checkbox.isEnabled = (mode != .everything)
cell.checkbox.tag = row
cell.checkbox.target = self
cell.checkbox.action = #selector(appCheckboxToggled(_:))
cell.iconView.image = app.icon
return cell
}
// Selecting a row shouldn't visually highlight interaction is via the checkbox.
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { false }
}
// One row: a leading app icon and a checkbox titled with the app name.
private final class AppCheckboxCell: NSTableCellView {
let iconView = NSImageView()
let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil)
init() {
super.init(frame: .zero)
iconView.translatesAutoresizingMaskIntoConstraints = false
checkbox.translatesAutoresizingMaskIntoConstraints = false
addSubview(iconView)
addSubview(checkbox)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 4),
iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 16),
iconView.heightAnchor.constraint(equalToConstant: 16),
checkbox.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 6),
checkbox.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) { fatalError() }
}