Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions BetterCapture/BetterCapture.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
Expand Down
45 changes: 41 additions & 4 deletions BetterCapture/BetterCaptureApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
2 changes: 2 additions & 0 deletions BetterCapture/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>BetterCapture needs access to your microphone to record audio alongside screen captures.</string>
<key>NSCameraUsageDescription</key>
<string>BetterCapture needs access to your camera to enable Presenter Overlay during screen recordings.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>BetterCapture needs access to screen recording to capture your screen content.</string>
<key>SUFeedURL</key>
Expand Down
27 changes: 27 additions & 0 deletions BetterCapture/Model/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions BetterCapture/Service/AssetWriter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
83 changes: 83 additions & 0 deletions BetterCapture/Service/CameraDeviceService.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
102 changes: 102 additions & 0 deletions BetterCapture/Service/CameraSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//
// CameraSession.swift
// BetterCapture
//
// Created by Joshua Sattler on 14.02.26.
//

import AVFoundation

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'AVFoundation'

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'AVFoundation'

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'AVFoundation'

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'AVFoundation'

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

add '@preconcurrency' to suppress 'Sendable'-related warnings from module 'AVFoundation'

Check warning on line 8 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

add '@preconcurrency' to suppress 'Sendable'-related warnings from module '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()

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 80 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'newSession' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure
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()

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Test

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure

Check warning on line 97 in BetterCapture/Service/CameraSession.swift

View workflow job for this annotation

GitHub Actions / Build

capture of 'current' with non-Sendable type 'AVCaptureSession' in a '@sendable' closure
}

logger.info("Camera session stopped")
}
}
Loading
Loading