feat: add QR code scanner for media URLs

This commit is contained in:
yumoqing 2026-06-23 09:09:23 +08:00
parent 9cab9dd2cb
commit ae96218d20
4 changed files with 181 additions and 0 deletions

View File

@ -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: "")
}

View File

@ -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() }

View File

@ -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

171
Sources/QRScannerView.swift Normal file
View File

@ -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