diff --git a/Resources/BetterShot.entitlements b/Resources/BetterShot.entitlements
index 0c67376..d459cb2 100644
--- a/Resources/BetterShot.entitlements
+++ b/Resources/BetterShot.entitlements
@@ -1,5 +1,8 @@
-
+
+ com.apple.security.device.audio-input
+
+
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 009592e..18204c5 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -24,6 +24,8 @@
NSScreenCaptureUsageDescription
BetterShot needs screen recording access to capture screenshots and record your screen.
+ NSMicrophoneUsageDescription
+ BetterShot needs microphone access to record audio along with your screen recordings.
NSRequiresAquaSystemAppearance
diff --git a/Sources/Capture/CaptureOrchestrator.swift b/Sources/Capture/CaptureOrchestrator.swift
index 43e327c..aae7549 100644
--- a/Sources/Capture/CaptureOrchestrator.swift
+++ b/Sources/Capture/CaptureOrchestrator.swift
@@ -146,11 +146,10 @@ final class CaptureOrchestrator {
}
private func saveImage(_ cgImage: CGImage) -> URL? {
- let dir = AppPreferences.saveDirectory
- let stamp = Int(Date().timeIntervalSince1970 * 1000)
+ let dir = AppPreferences.saveDirectoryScreenshots
let ext = AppPreferences.exportFormat.fileExtension
- let path = "\(dir)/bettershot_\(stamp).\(ext)"
- let url = URL(fileURLWithPath: path)
+ let filename = AppPreferences.generateFileName(extension: ext)
+ let url = URL(fileURLWithPath: "\(dir)/\(filename)")
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL,
@@ -204,9 +203,12 @@ final class CaptureOrchestrator {
private func copyToClipboard(_ url: URL) {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return }
- let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
let pb = NSPasteboard.general
pb.clearContents()
- pb.writeObjects([nsImage])
+ let newRep = NSBitmapImageRep(cgImage: cgImage)
+ if let pngData = newRep.representation(using: .png, properties: [:]) {
+ pb.declareTypes([.png], owner: nil)
+ pb.setData(pngData, forType: .png)
+ }
}
}
diff --git a/Sources/Capture/RegionSelectionOverlay.swift b/Sources/Capture/RegionSelectionOverlay.swift
index fd87f17..039dfd1 100644
--- a/Sources/Capture/RegionSelectionOverlay.swift
+++ b/Sources/Capture/RegionSelectionOverlay.swift
@@ -4,6 +4,7 @@ import ScreenCaptureKit
struct RegionSelection {
let pointsRect: CGRect // In global display points (top-left origin, matching SCK coordinates)
let scaleFactor: CGFloat
+ let displayID: CGDirectDisplayID
}
@MainActor
@@ -68,9 +69,12 @@ final class RegionSelectionOverlay {
height: rect.height
)
+ let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? kCGNullDirectDisplay
+
let selection = RegionSelection(
pointsRect: pointsRect,
- scaleFactor: screen.backingScaleFactor
+ scaleFactor: screen.backingScaleFactor,
+ displayID: displayID
)
closeOverlays()
diff --git a/Sources/Capture/ScreenCapture.swift b/Sources/Capture/ScreenCapture.swift
index 7a25ab6..1272f5e 100644
--- a/Sources/Capture/ScreenCapture.swift
+++ b/Sources/Capture/ScreenCapture.swift
@@ -125,8 +125,7 @@ final class ScreenCapture {
private func makeTempPath() -> String {
let dir = NSTemporaryDirectory()
- let stamp = Int(Date().timeIntervalSince1970 * 1000)
- return "\(dir)bettershot_\(stamp).png"
+ return "\(dir)\(AppPreferences.generateFileName(extension: "png"))"
}
private func runScreencapture(_ arguments: [String]) async -> Bool {
diff --git a/Sources/Editor/EditorCanvasView.swift b/Sources/Editor/EditorCanvasView.swift
index 5d4287e..e2a688f 100644
--- a/Sources/Editor/EditorCanvasView.swift
+++ b/Sources/Editor/EditorCanvasView.swift
@@ -294,9 +294,7 @@ private struct CanvasScreenshotView: View {
}
var body: some View {
- let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
-
- Image(nsImage: nsImage)
+ Image(decorative: image, scale: 1.0)
.resizable()
.interpolation(.high)
.clipShape(clipShape)
diff --git a/Sources/Editor/EditorWindowView.swift b/Sources/Editor/EditorWindowView.swift
index 8f5b77e..3e1b83a 100644
--- a/Sources/Editor/EditorWindowView.swift
+++ b/Sources/Editor/EditorWindowView.swift
@@ -78,7 +78,7 @@ struct EditorWindowView: View {
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
- .keyboardShortcut("c", modifiers: [.command, .shift])
+ .keyboardShortcut("c", modifiers: .command)
Button {
Task { await exportImage() }
@@ -99,11 +99,10 @@ struct EditorWindowView: View {
private func exportImage() async {
guard let rendered = model.renderFinal() else { return }
- let dir = AppPreferences.saveDirectory
- let stamp = Int(Date().timeIntervalSince1970 * 1000)
+ let dir = AppPreferences.saveDirectoryScreenshots
let ext = AppPreferences.exportFormat.fileExtension
- let path = "\(dir)/bettershot_\(stamp).\(ext)"
- let url = URL(fileURLWithPath: path)
+ let filename = AppPreferences.generateFileName(extension: ext)
+ let url = URL(fileURLWithPath: "\(dir)/\(filename)")
guard let dest = CGImageDestinationCreateWithURL(
url as CFURL,
@@ -136,8 +135,9 @@ struct EditorWindowView: View {
if AppPreferences.copyAfterSave {
let pb = NSPasteboard.general
pb.clearContents()
- if let nsImage = NSImage(contentsOf: url) {
- pb.writeObjects([nsImage])
+ if let data = try? Data(contentsOf: url) {
+ pb.declareTypes([.png], owner: nil)
+ pb.setData(data, forType: .png)
}
}
@@ -161,10 +161,13 @@ struct EditorWindowView: View {
private func copyToClipboard() async {
guard let rendered = model.renderFinal() else { return }
- let nsImage = NSImage(cgImage: rendered, size: NSSize(width: rendered.width, height: rendered.height))
let pb = NSPasteboard.general
pb.clearContents()
- pb.writeObjects([nsImage])
+ let newRep = NSBitmapImageRep(cgImage: rendered)
+ if let pngData = newRep.representation(using: .png, properties: [:]) {
+ pb.declareTypes([.png], owner: nil)
+ pb.setData(pngData, forType: .png)
+ }
withAnimation { model.toastMessage = "Copied to clipboard" }
}
}
diff --git a/Sources/History/HistoryStore.swift b/Sources/History/HistoryStore.swift
index cfb4cd2..18ed660 100644
--- a/Sources/History/HistoryStore.swift
+++ b/Sources/History/HistoryStore.swift
@@ -25,10 +25,10 @@ final class HistoryStore {
func importCapture(from tempURL: URL, deleteSource: Bool = true, kind: CaptureKind = .screenshot) -> CaptureRecord? {
let ext = tempURL.pathExtension.isEmpty ? "png" : tempURL.pathExtension
- var filename = "bettershot_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
+ var filename = AppPreferences.generateFileName(extension: ext)
var destURL = storageDir.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destURL.path) {
- filename = "bettershot_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(6)).\(ext)"
+ filename = AppPreferences.generateFileName(extension: ext)
destURL = storageDir.appendingPathComponent(filename)
}
diff --git a/Sources/Models/AppPreferences.swift b/Sources/Models/AppPreferences.swift
index 2f3a628..5019230 100644
--- a/Sources/Models/AppPreferences.swift
+++ b/Sources/Models/AppPreferences.swift
@@ -6,6 +6,8 @@ enum AppPreferences {
// MARK: - Keys
private static let appearanceKey = "bs_appAppearance"
private static let saveDirKey = "bs_saveDirectory"
+ private static let saveDirScreenshotsKey = "bs_saveDirectoryScreenshots"
+ private static let saveDirRecordingsKey = "bs_saveDirectoryRecordings"
private static let copyAfterSaveKey = "bs_copyAfterSave"
private static let playSoundKey = "bs_playSound"
private static let overlayPositionKey = "bs_overlayPosition"
@@ -16,6 +18,7 @@ enum AppPreferences {
private static let recordingFPSKey = "bs_recordingFPS"
private static let recordingShowCursorKey = "bs_recordingShowCursor"
private static let recordingCaptureAudioKey = "bs_recordingCaptureAudio"
+ private static let recordingCaptureMicrophoneKey = "bs_recordingCaptureMicrophone"
private static let recordingOpenEditorKey = "bs_recordingOpenEditor"
// MARK: - Appearance
@@ -39,6 +42,16 @@ enum AppPreferences {
set { UserDefaults.standard.set(newValue, forKey: saveDirKey) }
}
+ static var saveDirectoryScreenshots: String {
+ get { UserDefaults.standard.string(forKey: saveDirScreenshotsKey) ?? UserDefaults.standard.string(forKey: saveDirKey) ?? NSHomeDirectory() + "/Desktop" }
+ set { UserDefaults.standard.set(newValue, forKey: saveDirScreenshotsKey) }
+ }
+
+ static var saveDirectoryRecordings: String {
+ get { UserDefaults.standard.string(forKey: saveDirRecordingsKey) ?? UserDefaults.standard.string(forKey: saveDirKey) ?? NSHomeDirectory() + "/Desktop" }
+ set { UserDefaults.standard.set(newValue, forKey: saveDirRecordingsKey) }
+ }
+
static var copyAfterSave: Bool {
get { UserDefaults.standard.object(forKey: copyAfterSaveKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: copyAfterSaveKey) }
@@ -113,6 +126,11 @@ enum AppPreferences {
set { UserDefaults.standard.set(newValue, forKey: recordingCaptureAudioKey) }
}
+ static var recordingCaptureMicrophone: Bool {
+ get { UserDefaults.standard.object(forKey: recordingCaptureMicrophoneKey) as? Bool ?? false }
+ set { UserDefaults.standard.set(newValue, forKey: recordingCaptureMicrophoneKey) }
+ }
+
static var recordingOpenEditor: Bool {
get { UserDefaults.standard.object(forKey: recordingOpenEditorKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: recordingOpenEditorKey) }
@@ -132,6 +150,17 @@ enum AppPreferences {
}
}
}
+
+ static func generateFileName(extension ext: String) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ let dateString = formatter.string(from: Date())
+
+ let allowedChars = "abcdefghijklmnopqrstuvwxyz"
+ let randomSuffix = String((0..<4).compactMap { _ in allowedChars.randomElement() })
+
+ return "bettershot_\(dateString)_\(randomSuffix).\(ext)"
+ }
}
// MARK: - Enums
diff --git a/Sources/Preview/PreviewOverlay.swift b/Sources/Preview/PreviewOverlay.swift
index 54516ea..73ecae7 100644
--- a/Sources/Preview/PreviewOverlay.swift
+++ b/Sources/Preview/PreviewOverlay.swift
@@ -200,7 +200,8 @@ struct PreviewCardView: View {
generator.maximumSize = CGSize(width: 260, height: 196)
if let result = try? await generator.image(at: .zero) {
let cgImage = result.image
- let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
+ let scale = NSScreen.main?.backingScaleFactor ?? 2.0
+ let nsImage = NSImage(cgImage: cgImage, size: NSSize(width: CGFloat(cgImage.width) / scale, height: CGFloat(cgImage.height) / scale))
await MainActor.run { thumbnail = nsImage }
}
}
diff --git a/Sources/Recording/RecordingSession.swift b/Sources/Recording/RecordingSession.swift
index fbb679f..c5851f5 100644
--- a/Sources/Recording/RecordingSession.swift
+++ b/Sources/Recording/RecordingSession.swift
@@ -6,6 +6,7 @@ final class RecordingSession: @unchecked Sendable {
private let videoInput: AVAssetWriterInput
private let adaptor: AVAssetWriterInputPixelBufferAdaptor
private let audioInput: AVAssetWriterInput?
+ private let micInput: AVAssetWriterInput?
private let lock = NSLock()
private var _isCapturing = false
@@ -17,7 +18,7 @@ final class RecordingSession: @unchecked Sendable {
set { lock.withLock { _isCapturing = newValue } }
}
- init(outputURL: URL, width: Int, height: Int, fps: Int, includeAudio: Bool) throws {
+ init(outputURL: URL, width: Int, height: Int, fps: Int, includeAudio: Bool, includeMic: Bool) throws {
writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
let videoSettings: [String: Any] = [
@@ -59,6 +60,21 @@ final class RecordingSession: @unchecked Sendable {
} else {
audioInput = nil
}
+
+ if includeMic {
+ let micSettings: [String: Any] = [
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
+ AVSampleRateKey: 48000,
+ AVNumberOfChannelsKey: 1,
+ AVEncoderBitRateKey: 64_000,
+ ]
+ let input = AVAssetWriterInput(mediaType: .audio, outputSettings: micSettings)
+ input.expectsMediaDataInRealTime = true
+ writer.add(input)
+ micInput = input
+ } else {
+ micInput = nil
+ }
}
func startWriting() -> Bool {
@@ -104,9 +120,23 @@ final class RecordingSession: @unchecked Sendable {
audioInput.append(sampleBuffer)
}
+ func appendMicSample(_ sampleBuffer: CMSampleBuffer) {
+ lock.lock()
+ guard _isCapturing, _sessionStarted else { lock.unlock(); return }
+
+ let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
+ guard let first = _firstTimestamp, timestamp >= first else { lock.unlock(); return }
+
+ lock.unlock()
+
+ guard let micInput, micInput.isReadyForMoreMediaData else { return }
+ micInput.append(sampleBuffer)
+ }
+
func finishInputs() {
videoInput.markAsFinished()
audioInput?.markAsFinished()
+ micInput?.markAsFinished()
}
func finishWriting() async {
diff --git a/Sources/Recording/RecordingStatusBar.swift b/Sources/Recording/RecordingStatusBar.swift
index a714def..93e6679 100644
--- a/Sources/Recording/RecordingStatusBar.swift
+++ b/Sources/Recording/RecordingStatusBar.swift
@@ -31,6 +31,12 @@ struct RecordingStatusBarView: View {
.frame(width: 1, height: 18)
HStack(spacing: 2) {
+ if AppPreferences.recordingCaptureMicrophone {
+ iconButton(icon: recorder.isMicMuted ? "mic.slash.fill" : "mic.fill", tint: recorder.isMicMuted ? .red : .white.opacity(0.85)) {
+ recorder.toggleMicMute()
+ }
+ }
+
iconButton(icon: isPaused ? "play.fill" : "pause.fill") {
recorder.togglePause()
}
diff --git a/Sources/Recording/ScreenRecordingManager.swift b/Sources/Recording/ScreenRecordingManager.swift
index d1bcdaa..dd86178 100644
--- a/Sources/Recording/ScreenRecordingManager.swift
+++ b/Sources/Recording/ScreenRecordingManager.swift
@@ -17,11 +17,13 @@ final class ScreenRecordingManager: NSObject {
private(set) var state: State = .idle
private(set) var elapsedSeconds: Int = 0
+ var isMicMuted: Bool = false
private var stream: SCStream?
private var session: RecordingSession?
private var outputURL: URL?
private var timer: Timer?
+ private var micCapture: MicrophoneCapture?
nonisolated(unsafe) private var _streamSession: RecordingSession?
private let videoQueue = DispatchQueue(label: "com.bettershot.recording.video", qos: .userInitiated)
@@ -42,8 +44,15 @@ final class ScreenRecordingManager: NSObject {
state = .preparing
let captureAudio = AppPreferences.recordingCaptureAudio
+
+ let mouseLocation = NSEvent.mouseLocation
+ let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } ?? NSScreen.screens.first
+ let targetDisplayID = targetScreen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
+
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
- guard let display = content.displays.first else {
+ let display = content.displays.first { $0.displayID == targetDisplayID } ?? content.displays.first
+
+ guard let display = display else {
state = .idle
return false
}
@@ -77,7 +86,8 @@ final class ScreenRecordingManager: NSObject {
state = .preparing
let captureAudio = AppPreferences.recordingCaptureAudio
let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
- guard let display = content.displays.first else {
+ let display = content.displays.first { $0.displayID == selection.displayID } ?? content.displays.first
+ guard let display = display else {
state = .idle
return false
}
@@ -94,21 +104,38 @@ final class ScreenRecordingManager: NSObject {
let contentRect = try await filter.contentRect
let pointPixelScale = try await filter.pointPixelScale
- let screenFrame = NSScreen.screens.first?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height))
+ let targetScreen = NSScreen.screens.first { screen in
+ let screenID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
+ return screenID == display.displayID
+ } ?? NSScreen.screens.first
+
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
+ let targetScreenFrame = targetScreen?.frame ?? NSRect(x: 0, y: 0, width: CGFloat(display.width), height: CGFloat(display.height))
+ let targetScreenQuartzFrame = CGRect(
+ x: targetScreenFrame.origin.x,
+ y: primaryHeight - targetScreenFrame.origin.y - targetScreenFrame.height,
+ width: targetScreenFrame.width,
+ height: targetScreenFrame.height
+ )
let selRect = selection.pointsRect
- let clampedX = max(selRect.minX, screenFrame.minX)
- let clampedY = max(selRect.minY, 0)
- let clampedMaxX = min(selRect.maxX, screenFrame.maxX)
- let clampedMaxY = min(selRect.maxY, screenFrame.height)
+ let clampedX = max(selRect.minX, targetScreenQuartzFrame.minX)
+ let clampedY = max(selRect.minY, targetScreenQuartzFrame.minY)
+ let clampedMaxX = min(selRect.maxX, targetScreenQuartzFrame.maxX)
+ let clampedMaxY = min(selRect.maxY, targetScreenQuartzFrame.maxY)
+
+ let localX = clampedX - targetScreenQuartzFrame.minX
+ let localY = clampedY - targetScreenQuartzFrame.minY
+ let localW = clampedMaxX - clampedX
+ let localH = clampedMaxY - clampedY
- let scaleX = contentRect.width / screenFrame.width
- let scaleY = contentRect.height / screenFrame.height
+ let scaleX = contentRect.width / targetScreenFrame.width
+ let scaleY = contentRect.height / targetScreenFrame.height
- let sourceX = contentRect.minX + (clampedX - screenFrame.minX) * scaleX
- let sourceY = contentRect.minY + clampedY * scaleY
- let sourceW = (clampedMaxX - clampedX) * scaleX
- let sourceH = (clampedMaxY - clampedY) * scaleY
+ let sourceX = contentRect.minX + localX * scaleX
+ let sourceY = contentRect.minY + localY * scaleY
+ let sourceW = localW * scaleX
+ let sourceH = localH * scaleY
let mappedSourceRect = CGRect(x: sourceX, y: sourceY, width: sourceW, height: sourceH)
@@ -151,10 +178,17 @@ final class ScreenRecordingManager: NSObject {
config.channelCount = 2
}
- let dir = AppPreferences.saveDirectory
- let stamp = Int(Date().timeIntervalSince1970 * 1000)
- let path = "\(dir)/bettershot_\(stamp).mp4"
- let url = URL(fileURLWithPath: path)
+ let captureMic = AppPreferences.recordingCaptureMicrophone
+ if captureMic {
+ let status = AVCaptureDevice.authorizationStatus(for: .audio)
+ if status == .notDetermined {
+ _ = await AVCaptureDevice.requestAccess(for: .audio)
+ }
+ }
+
+ let dir = AppPreferences.saveDirectoryRecordings
+ let filename = AppPreferences.generateFileName(extension: "mp4")
+ let url = URL(fileURLWithPath: "\(dir)/\(filename)")
outputURL = url
let recordingSession = try RecordingSession(
@@ -162,7 +196,8 @@ final class ScreenRecordingManager: NSObject {
width: width,
height: height,
fps: fps,
- includeAudio: captureAudio
+ includeAudio: captureAudio,
+ includeMic: captureMic
)
guard recordingSession.startWriting() else {
@@ -184,6 +219,19 @@ final class ScreenRecordingManager: NSObject {
try await scStream.startCapture()
recordingSession.isCapturing = true
+ if captureMic {
+ let mic = MicrophoneCapture()
+ mic.onAudioSample = { [weak self] sampleBuffer in
+ guard let self = self else { return }
+ if self.isMicMuted {
+ self.silenceAudioBuffer(sampleBuffer)
+ }
+ self._streamSession?.appendMicSample(sampleBuffer)
+ }
+ mic.start()
+ self.micCapture = mic
+ }
+
state = .recording
elapsedSeconds = 0
startTimer()
@@ -200,6 +248,10 @@ final class ScreenRecordingManager: NSObject {
session?.isCapturing = false
+ micCapture?.stop()
+ micCapture = nil
+ isMicMuted = false
+
if let stream {
try? stream.removeStreamOutput(self, type: .screen)
try? stream.removeStreamOutput(self, type: .audio)
@@ -248,6 +300,10 @@ final class ScreenRecordingManager: NSObject {
stopTimer()
session?.isCapturing = false
+ micCapture?.stop()
+ micCapture = nil
+ isMicMuted = false
+
if let stream {
try? stream.removeStreamOutput(self, type: .screen)
try? stream.removeStreamOutput(self, type: .audio)
@@ -282,6 +338,16 @@ final class ScreenRecordingManager: NSObject {
timer?.invalidate()
timer = nil
}
+
+ private func silenceAudioBuffer(_ sampleBuffer: CMSampleBuffer) {
+ guard let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
+ let length = CMBlockBufferGetDataLength(blockBuffer)
+ _ = CMBlockBufferFillDataBytes(with: 0, blockBuffer: blockBuffer, offsetIntoDestination: 0, dataLength: length)
+ }
+
+ func toggleMicMute() {
+ isMicMuted.toggle()
+ }
}
// MARK: - SCStreamDelegate
@@ -306,8 +372,64 @@ extension ScreenRecordingManager: SCStreamOutput {
_streamSession?.appendVideoSample(sampleBuffer)
case .audio:
_streamSession?.appendAudioSample(sampleBuffer)
+ case .microphone:
+ break
@unknown default:
break
}
}
}
+
+// MARK: - MicrophoneCapture Helper
+
+final class MicrophoneCapture: NSObject, AVCaptureAudioDataOutputSampleBufferDelegate {
+ private var captureSession: AVCaptureSession?
+ private var audioOutput: AVCaptureAudioDataOutput?
+ private let sampleQueue = DispatchQueue(label: "com.bettershot.recording.mic", qos: .userInteractive)
+
+ var onAudioSample: ((CMSampleBuffer) -> Void)?
+
+ func start() {
+ let session = AVCaptureSession()
+
+ guard let mic = AVCaptureDevice.default(for: .audio),
+ let input = try? AVCaptureDeviceInput(device: mic) else { return }
+
+ if session.canAddInput(input) {
+ session.addInput(input)
+ }
+
+ let output = AVCaptureAudioDataOutput()
+
+ let audioSettings: [String: Any] = [
+ AVFormatIDKey: kAudioFormatLinearPCM,
+ AVNumberOfChannelsKey: 1,
+ AVSampleRateKey: 48000.0,
+ AVLinearPCMBitDepthKey: 16,
+ AVLinearPCMIsBigEndianKey: false,
+ AVLinearPCMIsFloatKey: false,
+ AVLinearPCMIsNonInterleaved: false
+ ]
+ output.audioSettings = audioSettings
+
+ output.setSampleBufferDelegate(self, queue: sampleQueue)
+ if session.canAddOutput(output) {
+ session.addOutput(output)
+ }
+
+ self.captureSession = session
+ self.audioOutput = output
+
+ session.startRunning()
+ }
+
+ func stop() {
+ captureSession?.stopRunning()
+ captureSession = nil
+ audioOutput = nil
+ }
+
+ nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+ onAudioSample?(sampleBuffer)
+ }
+}
diff --git a/Sources/Recording/VideoEditorModel.swift b/Sources/Recording/VideoEditorModel.swift
index f29febe..fc083b0 100644
--- a/Sources/Recording/VideoEditorModel.swift
+++ b/Sources/Recording/VideoEditorModel.swift
@@ -115,10 +115,9 @@ final class VideoEditorModel {
guard let sourceURL else { return nil }
let asset = AVURLAsset(url: sourceURL)
- let dir = AppPreferences.saveDirectory
- let stamp = Int(Date().timeIntervalSince1970 * 1000)
- let outputPath = "\(dir)/bettershot_\(stamp).mp4"
- let outputURL = URL(fileURLWithPath: outputPath)
+ let dir = AppPreferences.saveDirectoryRecordings
+ let filename = AppPreferences.generateFileName(extension: "mp4")
+ let outputURL = URL(fileURLWithPath: "\(dir)/\(filename)")
var exportConfig = config
if exportConfig.style != .none && exportConfig.padding <= 0 {
diff --git a/Sources/Settings/PreferencesView.swift b/Sources/Settings/PreferencesView.swift
index 4858379..f8199fa 100644
--- a/Sources/Settings/PreferencesView.swift
+++ b/Sources/Settings/PreferencesView.swift
@@ -69,6 +69,8 @@ struct PreferencesView: View {
struct GeneralSettingsTab: View {
@AppStorage("bs_appAppearance") private var appAppearanceRaw: String = AppAppearance.system.rawValue
@AppStorage("bs_saveDirectory") private var saveDir = NSHomeDirectory() + "/Desktop"
+ @AppStorage("bs_saveDirectoryScreenshots") private var saveDirScreenshots = NSHomeDirectory() + "/Desktop"
+ @AppStorage("bs_saveDirectoryRecordings") private var saveDirRecordings = NSHomeDirectory() + "/Desktop"
@AppStorage("bs_copyAfterSave") private var copyAfterSave = true
@AppStorage("bs_playSound") private var playSound = true
@AppStorage("bs_exportFormat") private var exportFormatRaw: String = ExportFormat.png.rawValue
@@ -93,9 +95,18 @@ struct GeneralSettingsTab: View {
)
}
- private var saveDirDisplayName: String {
- let url = URL(fileURLWithPath: saveDir)
- return url.lastPathComponent
+ private var saveDirScreenshotsBinding: Binding {
+ Binding(
+ get: { AppPreferences.saveDirectoryScreenshots },
+ set: { saveDirScreenshots = $0 }
+ )
+ }
+
+ private var saveDirRecordingsBinding: Binding {
+ Binding(
+ get: { AppPreferences.saveDirectoryRecordings },
+ set: { saveDirRecordings = $0 }
+ )
}
var body: some View {
@@ -111,9 +122,29 @@ struct GeneralSettingsTab: View {
Section("Save") {
HStack {
- Text("Save to")
+ Text("Save screenshots to")
+ Spacer()
+ Text(URL(fileURLWithPath: saveDirScreenshotsBinding.wrappedValue).lastPathComponent)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.head)
+ Button("Choose...") {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = false
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = false
+ panel.directoryURL = URL(fileURLWithPath: saveDirScreenshotsBinding.wrappedValue)
+ if panel.runModal() == .OK, let url = panel.url {
+ saveDirScreenshotsBinding.wrappedValue = url.path
+ }
+ }
+ .controlSize(.small)
+ }
+
+ HStack {
+ Text("Save recordings to")
Spacer()
- Text(saveDirDisplayName)
+ Text(URL(fileURLWithPath: saveDirRecordingsBinding.wrappedValue).lastPathComponent)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.head)
@@ -122,9 +153,9 @@ struct GeneralSettingsTab: View {
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
- panel.directoryURL = URL(fileURLWithPath: saveDir)
+ panel.directoryURL = URL(fileURLWithPath: saveDirRecordingsBinding.wrappedValue)
if panel.runModal() == .OK, let url = panel.url {
- saveDir = url.path
+ saveDirRecordingsBinding.wrappedValue = url.path
}
}
.controlSize(.small)
@@ -196,6 +227,8 @@ struct GeneralSettingsTab: View {
appAppearanceRaw = AppAppearance.system.rawValue
AppPreferences.applyAppearance()
saveDir = NSHomeDirectory() + "/Desktop"
+ saveDirScreenshots = NSHomeDirectory() + "/Desktop"
+ saveDirRecordings = NSHomeDirectory() + "/Desktop"
copyAfterSave = true
playSound = true
exportFormatRaw = ExportFormat.png.rawValue
@@ -671,6 +704,7 @@ struct RecordingSettingsTab: View {
@AppStorage("bs_recordingFPS") private var recordingFPS: Int = 30
@AppStorage("bs_recordingShowCursor") private var showCursor: Bool = true
@AppStorage("bs_recordingCaptureAudio") private var captureAudio: Bool = false
+ @AppStorage("bs_recordingCaptureMicrophone") private var captureMicrophone: Bool = false
@AppStorage("bs_recordingOpenEditor") private var openEditor: Bool = true
var body: some View {
@@ -691,6 +725,7 @@ struct RecordingSettingsTab: View {
Section("Capture") {
Toggle("Show cursor in recording", isOn: $showCursor)
Toggle("Capture system audio", isOn: $captureAudio)
+ Toggle("Capture microphone", isOn: $captureMicrophone)
}
Section("After Recording") {
@@ -706,6 +741,7 @@ struct RecordingSettingsTab: View {
recordingFPS = 30
showCursor = true
captureAudio = false
+ captureMicrophone = false
openEditor = true
}
.controlSize(.small)