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)