MiniPlayer/Sources/QRScannerView.swift

242 lines
8.6 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AVFoundation
#if os(macOS)
import AppKit
// MARK: - NSWindow
class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var window: NSWindow?
var session: AVCaptureSession?
weak var bridge: PlayerBridge?
private var lastDetected: String?
private var lastDetectedTime: Date = .distantPast
private weak var resultLabel: NSTextField?
private weak var previewHost: NSView?
private weak var loadingLabel: NSTextField?
init(bridge: PlayerBridge) {
self.bridge = bridge
super.init()
}
func show() {
if let win = window, win.isVisible {
win.makeKeyAndOrderFront(nil)
return
}
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 380),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
win.title = L.scanQRCode
win.isReleasedWhenClosed = false
win.center()
let container = NSView(frame: win.contentView!.bounds)
container.autoresizingMask = [.width, .height]
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
//
let previewHost = NSView(frame: NSRect(x: 20, y: 80, width: 360, height: 240))
previewHost.wantsLayer = true
previewHost.layer?.backgroundColor = NSColor.darkGray.cgColor
previewHost.autoresizingMask = [.width, .height]
container.addSubview(previewHost)
self.previewHost = previewHost
// "..."
let loadingLabel = NSTextField(labelWithString: "Initializing camera...")
loadingLabel.font = NSFont.systemFont(ofSize: 12)
loadingLabel.textColor = .secondaryLabelColor
loadingLabel.frame = NSRect(x: 0, y: 100, width: 360, height: 40)
loadingLabel.alignment = .center
previewHost.addSubview(loadingLabel)
self.loadingLabel = loadingLabel
//
let hintLabel = NSTextField(wrappingLabelWithString: L.scanHint)
hintLabel.font = NSFont.systemFont(ofSize: 11)
hintLabel.textColor = .secondaryLabelColor
hintLabel.frame = NSRect(x: 20, y: 20, width: 360, height: 40)
hintLabel.alignment = .center
container.addSubview(hintLabel)
//
let resultLabel = NSTextField(labelWithString: "")
resultLabel.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
resultLabel.textColor = .labelColor
resultLabel.frame = NSRect(x: 20, y: 330, width: 360, height: 30)
resultLabel.alignment = .center
resultLabel.lineBreakMode = .byTruncatingMiddle
resultLabel.tag = 100
container.addSubview(resultLabel)
self.resultLabel = resultLabel
win.contentView = container
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = win
// 线
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.setupCaptureOnBackground(previewHost: previewHost)
}
}
func close() {
session?.stopRunning()
DispatchQueue.main.async { [weak self] in
self?.window?.close()
}
}
// 线
private func setupCaptureOnBackground(previewHost: NSView) {
//
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
self?.setupCaptureOnBackground(previewHost: previewHost)
} else {
DispatchQueue.main.async {
self?.showError("Camera permission denied")
}
}
}
return
case .denied, .restricted:
DispatchQueue.main.async { [weak self] in
self?.showError("Camera permission denied")
}
return
case .authorized:
break
@unknown default:
break
}
let session = AVCaptureSession()
session.sessionPreset = .high
// Continuity Camera metadata
var device: AVCaptureDevice?
if let builtIn = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .unspecified) {
device = builtIn
} else {
device = AVCaptureDevice.default(for: .video)
}
guard let device = device else {
DispatchQueue.main.async { [weak self] in
self?.showError("No camera available")
}
return
}
guard let input = try? AVCaptureDeviceInput(device: device) else {
DispatchQueue.main.async { [weak self] in
self?.showError("Cannot access camera")
}
return
}
if session.canAddInput(input) {
session.addInput(input)
}
let output = AVCaptureMetadataOutput()
if session.canAddOutput(output) {
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
}
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
self.session = session
// 线 UI
DispatchQueue.main.async { [weak self] in
guard let self = self, previewHost.window != nil else {
session.stopRunning()
return
}
// loading
self.loadingLabel?.removeFromSuperview()
//
previewLayer.frame = previewHost.bounds
previewHost.layer?.addSublayer(previewLayer)
//
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
// session metadata types availableMetadataObjectTypes
if session.canAddOutput(output) == false,
output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else {
//
let available = output.availableMetadataObjectTypes
if available.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else if !available.isEmpty {
output.metadataObjectTypes = available
} else {
DispatchQueue.main.async { [weak self] in
self?.showError("Camera does not support QR scanning. Check System Settings → Privacy → Camera.")
}
}
}
}
}
}
private func showError(_ msg: String) {
resultLabel?.stringValue = msg
resultLabel?.textColor = .systemRed
}
// MARK: - AVCaptureMetadataOutputObjectsDelegate
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let code = metadata.stringValue else { return }
let now = Date()
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
return
}
lastDetected = code
lastDetectedTime = now
//
resultLabel?.stringValue = code
resultLabel?.textColor = .systemGreen
// URL
if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
DispatchQueue.main.async { [weak self] in
self?.bridge?.addURL(code)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.close()
}
}
}
deinit {
session?.stopRunning()
}
}
#endif