diff --git a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift index 77aafa4..f4db3ff 100644 --- a/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift +++ b/NucleicRemote/NucleicRemote/Views/PairingScannerView.swift @@ -99,17 +99,34 @@ struct QRScannerRepresentable: UIViewControllerRepresentable { func updateUIViewController(_ controller: QRScannerController, context: Context) {} } +/// A view whose *backing layer* is the camera preview, so it always fills the view through the +/// layer hierarchy — no manual frame bookkeeping. The prior version added the preview as a +/// sublayer and set its frame in viewDidLoad/viewDidLayoutSubviews, which raced with the async +/// permission callback and layout and could leave a zero-sized (black) preview on device. +final class CameraPreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } +} + final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { var onFound: ((String) -> Void)? var onState: ((ScannerCameraState) -> Void)? private let session = AVCaptureSession() + /// AVCaptureSession isn't thread-safe — all configuration + start/stop happen here; only the + /// preview layer and delegate callbacks touch the main thread. private let sessionQueue = DispatchQueue(label: "nucleic.qrscanner.session") - private var preview: AVCaptureVideoPreviewLayer? private var didFind = false + private var previewView: CameraPreviewView { view as! CameraPreviewView } + + override func loadView() { view = CameraPreviewView() } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black + previewView.previewLayer.session = session + previewView.previewLayer.videoGravity = .resizeAspectFill + // Gate on camera authorization first — without this the prompt may never appear and the // feed silently fails (and on a denied device it would just stay black). switch AVCaptureDevice.authorizationStatus(for: .video) { @@ -117,9 +134,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject configureSession() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - DispatchQueue.main.async { - granted ? self?.configureSession() : self?.report(.denied) - } + granted ? self?.configureSession() : self?.report(.denied) } case .denied, .restricted: report(.denied) @@ -128,46 +143,40 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject } } - /// Build the capture graph. Reports `.unavailable` when there's no camera (the Simulator) so - /// the UI can explain the black screen instead of leaving the user guessing. + /// Configure + start the capture graph on the session queue. Reports `.unavailable` when + /// there's no usable camera (e.g. the Simulator) and `.scanning` once frames are flowing. private func configureSession() { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { - report(.unavailable); return + sessionQueue.async { [weak self] in + guard let self else { return } + self.session.beginConfiguration() + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + self.session.canAddInput(input) else { + self.session.commitConfiguration(); self.report(.unavailable); return + } + self.session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard self.session.canAddOutput(output) else { + self.session.commitConfiguration(); self.report(.unavailable); return + } + self.session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + self.session.commitConfiguration() + + self.session.startRunning() + self.report(.scanning) } - session.addInput(input) - - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { report(.unavailable); return } - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] - - let preview = AVCaptureVideoPreviewLayer(session: session) - preview.videoGravity = .resizeAspectFill - preview.frame = view.layer.bounds - view.layer.addSublayer(preview) - self.preview = preview - - report(.scanning) - sessionQueue.async { [session] in session.startRunning() } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - // Keep the preview filling the view across layout/rotation (setting it once in viewDidLoad - // left it mis-sized when bounds weren't final yet). - preview?.frame = view.layer.bounds } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - sessionQueue.async { [session] in session.stopRunning() } + sessionQueue.async { [session] in if session.isRunning { session.stopRunning() } } } private func report(_ state: ScannerCameraState) { - onState?(state) + DispatchQueue.main.async { [weak self] in self?.onState?(state) } } func metadataOutput(