From 286496f6554a4085cc0df8fdbcb89174d0b79bef Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:13:17 +0100 Subject: [PATCH 1/2] fix: prevent menu bar timer to jump --- BetterCapture/BetterCaptureApp.swift | 45 +++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/BetterCapture/BetterCaptureApp.swift b/BetterCapture/BetterCaptureApp.swift index 37f1419..b57638a 100644 --- a/BetterCapture/BetterCaptureApp.swift +++ b/BetterCapture/BetterCaptureApp.swift @@ -39,12 +39,49 @@ struct MenuBarLabel: View { var body: some View { if viewModel.isRecording { - // Show recording duration as text - Text(viewModel.formattedDuration) - .monospacedDigit() + // Render the duration into a fixed-size image so the + // NSStatusItem never recalculates its width on each tick. + if let image = timerImage { + Image(nsImage: image) + } } else { - // Show app icon Image(systemName: "record.circle") } } + + /// Renders the formatted duration into an ``NSImage`` with a stable + /// width derived from the widest possible string for the current format. + private var timerImage: NSImage? { + let text = viewModel.formattedDuration + + // Use the widest possible string for the current format to + // compute a stable size that won't change between ticks. + let referenceText: String = if viewModel.recordingDuration >= 3600 { + "0:00:00" + } else { + "00:00" + } + + let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.white, + ] + + let referenceSize = (referenceText as NSString).size(withAttributes: attrs) + let imageSize = NSSize(width: ceil(referenceSize.width), height: ceil(referenceSize.height)) + + let textSize = (text as NSString).size(withAttributes: attrs) + let origin = NSPoint( + x: (imageSize.width - textSize.width) / 2, + y: (imageSize.height - textSize.height) / 2 + ) + + let image = NSImage(size: imageSize, flipped: false) { _ in + (text as NSString).draw(at: origin, withAttributes: attrs) + return true + } + image.isTemplate = true + return image + } } From f556f409ec29b505e9732d24e51956f4958a6c3c Mon Sep 17 00:00:00 2001 From: Joshua Sattler <34030048+jsattler@users.noreply.github.com> Date: Sun, 15 Feb 2026 15:22:11 +0100 Subject: [PATCH 2/2] feat(presenter-overlay): add support for macOS Presenter Overlay --- BetterCapture/BetterCapture.entitlements | 2 + BetterCapture/Info.plist | 2 + BetterCapture/Model/SettingsStore.swift | 27 +++++ BetterCapture/Service/AssetWriter.swift | 16 +++ .../Service/CameraDeviceService.swift | 83 +++++++++++++ BetterCapture/Service/CameraSession.swift | 102 ++++++++++++++++ BetterCapture/Service/CaptureEngine.swift | 24 ++++ BetterCapture/View/MenuBarSettingsView.swift | 113 ++++++++++++++++++ BetterCapture/View/MenuBarView.swift | 5 + .../ViewModel/RecorderViewModel.swift | 21 +++- 10 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 BetterCapture/Service/CameraDeviceService.swift create mode 100644 BetterCapture/Service/CameraSession.swift diff --git a/BetterCapture/BetterCapture.entitlements b/BetterCapture/BetterCapture.entitlements index db9d655..2b79602 100644 --- a/BetterCapture/BetterCapture.entitlements +++ b/BetterCapture/BetterCapture.entitlements @@ -12,6 +12,8 @@ com.apple.security.device.audio-input + com.apple.security.device.camera + com.apple.security.network.client com.apple.security.temporary-exception.mach-lookup.global-name diff --git a/BetterCapture/Info.plist b/BetterCapture/Info.plist index efc83c6..aaff686 100644 --- a/BetterCapture/Info.plist +++ b/BetterCapture/Info.plist @@ -6,6 +6,8 @@ NSMicrophoneUsageDescription BetterCapture needs access to your microphone to record audio alongside screen captures. + NSCameraUsageDescription + BetterCapture needs access to your camera to enable Presenter Overlay during screen recordings. NSScreenCaptureUsageDescription BetterCapture needs access to screen recording to capture your screen content. SUFeedURL diff --git a/BetterCapture/Model/SettingsStore.swift b/BetterCapture/Model/SettingsStore.swift index 0f3788f..323409f 100644 --- a/BetterCapture/Model/SettingsStore.swift +++ b/BetterCapture/Model/SettingsStore.swift @@ -294,6 +294,33 @@ final class SettingsStore { } } + // MARK: - Presenter Overlay Settings + + var presenterOverlayEnabled: Bool { + get { + access(keyPath: \.presenterOverlayEnabled) + return UserDefaults.standard.bool(forKey: "presenterOverlayEnabled") + } + set { + withMutation(keyPath: \.presenterOverlayEnabled) { + UserDefaults.standard.set(newValue, forKey: "presenterOverlayEnabled") + } + } + } + + /// The selected camera device ID for Presenter Overlay, or `nil` for the system default. + var selectedCameraID: String? { + get { + access(keyPath: \.selectedCameraID) + return UserDefaults.standard.string(forKey: "selectedCameraID") + } + set { + withMutation(keyPath: \.selectedCameraID) { + UserDefaults.standard.set(newValue, forKey: "selectedCameraID") + } + } + } + // MARK: - Content Filter Settings var showCursor: Bool { diff --git a/BetterCapture/Service/AssetWriter.swift b/BetterCapture/Service/AssetWriter.swift index 19393f3..65706a2 100644 --- a/BetterCapture/Service/AssetWriter.swift +++ b/BetterCapture/Service/AssetWriter.swift @@ -31,6 +31,11 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable private var hasStartedSession = false private var sessionStartTime: CMTime = .zero + /// Last appended video presentation time — used to enforce monotonically + /// increasing timestamps and protect the writer from timing glitches that + /// occur when Presenter Overlay composites the camera into the stream. + private var lastVideoPresentationTime: CMTime = .invalid + // Lock for thread-safe access to writer state private let lock = OSAllocatedUnfairLock() @@ -109,6 +114,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable outputURL = url hasStartedSession = false sessionStartTime = .zero + lastVideoPresentationTime = .invalid frameCount = 0 logger.info("AssetWriter configured for output: \(url.lastPathComponent)") @@ -166,6 +172,13 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable sessionStartTime = presentationTime hasStartedSession = true logger.info("Session started at time: \(presentationTime.seconds)") + } else { + // Guard against non-monotonic timestamps. Presenter Overlay can + // cause timing glitches when compositing the camera into the + // stream; a single bad timestamp permanently fails the writer. + if lastVideoPresentationTime.isValid && presentationTime <= lastVideoPresentationTime { + return + } } // Extract pixel buffer from sample buffer @@ -176,6 +189,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable // Append using the pixel buffer adaptor if adaptor.append(pixelBuffer, withPresentationTime: presentationTime) { + lastVideoPresentationTime = presentationTime frameCount += 1 if frameCount == 1 { logger.info("First video frame appended successfully") @@ -287,6 +301,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable isWriting = false hasStartedSession = false + lastVideoPresentationTime = .invalid logger.info("AssetWriter finished writing \(self.frameCount) frames to: \(url.lastPathComponent)") frameCount = 0 @@ -308,6 +323,7 @@ final class AssetWriter: CaptureEngineSampleBufferDelegate, @unchecked Sendable assetWriter?.cancelWriting() isWriting = false hasStartedSession = false + lastVideoPresentationTime = .invalid frameCount = 0 // Clean up temp file if it exists diff --git a/BetterCapture/Service/CameraDeviceService.swift b/BetterCapture/Service/CameraDeviceService.swift new file mode 100644 index 0000000..d6967c2 --- /dev/null +++ b/BetterCapture/Service/CameraDeviceService.swift @@ -0,0 +1,83 @@ +// +// CameraDeviceService.swift +// BetterCapture +// +// Created by Joshua Sattler on 15.02.26. +// + +import AVFoundation +import OSLog + +/// Represents a camera device +struct CameraDevice: Identifiable, Hashable { + let id: String + let name: String +} + +/// Service for enumerating and monitoring available camera devices +@MainActor +@Observable +final class CameraDeviceService { + + // MARK: - Properties + + private(set) var availableDevices: [CameraDevice] = [] + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", + category: "CameraDeviceService" + ) + + // MARK: - Initialization + + init() { + refreshDevices() + setupNotifications() + } + + // MARK: - Public Methods + + /// Refreshes the list of available camera devices + func refreshDevices() { + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.builtInWideAngleCamera, .external], + mediaType: .video, + position: .unspecified + ) + + availableDevices = discoverySession.devices.map { device in + CameraDevice( + id: device.uniqueID, + name: device.localizedName + ) + } + + logger.info("Found \(self.availableDevices.count) camera devices") + } + + // MARK: - Private Methods + + private func setupNotifications() { + NotificationCenter.default.addObserver( + forName: AVCaptureDevice.wasConnectedNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshDevices() + self?.logger.info("Camera device connected") + } + } + + NotificationCenter.default.addObserver( + forName: AVCaptureDevice.wasDisconnectedNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.refreshDevices() + self?.logger.info("Camera device disconnected") + } + } + } +} diff --git a/BetterCapture/Service/CameraSession.swift b/BetterCapture/Service/CameraSession.swift new file mode 100644 index 0000000..c64372c --- /dev/null +++ b/BetterCapture/Service/CameraSession.swift @@ -0,0 +1,102 @@ +// +// CameraSession.swift +// BetterCapture +// +// Created by Joshua Sattler on 14.02.26. +// + +import AVFoundation +import OSLog + +/// Manages a minimal AVCaptureSession so the system recognises an active camera +/// and makes Presenter Overlay available in the Video menu bar item. +@MainActor +final class CameraSession { + + private var session: AVCaptureSession? + + /// Dedicated queue — `startRunning` / `stopRunning` block and must not run on the main thread. + private let queue = DispatchQueue(label: "com.bettercapture.cameraSession") + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", + category: "CameraSession" + ) + + /// Starts a capture session with the specified camera, falling back to the system default. + /// + /// An `AVCaptureVideoDataOutput` is attached so the system considers the + /// camera hardware active (an input alone is not enough). All delivered + /// frames are discarded — only the session's existence matters for + /// Presenter Overlay. + /// + /// `startRunning()` blocks until the session is fully running, so it is + /// dispatched off the main thread and this method suspends until it + /// completes. This guarantees the camera is active before the caller + /// starts the `SCStream`. + /// + /// - Parameter deviceID: The unique ID of the camera to use, or `nil` for the system default. + func start(deviceID: String? = nil) async { + guard session == nil else { return } + + let device: AVCaptureDevice? = if let deviceID { + AVCaptureDevice(uniqueID: deviceID) + } else { + AVCaptureDevice.default(for: .video) + } + + guard let device else { + logger.warning("No camera available for Presenter Overlay") + return + } + + do { + let input = try AVCaptureDeviceInput(device: device) + let newSession = AVCaptureSession() + + newSession.beginConfiguration() + + guard newSession.canAddInput(input) else { + logger.error("Cannot add camera input to session") + return + } + newSession.addInput(input) + + // An output is required for the system to consider the camera active. + let output = AVCaptureVideoDataOutput() + guard newSession.canAddOutput(output) else { + logger.error("Cannot add video output to session") + return + } + newSession.addOutput(output) + + newSession.commitConfiguration() + + session = newSession + + // Wait for the session to actually be running before returning. + let isRunning = await withCheckedContinuation { continuation in + queue.async { + newSession.startRunning() + continuation.resume(returning: newSession.isRunning) + } + } + + logger.info("Camera session started for Presenter Overlay (running: \(isRunning))") + } catch { + logger.error("Failed to start camera session: \(error.localizedDescription)") + } + } + + /// Stops the capture session and releases resources. + func stop() { + guard let current = session else { return } + session = nil + + queue.async { + current.stopRunning() + } + + logger.info("Camera session stopped") + } +} diff --git a/BetterCapture/Service/CaptureEngine.swift b/BetterCapture/Service/CaptureEngine.swift index d5a1d90..dbfb5dc 100644 --- a/BetterCapture/Service/CaptureEngine.swift +++ b/BetterCapture/Service/CaptureEngine.swift @@ -15,6 +15,7 @@ protocol CaptureEngineDelegate: AnyObject { func captureEngine(_ engine: CaptureEngine, didUpdateFilter filter: SCContentFilter) func captureEngine(_ engine: CaptureEngine, didStopWithError error: Error?) func captureEngineDidCancelPicker(_ engine: CaptureEngine) + func captureEngine(_ engine: CaptureEngine, presenterOverlayDidChange isActive: Bool) } /// Protocol for receiving sample buffers - called synchronously on capture queue @@ -38,6 +39,7 @@ final class CaptureEngine: NSObject { private(set) var contentFilter: SCContentFilter? private(set) var isCapturing = false + private(set) var isPresenterOverlayActive = false private var stream: SCStream? private let picker = SCContentSharingPicker.shared @@ -166,6 +168,7 @@ final class CaptureEngine: NSObject { try await stream.stopCapture() self.stream = nil isCapturing = false + isPresenterOverlayActive = false logger.info("Capture stopped successfully") } @@ -221,6 +224,11 @@ final class CaptureEngine: NSObject { config.microphoneCaptureDeviceID = microphoneID } + // Presenter Overlay: always show the alert so the user knows overlay is available + if settings.presenterOverlayEnabled { + config.presenterOverlayPrivacyAlertSetting = .always + } + // Configure pixel format and dynamic range based on HDR setting if settings.captureHDR && settings.videoCodec.supportsHDR { // HDR: Use 10-bit YCbCr format with HDR dynamic range @@ -298,6 +306,22 @@ extension CaptureEngine: SCStreamDelegate { logger.error("Stream stopped with error: \(error.localizedDescription)") } } + + nonisolated func outputVideoEffectDidStart(for stream: SCStream) { + Task { @MainActor in + self.isPresenterOverlayActive = true + self.delegate?.captureEngine(self, presenterOverlayDidChange: true) + logger.info("Presenter Overlay started") + } + } + + nonisolated func outputVideoEffectDidStop(for stream: SCStream) { + Task { @MainActor in + self.isPresenterOverlayActive = false + self.delegate?.captureEngine(self, presenterOverlayDidChange: false) + logger.info("Presenter Overlay stopped") + } + } } // MARK: - SCStreamOutput diff --git a/BetterCapture/View/MenuBarSettingsView.swift b/BetterCapture/View/MenuBarSettingsView.swift index 9904473..241c66d 100644 --- a/BetterCapture/View/MenuBarSettingsView.swift +++ b/BetterCapture/View/MenuBarSettingsView.swift @@ -529,11 +529,124 @@ struct AudioSettingsSection: View { } } +// MARK: - Camera Expandable Picker + +/// A camera picker with device-style rows, matching the microphone picker pattern +struct CameraExpandablePicker: View { + @Binding var selectedID: String? + let devices: [CameraDevice] + @State private var isExpanded = false + @State private var isHovered = false + + var body: some View { + VStack(spacing: 0) { + // Header row + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack { + Text("Camera") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + Spacer() + Text(currentLabel) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isHovered ? .gray.opacity(0.1) : .clear) + .padding(.horizontal, 4) + ) + .onHover { hovering in + isHovered = hovering + } + + // Expanded device options + if isExpanded { + VStack(spacing: 0) { + // System Default option + DeviceRow( + name: "System Default", + icon: "camera", + isSelected: selectedID == nil + ) { + selectedID = nil + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded = false + } + } + + // Available devices + ForEach(devices) { device in + DeviceRow( + name: device.name, + icon: "camera", + isSelected: selectedID == device.id + ) { + selectedID = device.id + withAnimation(.easeInOut(duration: 0.2)) { + isExpanded = false + } + } + } + } + .padding(.leading, 12) + .background(.quaternary.opacity(0.3)) + } + } + } + + private var currentLabel: String { + if let id = selectedID, let device = devices.first(where: { $0.id == id }) { + return device.name + } + return "System Default" + } +} + +// MARK: - Presenter Overlay Settings Section + +/// Presenter Overlay toggle and camera picker +struct PresenterOverlaySettingsSection: View { + @Bindable var settings: SettingsStore + let cameraDeviceService: CameraDeviceService + + var body: some View { + VStack(spacing: 0) { + SectionDivider() + + SectionHeader(title: "Camera") + + MenuBarToggle(name: "Presenter Overlay", isOn: $settings.presenterOverlayEnabled) + + if settings.presenterOverlayEnabled { + CameraExpandablePicker( + selectedID: $settings.selectedCameraID, + devices: cameraDeviceService.availableDevices + ) + } + } + } +} + // MARK: - Preview #Preview { VStack(spacing: 0) { VideoSettingsSection(settings: SettingsStore()) + PresenterOverlaySettingsSection(settings: SettingsStore(), cameraDeviceService: CameraDeviceService()) AudioSettingsSection(settings: SettingsStore(), audioDeviceService: AudioDeviceService()) } .frame(width: 320) diff --git a/BetterCapture/View/MenuBarView.swift b/BetterCapture/View/MenuBarView.swift index 03d9586..238efcb 100644 --- a/BetterCapture/View/MenuBarView.swift +++ b/BetterCapture/View/MenuBarView.swift @@ -103,6 +103,11 @@ struct MenuBarView: View { // Settings Sections (no divider between them - section headers provide separation) VideoSettingsSection(settings: viewModel.settings) + PresenterOverlaySettingsSection( + settings: viewModel.settings, + cameraDeviceService: viewModel.cameraDeviceService + ) + AudioSettingsSection( settings: viewModel.settings, audioDeviceService: viewModel.audioDeviceService diff --git a/BetterCapture/ViewModel/RecorderViewModel.swift b/BetterCapture/ViewModel/RecorderViewModel.swift index e07d04f..ae096ac 100644 --- a/BetterCapture/ViewModel/RecorderViewModel.swift +++ b/BetterCapture/ViewModel/RecorderViewModel.swift @@ -65,15 +65,20 @@ final class RecorderViewModel { } } + /// Whether Presenter Overlay is currently active (camera composited into stream) + private(set) var isPresenterOverlayActive = false + // MARK: - Dependencies let settings: SettingsStore let audioDeviceService: AudioDeviceService + let cameraDeviceService: CameraDeviceService let previewService: PreviewService let notificationService: NotificationService let permissionService: PermissionService private let captureEngine: CaptureEngine private let assetWriter: AssetWriter + private let cameraSession = CameraSession() private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "BetterCapture", category: "RecorderViewModel") @@ -90,6 +95,7 @@ final class RecorderViewModel { init() { self.settings = SettingsStore() self.audioDeviceService = AudioDeviceService() + self.cameraDeviceService = CameraDeviceService() self.previewService = PreviewService() self.notificationService = NotificationService() self.permissionService = PermissionService() @@ -222,6 +228,11 @@ final class RecorderViewModel { try assetWriter.startWriting() logger.info("AssetWriter ready") + // Start camera for Presenter Overlay before capture so the system detects it + if settings.presenterOverlayEnabled { + await cameraSession.start(deviceID: settings.selectedCameraID) + } + // Start capture with the calculated video size logger.info("Starting capture engine...") try await captureEngine.startCapture(with: settings, videoSize: videoSize, sourceRect: selectedSourceRect) @@ -234,6 +245,7 @@ final class RecorderViewModel { } catch { state = .idle lastError = error + cameraSession.stop() selectionBorderFrame.dismiss() logger.error("Failed to start recording: \(error.localizedDescription)") } @@ -248,8 +260,10 @@ final class RecorderViewModel { selectionBorderFrame.dismiss() do { - // Stop capture first + // Stop capture and camera session try await captureEngine.stopCapture() + cameraSession.stop() + isPresenterOverlayActive = false // Finalize file let outputURL = try await assetWriter.finishWriting() @@ -399,6 +413,11 @@ extension RecorderViewModel: CaptureEngineDelegate { } } + func captureEngine(_ engine: CaptureEngine, presenterOverlayDidChange isActive: Bool) { + isPresenterOverlayActive = isActive + logger.info("Presenter Overlay \(isActive ? "activated" : "deactivated")") + } + func captureEngineDidCancelPicker(_ engine: CaptureEngine) { logger.info("Picker was cancelled, clearing selection and preview")