172 lines
5.1 KiB
Swift
172 lines
5.1 KiB
Swift
import SwiftUI
|
||
import AVFoundation
|
||
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
// MARK: - 二维码扫描器视图
|
||
struct QRScannerView: NSViewRepresentable {
|
||
let onCodeDetected: (String) -> Void
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(onCodeDetected: onCodeDetected)
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
let view = QRScannerHostView()
|
||
view.coordinator = context.coordinator
|
||
context.coordinator.previewLayer = view.previewLayer
|
||
|
||
// 配置捕获会话
|
||
let session = AVCaptureSession()
|
||
context.coordinator.session = session
|
||
|
||
guard let device = AVCaptureDevice.default(for: .video),
|
||
let input = try? AVCaptureDeviceInput(device: device) else {
|
||
return view
|
||
}
|
||
|
||
if session.canAddInput(input) {
|
||
session.addInput(input)
|
||
}
|
||
|
||
let output = AVCaptureMetadataOutput()
|
||
if session.canAddOutput(output) {
|
||
session.addOutput(output)
|
||
output.setMetadataObjectsDelegate(context.coordinator, queue: .main)
|
||
output.metadataObjectTypes = [.qr]
|
||
}
|
||
|
||
view.previewLayer.session = session
|
||
view.previewLayer.videoGravity = .resizeAspectFill
|
||
|
||
// 后台启动会话
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
session.startRunning()
|
||
}
|
||
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||
|
||
// MARK: - Coordinator
|
||
class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||
let onCodeDetected: (String) -> Void
|
||
var session: AVCaptureSession?
|
||
weak var previewLayer: AVCaptureVideoPreviewLayer?
|
||
private var lastDetected: String?
|
||
private var lastDetectedTime: Date = .distantPast
|
||
|
||
init(onCodeDetected: @escaping (String) -> Void) {
|
||
self.onCodeDetected = onCodeDetected
|
||
}
|
||
|
||
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
|
||
|
||
onCodeDetected(code)
|
||
}
|
||
|
||
deinit {
|
||
session?.stopRunning()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 宿主视图
|
||
class QRScannerHostView: NSView {
|
||
let previewLayer = AVCaptureVideoPreviewLayer()
|
||
weak var coordinator: QRScannerView.Coordinator?
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
wantsLayer = true
|
||
layer = previewLayer
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
previewLayer.frame = bounds
|
||
}
|
||
}
|
||
|
||
// MARK: - 扫码弹窗
|
||
struct QRScannerDialog: View {
|
||
@ObservedObject var bridge: PlayerBridge
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var detectedCode: String?
|
||
@State private var isProcessing = false
|
||
|
||
var body: some View {
|
||
VStack(spacing: 16) {
|
||
Text(L.scanQRCode)
|
||
.font(.headline)
|
||
|
||
Text(L.scanHint)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
QRScannerView { code in
|
||
detectedCode = code
|
||
handleCode(code)
|
||
}
|
||
.frame(width: 320, height: 240)
|
||
.cornerRadius(8)
|
||
|
||
if let code = detectedCode {
|
||
VStack(spacing: 8) {
|
||
Text(L.detectedCode)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
Text(code)
|
||
.font(.system(.body, design: .monospaced))
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.padding(12)
|
||
.background(Color.secondary.opacity(0.1))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
HStack {
|
||
Button(L.close) { dismiss() }
|
||
.keyboardShortcut(.cancelAction)
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(20)
|
||
.frame(width: 380)
|
||
}
|
||
|
||
private func handleCode(_ code: String) {
|
||
guard !isProcessing else { return }
|
||
|
||
// 检查是否为有效 URL
|
||
if let url = URL(string: code), url.scheme == "http" || url.scheme == "https" {
|
||
isProcessing = true
|
||
bridge.addURL(code)
|
||
|
||
// 延迟关闭弹窗
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#endif
|