iOS pairing scanner: start camera only when foreground (fix black preview)
Root cause (from on-device logs): the session was started from viewDidLoad while the pairing sheet was still presenting, so iOS interrupted it with reason 1 (videoDeviceNotAvailableInBackground) and it never ran (isRunning=false) — a black preview. The CMVideoFormatDescription -12710 errors were unrelated noise. Defer startRunning until the camera can actually run: configure the graph up front, then start only once the view is on screen and the app is foreground-active (viewDidAppear + a guarded startSessionIfReady). Recover on AVCaptureSessionInterruptionEnded / didBecomeActive / runtimeError. All start triggers funnel through the session queue and no-op if already running, so it's idempotent. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
783aac7683
commit
07b6769410
@@ -120,6 +120,7 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
||||
/// 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 configured = false
|
||||
private var didFind = false
|
||||
|
||||
private var previewView: CameraPreviewView { view as! CameraPreviewView }
|
||||
@@ -139,6 +140,13 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
||||
name: .AVCaptureSessionRuntimeError, object: session)
|
||||
nc.addObserver(self, selector: #selector(sessionInterrupted(_:)),
|
||||
name: .AVCaptureSessionWasInterrupted, object: session)
|
||||
// Recover the session once it's allowed to run again (interruption cleared, or the app
|
||||
// returned to the foreground) — the reason-1 interruption at presentation time left it
|
||||
// stopped with no retry.
|
||||
nc.addObserver(self, selector: #selector(startSessionIfReady),
|
||||
name: .AVCaptureSessionInterruptionEnded, object: session)
|
||||
nc.addObserver(self, selector: #selector(startSessionIfReady),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
// 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).
|
||||
@@ -192,23 +200,47 @@ final class QRScannerController: UIViewController, AVCaptureMetadataOutputObject
|
||||
output.metadataObjectTypes = output.availableMetadataObjectTypes.contains(.qr)
|
||||
? [.qr] : output.availableMetadataObjectTypes
|
||||
|
||||
self.session.startRunning()
|
||||
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public) inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
|
||||
self.report(.scanning)
|
||||
self.configured = true
|
||||
scannerLog.notice("📷 configured inputs=\(self.session.inputs.count, privacy: .public) outputs=\(self.session.outputs.count, privacy: .public)")
|
||||
self.startSessionIfReady()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
startSessionIfReady()
|
||||
}
|
||||
|
||||
/// Start the camera only once it can actually run — configured, on screen, and
|
||||
/// foreground-active. Starting during the sheet's presentation made iOS interrupt the
|
||||
/// session (`videoDeviceNotAvailableInBackground`, reason 1) and leave it stopped → black.
|
||||
/// Every start trigger (config finished, view appeared, interruption ended, app foregrounded)
|
||||
/// funnels here; the session-queue hop no-ops if already running, so it's idempotent.
|
||||
@objc private func startSessionIfReady() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.isViewLoaded, self.view.window != nil,
|
||||
UIApplication.shared.applicationState == .active else { return }
|
||||
self.sessionQueue.async {
|
||||
guard self.configured, !self.session.isRunning else { return }
|
||||
self.session.startRunning()
|
||||
scannerLog.notice("📷 startRunning -> isRunning=\(self.session.isRunning, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func sessionDidStart() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.report(.scanning)
|
||||
let connection = self.previewView.previewLayer.connection
|
||||
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) enabled=\(connection?.isEnabled ?? false, privacy: .public) viewBounds=\(self.view.bounds.debugDescription, privacy: .public) layerBounds=\(self.previewView.previewLayer.bounds.debugDescription, privacy: .public)")
|
||||
scannerLog.notice("📷 didStartRunning: previewConnection=\(connection != nil, privacy: .public) active=\(connection?.isActive ?? false, privacy: .public) viewBounds=\(self.view.bounds.debugDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func sessionRuntimeError(_ note: Notification) {
|
||||
let error = note.userInfo?[AVCaptureSessionErrorKey] as? NSError
|
||||
scannerLog.error("📷 runtimeError=\(error?.description ?? "nil", privacy: .public)")
|
||||
startSessionIfReady() // e.g. media-services reset — try to bring it back
|
||||
}
|
||||
|
||||
@objc private func sessionInterrupted(_ note: Notification) {
|
||||
|
||||
Reference in New Issue
Block a user