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/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
+ }
}
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")