212 lines
8.8 KiB
Swift
212 lines
8.8 KiB
Swift
import AVFoundation
|
|
import UIKit
|
|
|
|
/// Native camera scanner shared by song transfer and data sync.
|
|
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
|
private let labels: [AnyHashable: Any]
|
|
private let onResult: (String) -> Void
|
|
private let session = AVCaptureSession()
|
|
private let sessionQueue = DispatchQueue(label: "llmp.qr-camera")
|
|
private lazy var preview = AVCaptureVideoPreviewLayer(session: session)
|
|
// Camera configuration and start/stop are serialized on sessionQueue.
|
|
private var configured = false
|
|
private var finished = false
|
|
private var requestedCamera = false
|
|
private var foregroundObserver: NSObjectProtocol?
|
|
|
|
init(labels: [AnyHashable: Any], onResult: @escaping (String) -> Void) {
|
|
self.labels = labels
|
|
self.onResult = onResult
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
deinit {
|
|
if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) }
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
preview.videoGravity = .resizeAspectFill
|
|
view.layer.addSublayer(preview)
|
|
|
|
let cancel = UIButton(type: .system)
|
|
var cancelStyle = UIButton.Configuration.plain()
|
|
cancelStyle.title = label("cancel")
|
|
cancelStyle.baseForegroundColor = .white
|
|
cancelStyle.background.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
|
cancelStyle.background.cornerRadius = 12
|
|
cancelStyle.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 20, bottom: 12, trailing: 20)
|
|
cancel.configuration = cancelStyle
|
|
cancel.addTarget(self, action: #selector(cancelScan), for: .touchUpInside)
|
|
cancel.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(cancel)
|
|
|
|
let hint = UILabel()
|
|
hint.text = label("title")
|
|
hint.textColor = .white
|
|
hint.font = .systemFont(ofSize: 18, weight: .medium)
|
|
hint.textAlignment = .center
|
|
hint.numberOfLines = 0
|
|
hint.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
|
hint.layer.cornerRadius = 12
|
|
hint.clipsToBounds = true
|
|
hint.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(hint)
|
|
NSLayoutConstraint.activate([
|
|
cancel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 20),
|
|
cancel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
|
hint.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
hint.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24),
|
|
hint.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24),
|
|
hint.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24),
|
|
hint.heightAnchor.constraint(greaterThanOrEqualToConstant: 56),
|
|
])
|
|
foregroundObserver = NotificationCenter.default.addObserver(
|
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let self, !self.finished, self.viewIfLoaded?.window != nil else { return }
|
|
self.startConfiguredSession()
|
|
}
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
guard !requestedCamera else { startConfiguredSession(); return }
|
|
requestedCamera = true
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
configureCamera()
|
|
case .notDetermined:
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
DispatchQueue.main.async {
|
|
guard let self, !self.finished else { return }
|
|
if granted { self.configureCamera() } else { self.showFailure("permission", offerSettings: true) }
|
|
}
|
|
}
|
|
case .denied, .restricted:
|
|
showFailure("permission", offerSettings: true)
|
|
@unknown default:
|
|
showFailure("failed")
|
|
}
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
sessionQueue.async { [session] in
|
|
if session.isRunning { session.stopRunning() }
|
|
}
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
// Also release the Kotlin request if the host dismisses this screen externally.
|
|
if isBeingDismissed && !finished {
|
|
finished = true
|
|
onResult("")
|
|
}
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
preview.frame = view.bounds
|
|
guard let orientation = view.window?.windowScene?.interfaceOrientation,
|
|
let connection = preview.connection else { return }
|
|
let angle: CGFloat
|
|
switch orientation {
|
|
case .landscapeLeft: angle = 180
|
|
case .landscapeRight: angle = 0
|
|
case .portraitUpsideDown: angle = 270
|
|
default: angle = 90
|
|
}
|
|
if connection.isVideoRotationAngleSupported(angle) { connection.videoRotationAngle = angle }
|
|
}
|
|
|
|
private func configureCamera() {
|
|
sessionQueue.async { [weak self] in
|
|
guard let self else { return }
|
|
guard let camera = AVCaptureDevice.default(for: .video) else {
|
|
self.reportFailure("unavailable")
|
|
return
|
|
}
|
|
do {
|
|
let input = try AVCaptureDeviceInput(device: camera)
|
|
let output = AVCaptureMetadataOutput()
|
|
self.session.beginConfiguration()
|
|
self.session.sessionPreset = .high
|
|
guard self.session.canAddInput(input), self.session.canAddOutput(output) else {
|
|
self.session.commitConfiguration()
|
|
self.reportFailure("failed")
|
|
return
|
|
}
|
|
self.session.addInput(input)
|
|
self.session.addOutput(output)
|
|
guard output.availableMetadataObjectTypes.contains(.qr) else {
|
|
self.session.commitConfiguration()
|
|
self.reportFailure("failed")
|
|
return
|
|
}
|
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
|
output.metadataObjectTypes = [.qr]
|
|
self.session.commitConfiguration()
|
|
self.configured = true
|
|
self.session.startRunning()
|
|
DispatchQueue.main.async { [weak self] in self?.view.setNeedsLayout() }
|
|
} catch {
|
|
self.reportFailure("failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startConfiguredSession() {
|
|
guard !finished else { return }
|
|
sessionQueue.async { [weak self] in
|
|
guard let self, self.configured, !self.session.isRunning else { return }
|
|
self.session.startRunning()
|
|
}
|
|
}
|
|
|
|
private func reportFailure(_ key: String) {
|
|
DispatchQueue.main.async { [weak self] in self?.showFailure(key) }
|
|
}
|
|
|
|
private func showFailure(_ key: String, offerSettings: Bool = false) {
|
|
guard !finished, presentedViewController == nil else { return }
|
|
let alert = UIAlertController(title: label("title"), message: label(key), preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: label("cancel"), style: .cancel) { [weak self] _ in self?.finish("") })
|
|
if offerSettings {
|
|
alert.addAction(UIAlertAction(title: label("settings"), style: .default) { [weak self] _ in
|
|
self?.finish("") {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
|
|
}
|
|
})
|
|
}
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
private func label(_ key: String) -> String { labels[key] as? String ?? "" }
|
|
|
|
@objc private func cancelScan() { finish("") }
|
|
|
|
private func finish(_ result: String, completion: (() -> Void)? = nil) {
|
|
guard !finished else { return }
|
|
finished = true
|
|
sessionQueue.async { [session] in
|
|
if session.isRunning { session.stopRunning() }
|
|
}
|
|
dismiss(animated: true) { [onResult] in
|
|
onResult(result)
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
|
guard !finished,
|
|
let code = objects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
|
|
let value = code.stringValue, !value.isEmpty else { return }
|
|
finish(value)
|
|
}
|
|
}
|