From 9f842d99a06eb8f150c73f58be67c337f65c8c97 Mon Sep 17 00:00:00 2001 From: MTAY Date: Sat, 31 Oct 2026 13:26:40 +1100 Subject: [PATCH 1/5] Let auto sliders move and double-click to reset - Exposure, White Balance, Focus and Hue sliders can be moved while auto is on; moving one switches that control to manual - Double-clicking a slider's thumb resets it to the camera's default Co-Authored-By: Claude Opus 5.5 --- .../ControlComponents/GenericControl.swift | 23 ++++++++-- .../Common/ControlComponents/Slider.swift | 42 ++++++++++++++++++- .../Views/Settings/Advanced/FocusView.swift | 1 + .../Views/Settings/Advanced/PanTiltView.swift | 6 ++- .../Views/Settings/Advanced/RollView.swift | 1 + .../Views/Settings/Advanced/ZoomView.swift | 1 + .../Views/Settings/Basic/BrightnessView.swift | 1 + .../Views/Settings/Basic/ContrastView.swift | 1 + .../Views/Settings/Basic/ExposureView.swift | 21 ++++++++-- .../Views/Settings/Basic/HueView.swift | 1 + .../Views/Settings/Basic/SaturationView.swift | 1 + .../Views/Settings/Basic/SharpnessView.swift | 1 + .../Settings/Basic/WhiteBalanceView.swift | 1 + 13 files changed, 91 insertions(+), 10 deletions(-) diff --git a/CameraController/Views/Common/ControlComponents/GenericControl.swift b/CameraController/Views/Common/ControlComponents/GenericControl.swift index 08768d3..e440cb1 100644 --- a/CameraController/Views/Common/ControlComponents/GenericControl.swift +++ b/CameraController/Views/Common/ControlComponents/GenericControl.swift @@ -14,6 +14,7 @@ struct GenericControl: View { @Binding var auto: Bool @State var step: Float @State var range: ClosedRange + let defaultValue: Float let title: String let imageName: String @@ -22,18 +23,34 @@ struct GenericControl: View { init(value: Binding, step: Float, range: ClosedRange, + defaultValue: Float, title: String, imageName: String, auto: Binding?) { self._value = value self.step = step self.range = range + self.defaultValue = defaultValue self.title = title self.imageName = imageName self.hasAuto = auto != nil self._auto = auto ?? .constant(false) } + var sliderValue: Binding { + Binding(get: { + value + }, set: { newValue in + // Moving the slider while in auto switches the control to manual + if hasAuto, auto { + withAnimation { + auto = false + } + } + value = newValue + }) + } + var body: some View { SectionView { SectionTitle(title: title, @@ -54,10 +71,10 @@ struct GenericControl: View { Toggle(isOn: .constant(false)) .hidden() } - Slider(value: $value, + Slider(value: sliderValue, step: step, - sliderRange: range) - .disabled($auto.wrappedValue) + sliderRange: range, + defaultValue: defaultValue) } } } diff --git a/CameraController/Views/Common/ControlComponents/Slider.swift b/CameraController/Views/Common/ControlComponents/Slider.swift index 337594e..39304e2 100644 --- a/CameraController/Views/Common/ControlComponents/Slider.swift +++ b/CameraController/Views/Common/ControlComponents/Slider.swift @@ -7,14 +7,19 @@ // import SwiftUI +import AppKit struct Slider: View { @Binding var value: Float @Environment(\.isEnabled) var isEnabled: Bool @State var lastCoordinateValue: Float = 0.0 + @State private var isDragging = false + @State private var isResetClick = false + @State private var lastThumbClick: Date? let sliderRange: ClosedRange let step: Float + let defaultValue: Float? let minTrackColor: Color = Constants.Colors.accentColor let maxTrackColor: Color = Constants.Colors.sliderBackground let disabledColor: Color = Constants.Colors.disabledSlider @@ -23,10 +28,12 @@ struct Slider: View { init(value: Binding, step: Float = 1, - sliderRange: ClosedRange = 0...100) { + sliderRange: ClosedRange = 0...100, + defaultValue: Float? = nil) { _value = value self.step = step self.sliderRange = sliderRange + self.defaultValue = defaultValue self.lastCoordinateValue = lastCoordinateValue } @@ -68,6 +75,19 @@ struct Slider: View { .gesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { value in + if !isDragging { + isDragging = true + isResetClick = isThumbDoubleClick(at: value.startLocation.x, + thumbStart: CGFloat(sliderVal), + thumbWidth: thumbWidth) + } + // Double-clicking the thumb resets to the default instead of dragging + if isResetClick { + if let defaultValue = defaultValue { + self.value = defaultValue + } + return + } let percentage = min( max( Float(value.location.x-thumbWidth/2) / Float(geoReader.size.width-thumbWidth), @@ -77,12 +97,32 @@ struct Slider: View { let tmpValue = percentageToValueInRange(percentage, sliderRange) self.value = tmpValue - tmpValue.truncatingRemainder(dividingBy: step) } + .onEnded { _ in + isDragging = false + } ) .allowsHitTesting(self.isEnabled) } .frame(height: 22) } + private func isThumbDoubleClick(at location: CGFloat, thumbStart: CGFloat, thumbWidth: CGFloat) -> Bool { + guard defaultValue != nil, + (thumbStart...thumbStart + thumbWidth).contains(location) else { + lastThumbClick = nil + return false + } + + let now = Date() + if let lastThumbClick = lastThumbClick, + now.timeIntervalSince(lastThumbClick) <= NSEvent.doubleClickInterval { + self.lastThumbClick = nil + return true + } + lastThumbClick = now + return false + } + private func percentageToValueInRange(_ percentage: Float, _ range: ClosedRange) -> Float { return range.lowerBound + (range.upperBound - range.lowerBound) * percentage } diff --git a/CameraController/Views/Settings/Advanced/FocusView.swift b/CameraController/Views/Settings/Advanced/FocusView.swift index fd70124..125b87f 100644 --- a/CameraController/Views/Settings/Advanced/FocusView.swift +++ b/CameraController/Views/Settings/Advanced/FocusView.swift @@ -21,6 +21,7 @@ struct FocusView: View { GenericControl(value: $focusAbsolute.sliderValue, step: focusAbsolute.resolution, range: focusAbsolute.minimum...focusAbsolute.maximum, + defaultValue: focusAbsolute.defaultValue, title: "Focus", imageName: "camera.aperture", auto: $focusAuto.isEnabled) diff --git a/CameraController/Views/Settings/Advanced/PanTiltView.swift b/CameraController/Views/Settings/Advanced/PanTiltView.swift index 0e98545..9bc38fa 100644 --- a/CameraController/Views/Settings/Advanced/PanTiltView.swift +++ b/CameraController/Views/Settings/Advanced/PanTiltView.swift @@ -25,7 +25,8 @@ struct PanTiltView: View { .hidden() Slider(value: $panTiltAbsolute.sliderValue1, step: panTiltAbsolute.resolution1, - sliderRange: panTiltAbsolute.minimum1...panTiltAbsolute.maximum1) + sliderRange: panTiltAbsolute.minimum1...panTiltAbsolute.maximum1, + defaultValue: panTiltAbsolute.defaultValue1) } } @@ -37,7 +38,8 @@ struct PanTiltView: View { .hidden() Slider(value: $panTiltAbsolute.sliderValue2, step: panTiltAbsolute.resolution2, - sliderRange: panTiltAbsolute.minimum2...panTiltAbsolute.maximum2) + sliderRange: panTiltAbsolute.minimum2...panTiltAbsolute.maximum2, + defaultValue: panTiltAbsolute.defaultValue2) } } } diff --git a/CameraController/Views/Settings/Advanced/RollView.swift b/CameraController/Views/Settings/Advanced/RollView.swift index dd1a97b..2900821 100644 --- a/CameraController/Views/Settings/Advanced/RollView.swift +++ b/CameraController/Views/Settings/Advanced/RollView.swift @@ -19,6 +19,7 @@ struct RollView: View { GenericControl(value: $rollAbsolute.sliderValue, step: rollAbsolute.resolution, range: rollAbsolute.minimum...rollAbsolute.maximum, + defaultValue: rollAbsolute.defaultValue, title: "Roll", imageName: "arrow.counterclockwise", auto: nil) diff --git a/CameraController/Views/Settings/Advanced/ZoomView.swift b/CameraController/Views/Settings/Advanced/ZoomView.swift index 392b37d..1a52dae 100644 --- a/CameraController/Views/Settings/Advanced/ZoomView.swift +++ b/CameraController/Views/Settings/Advanced/ZoomView.swift @@ -19,6 +19,7 @@ struct ZoomView: View { GenericControl(value: $zoomAbsolute.sliderValue, step: zoomAbsolute.resolution, range: zoomAbsolute.minimum...zoomAbsolute.maximum, + defaultValue: zoomAbsolute.defaultValue, title: "Zoom", imageName: "plus.magnifyingglass", auto: nil) diff --git a/CameraController/Views/Settings/Basic/BrightnessView.swift b/CameraController/Views/Settings/Basic/BrightnessView.swift index b9539be..cb8b056 100644 --- a/CameraController/Views/Settings/Basic/BrightnessView.swift +++ b/CameraController/Views/Settings/Basic/BrightnessView.swift @@ -19,6 +19,7 @@ struct BrightnessView: View { GenericControl(value: $brightness.sliderValue, step: brightness.resolution, range: brightness.minimum...brightness.maximum, + defaultValue: brightness.defaultValue, title: "Brightness", imageName: "sun.max.fill", auto: nil) diff --git a/CameraController/Views/Settings/Basic/ContrastView.swift b/CameraController/Views/Settings/Basic/ContrastView.swift index 6b95280..a10d183 100644 --- a/CameraController/Views/Settings/Basic/ContrastView.swift +++ b/CameraController/Views/Settings/Basic/ContrastView.swift @@ -19,6 +19,7 @@ struct ContrastView: View { GenericControl(value: $contrast.sliderValue, step: contrast.resolution, range: contrast.minimum...contrast.maximum, + defaultValue: contrast.defaultValue, title: "Contrast", imageName: "moonphase.first.quarter", auto: nil) diff --git a/CameraController/Views/Settings/Basic/ExposureView.swift b/CameraController/Views/Settings/Basic/ExposureView.swift index f5cfdd4..179c668 100644 --- a/CameraController/Views/Settings/Basic/ExposureView.swift +++ b/CameraController/Views/Settings/Basic/ExposureView.swift @@ -24,6 +24,18 @@ struct ExposureView: View { }) } + var exposureValue: Binding { + Binding(get: { + exposureTime.sliderValue + }, set: { value in + // Moving the slider while in auto switches exposure to manual + if auto.wrappedValue { + auto.wrappedValue = false + } + exposureTime.sliderValue = value + }) + } + init(controller: DeviceController) { self.exposureTime = controller.exposureTime self.exposureMode = controller.exposureMode @@ -42,10 +54,10 @@ struct ExposureView: View { HStack { Toggle(isOn: auto.animation()) - Slider(value: $exposureTime.sliderValue, + Slider(value: exposureValue, step: exposureTime.resolution, - sliderRange: exposureTime.minimum...exposureTime.maximum) - .disabled(auto.wrappedValue) + sliderRange: exposureTime.minimum...exposureTime.maximum, + defaultValue: exposureTime.defaultValue) } if !auto.wrappedValue { @@ -61,7 +73,8 @@ struct ExposureView: View { HStack { Slider(value: $gain.sliderValue, step: gain.resolution, - sliderRange: gain.minimum...gain.maximum) + sliderRange: gain.minimum...gain.maximum, + defaultValue: gain.defaultValue) .disabled(auto.wrappedValue) } } diff --git a/CameraController/Views/Settings/Basic/HueView.swift b/CameraController/Views/Settings/Basic/HueView.swift index e09cc62..6601c73 100644 --- a/CameraController/Views/Settings/Basic/HueView.swift +++ b/CameraController/Views/Settings/Basic/HueView.swift @@ -21,6 +21,7 @@ struct HueView: View { GenericControl(value: $hue.sliderValue, step: hue.resolution, range: hue.minimum...hue.maximum, + defaultValue: hue.defaultValue, title: "Hue", imageName: "hue", auto: $hueAuto.isEnabled) diff --git a/CameraController/Views/Settings/Basic/SaturationView.swift b/CameraController/Views/Settings/Basic/SaturationView.swift index bfb4ca9..4e45a36 100644 --- a/CameraController/Views/Settings/Basic/SaturationView.swift +++ b/CameraController/Views/Settings/Basic/SaturationView.swift @@ -19,6 +19,7 @@ struct SaturationView: View { GenericControl(value: $saturation.sliderValue, step: saturation.resolution, range: saturation.minimum...saturation.maximum, + defaultValue: saturation.defaultValue, title: "Saturation", imageName: "eyedropper.halffull", auto: nil) diff --git a/CameraController/Views/Settings/Basic/SharpnessView.swift b/CameraController/Views/Settings/Basic/SharpnessView.swift index 6ebad55..4312544 100644 --- a/CameraController/Views/Settings/Basic/SharpnessView.swift +++ b/CameraController/Views/Settings/Basic/SharpnessView.swift @@ -19,6 +19,7 @@ struct SharpnessView: View { GenericControl(value: $sharpness.sliderValue, step: sharpness.resolution, range: sharpness.minimum...sharpness.maximum, + defaultValue: sharpness.defaultValue, title: "Sharpness", imageName: "triangle.fill", auto: nil) diff --git a/CameraController/Views/Settings/Basic/WhiteBalanceView.swift b/CameraController/Views/Settings/Basic/WhiteBalanceView.swift index 954502b..e2ea5db 100644 --- a/CameraController/Views/Settings/Basic/WhiteBalanceView.swift +++ b/CameraController/Views/Settings/Basic/WhiteBalanceView.swift @@ -21,6 +21,7 @@ struct WhiteBalanceView: View { GenericControl(value: $whiteBalance.sliderValue, step: whiteBalance.resolution, range: whiteBalance.minimum...whiteBalance.maximum, + defaultValue: whiteBalance.defaultValue, title: "White Balance", imageName: "slider.horizontal.3", auto: $whiteBalanceAuto.isEnabled) From d0da41168090145421a2e4ac4765b29a57ba2c5f Mon Sep 17 00:00:00 2001 From: MTAY Date: Sat, 31 Oct 2026 15:16:56 +1100 Subject: [PATCH 2/5] Fix overflow when setting pan/tilt Co-Authored-By: Claude Opus 5.5 --- UVC/Controls/UVCMultipleIntControl.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/UVC/Controls/UVCMultipleIntControl.swift b/UVC/Controls/UVCMultipleIntControl.swift index 18719ea..98fcdb4 100644 --- a/UVC/Controls/UVCMultipleIntControl.swift +++ b/UVC/Controls/UVCMultipleIntControl.swift @@ -50,12 +50,16 @@ public final class UVCMultipleIntControl: UVCControl { } private func set1(_ val1: Int) -> Bool { - let newValue = (val1*3600) << 32 + (_current2*3600) + let part1 = (val1 &* 3600) &<< 32 + let part2 = _current2 &* 3600 + let newValue = part1 &+ part2 return setData(value: newValue, length: uvcSize) } private func set2(_ val2: Int) -> Bool { - let newValue = (_current1*3600) << 32 + (val2*3600) + let part1 = (_current1 &* 3600) &<< 32 + let part2 = val2 &* 3600 + let newValue = part1 &+ part2 return setData(value: newValue, length: uvcSize) } @@ -128,17 +132,18 @@ public final class UVCMultipleIntControl: UVCControl { } } -public extension SignedInteger { +public extension FixedWidthInteger { init(_ bytes: [UInt8]) { precondition(bytes.count <= MemoryLayout.size) - var value: Int32 = 0 + var value: Self = 0 for byte in bytes { - value <<= 8 - value |= Int32(byte) + // Use masking left-shift &<< to completely prevent arithmetic overflows when shifting + value = value &<< 8 + value |= Self(byte) } - self.init(value) + self = value } } From f89331c2397d03b58768e450f4074c801d7199e5 Mon Sep 17 00:00:00 2001 From: MTAY Date: Sat, 31 Oct 2026 15:17:04 +1100 Subject: [PATCH 3/5] Parse config descriptor before its memory is released Co-Authored-By: Claude Opus 5.5 --- UVC/Extensions/AVCaptureDevice+USB.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/UVC/Extensions/AVCaptureDevice+USB.swift b/UVC/Extensions/AVCaptureDevice+USB.swift index fefab89..13dbeaf 100644 --- a/UVC/Extensions/AVCaptureDevice+USB.swift +++ b/UVC/Extensions/AVCaptureDevice+USB.swift @@ -73,7 +73,11 @@ extension AVCaptureDevice { assert( code == kIOReturnSuccess ) } var interfaceRef: UnsafeMutablePointer>? - var configDesc: IOUSBConfigurationDescriptorPtr? + // IMPORTANT: GetConfigurationDescriptorPtr returns a pointer whose lifetime is + // tied to the device interface. We must parse it BEFORE Release()ing the device + // interface — otherwise the descriptor memory is freed and we read garbage on + // newer macOS/IOKit. Parse inside the closure into a value type and use that. + var descriptor: UVCDescriptor? try camera.ioCreatePluginInterfaceFor(service: kIOUSBDeviceUserClientTypeID) { let deviceInterface: DeviceInterfacePointer = try $0.getInterface(uuid: kIOUSBDeviceInterfaceID) defer { _ = deviceInterface.pointee.pointee.Release(deviceInterface) } @@ -93,15 +97,20 @@ extension AVCaptureDevice { return } + var configDesc: IOUSBConfigurationDescriptorPtr? returnCode = deviceInterface.pointee.pointee.GetConfigurationDescriptorPtr(deviceInterface, 0, &configDesc) if returnCode != kIOReturnSuccess { print("unable to get config description for config 0 (index)") return } + + // Parse while deviceInterface (and therefore configDesc's backing memory) is still alive. + descriptor = configDesc?.proccessDescriptor() } guard interfaceRef != nil else { throw NSError(domain: #function, code: #line, userInfo: nil) } - - let descriptor = configDesc!.proccessDescriptor() + guard let descriptor = descriptor else { + throw NSError(domain: #function, code: #line, userInfo: ["reason": "failed to parse UVC descriptor"]) + } return USBDevice(interface: interfaceRef.unsafelyUnwrapped, descriptor: descriptor) From a4dc32c6f86539515fe30efb5d1105dbdf76bd79 Mon Sep 17 00:00:00 2001 From: MTAY Date: Sat, 31 Oct 2026 15:17:09 +1100 Subject: [PATCH 4/5] Fix file paths in Move to Applications Co-Authored-By: Claude Opus 5.5 --- .../Vendored/MoveApplication.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/CameraController/Vendored/MoveApplication.swift b/CameraController/Vendored/MoveApplication.swift index fa8f91e..66f6d9e 100644 --- a/CameraController/Vendored/MoveApplication.swift +++ b/CameraController/Vendored/MoveApplication.swift @@ -66,21 +66,21 @@ class LetsMove: NSObject { // Path of the bundle let bundlePath = Bundle.main.bundlePath - let bundleNameURL = URL(string: bundlePath) + let bundleNameURL = URL(fileURLWithPath: bundlePath) // Skip if the application is already in some Applications folder - guard !isInApplicationsFolder(bundleNameURL!) else { return } + guard !isInApplicationsFolder(bundleNameURL) else { return } // Since we are good to go, get the preferred installation directory. let (applicationsDirectory, installToUserApplications) = preferredInstallLocation() - let bundleName = bundleNameURL!.lastPathComponent + let bundleName = bundleNameURL.lastPathComponent let destinationURL = applicationsDirectory!.appendingPathComponent(bundleName) // Check if we need admin password to write to the Applications directory // Check if the destination bundle is already there but not writable - let isWritableFileSrc = fileManager.isWritableFile(atPath: applicationsDirectory!.absoluteString) - let isFileExists = fileManager.fileExists(atPath: destinationURL.absoluteString) - let isWritableFileDst = fileManager.isWritableFile(atPath: destinationURL.absoluteString) + let isWritableFileSrc = fileManager.isWritableFile(atPath: applicationsDirectory!.path) + let isFileExists = fileManager.fileExists(atPath: destinationURL.path) + let isWritableFileDst = fileManager.isWritableFile(atPath: destinationURL.path) let isNeedAuthorization = !isWritableFileSrc || (isFileExists && !isWritableFileDst) @@ -96,7 +96,7 @@ class LetsMove: NSObject { print("INFO -- Moving myself to the Applications folder") if isNeedAuthorization { - if !authorizedInstall(srcPath: bundlePath, dstPath: destinationURL.absoluteString) { + if !authorizedInstall(srcPath: bundlePath, dstPath: destinationURL.path) { print("ERROR -- Could not copy myself to /Applications with authorization") return } @@ -113,7 +113,7 @@ class LetsMove: NSObject { // Relaunch. print("relaunch") - relaunch(destinationPath: destinationURL.absoluteString) + relaunch(destinationPath: destinationURL.path) exit(0) } else if alert.suppressionButton!.state == .on { // Save the alert suppress preference if checked @@ -162,17 +162,17 @@ class LetsMove: NSObject { _ bundleName: String, _ moveStrings: LetsMove.MoveStrings) -> Bool { // If a copy already exists in the Applications folder, put it in the Trash - if fileManager.fileExists(atPath: destinationURL.absoluteString) { + if fileManager.fileExists(atPath: destinationURL.path) { // But first, make sure that it's not running - if isApplicationAtPathRunning(path: destinationURL.absoluteString) { + if isApplicationAtPathRunning(path: destinationURL.path) { // Give the running app focus and terminate myself print("INFO -- Switching to an already running version") Process.launchedProcess(launchPath: "/usr/bin/open", arguments: - [destinationURL.absoluteString]).waitUntilExit() + [destinationURL.path]).waitUntilExit() exit(0) } else { - let path = applicationsDirectory!.appendingPathComponent(bundleName).absoluteString + let path = applicationsDirectory!.appendingPathComponent(bundleName).path if !trash(path: path) { let alert = NSAlert() alert.messageText = moveStrings.couldNotMove @@ -182,8 +182,8 @@ class LetsMove: NSObject { } } - if !copyBundle(srcPath: bundlePath, dstPath: destinationURL.absoluteString) { - print("Could not copy myself to \(destinationURL.absoluteString)") + if !copyBundle(srcPath: bundlePath, dstPath: destinationURL.path) { + print("Could not copy myself to \(destinationURL.path)") return false } @@ -214,7 +214,7 @@ class LetsMove: NSObject { if appDir == nil { let localLocations = NSSearchPathForDirectoriesInDomains(.applicationDirectory, .localDomainMask, true) if let last = localLocations.last { - appDir = URL(string: last) + appDir = URL(fileURLWithPath: last) } } return (appDir, isUserDir) From d1bc05a3c2cdb44f2cbeb4f1d3056f2e2ca97b30 Mon Sep 17 00:00:00 2001 From: MTAY Date: Sat, 31 Oct 2026 15:17:16 +1100 Subject: [PATCH 5/5] Start and stop the camera preview off the main thread Co-Authored-By: Claude Opus 5.5 --- .../Views/Preview/CameraPreviewInternal.swift | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/CameraController/Views/Preview/CameraPreviewInternal.swift b/CameraController/Views/Preview/CameraPreviewInternal.swift index c71b4a0..74bb39b 100644 --- a/CameraController/Views/Preview/CameraPreviewInternal.swift +++ b/CameraController/Views/Preview/CameraPreviewInternal.swift @@ -1,5 +1,5 @@ // -// CameraPrreviewInternal.swift +// CameraPreviewInternal.swift // CameraController // // Created by Itay Brenner on 7/21/20. @@ -15,6 +15,9 @@ final class CameraPreviewInternal: NSView { private var captureSession: AVCaptureSession private var previewLayer: AVCaptureVideoPreviewLayer! private var captureInput: AVCaptureInput? + + // Serial queue to handle all blocking AVCaptureSession start/stop and device lock operations + private let sessionQueue = DispatchQueue(label: "com.itaysoft.CameraController.sessionQueue", qos: .userInitiated) init(frame frameRect: NSRect, device: AVCaptureDevice?) { captureDevice = device @@ -22,18 +25,28 @@ final class CameraPreviewInternal: NSView { super.init(frame: frameRect) + // 1. Enable layer-backing for this NSView + self.wantsLayer = true + setupPreviewLayer(captureSession) + + // 2. Add the sublayer once here instead of during layout + if let layer = self.layer { + layer.addSublayer(previewLayer) + } - Task { - configureDevice(device) - // lock configuration to keep device.activeFormat - do { - try captureDevice?.lockForConfiguration() - captureSession.startRunning() - captureDevice?.unlockForConfiguration() - } catch { + configureDevice(device) + + let session = captureSession + let dev = captureDevice + sessionQueue.async { + do { + try dev?.lockForConfiguration() + session.startRunning() + dev?.unlockForConfiguration() + } catch { // Handle error. - } + } } NotificationCenter.default.addObserver(self, @@ -67,27 +80,36 @@ final class CameraPreviewInternal: NSView { override func layout() { super.layout() + // 3. Only update the bounds here. Do not add or manipulate layers. previewLayer.frame = bounds - layer?.addSublayer(previewLayer) } func stopRunning() { - if captureSession.isRunning { - captureSession.stopRunning() + let session = captureSession + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } } } func updateCamera(_ cam: AVCaptureDevice?) { if captureDevice != cam { - captureSession.stopRunning() + let session = captureSession + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + } - Task { - configureDevice(cam) - // lock configuration to keep device.activeFormat + configureDevice(cam) + + let dev = captureDevice + sessionQueue.async { do { - try captureDevice?.lockForConfiguration() - captureSession.startRunning() - captureDevice?.unlockForConfiguration() + try dev?.lockForConfiguration() + session.startRunning() + dev?.unlockForConfiguration() } catch { // Handle error. } @@ -127,8 +149,11 @@ final class CameraPreviewInternal: NSView { @objc func windowOpen() { - if !captureSession.isRunning { - captureSession.startRunning() + let session = captureSession + sessionQueue.async { + if !session.isRunning { + session.startRunning() + } } } }