136 lines
6.4 KiB
Swift
136 lines
6.4 KiB
Swift
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)
|
|
}
|
|
}
|