Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
86 lines
3.4 KiB
Swift
86 lines
3.4 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
import Vision
|
|
|
|
/// Webcam QR reader for the Device Mesh join flow. macOS has no
|
|
/// AVCaptureMetadataOutput barcode support (that's iOS-only), so frames go
|
|
/// through Vision's barcode detector instead — throttled, QR symbology only.
|
|
/// Emits each decoded payload string via `onCode` (the caller decides when to
|
|
/// stop the session by tearing the view down).
|
|
struct QRScannerView: NSViewRepresentable {
|
|
let onCode: (String) -> Void
|
|
|
|
func makeCoordinator() -> Scanner { Scanner(onCode: onCode) }
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let view = NSView()
|
|
view.wantsLayer = true
|
|
context.coordinator.attach(to: view)
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {}
|
|
|
|
static func dismantleNSView(_ nsView: NSView, coordinator: Scanner) {
|
|
coordinator.stop()
|
|
}
|
|
|
|
/// Owns the capture session + Vision pass. Frames arrive on a background
|
|
/// queue; detection runs on every ~6th frame (a QR held to a webcam doesn't
|
|
/// need 30 fps of OCR); hits hop to main.
|
|
final class Scanner: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
|
private let onCode: (String) -> Void
|
|
private let session = AVCaptureSession()
|
|
private let queue = DispatchQueue(label: "tva.qr-scan")
|
|
private var frameCount = 0
|
|
|
|
init(onCode: @escaping (String) -> Void) {
|
|
self.onCode = onCode
|
|
}
|
|
|
|
func attach(to view: NSView) {
|
|
let preview = AVCaptureVideoPreviewLayer(session: session)
|
|
preview.videoGravity = .resizeAspectFill
|
|
preview.frame = view.bounds
|
|
preview.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
|
|
view.layer?.addSublayer(preview)
|
|
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
guard granted, let self else { return }
|
|
self.queue.async { self.start() }
|
|
}
|
|
}
|
|
|
|
private func start() {
|
|
guard session.inputs.isEmpty,
|
|
let camera = AVCaptureDevice.default(for: .video),
|
|
let input = try? AVCaptureDeviceInput(device: camera),
|
|
session.canAddInput(input) else { return }
|
|
session.addInput(input)
|
|
|
|
let output = AVCaptureVideoDataOutput()
|
|
output.alwaysDiscardsLateVideoFrames = true
|
|
output.setSampleBufferDelegate(self, queue: queue)
|
|
if session.canAddOutput(output) { session.addOutput(output) }
|
|
session.startRunning()
|
|
}
|
|
|
|
func stop() {
|
|
queue.async { [session] in
|
|
if session.isRunning { session.stopRunning() }
|
|
}
|
|
}
|
|
|
|
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
|
|
from connection: AVCaptureConnection) {
|
|
frameCount += 1
|
|
guard frameCount % 6 == 0,
|
|
let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
let request = VNDetectBarcodesRequest()
|
|
request.symbologies = [.qr]
|
|
try? VNImageRequestHandler(cvPixelBuffer: pixelBuffer).perform([request])
|
|
guard let payload = request.results?.compactMap(\.payloadStringValue).first else { return }
|
|
DispatchQueue.main.async { [onCode] in onCode(payload) }
|
|
}
|
|
}
|
|
}
|