fix: QR scanner crash - use independent NSWindow instead of SwiftUI sheet
This commit is contained in:
parent
32f722bd34
commit
6d1ff50074
@ -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() }
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user