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 4049adf..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! @@ -79,6 +80,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, @@ -86,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 @@ -101,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 @@ -109,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 @@ -177,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/ClassicProcessing.swift b/Azayaka/ClassicProcessing.swift index 68299e6..615f219 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. diff --git a/Azayaka/Menu.swift b/Azayaka/Menu.swift index 5d786a2..1e27271 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,21 @@ 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) + + 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)) let audio = NSMenuItem(title: "System Audio".local, action: #selector(prepRecord), keyEquivalent: "") @@ -208,8 +224,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") @@ -219,6 +240,38 @@ 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 { + if let defaultMic = AVCaptureDevice.default(for: .audio) { + ud.set(defaultMic.uniqueID, forKey: Preferences.kSelectedMicrophone) + } + } + 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() + } + } } class NSMenuItemWithIcon: NSMenuItem { diff --git a/Azayaka/MenuBar.swift b/Azayaka/MenuBar.swift index 96db28e..6468f50 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,16 @@ struct MenuBar: View { Text(recordingLength) .offset(y: -0.5) .monospacedDigit() + if micMuted { + Image(systemName: "mic.slash") + .font(.system(size: 12)) + } } else { Image("menuBarIcon") + if micMuted { + Image(systemName: "mic.slash") + .font(.system(size: 12)) + } } } } diff --git a/Azayaka/Preferences.swift b/Azayaka/Preferences.swift index 90e5820..86f9258 100644 --- a/Azayaka/Preferences.swift +++ b/Azayaka/Preferences.swift @@ -23,17 +23,23 @@ 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" 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 { @@ -46,6 +52,10 @@ struct Preferences: View { Label("Audio", systemImage: "waveform") } + CameraSettings().tabItem { + Label("Camera", systemImage: "camera") + } + OutputSettings().tabItem { Label("Destination", systemImage: "folder") } @@ -58,7 +68,7 @@ struct Preferences: View { Label("Other", systemImage: "gearshape") } } - }.frame(width: 350) + }.frame(width: 420) } struct VideoSettings: View { @@ -157,10 +167,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 +202,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 @@ -229,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? @@ -297,7 +398,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/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 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 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)