fix: QR scanner - move camera init to background thread, window shows immediately
This commit is contained in:
parent
6d1ff50074
commit
f7c295c653
@ -4,16 +4,17 @@ import AVFoundation
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
// MARK: - 扫码窗口(独立 NSWindow,避免 SwiftUI sheet 布局崩溃)
|
// MARK: - 扫码窗口控制器(独立 NSWindow)
|
||||||
class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
var window: NSWindow?
|
var window: NSWindow?
|
||||||
var session: AVCaptureSession?
|
var session: AVCaptureSession?
|
||||||
var previewLayer: AVCaptureVideoPreviewLayer?
|
|
||||||
var hostView: QRScannerHostView?
|
|
||||||
weak var bridge: PlayerBridge?
|
weak var bridge: PlayerBridge?
|
||||||
|
|
||||||
private var lastDetected: String?
|
private var lastDetected: String?
|
||||||
private var lastDetectedTime: Date = .distantPast
|
private var lastDetectedTime: Date = .distantPast
|
||||||
|
private weak var resultLabel: NSTextField?
|
||||||
|
private weak var previewHost: NSView?
|
||||||
|
private weak var loadingLabel: NSTextField?
|
||||||
|
|
||||||
init(bridge: PlayerBridge) {
|
init(bridge: PlayerBridge) {
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
@ -36,25 +37,37 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
win.isReleasedWhenClosed = false
|
win.isReleasedWhenClosed = false
|
||||||
win.center()
|
win.center()
|
||||||
|
|
||||||
// 创建宿主视图
|
let container = NSView(frame: win.contentView!.bounds)
|
||||||
let container = NSView(frame: win.contentRect(forFrameRect: win.frame).insetBy(dx: 0, dy: 0))
|
container.autoresizingMask = [.width, .height]
|
||||||
container.wantsLayer = true
|
container.wantsLayer = true
|
||||||
container.layer?.backgroundColor = NSColor.black.cgColor
|
container.layer?.backgroundColor = NSColor.black.cgColor
|
||||||
|
|
||||||
let hostView = QRScannerHostView(frame: NSRect(x: 20, y: 80, width: 360, height: 240))
|
// 摄像头预览区域(占位)
|
||||||
container.addSubview(hostView)
|
let previewHost = NSView(frame: NSRect(x: 20, y: 80, width: 360, height: 240))
|
||||||
self.hostView = hostView
|
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 label = NSTextField(labelWithString: L.scanHint)
|
let hintLabel = NSTextField(wrappingLabelWithString: L.scanHint)
|
||||||
label.font = NSFont.systemFont(ofSize: 11)
|
hintLabel.font = NSFont.systemFont(ofSize: 11)
|
||||||
label.textColor = .secondaryLabelColor
|
hintLabel.textColor = .secondaryLabelColor
|
||||||
label.frame = NSRect(x: 20, y: 20, width: 360, height: 40)
|
hintLabel.frame = NSRect(x: 20, y: 20, width: 360, height: 40)
|
||||||
label.alignment = .center
|
hintLabel.alignment = .center
|
||||||
label.lineBreakMode = .byWordWrapping
|
container.addSubview(hintLabel)
|
||||||
container.addSubview(label)
|
|
||||||
|
|
||||||
// 检测结果文字
|
// 检测结果
|
||||||
let resultLabel = NSTextField(labelWithString: "")
|
let resultLabel = NSTextField(labelWithString: "")
|
||||||
resultLabel.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
resultLabel.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||||
resultLabel.textColor = .labelColor
|
resultLabel.textColor = .labelColor
|
||||||
@ -63,29 +76,41 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
resultLabel.lineBreakMode = .byTruncatingMiddle
|
resultLabel.lineBreakMode = .byTruncatingMiddle
|
||||||
resultLabel.tag = 100
|
resultLabel.tag = 100
|
||||||
container.addSubview(resultLabel)
|
container.addSubview(resultLabel)
|
||||||
|
self.resultLabel = resultLabel
|
||||||
|
|
||||||
win.contentView = container
|
win.contentView = container
|
||||||
|
|
||||||
// 配置摄像头
|
|
||||||
setupCapture(hostView: hostView)
|
|
||||||
|
|
||||||
win.makeKeyAndOrderFront(nil)
|
win.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
self.window = win
|
self.window = win
|
||||||
|
|
||||||
|
// 后台初始化摄像头(不阻塞主线程)
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
self?.setupCaptureOnBackground(previewHost: previewHost)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
session?.stopRunning()
|
session?.stopRunning()
|
||||||
window?.close()
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.window?.close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupCapture(hostView: QRScannerHostView) {
|
// 在后台线程初始化摄像头
|
||||||
|
private func setupCaptureOnBackground(previewHost: NSView) {
|
||||||
let session = AVCaptureSession()
|
let session = AVCaptureSession()
|
||||||
self.session = session
|
|
||||||
|
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard let device = AVCaptureDevice.default(for: .video) else {
|
||||||
let input = try? AVCaptureDeviceInput(device: device) else {
|
DispatchQueue.main.async { [weak self] in
|
||||||
showError("No camera available")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,22 +127,34 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
|
|
||||||
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
|
||||||
previewLayer.videoGravity = .resizeAspectFill
|
previewLayer.videoGravity = .resizeAspectFill
|
||||||
hostView.previewLayer = previewLayer
|
|
||||||
self.previewLayer = previewLayer
|
|
||||||
|
|
||||||
// 后台启动
|
self.session = session
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
|
// 回到主线程更新 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.startRunning()
|
||||||
DispatchQueue.main.async {
|
|
||||||
self?.previewLayer?.frame = hostView.bounds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showError(_ msg: String) {
|
private func showError(_ msg: String) {
|
||||||
guard let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField else { return }
|
resultLabel?.stringValue = msg
|
||||||
resultLabel.stringValue = msg
|
resultLabel?.textColor = .systemRed
|
||||||
resultLabel.textColor = .systemRed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
||||||
@ -125,7 +162,6 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||||
let code = metadata.stringValue else { return }
|
let code = metadata.stringValue else { return }
|
||||||
|
|
||||||
// 防抖 3 秒
|
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
|
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
|
||||||
return
|
return
|
||||||
@ -134,18 +170,14 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
lastDetectedTime = now
|
lastDetectedTime = now
|
||||||
|
|
||||||
// 显示检测结果
|
// 显示检测结果
|
||||||
if let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField {
|
resultLabel?.stringValue = code
|
||||||
resultLabel.stringValue = code
|
resultLabel?.textColor = .systemGreen
|
||||||
resultLabel.textColor = .systemGreen
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为有效 URL
|
// 有效 URL → 添加并播放
|
||||||
if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
|
if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.bridge?.addURL(code)
|
self?.bridge?.addURL(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟关闭
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||||
self?.close()
|
self?.close()
|
||||||
}
|
}
|
||||||
@ -157,23 +189,4 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
#endif
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user