180 lines
5.7 KiB
Swift
180 lines
5.7 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
// MARK: - 扫码窗口(独立 NSWindow,避免 SwiftUI sheet 布局崩溃)
|
||
class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||
var window: NSWindow?
|
||
var session: AVCaptureSession?
|
||
var previewLayer: AVCaptureVideoPreviewLayer?
|
||
var hostView: QRScannerHostView?
|
||
weak var bridge: PlayerBridge?
|
||
|
||
private var lastDetected: String?
|
||
private var lastDetectedTime: Date = .distantPast
|
||
|
||
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.contentRect(forFrameRect: win.frame).insetBy(dx: 0, dy: 0))
|
||
container.wantsLayer = true
|
||
container.layer?.backgroundColor = NSColor.black.cgColor
|
||
|
||
let hostView = QRScannerHostView(frame: NSRect(x: 20, y: 80, width: 360, height: 240))
|
||
container.addSubview(hostView)
|
||
self.hostView = hostView
|
||
|
||
// 提示文字
|
||
let label = NSTextField(labelWithString: L.scanHint)
|
||
label.font = NSFont.systemFont(ofSize: 11)
|
||
label.textColor = .secondaryLabelColor
|
||
label.frame = NSRect(x: 20, y: 20, width: 360, height: 40)
|
||
label.alignment = .center
|
||
label.lineBreakMode = .byWordWrapping
|
||
container.addSubview(label)
|
||
|
||
// 检测结果文字
|
||
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)
|
||
|
||
win.contentView = container
|
||
|
||
// 配置摄像头
|
||
setupCapture(hostView: hostView)
|
||
|
||
win.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
self.window = win
|
||
}
|
||
|
||
func close() {
|
||
session?.stopRunning()
|
||
window?.close()
|
||
}
|
||
|
||
private func setupCapture(hostView: QRScannerHostView) {
|
||
let session = AVCaptureSession()
|
||
self.session = session
|
||
|
||
guard let device = AVCaptureDevice.default(for: .video),
|
||
let input = try? AVCaptureDeviceInput(device: device) else {
|
||
showError("No camera available")
|
||
return
|
||
}
|
||
|
||
if session.canAddInput(input) {
|
||
session.addInput(input)
|
||
}
|
||
|
||
let output = AVCaptureMetadataOutput()
|
||
if session.canAddOutput(output) {
|
||
session.addOutput(output)
|
||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||
output.metadataObjectTypes = [.qr]
|
||
}
|
||
|
||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||
previewLayer.videoGravity = .resizeAspectFill
|
||
hostView.previewLayer = previewLayer
|
||
self.previewLayer = previewLayer
|
||
|
||
// 后台启动
|
||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||
session.startRunning()
|
||
DispatchQueue.main.async {
|
||
self?.previewLayer?.frame = hostView.bounds
|
||
}
|
||
}
|
||
}
|
||
|
||
private func showError(_ msg: String) {
|
||
guard let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField else { return }
|
||
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 }
|
||
|
||
// 防抖 3 秒
|
||
let now = Date()
|
||
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
|
||
return
|
||
}
|
||
lastDetected = code
|
||
lastDetectedTime = now
|
||
|
||
// 显示检测结果
|
||
if let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField {
|
||
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()
|
||
}
|
||
}
|
||
|
||
// MARK: - 宿主视图
|
||
class QRScannerHostView: NSView {
|
||
var previewLayer: AVCaptureVideoPreviewLayer?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
wantsLayer = true
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
previewLayer?.frame = bounds
|
||
}
|
||
}
|
||
|
||
#endif
|