From f7c295c653d4cd94ac6e8f1ba43bf6fb792a0981 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 23 Jun 2026 23:03:48 +0800 Subject: [PATCH] fix: QR scanner - move camera init to background thread, window shows immediately --- Sources/QRScannerView.swift | 139 ++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 63 deletions(-) diff --git a/Sources/QRScannerView.swift b/Sources/QRScannerView.swift index d6b8c22..1c1bbe4 100644 --- a/Sources/QRScannerView.swift +++ b/Sources/QRScannerView.swift @@ -4,16 +4,17 @@ import AVFoundation #if os(macOS) import AppKit -// MARK: - 扫码窗口(独立 NSWindow,避免 SwiftUI sheet 布局崩溃) +// MARK: - 扫码窗口控制器(独立 NSWindow) 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 + private weak var resultLabel: NSTextField? + private weak var previewHost: NSView? + private weak var loadingLabel: NSTextField? init(bridge: PlayerBridge) { self.bridge = bridge @@ -36,25 +37,37 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat win.isReleasedWhenClosed = false win.center() - // 创建宿主视图 - let container = NSView(frame: win.contentRect(forFrameRect: win.frame).insetBy(dx: 0, dy: 0)) + let container = NSView(frame: win.contentView!.bounds) + container.autoresizingMask = [.width, .height] 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 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 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 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 @@ -63,29 +76,41 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat resultLabel.lineBreakMode = .byTruncatingMiddle resultLabel.tag = 100 container.addSubview(resultLabel) + self.resultLabel = resultLabel win.contentView = container - - // 配置摄像头 - setupCapture(hostView: hostView) - 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() - window?.close() + DispatchQueue.main.async { [weak self] in + self?.window?.close() + } } - private func setupCapture(hostView: QRScannerHostView) { + // 在后台线程初始化摄像头 + private func setupCaptureOnBackground(previewHost: NSView) { let session = AVCaptureSession() - self.session = session - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device) else { - showError("No camera available") + guard let device = AVCaptureDevice.default(for: .video) 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 } @@ -102,22 +127,34 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat 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 + 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() } } } private func showError(_ msg: String) { - guard let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField else { return } - resultLabel.stringValue = msg - resultLabel.textColor = .systemRed + resultLabel?.stringValue = msg + resultLabel?.textColor = .systemRed } // MARK: - AVCaptureMetadataOutputObjectsDelegate @@ -125,7 +162,6 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat 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 @@ -134,18 +170,14 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat lastDetectedTime = now // 显示检测结果 - if let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField { - resultLabel.stringValue = code - resultLabel.textColor = .systemGreen - } + resultLabel?.stringValue = code + resultLabel?.textColor = .systemGreen - // 检查是否为有效 URL + // 有效 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() } @@ -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