diff --git a/Sources/Localization.swift b/Sources/Localization.swift index f17baa0..1936b42 100644 --- a/Sources/Localization.swift +++ b/Sources/Localization.swift @@ -50,4 +50,9 @@ enum L { // 打开按钮 static let open = NSLocalizedString("open", value: "Open", comment: "") static let list = NSLocalizedString("list", value: "List", comment: "") + + // 扫码 + static let scanQRCode = NSLocalizedString("scan_qr_code", value: "Scan QR Code", comment: "") + static let scanHint = NSLocalizedString("scan_hint", value: "Point camera at a QR code containing a media URL", comment: "") + static let detectedCode = NSLocalizedString("detected_code", value: "Detected:", comment: "") } diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 9ecabd8..0a76b5a 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -262,6 +262,9 @@ struct BricksAppView: View { .sheet(isPresented: $bridge.showPlaylistSheet) { PlaylistWindowView(bridge: bridge) } + .sheet(isPresented: $bridge.showQRScanner) { + QRScannerDialog(bridge: bridge) + } } } @@ -294,6 +297,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: "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 50416e1..6abc8bc 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -51,6 +51,7 @@ final class PlayerBridge: ObservableObject { // MARK: - Published状态 @Published var showURLDialog = false @Published var showTrackDialog = false + @Published var showQRScanner = false @Published var toastMessage: String? @Published var availableTracks: [String] = [] @Published var currentTrackIndex: Int = 0 diff --git a/Sources/QRScannerView.swift b/Sources/QRScannerView.swift new file mode 100644 index 0000000..80908e2 --- /dev/null +++ b/Sources/QRScannerView.swift @@ -0,0 +1,171 @@ +import SwiftUI +import AVFoundation + +#if os(macOS) +import AppKit + +// MARK: - 二维码扫描器视图 +struct QRScannerView: NSViewRepresentable { + let onCodeDetected: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCodeDetected: onCodeDetected) + } + + func makeNSView(context: Context) -> NSView { + let view = QRScannerHostView() + view.coordinator = context.coordinator + context.coordinator.previewLayer = view.previewLayer + + // 配置捕获会话 + let session = AVCaptureSession() + context.coordinator.session = session + + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) else { + return view + } + + if session.canAddInput(input) { + session.addInput(input) + } + + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(context.coordinator, queue: .main) + output.metadataObjectTypes = [.qr] + } + + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + + // 后台启动会话 + DispatchQueue.global(qos: .userInitiated).async { + session.startRunning() + } + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + // 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 + + init(onCodeDetected: @escaping (String) -> Void) { + self.onCodeDetected = onCodeDetected + } + + 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 + } + lastDetected = code + lastDetectedTime = now + + onCodeDetected(code) + } + + deinit { + session?.stopRunning() + } + } +} + +// MARK: - 宿主视图 +class QRScannerHostView: NSView { + let previewLayer = AVCaptureVideoPreviewLayer() + weak var coordinator: QRScannerView.Coordinator? + + override init(frame: CGRect) { + super.init(frame: frame) + wantsLayer = true + layer = previewLayer + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + super.layout() + 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() + } + } + } +} + +#endif