feat(macos): per-app audio selection for screen sharing

Let users choose what the SCREEN_AUDIO stream captures before sharing:
share everything, only selected apps, or all except selected apps, plus a
first-class "Exclude screen reader (VoiceOver) audio" toggle.

ScreenCaptureKit filters audio per application, so ScreenAudioCapture now
takes a ScreenAudioSelection and builds the matching SCContentFilter
(including:/excludingApplications:). New ScreenSharePickerSheet lists
running apps from SCShareableContent. iOS left untouched -- ReplayKit only
delivers the mixed system stream, so per-app filtering is impossible there.
This commit is contained in:
2026-06-21 13:35:01 +02:00
parent 6b7f06a282
commit c1e6f4f7ff
6 changed files with 389 additions and 16 deletions

View File

@@ -31,6 +31,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
internal var micStreamId: UInt32 = 0
private var screenStreamId: UInt32 = 0
private var screenCapture: ScreenAudioCapture?
// Last app/exclusion choice from the share picker; reused as the default next time.
private var screenAudioSelection: ScreenAudioSelection = .default
internal var pttKeyCode: UInt16 = 0x60 // F8
private var pttMonitor: Any?
private var serverMuted = false
@@ -720,17 +722,15 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
@objc private func screenAudioClicked() {
if screenStreamId == 0 {
// Announce the stream now; ScreenCaptureKit capture starts once the server's
// StreamAnnounceResult lands (the .streamStarted event), when the effective audio
// config and thus the channel count to capture is known. See startScreenCapture.
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
if result == .ok {
screenStreamId = streamId
setShareScreenButton(active: true)
addActivity("Starting screen audio share…")
} else {
addActivity("Failed to start screen audio: \(result)")
// Let the user pick what to share (apps to include/exclude, screen-reader audio)
// before we announce anything. The picker remembers the previous choice.
let sheet = ScreenSharePickerSheet(selection: screenAudioSelection)
sheet.onComplete = { [weak self] selection in
guard let self, let selection else { return } // nil = cancelled
self.screenAudioSelection = selection
self.beginScreenAudioShare()
}
presentSheet(sheet)
} else {
stopScreenCapture()
client.stopStream(screenStreamId)
@@ -740,6 +740,21 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
}
}
/// Announce the SCREEN_AUDIO stream with the chosen selection in hand. ScreenCaptureKit
/// capture starts once the server's StreamAnnounceResult lands (the .streamStarted event),
/// when the effective audio config and thus the channel count is known. See
/// startScreenCapture.
private func beginScreenAudioShare() {
let (result, streamId) = client.startStream(StreamDescriptor(kind: .screenAudio, deviceId: nil, label: "Desktop audio"))
if result == .ok {
screenStreamId = streamId
setShareScreenButton(active: true)
addActivity("Starting screen audio share…")
} else {
addActivity("Failed to start screen audio: \(result)")
}
}
private func setShareScreenButton(active: Bool) {
shareScreenButton?.title = active ? "Stop Screen Audio" : "Share Screen Audio"
shareScreenButton?.image = NSImage(
@@ -760,7 +775,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
let channels: UInt32 = (cfgResult == .ok && cfg?.stereo == true) ? 2 : 1
let streamId = screenStreamId
let capture = ScreenAudioCapture(channels: channels) { [weak self] pcm, samples, ch in
let capture = ScreenAudioCapture(channels: channels, selection: screenAudioSelection) { [weak self] pcm, samples, ch in
self?.client.feedPcm(streamId: streamId, pcm: pcm, samplesPerChannel: samples, channels: ch)
}
screenCapture = capture
@@ -768,7 +783,8 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
Task { @MainActor in
do {
try await capture.start()
addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))")
addActivity("Started sharing screen audio (\(channels == 2 ? "stereo" : "mono"))"
+ "\(Self.scopeSuffix(for: screenAudioSelection))")
} catch {
// Most commonly: Screen Recording permission denied. Roll back the stream.
screenCapture = nil
@@ -788,6 +804,21 @@ final class MainWindowController: NSWindowController, NSWindowDelegate {
screenCapture = nil
}
/// A short human-readable description of the share scope for the activity log.
private static func scopeSuffix(for selection: ScreenAudioSelection) -> String {
var parts: [String] = []
switch selection.scope {
case .entireDesktop: break
case .onlyApps(let ids): if !ids.isEmpty { parts.append("only \(ids.count) app(s)") }
case .allExcept(let ids): if !ids.isEmpty { parts.append("excluding \(ids.count) app(s)") }
}
// For .onlyApps the screen reader is already excluded, so don't claim it twice.
if selection.excludeScreenReader {
if case .onlyApps = selection.scope {} else { parts.append("no screen reader") }
}
return parts.isEmpty ? "" : "" + parts.joined(separator: ", ")
}
@objc private func muteChanged() {
let muted = muteButton?.state == .on
let deafened = deafenButton?.state == .on