pi-remote-ios/Sources/UI/Pairing/QRScannerView.swift

127 lines
3.8 KiB
Swift

// Sources/UI/Pairing/QRScannerView.swift
// T-2.2: AVFoundation QR scanner wrapped as UIViewRepresentable
import AVFoundation
import SwiftUI
/// A fullscreen AVFoundation-backed QR code scanner.
///
/// Calls `onScan` exactly once with the raw QR string, then stops the
/// capture session so the caller can drive the next state transition.
struct QRScannerView: UIViewRepresentable {
/// Called on the **main actor** with the raw decoded QR string.
let onScan: @MainActor (String) -> Void
// MARK: UIViewRepresentable
func makeCoordinator() -> Coordinator {
Coordinator(onScan: onScan)
}
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
let coordinator = context.coordinator
let session = coordinator.session
// Input
guard
let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device)
else {
return view
}
guard session.canAddInput(input) else { return view }
session.addInput(input)
// Output
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return view }
session.addOutput(output)
output.setMetadataObjectsDelegate(coordinator,
queue: DispatchQueue.main)
output.metadataObjectTypes = [.qr]
// Preview layer
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
view.previewLayer = previewLayer
view.layer.addSublayer(previewLayer)
coordinator.previewLayer = previewLayer
// Start capture on a background thread to avoid blocking the main queue.
Task.detached(priority: .userInitiated) {
session.startRunning()
}
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
// Layout is handled inside PreviewView.layoutSubviews.
}
static func dismantleUIView(_ uiView: PreviewView, coordinator: Coordinator) {
coordinator.stop()
}
// MARK: - Coordinator
@MainActor
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
let session = AVCaptureSession()
var previewLayer: AVCaptureVideoPreviewLayer?
private let onScan: @MainActor (String) -> Void
private var hasScanned = false
init(onScan: @MainActor @escaping (String) -> Void) {
self.onScan = onScan
}
// Called on main queue (set via setMetadataObjectsDelegate).
nonisolated func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
MainActor.assumeIsolated {
guard !hasScanned else { return }
guard
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
object.type == .qr,
let string = object.stringValue
else { return }
hasScanned = true
stop()
onScan(string)
}
}
func stop() {
guard session.isRunning else { return }
Task.detached(priority: .userInitiated) { [session] in
session.stopRunning()
}
}
}
// MARK: - PreviewView
/// UIView subclass that keeps the `AVCaptureVideoPreviewLayer` sized to its bounds.
final class PreviewView: UIView {
var previewLayer: AVCaptureVideoPreviewLayer?
override func layoutSubviews() {
super.layoutSubviews()
previewLayer?.frame = bounds
}
}
}