From 6d1ff50074ca53dcfd85b7c06fa78329ef2bae97 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 23 Jun 2026 09:14:23 +0800 Subject: [PATCH] fix: QR scanner crash - use independent NSWindow instead of SwiftUI sheet --- Sources/MiniPlayerApp.swift | 5 +- Sources/PlayerBridge.swift | 11 ++ Sources/QRScannerView.swift | 235 ++++++++++++++++++------------------ 3 files changed, 132 insertions(+), 119 deletions(-) diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 0a76b5a..2f39513 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -262,9 +262,6 @@ struct BricksAppView: View { .sheet(isPresented: $bridge.showPlaylistSheet) { PlaylistWindowView(bridge: bridge) } - .sheet(isPresented: $bridge.showQRScanner) { - QRScannerDialog(bridge: bridge) - } } } @@ -297,7 +294,7 @@ struct ControlToolbar: View { HStack(spacing: 4) { TB(icon: "folder") { bridge.openFileDialog(); onTouch() } TB(icon: "link") { bridge.showURLDialog = true; onTouch() } - TB(icon: "qrcode.viewfinder") { bridge.showQRScanner = true; onTouch() } + TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() } TB(icon: "backward.fill") { bridge.playPrev(); onTouch() } TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() } TB(icon: "forward.fill") { bridge.playNext(); onTouch() } diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 6abc8bc..9232a7c 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -52,6 +52,7 @@ final class PlayerBridge: ObservableObject { @Published var showURLDialog = false @Published var showTrackDialog = false @Published var showQRScanner = false + var qrScannerController: QRScannerWindowController? @Published var toastMessage: String? @Published var availableTracks: [String] = [] @Published var currentTrackIndex: Int = 0 @@ -387,6 +388,16 @@ final class PlayerBridge: ObservableObject { func recordInteraction() { lastInteraction = Date() } + // MARK: - 扫码窗口 + func showQRScannerWindow() { + #if os(macOS) + if qrScannerController == nil { + qrScannerController = QRScannerWindowController(bridge: self) + } + qrScannerController?.show() + #endif + } + func togglePlaylistWindow() { #if os(macOS) if let win = playlistWindow, win.isVisible { diff --git a/Sources/QRScannerView.swift b/Sources/QRScannerView.swift index 8617876..d6b8c22 100644 --- a/Sources/QRScannerView.swift +++ b/Sources/QRScannerView.swift @@ -4,26 +4,89 @@ import AVFoundation #if os(macOS) import AppKit -// MARK: - 二维码扫描器视图 -struct QRScannerView: NSViewRepresentable { - let onCodeDetected: (String) -> Void +// MARK: - 扫码窗口(独立 NSWindow,避免 SwiftUI sheet 布局崩溃) +class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate { + var window: NSWindow? + var session: AVCaptureSession? + var previewLayer: AVCaptureVideoPreviewLayer? + var hostView: QRScannerHostView? + weak var bridge: PlayerBridge? - func makeCoordinator() -> Coordinator { - Coordinator(onCodeDetected: onCodeDetected) + private var lastDetected: String? + private var lastDetectedTime: Date = .distantPast + + init(bridge: PlayerBridge) { + self.bridge = bridge + super.init() } - func makeNSView(context: Context) -> NSView { - let view = QRScannerHostView() - view.coordinator = context.coordinator - context.coordinator.previewLayer = view.previewLayer + 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() - context.coordinator.session = session + self.session = session guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) else { - return view + showError("No camera available") + return } if session.canAddInput(input) { @@ -33,65 +96,74 @@ struct QRScannerView: NSViewRepresentable { let output = AVCaptureMetadataOutput() if session.canAddOutput(output) { session.addOutput(output) - output.setMetadataObjectsDelegate(context.coordinator, queue: .main) + output.setMetadataObjectsDelegate(self, queue: .main) output.metadataObjectTypes = [.qr] } - view.previewLayer.session = session - view.previewLayer.videoGravity = .resizeAspectFill + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + hostView.previewLayer = previewLayer + self.previewLayer = previewLayer - // 后台启动会话 - DispatchQueue.global(qos: .userInitiated).async { + // 后台启动 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in session.startRunning() + DispatchQueue.main.async { + self?.previewLayer?.frame = hostView.bounds + } } - - return view } - func updateNSView(_ nsView: NSView, context: Context) {} + private func showError(_ msg: String) { + guard let resultLabel = window?.contentView?.viewWithTag(100) as? NSTextField else { return } + resultLabel.stringValue = msg + resultLabel.textColor = .systemRed + } - // 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 + // 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 } - init(onCodeDetected: @escaping (String) -> Void) { - self.onCodeDetected = onCodeDetected + // 防抖 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 } - 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 + // 检查是否为有效 URL + if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") { + DispatchQueue.main.async { [weak self] in + self?.bridge?.addURL(code) } - lastDetected = code - lastDetectedTime = now - onCodeDetected(code) - } - - deinit { - session?.stopRunning() + // 延迟关闭 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.close() + } } } + + deinit { + session?.stopRunning() + } } // MARK: - 宿主视图 class QRScannerHostView: NSView { - let previewLayer = AVCaptureVideoPreviewLayer() - weak var coordinator: QRScannerView.Coordinator? + var previewLayer: AVCaptureVideoPreviewLayer? override init(frame: CGRect) { super.init(frame: frame) wantsLayer = true - // 不在 init 时设置 layer = previewLayer,等 layout 时再添加为 sublayer } required init?(coder: NSCoder) { @@ -100,74 +172,7 @@ class QRScannerHostView: NSView { override func layout() { super.layout() - if previewLayer.superlayer == nil { - layer?.addSublayer(previewLayer) - } - 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() - } - } + previewLayer?.frame = bounds } }