fix: QR scanner crash - use independent NSWindow instead of SwiftUI sheet

This commit is contained in:
yumoqing 2026-06-23 09:14:23 +08:00
parent 32f722bd34
commit 6d1ff50074
3 changed files with 132 additions and 119 deletions

View File

@ -262,9 +262,6 @@ struct BricksAppView: View {
.sheet(isPresented: $bridge.showPlaylistSheet) { .sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge) PlaylistWindowView(bridge: bridge)
} }
.sheet(isPresented: $bridge.showQRScanner) {
QRScannerDialog(bridge: bridge)
}
} }
} }
@ -297,7 +294,7 @@ struct ControlToolbar: View {
HStack(spacing: 4) { HStack(spacing: 4) {
TB(icon: "folder") { bridge.openFileDialog(); onTouch() } TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
TB(icon: "link") { bridge.showURLDialog = true; 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: "backward.fill") { bridge.playPrev(); onTouch() }
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() } TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
TB(icon: "forward.fill") { bridge.playNext(); onTouch() } TB(icon: "forward.fill") { bridge.playNext(); onTouch() }

View File

@ -52,6 +52,7 @@ final class PlayerBridge: ObservableObject {
@Published var showURLDialog = false @Published var showURLDialog = false
@Published var showTrackDialog = false @Published var showTrackDialog = false
@Published var showQRScanner = false @Published var showQRScanner = false
var qrScannerController: QRScannerWindowController?
@Published var toastMessage: String? @Published var toastMessage: String?
@Published var availableTracks: [String] = [] @Published var availableTracks: [String] = []
@Published var currentTrackIndex: Int = 0 @Published var currentTrackIndex: Int = 0
@ -387,6 +388,16 @@ final class PlayerBridge: ObservableObject {
func recordInteraction() { lastInteraction = Date() } func recordInteraction() { lastInteraction = Date() }
// MARK: -
func showQRScannerWindow() {
#if os(macOS)
if qrScannerController == nil {
qrScannerController = QRScannerWindowController(bridge: self)
}
qrScannerController?.show()
#endif
}
func togglePlaylistWindow() { func togglePlaylistWindow() {
#if os(macOS) #if os(macOS)
if let win = playlistWindow, win.isVisible { if let win = playlistWindow, win.isVisible {

View File

@ -4,26 +4,89 @@ import AVFoundation
#if os(macOS) #if os(macOS)
import AppKit import AppKit
// MARK: - // MARK: - NSWindow SwiftUI sheet
struct QRScannerView: NSViewRepresentable { class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
let onCodeDetected: (String) -> Void var window: NSWindow?
var session: AVCaptureSession?
var previewLayer: AVCaptureVideoPreviewLayer?
var hostView: QRScannerHostView?
weak var bridge: PlayerBridge?
func makeCoordinator() -> Coordinator { private var lastDetected: String?
Coordinator(onCodeDetected: onCodeDetected) private var lastDetectedTime: Date = .distantPast
init(bridge: PlayerBridge) {
self.bridge = bridge
super.init()
} }
func makeNSView(context: Context) -> NSView { func show() {
let view = QRScannerHostView() if let win = window, win.isVisible {
view.coordinator = context.coordinator win.makeKeyAndOrderFront(nil)
context.coordinator.previewLayer = view.previewLayer 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() let session = AVCaptureSession()
context.coordinator.session = session self.session = session
guard let device = AVCaptureDevice.default(for: .video), guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device) else { let input = try? AVCaptureDeviceInput(device: device) else {
return view showError("No camera available")
return
} }
if session.canAddInput(input) { if session.canAddInput(input) {
@ -33,65 +96,74 @@ struct QRScannerView: NSViewRepresentable {
let output = AVCaptureMetadataOutput() let output = AVCaptureMetadataOutput()
if session.canAddOutput(output) { if session.canAddOutput(output) {
session.addOutput(output) session.addOutput(output)
output.setMetadataObjectsDelegate(context.coordinator, queue: .main) output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr] output.metadataObjectTypes = [.qr]
} }
view.previewLayer.session = session let previewLayer = AVCaptureVideoPreviewLayer(session: session)
view.previewLayer.videoGravity = .resizeAspectFill previewLayer.videoGravity = .resizeAspectFill
hostView.previewLayer = previewLayer
self.previewLayer = previewLayer
// //
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async { [weak self] in
session.startRunning() 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 // MARK: - AVCaptureMetadataOutputObjectsDelegate
class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
let onCodeDetected: (String) -> Void guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
var session: AVCaptureSession? let code = metadata.stringValue else { return }
weak var previewLayer: AVCaptureVideoPreviewLayer?
private var lastDetected: String?
private var lastDetectedTime: Date = .distantPast
init(onCodeDetected: @escaping (String) -> Void) { // 3
self.onCodeDetected = onCodeDetected 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) { // URL
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
let code = metadata.stringValue else { return } DispatchQueue.main.async { [weak self] in
self?.bridge?.addURL(code)
// 3
let now = Date()
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
return
} }
lastDetected = code
lastDetectedTime = now
onCodeDetected(code) //
} DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.close()
deinit { }
session?.stopRunning()
} }
} }
deinit {
session?.stopRunning()
}
} }
// MARK: - 宿 // MARK: - 宿
class QRScannerHostView: NSView { class QRScannerHostView: NSView {
let previewLayer = AVCaptureVideoPreviewLayer() var previewLayer: AVCaptureVideoPreviewLayer?
weak var coordinator: QRScannerView.Coordinator?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
wantsLayer = true wantsLayer = true
// init layer = previewLayer layout sublayer
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -100,74 +172,7 @@ class QRScannerHostView: NSView {
override func layout() { override func layout() {
super.layout() super.layout()
if previewLayer.superlayer == nil { previewLayer?.frame = bounds
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()
}
}
} }
} }