import SwiftUI import AVFoundation struct QRScannerSheet: View { let receive: (String) -> Void @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var phase @State private var error: String? var body: some View { NavigationStack { VStack(spacing: 18) { Text("Наведите камеру на QR в окне MusicBridge на ПК.") .multilineTextAlignment(.center) if let error { ContentUnavailableView("Камера недоступна", systemImage: "camera", description: Text(error)) Button("Открыть настройки") { if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) } } Text("Можно закрыть сканер и ввести данные вручную.").font(.footnote) } else { QRScanner(receive: { text in receive(text); dismiss() }, failed: { error = $0 }) .clipShape(RoundedRectangle(cornerRadius: 20)) .accessibilityLabel("Камера для сканирования QR") } }.padding() .navigationTitle("Сканировать QR") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Закрыть") { dismiss() } } } .onChange(of: phase) { _, value in if value == .background { dismiss() } } } } } private struct QRScanner: UIViewControllerRepresentable { let receive: (String) -> Void let failed: (String) -> Void func makeUIViewController(context: Context) -> QRScannerController { QRScannerController(receive: receive, failed: failed) } func updateUIViewController(_ controller: QRScannerController, context: Context) {} static func dismantleUIViewController(_ controller: QRScannerController, coordinator: ()) { controller.stop() } } private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { private let capture = AVCaptureSession() private let queue = DispatchQueue(label: "MusicBridge.camera") private var preview: AVCaptureVideoPreviewLayer? private var visible = false private var delivered = false private var wantsRunning = false // camera queue only private var configured = false // camera queue only private let receive: (String) -> Void private let failed: (String) -> Void init(receive: @escaping (String) -> Void, failed: @escaping (String) -> Void) { self.receive = receive; self.failed = failed super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black let preview = AVCaptureVideoPreviewLayer(session: capture) preview.videoGravity = .resizeAspectFill view.layer.addSublayer(preview) self.preview = preview } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); preview?.frame = view.bounds } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) visible = true switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: start() case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in DispatchQueue.main.async { guard let self, self.visible else { return } if granted { self.start() } else { self.failed("Разрешите доступ к камере в настройках MusicBridge.") } } } default: failed("Разрешите доступ к камере в настройках MusicBridge.") } } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated); stop() } private func start() { queue.async { [weak self] in guard let self else { return } self.wantsRunning = true do { if !self.configured { self.capture.beginConfiguration() defer { self.capture.commitConfiguration() } guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { throw BridgeError.message("Задняя камера не найдена. На симуляторе сканирование недоступно.") } let input = try AVCaptureDeviceInput(device: camera) let output = AVCaptureMetadataOutput() guard self.capture.canAddInput(input), self.capture.canAddOutput(output) else { throw BridgeError.message("Не удалось запустить камеру. Закройте сканер и повторите попытку.") } self.capture.addInput(input); self.capture.addOutput(output) guard output.availableMetadataObjectTypes.contains(.qr) else { throw BridgeError.message("Сканирование QR недоступно.") } output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] self.configured = true } if self.wantsRunning { self.capture.startRunning() } } catch { DispatchQueue.main.async { [weak self] in guard let self, self.visible else { return } self.failed(error.localizedDescription) } } } } func stop() { visible = false queue.async { [weak self] in guard let self else { return } self.wantsRunning = false if self.capture.isRunning { self.capture.stopRunning() } } } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) { guard visible, !delivered, let text = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return } delivered = true stop() receive(text) } }