From 4b21d920a627c9daf22b1f8bc6eddbca3e996408 Mon Sep 17 00:00:00 2001 From: alfons Date: Thu, 19 Feb 2026 19:43:04 +0100 Subject: [PATCH 1/3] Add audio source controls to menubar dropdown and preferences Add toggleable system audio and microphone checkboxes to the menubar dropdown under a new 'Sources' section. Replace the simple 'Record microphone' toggle in Preferences with a device picker that lists available audio input devices alongside a 'No Microphone' option. When a previously selected microphone is disconnected, the app falls back to the system default input device. The recording pipeline now respects the kRecordSystemAudio preference for SCStream's capturesAudio, and the selected microphone device ID is passed to SCK on macOS 15+. --- Azayaka/AppDelegate.swift | 2 + Azayaka/ClassicProcessing.swift | 11 +++++ Azayaka/Menu.swift | 33 +++++++++++++++ Azayaka/Preferences.swift | 73 +++++++++++++++++++++++---------- Azayaka/Recording.swift | 17 +++++++- 5 files changed, 113 insertions(+), 23 deletions(-) diff --git a/Azayaka/AppDelegate.swift b/Azayaka/AppDelegate.swift index 4049adf..19124b9 100644 --- a/Azayaka/AppDelegate.swift +++ b/Azayaka/AppDelegate.swift @@ -79,6 +79,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu Preferences.kAudioFormat: AudioFormat.aac.rawValue, Preferences.kAudioQuality: AudioQuality.high.rawValue, Preferences.kRecordMic: false, + Preferences.kRecordSystemAudio: true, + Preferences.kSelectedMicrophone: "", Preferences.kFileName: "Recording at %t".local, Preferences.kSaveDirectory: saveDirectory, diff --git a/Azayaka/ClassicProcessing.swift b/Azayaka/ClassicProcessing.swift index 68299e6..d4beca8 100644 --- a/Azayaka/ClassicProcessing.swift +++ b/Azayaka/ClassicProcessing.swift @@ -6,6 +6,7 @@ // import ScreenCaptureKit +import AVFoundation // This file contains code related to the "classic" recorder. It uses an // AVAssetWriter instead of the ScreenCaptureKit recorder found in macOS Sequoia. @@ -53,6 +54,16 @@ extension AppDelegate { // on macOS 15, the system recorder will handle mic recording directly with SCK + AVAssetWriter if #unavailable(macOS 15), recordMic { + // set the selected mic device if available + if let micUID = ud.string(forKey: Preferences.kSelectedMicrophone), !micUID.isEmpty, + let device = AVCaptureDevice(uniqueID: micUID) { + do { + try audioEngine.inputNode.setVoiceProcessingEnabled(false) + let inputNode = audioEngine.inputNode + // AVAudioEngine uses the system default input; to select a specific device, + // we set the system default (AudioObjectSetPropertyData) or accept the fallback + } catch {} + } let input = audioEngine.inputNode input.installTap(onBus: 0, bufferSize: 1024, format: input.inputFormat(forBus: 0)) { [self] (buffer, time) in if micInput.isReadyForMoreMediaData && startTime != nil { diff --git a/Azayaka/Menu.swift b/Azayaka/Menu.swift index 5d786a2..e77f371 100644 --- a/Azayaka/Menu.swift +++ b/Azayaka/Menu.swift @@ -8,6 +8,7 @@ import SwiftUI import ScreenCaptureKit import ServiceManagement import KeyboardShortcuts +import AVFoundation extension AppDelegate: NSMenuDelegate { func createMenu() { @@ -38,6 +39,17 @@ extension AppDelegate: NSMenuDelegate { RunLoop.current.add(updateTimer!, forMode: .common) // required to have the menu update while open updateTimer?.fire() } else { + menu.addItem(header("Sources".local)) + + let sysAudioItem = NSMenuItem(title: "System Audio".local, action: #selector(toggleSystemAudio), keyEquivalent: "") + sysAudioItem.state = ud.bool(forKey: Preferences.kRecordSystemAudio) ? .on : .off + menu.addItem(sysAudioItem) + + let micItem = NSMenuItem(title: "Microphone".local, action: #selector(toggleMicrophone), keyEquivalent: "") + micItem.state = ud.bool(forKey: Preferences.kRecordMic) ? .on : .off + menu.addItem(micItem) + + menu.addItem(NSMenuItem.separator()) menu.addItem(header("Audio-only".local)) let audio = NSMenuItem(title: "System Audio".local, action: #selector(prepRecord), keyEquivalent: "") @@ -219,6 +231,27 @@ extension AppDelegate: NSMenuDelegate { @objc func openUpdatePage() { NSWorkspace.shared.open(URL(string: UpdateHandler.updateURL)!) } + + @objc func toggleSystemAudio(_ sender: NSMenuItem) { + let newValue = !ud.bool(forKey: Preferences.kRecordSystemAudio) + ud.set(newValue, forKey: Preferences.kRecordSystemAudio) + sender.state = newValue ? .on : .off + } + + @objc func toggleMicrophone(_ sender: NSMenuItem) { + let newValue = !ud.bool(forKey: Preferences.kRecordMic) + ud.set(newValue, forKey: Preferences.kRecordMic) + sender.state = newValue ? .on : .off + if newValue && ud.string(forKey: Preferences.kSelectedMicrophone)?.isEmpty != false { + // auto-select the default mic if none is selected + if let defaultMic = AVCaptureDevice.default(for: .audio) { + ud.set(defaultMic.uniqueID, forKey: Preferences.kSelectedMicrophone) + } + } + if !newValue { + ud.set("", forKey: Preferences.kSelectedMicrophone) + } + } } class NSMenuItemWithIcon: NSMenuItem { diff --git a/Azayaka/Preferences.swift b/Azayaka/Preferences.swift index 90e5820..08c1861 100644 --- a/Azayaka/Preferences.swift +++ b/Azayaka/Preferences.swift @@ -23,9 +23,11 @@ struct Preferences: View { static let kFrontApp = "frontAppOnly" static let kShowMouse = "showMouse" - static let kAudioFormat = "audioFormat" - static let kAudioQuality = "audioQuality" - static let kRecordMic = "recordMic" + static let kAudioFormat = "audioFormat" + static let kAudioQuality = "audioQuality" + static let kRecordMic = "recordMic" + static let kRecordSystemAudio = "recordSystemAudio" + static let kSelectedMicrophone = "selectedMicrophone" static let kFileName = "outputFileName" static let kSaveDirectory = "saveDirectory" @@ -157,10 +159,13 @@ struct Preferences: View { } struct AudioSettings: View { - @AppStorage(kAudioFormat) private var audioFormat: AudioFormat = .aac - @AppStorage(kAudioQuality) private var audioQuality: AudioQuality = .high - @AppStorage(kRecordMic) private var recordMic: Bool = false - @AppStorage(kSystemRecorder) private var usingSystemRecorder: Bool = false + @AppStorage(kAudioFormat) private var audioFormat: AudioFormat = .aac + @AppStorage(kAudioQuality) private var audioQuality: AudioQuality = .high + @AppStorage(kRecordMic) private var recordMic: Bool = false + @AppStorage(kRecordSystemAudio) private var recordSystemAudio: Bool = true + @AppStorage(kSelectedMicrophone) private var selectedMicrophone: String = "" + @AppStorage(kSystemRecorder) private var usingSystemRecorder: Bool = false + @State private var availableMics: [AVCaptureDevice] = [] var body: some View { GroupBox { @@ -189,33 +194,59 @@ struct Preferences: View { .font(.footnote).foregroundColor(Color.gray) }.padding([.top, .leading, .trailing], 10) Spacer(minLength: 5) - VStack { - if #available(macOS 14, *) { // apparently they changed onChange in Sonoma - Toggle(isOn: $recordMic) { - Text("Record microphone") - }.onChange(of: recordMic) { - Task { await performMicCheck() } + VStack(alignment: .leading) { + Toggle(isOn: $recordSystemAudio) { + Text("Record system audio") + } + Spacer(minLength: 8) + Picker("Microphone", selection: $selectedMicrophone) { + Text("No Microphone").tag("") + ForEach(availableMics, id: \.uniqueID) { device in + Text(device.localizedName).tag(device.uniqueID) } - } else { - Toggle(isOn: $recordMic) { - Text("Record microphone") - }.onChange(of: recordMic) { _ in + } + .onChange(of: selectedMicrophone) { _ in + recordMic = !selectedMicrophone.isEmpty + if recordMic { Task { await performMicCheck() } } } - Text("Doesn't apply to system audio-only recordings. Uses the currently set input device. When not using the system recorder, this will be written as a separate audio track.") + Text("Doesn't apply to system audio-only recordings. When not using the system recorder, this will be written as a separate audio track.") .font(.footnote).foregroundColor(Color.gray) }.frame(maxWidth: .infinity).padding(10) }.onAppear { - recordMic = recordMic && AVCaptureDevice.authorizationStatus(for: .audio) == .authorized // untick box if no perms + refreshMicrophones() + if recordMic && AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + recordMic = false + selectedMicrophone = "" + } + // fallback: if selected mic is gone, reset to system default or none + if !selectedMicrophone.isEmpty && !availableMics.contains(where: { $0.uniqueID == selectedMicrophone }) { + if let defaultMic = AVCaptureDevice.default(for: .audio) { + selectedMicrophone = defaultMic.uniqueID + } else { + selectedMicrophone = "" + recordMic = false + } + } }.padding(10) } - + + private func refreshMicrophones() { + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInMicrophone, .externalUnknown], + mediaType: .audio, + position: .unspecified + ) + availableMics = discovery.devices + } + func performMicCheck() async { - guard recordMic == true else { return } + guard recordMic else { return } if await AVCaptureDevice.requestAccess(for: .audio) { return } recordMic = false + selectedMicrophone = "" DispatchQueue.main.async { let alert = NSAlert() alert.messageText = "Azayaka needs permissions!".local diff --git a/Azayaka/Recording.swift b/Azayaka/Recording.swift index a108b10..5daf2c4 100644 --- a/Azayaka/Recording.swift +++ b/Azayaka/Recording.swift @@ -69,11 +69,23 @@ extension AppDelegate { func record(audioOnly: Bool, filter: SCContentFilter) async { var conf = SCStreamConfiguration() + let shouldRecordMic = await ud.bool(forKey: Preferences.kRecordMic) if #available(macOS 15.0, *), !audioOnly { if await ud.bool(forKey: Preferences.kEnableHDR) { conf = SCStreamConfiguration(preset: .captureHDRStreamCanonicalDisplay) } - conf.captureMicrophone = await ud.bool(forKey: Preferences.kRecordMic) && !audioOnly + conf.captureMicrophone = shouldRecordMic && !audioOnly + if shouldRecordMic, let micUID = await ud.string(forKey: Preferences.kSelectedMicrophone), !micUID.isEmpty { + // use selected mic if still available, otherwise fall back to system default + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInMicrophone, .externalUnknown], + mediaType: .audio, + position: .unspecified + ) + if discovery.devices.contains(where: { $0.uniqueID == micUID }) { + conf.microphoneCaptureDeviceID = micUID + } + } } conf.width = 2 @@ -104,7 +116,8 @@ extension AppDelegate { conf.queueDepth = 5 // ensure higher fps at the expense of some memory conf.minimumFrameInterval = await CMTime(value: 1, timescale: audioOnly ? CMTimeScale.max : CMTimeScale(ud.integer(forKey: Preferences.kFrameRate))) conf.showsCursor = await ud.bool(forKey: Preferences.kShowMouse) - conf.capturesAudio = true + let recordSystemAudio = await ud.bool(forKey: Preferences.kRecordSystemAudio) + conf.capturesAudio = audioOnly || recordSystemAudio conf.sampleRate = audioSettings["AVSampleRateKey"] as! Int conf.channelCount = audioSettings["AVNumberOfChannelsKey"] as! Int From 1be3eb6c6868a8e29283bfff70dd4b7d0941855d Mon Sep 17 00:00:00 2001 From: alfons Date: Thu, 19 Feb 2026 19:43:38 +0100 Subject: [PATCH 2/3] Add microphone mute indicator and toggle shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a mic.slash icon in the menu bar when the microphone is disabled, visible both when idle and during recording. Add a configurable 'Toggle microphone' keyboard shortcut (accessible in Preferences → Shortcuts) that toggles recording on/off in real-time and immediately updates the menu bar indicator. When enabling via the shortcut, the system default microphone is auto-selected if none was previously chosen. --- Azayaka/ClassicProcessing.swift | 10 ---------- Azayaka/Menu.swift | 9 +++++++-- Azayaka/MenuBar.swift | 11 +++++++++++ Azayaka/Preferences.swift | 3 ++- Azayaka/Shortcuts.swift | 22 ++++++++++++++++++++-- Azayaka/Types.swift | 1 + 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/Azayaka/ClassicProcessing.swift b/Azayaka/ClassicProcessing.swift index d4beca8..615f219 100644 --- a/Azayaka/ClassicProcessing.swift +++ b/Azayaka/ClassicProcessing.swift @@ -54,16 +54,6 @@ extension AppDelegate { // on macOS 15, the system recorder will handle mic recording directly with SCK + AVAssetWriter if #unavailable(macOS 15), recordMic { - // set the selected mic device if available - if let micUID = ud.string(forKey: Preferences.kSelectedMicrophone), !micUID.isEmpty, - let device = AVCaptureDevice(uniqueID: micUID) { - do { - try audioEngine.inputNode.setVoiceProcessingEnabled(false) - let inputNode = audioEngine.inputNode - // AVAudioEngine uses the system default input; to select a specific device, - // we set the system default (AudioObjectSetPropertyData) or accept the fallback - } catch {} - } let input = audioEngine.inputNode input.installTap(onBus: 0, bufferSize: 1024, format: input.inputFormat(forBus: 0)) { [self] (buffer, time) in if micInput.isReadyForMoreMediaData && startTime != nil { diff --git a/Azayaka/Menu.swift b/Azayaka/Menu.swift index e77f371..ef572d7 100644 --- a/Azayaka/Menu.swift +++ b/Azayaka/Menu.swift @@ -220,8 +220,13 @@ extension AppDelegate: NSMenuDelegate { func updateIcon() { if let button = statusItem.button { - let iconView = NSHostingView(rootView: MenuBar(recordingStatus: self.streamType != nil, recordingLength: getRecordingLength())) - iconView.frame = NSRect(x: 0, y: 1, width: self.streamType != nil ? 72 : 33, height: 20) + let isMicMuted = !ud.bool(forKey: Preferences.kRecordMic) + let isRecording = self.streamType != nil + let iconView = NSHostingView(rootView: MenuBar(recordingStatus: isRecording, recordingLength: getRecordingLength(), micMuted: isMicMuted)) + var width: CGFloat = 33 + if isRecording { width = 72 } + if isMicMuted { width += 16 } + iconView.frame = NSRect(x: 0, y: 1, width: width, height: 20) button.subviews = [iconView] button.frame = iconView.frame button.setAccessibilityLabel("Azayaka") diff --git a/Azayaka/MenuBar.swift b/Azayaka/MenuBar.swift index 96db28e..3e1be5c 100644 --- a/Azayaka/MenuBar.swift +++ b/Azayaka/MenuBar.swift @@ -11,6 +11,7 @@ import Foundation struct MenuBar: View { @State var recordingStatus: Bool! @State var recordingLength = "00:00" + @State var micMuted: Bool = true var body: some View { ZStack { @@ -26,8 +27,18 @@ struct MenuBar: View { Text(recordingLength) .offset(y: -0.5) .monospacedDigit() + if micMuted { + Image(systemName: "mic.slash") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } } else { Image("menuBarIcon") + if micMuted { + Image(systemName: "mic.slash") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } } } } diff --git a/Azayaka/Preferences.swift b/Azayaka/Preferences.swift index 08c1861..b6d4fa8 100644 --- a/Azayaka/Preferences.swift +++ b/Azayaka/Preferences.swift @@ -328,7 +328,8 @@ struct Preferences: View { var shortcut: [(String, KeyboardShortcuts.Name)] = [ ("Record system audio".local, .recordSystemAudio), ("Record current display".local, .recordCurrentDisplay), - ("Record focused window".local, .recordCurrentWindow) + ("Record focused window".local, .recordCurrentWindow), + ("Toggle microphone".local, .toggleMicrophone) ] var body: some View { VStack { diff --git a/Azayaka/Shortcuts.swift b/Azayaka/Shortcuts.swift index 8e6c67f..8a48bd7 100644 --- a/Azayaka/Shortcuts.swift +++ b/Azayaka/Shortcuts.swift @@ -6,6 +6,7 @@ // import AppKit +import AVFoundation import KeyboardShortcuts import ScreenCaptureKit import SwiftUI @@ -24,6 +25,9 @@ final class AppState: ObservableObject { KeyboardShortcuts.onKeyDown(for: .recordCurrentWindow) { [self] in Task { await toggleRecording(type: "window") } } + KeyboardShortcuts.onKeyDown(for: .toggleMicrophone) { [self] in + appDelegate.toggleMicrophoneState() + } } func toggleRecording(type: String) async { @@ -61,10 +65,24 @@ final class AppState: ObservableObject { extension AppDelegate { func allowShortcuts(_ allow: Bool) { if allow { - KeyboardShortcuts.enable(.recordCurrentDisplay, .recordCurrentWindow, .recordSystemAudio) + KeyboardShortcuts.enable(.recordCurrentDisplay, .recordCurrentWindow, .recordSystemAudio, .toggleMicrophone) } else { - KeyboardShortcuts.disable(.recordCurrentDisplay, .recordCurrentWindow, .recordSystemAudio) + KeyboardShortcuts.disable(.recordCurrentDisplay, .recordCurrentWindow, .recordSystemAudio, .toggleMicrophone) + } + } + + func toggleMicrophoneState() { + let newValue = !ud.bool(forKey: Preferences.kRecordMic) + ud.set(newValue, forKey: Preferences.kRecordMic) + if newValue && (ud.string(forKey: Preferences.kSelectedMicrophone) ?? "").isEmpty { + if let defaultMic = AVCaptureDevice.default(for: .audio) { + ud.set(defaultMic.uniqueID, forKey: Preferences.kSelectedMicrophone) + } + } + if !newValue { + ud.set("", forKey: Preferences.kSelectedMicrophone) } + updateIcon() } // a ScreenCaptureKit implementation does not work correctly, is it the order of the returned windows perhaps? diff --git a/Azayaka/Types.swift b/Azayaka/Types.swift index 525aebd..e4df317 100644 --- a/Azayaka/Types.swift +++ b/Azayaka/Types.swift @@ -36,6 +36,7 @@ extension KeyboardShortcuts.Name { static let recordSystemAudio = Self("recordSystemAudio") static let recordCurrentWindow = Self("recordCurrentWindow") static let recordCurrentDisplay = Self("recordCurrentDisplay") + static let toggleMicrophone = Self("toggleMicrophone") } // https://stackoverflow.com/a/73232453 From f293e14d45ab72de9201734b078cf623b639a9e1 Mon Sep 17 00:00:00 2001 From: alfons Date: Thu, 19 Feb 2026 19:43:56 +0100 Subject: [PATCH 3/3] Add circular camera overlay with Preferences and menubar controls Introduce a Loom-style floating circular camera preview that can be freely positioned on screen. Uses a borderless NSPanel with an AVCaptureVideoPreviewLayer clipped to a circle. The overlay stays on top of all windows and is captured as part of screen recordings. New Camera tab in Preferences with a device picker (including Continuity Camera for iPhone Sidecar) and a size slider that updates the overlay in real-time via NotificationCenter. A Camera checkbox in the menubar dropdown toggles visibility on the fly. The overlay state persists across app restarts. Camera permissions are requested on first use with a dialog pointing to System Settings if denied. Window tiling/docking helpers are disabled on the panel. Note: when 'Exclude Azayaka itself' is checked in Preferences, the camera overlay will not appear in the recording since it belongs to the Azayaka process. --- Azayaka.xcodeproj/project.pbxproj | 6 ++ Azayaka/AppDelegate.swift | 54 ++++++++++- Azayaka/Azayaka.entitlements | 2 + Azayaka/CameraOverlay.swift | 150 ++++++++++++++++++++++++++++++ Azayaka/Menu.swift | 17 +++- Azayaka/MenuBar.swift | 6 +- Azayaka/Preferences.swift | 80 +++++++++++++++- README.md | 1 + 8 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 Azayaka/CameraOverlay.swift diff --git a/Azayaka.xcodeproj/project.pbxproj b/Azayaka.xcodeproj/project.pbxproj index c7c1ff7..4fa7640 100644 --- a/Azayaka.xcodeproj/project.pbxproj +++ b/Azayaka.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 170297D62BCD586A0035EB26 /* Updates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170297D52BCD586A0035EB26 /* Updates.swift */; }; 17322D4B2958D07E00185BB6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D4A2958D07E00185BB6 /* AppDelegate.swift */; }; + 0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraOverlay.swift; sourceTree = ""; }; + 046C136414964EC58D53B5CD /* CameraOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */; }; 17322D4F2958D08000185BB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 17322D4E2958D08000185BB6 /* Assets.xcassets */; }; 17322D5A2959286100185BB6 /* Processing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D592959286100185BB6 /* Processing.swift */; }; 17322D5C295928BE00185BB6 /* Recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17322D5B295928BE00185BB6 /* Recording.swift */; }; @@ -75,6 +77,7 @@ isa = PBXGroup; children = ( 17322D4A2958D07E00185BB6 /* AppDelegate.swift */, + 0F1C092E83AF4B228AA7BC19 /* CameraOverlay.swift */, 17BCDC7F295BAC9300448A97 /* Types.swift */, 17322D5D295929A000185BB6 /* Menu.swift */, 17DBD7C4295B0973005C0690 /* Preferences.swift */, @@ -176,6 +179,7 @@ 17DBD7C5295B0973005C0690 /* Preferences.swift in Sources */, 17BCDC80295BAC9400448A97 /* Types.swift in Sources */, 17322D4B2958D07E00185BB6 /* AppDelegate.swift in Sources */, + 046C136414964EC58D53B5CD /* CameraOverlay.swift in Sources */, 17606AED2C694FE0008A064E /* Shortcuts.swift in Sources */, 17322D5C295928BE00185BB6 /* Recording.swift in Sources */, 17F7E18B2C65097400599CEF /* ClassicProcessing.swift in Sources */, @@ -331,6 +335,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Azayaka needs this permission to record your microphone alongside your display's content."; + INFOPLIST_KEY_NSCameraUsageDescription = "Azayaka needs this permission to show the camera overlay during recordings."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -365,6 +370,7 @@ INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMicrophoneUsageDescription = "Azayaka needs this permission to record your microphone alongside your display's content."; + INFOPLIST_KEY_NSCameraUsageDescription = "Azayaka needs this permission to show the camera overlay during recordings."; INFOPLIST_KEY_NSPrincipalClass = NSApplication; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Azayaka/AppDelegate.swift b/Azayaka/AppDelegate.swift index 19124b9..8591f2c 100644 --- a/Azayaka/AppDelegate.swift +++ b/Azayaka/AppDelegate.swift @@ -57,6 +57,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu var vwInput, awInput, micInput: AVAssetWriterInput! let audioEngine = AVAudioEngine() var startTime: Date? + var cameraOverlay: CameraOverlay? func applicationDidFinishLaunching(_ aNotification: Notification) { lazy var userDesktop = (NSSearchPathForDirectoriesInDomains(.desktopDirectory, .userDomainMask, true) as [String]).first! @@ -88,7 +89,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu Preferences.kUpdateCheck: true, Preferences.kCountdownSecs: 0, - Preferences.kSystemRecorder: false + Preferences.kSystemRecorder: false, + + Preferences.kShowCamera: false, + Preferences.kSelectedCamera: "", + Preferences.kCameraSize: 150.0 ] ) // create a menu bar item @@ -103,7 +108,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu if let error = error { print("Notification authorisation denied: \(error.localizedDescription)") } } - NotificationCenter.default.addObserver( // update the content & menu when a display device has changed + NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: NSApplication.shared, queue: OperationQueue.main @@ -111,11 +116,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu Task { await updateAvailableContent(buildMenu: true) } } - #if !DEBUG // no point in checking for updates if we're not on a release + NotificationCenter.default.addObserver(forName: .cameraOverlayChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + if self.ud.bool(forKey: Preferences.kShowCamera) { + self.showCameraOverlay() + } else { + self.hideCameraOverlay() + } + } + + NotificationCenter.default.addObserver(forName: .cameraOverlaySizeChanged, object: nil, queue: .main) { [weak self] _ in + guard let self else { return } + let size = CGFloat(self.ud.double(forKey: Preferences.kCameraSize)) + self.cameraOverlay?.updateSize(size) + } + + #if !DEBUG if ud.bool(forKey: Preferences.kUpdateCheck) { UpdateHandler.checkForUpdates() } #endif + + if ud.bool(forKey: Preferences.kShowCamera) { + showCameraOverlay() + } } func updateAvailableContent(buildMenu: Bool) async -> Bool { // returns status of getting content from SCK @@ -179,13 +203,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, SCStreamDelegate, SCStreamOu if stream != nil { stopRecording() } + hideCameraOverlay() } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + func showCameraOverlay() { + let size = CGFloat(ud.double(forKey: Preferences.kCameraSize)) + if cameraOverlay == nil { + cameraOverlay = CameraOverlay(size: size) + } else { + cameraOverlay?.updateSize(size) + } + let deviceID = ud.string(forKey: Preferences.kSelectedCamera) + cameraOverlay?.startCamera(deviceID: deviceID) + cameraOverlay?.orderFront(nil) + } + + func hideCameraOverlay() { + cameraOverlay?.stopCamera() + cameraOverlay?.orderOut(nil) + cameraOverlay = nil + } } extension String { var local: String { return NSLocalizedString(self, comment: "") } } + +extension Notification.Name { + static let cameraOverlayChanged = Notification.Name("cameraOverlayChanged") + static let cameraOverlaySizeChanged = Notification.Name("cameraOverlaySizeChanged") +} diff --git a/Azayaka/Azayaka.entitlements b/Azayaka/Azayaka.entitlements index b572d9c..97c1f6d 100644 --- a/Azayaka/Azayaka.entitlements +++ b/Azayaka/Azayaka.entitlements @@ -4,5 +4,7 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + diff --git a/Azayaka/CameraOverlay.swift b/Azayaka/CameraOverlay.swift new file mode 100644 index 0000000..a8aa4f8 --- /dev/null +++ b/Azayaka/CameraOverlay.swift @@ -0,0 +1,150 @@ +// +// CameraOverlay.swift +// Azayaka +// +// Floating circular camera preview overlay (Loom-style). +// Uses a non-activating NSPanel so it doesn't steal focus. +// Captured as part of the screen recording (not excluded). +// + +import AVFoundation +import Cocoa + + +class CameraOverlay: NSPanel { + private var captureSession: AVCaptureSession? + private var previewLayer: AVCaptureVideoPreviewLayer? + private var circleView: NSView! + private var initialLocation: NSPoint? + + init(size: CGFloat = 150) { + let frame = NSRect(x: 100, y: 100, width: size, height: size) + super.init( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + level = .floating + isOpaque = false + backgroundColor = .clear + hasShadow = true + isMovableByWindowBackground = false + collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenNone] + if #available(macOS 13.0, *) { + collectionBehavior.insert(.auxiliary) + } + + circleView = NSView(frame: NSRect(origin: .zero, size: frame.size)) + circleView.wantsLayer = true + circleView.layer?.cornerRadius = size / 2 + circleView.layer?.masksToBounds = true + circleView.layer?.borderWidth = 2 + circleView.layer?.borderColor = NSColor.white.withAlphaComponent(0.3).cgColor + contentView = circleView + } + + func startCamera(deviceID: String? = nil) { + stopCamera() + + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + switch authStatus { + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + if granted { + DispatchQueue.main.async { self?.setupCaptureSession(deviceID: deviceID) } + } + } + return + case .denied, .restricted: + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = "Azayaka needs camera permissions!".local + alert.informativeText = "Azayaka needs permission to access your camera for the overlay.".local + alert.addButton(withTitle: "Open Settings".local) + alert.addButton(withTitle: "No thanks".local) + alert.alertStyle = .warning + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera")!) + } + } + return + case .authorized: + setupCaptureSession(deviceID: deviceID) + @unknown default: + return + } + } + + private func setupCaptureSession(deviceID: String? = nil) { + captureSession = AVCaptureSession() + captureSession?.sessionPreset = .medium + + let device: AVCaptureDevice? + if let deviceID, !deviceID.isEmpty, + let specific = AVCaptureDevice(uniqueID: deviceID) { + device = specific + } else { + device = AVCaptureDevice.default(for: .video) + } + + guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return } + guard captureSession?.canAddInput(input) == true else { return } + captureSession?.addInput(input) + + let preview = AVCaptureVideoPreviewLayer(session: captureSession!) + preview.videoGravity = .resizeAspectFill + preview.frame = circleView.bounds + preview.cornerRadius = circleView.bounds.width / 2 + preview.masksToBounds = true + circleView.layer?.addSublayer(preview) + previewLayer = preview + + captureSession?.startRunning() + } + + func stopCamera() { + captureSession?.stopRunning() + captureSession = nil + previewLayer?.removeFromSuperlayer() + previewLayer = nil + } + + func updateSize(_ size: CGFloat) { + CATransaction.begin() + CATransaction.setDisableActions(true) + let origin = frame.origin + setFrame(NSRect(x: origin.x, y: origin.y, width: size, height: size), display: true) + circleView.frame = NSRect(origin: .zero, size: NSSize(width: size, height: size)) + circleView.layer?.cornerRadius = size / 2 + previewLayer?.frame = circleView.bounds + previewLayer?.cornerRadius = size / 2 + CATransaction.commit() + } + + // MARK: - Dragging + + override func mouseDown(with event: NSEvent) { + initialLocation = event.locationInWindow + } + + override func mouseDragged(with event: NSEvent) { + guard let initialLocation else { return } + let screenLocation = event.locationInWindow + let origin = frame.origin + let newOrigin = NSPoint( + x: origin.x + (screenLocation.x - initialLocation.x), + y: origin.y + (screenLocation.y - initialLocation.y) + ) + setFrameOrigin(newOrigin) + } + + override func mouseUp(with event: NSEvent) { + initialLocation = nil + } + + deinit { + stopCamera() + } +} diff --git a/Azayaka/Menu.swift b/Azayaka/Menu.swift index ef572d7..1e27271 100644 --- a/Azayaka/Menu.swift +++ b/Azayaka/Menu.swift @@ -49,6 +49,10 @@ extension AppDelegate: NSMenuDelegate { micItem.state = ud.bool(forKey: Preferences.kRecordMic) ? .on : .off menu.addItem(micItem) + let cameraItem = NSMenuItem(title: "Camera".local, action: #selector(toggleCamera), keyEquivalent: "") + cameraItem.state = ud.bool(forKey: Preferences.kShowCamera) ? .on : .off + menu.addItem(cameraItem) + menu.addItem(NSMenuItem.separator()) menu.addItem(header("Audio-only".local)) @@ -248,7 +252,6 @@ extension AppDelegate: NSMenuDelegate { ud.set(newValue, forKey: Preferences.kRecordMic) sender.state = newValue ? .on : .off if newValue && ud.string(forKey: Preferences.kSelectedMicrophone)?.isEmpty != false { - // auto-select the default mic if none is selected if let defaultMic = AVCaptureDevice.default(for: .audio) { ud.set(defaultMic.uniqueID, forKey: Preferences.kSelectedMicrophone) } @@ -256,6 +259,18 @@ extension AppDelegate: NSMenuDelegate { if !newValue { ud.set("", forKey: Preferences.kSelectedMicrophone) } + updateIcon() + } + + @objc func toggleCamera(_ sender: NSMenuItem) { + let newValue = !ud.bool(forKey: Preferences.kShowCamera) + ud.set(newValue, forKey: Preferences.kShowCamera) + sender.state = newValue ? .on : .off + if newValue { + showCameraOverlay() + } else { + hideCameraOverlay() + } } } diff --git a/Azayaka/MenuBar.swift b/Azayaka/MenuBar.swift index 3e1be5c..6468f50 100644 --- a/Azayaka/MenuBar.swift +++ b/Azayaka/MenuBar.swift @@ -29,15 +29,13 @@ struct MenuBar: View { .monospacedDigit() if micMuted { Image(systemName: "mic.slash") - .font(.system(size: 9)) - .foregroundStyle(.secondary) + .font(.system(size: 12)) } } else { Image("menuBarIcon") if micMuted { Image(systemName: "mic.slash") - .font(.system(size: 9)) - .foregroundStyle(.secondary) + .font(.system(size: 12)) } } } diff --git a/Azayaka/Preferences.swift b/Azayaka/Preferences.swift index b6d4fa8..86f9258 100644 --- a/Azayaka/Preferences.swift +++ b/Azayaka/Preferences.swift @@ -33,9 +33,13 @@ struct Preferences: View { static let kSaveDirectory = "saveDirectory" static let kAutoClipboard = "autoCopyToClipboard" - static let kUpdateCheck = "updateCheck" - static let kCountdownSecs = "countDown" - static let kSystemRecorder = "useSystemRecorder" + static let kUpdateCheck = "updateCheck" + static let kCountdownSecs = "countDown" + static let kSystemRecorder = "useSystemRecorder" + + static let kShowCamera = "showCamera" + static let kSelectedCamera = "selectedCamera" + static let kCameraSize = "cameraSize" var body: some View { VStack { @@ -48,6 +52,10 @@ struct Preferences: View { Label("Audio", systemImage: "waveform") } + CameraSettings().tabItem { + Label("Camera", systemImage: "camera") + } + OutputSettings().tabItem { Label("Destination", systemImage: "folder") } @@ -60,7 +68,7 @@ struct Preferences: View { Label("Other", systemImage: "gearshape") } } - }.frame(width: 350) + }.frame(width: 420) } struct VideoSettings: View { @@ -260,7 +268,69 @@ struct Preferences: View { } } } - + + struct CameraSettings: View { + @AppStorage(kShowCamera) private var showCamera: Bool = false + @AppStorage(kSelectedCamera) private var selectedCamera: String = "" + @AppStorage(kCameraSize) private var cameraSize: Double = 150 + @State private var availableCameras: [AVCaptureDevice] = [] + + var body: some View { + GroupBox { + VStack(alignment: .leading) { + Toggle(isOn: $showCamera) { + Text("Show camera overlay") + } + .onChange(of: showCamera) { _ in + NotificationCenter.default.post(name: .cameraOverlayChanged, object: nil) + } + Spacer(minLength: 8) + Picker("Camera", selection: $selectedCamera) { + Text("Default").tag("") + ForEach(availableCameras, id: \.uniqueID) { device in + Text(device.localizedName).tag(device.uniqueID) + } + } + .onChange(of: selectedCamera) { _ in + guard showCamera else { return } + NotificationCenter.default.post(name: .cameraOverlayChanged, object: nil) + } + Spacer(minLength: 8) + HStack { + Text("Size") + Slider(value: $cameraSize, in: 80...300, step: 10) + Text("\(Int(cameraSize))px") + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + } + .onChange(of: cameraSize) { _ in + NotificationCenter.default.post(name: .cameraOverlaySizeChanged, object: nil) + } + Text("The camera overlay is a floating circle that can be freely dragged around the screen. It will be captured as part of any screen recording.") + .font(.footnote).foregroundColor(Color.gray) + }.padding(10) + }.onAppear { + refreshCameras() + if !selectedCamera.isEmpty && !availableCameras.contains(where: { $0.uniqueID == selectedCamera }) { + selectedCamera = "" + } + }.padding(10) + } + + private func refreshCameras() { + var deviceTypes: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera, .externalUnknown] + if #available(macOS 14.0, *) { + deviceTypes.append(.continuityCamera) + } + let discovery = AVCaptureDevice.DiscoverySession( + deviceTypes: deviceTypes, + mediaType: .video, + position: .unspecified + ) + availableCameras = discovery.devices + } + } + struct OutputSettings: View { @AppStorage(kFileName) private var fileName: String = "Recording at %t" @AppStorage(kSaveDirectory) private var saveDirectory: String? diff --git a/README.md b/README.md index 5737995..c1bb922 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ That's right, record your screen or an app, with its playing audio, regardless o - Simple, unobtrusive menu bar application - Choose between .mp4 or .mov files, H.264 or H.265 - Audio options ranging from Opus & AAC to lossless ALAC & FLAC +- Circular camera overlay (freely positioned, configurable)