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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user