import SwiftUI import AVFoundation #if os(iOS) import UIKit // MARK: - iOS 扫码界面(SwiftUI sheet,替代 macOS 的独立 NSWindow 控制器) /// 使用 AVCaptureSession + AVCaptureMetadataOutput 扫描包含媒体 URL 的二维码。 /// 扫到有效 http/https URL 后调用 `bridge.addURL`(自动入库 + 播放)并关闭。 struct QRScannerSheet: View { @ObservedObject var bridge: PlayerBridge let onDismiss: () -> Void @State private var statusText: String = "" @State private var statusColor: Color = .white @State private var isAuthorized = false @State private var hasCamera = true var body: some View { VStack(spacing: 0) { HStack { Text(L.scanQRCode).font(.headline).foregroundColor(.white) Spacer() Button(L.cancel) { onDismiss() } .foregroundColor(.white) } .padding() .background(Color.black) ZStack { Color.black if isAuthorized && hasCamera { QRCaptureRepresentable(onCode: handleCode) .ignoresSafeArea(edges: .horizontal) } else { VStack(spacing: 12) { Image(systemName: hasCamera ? "lock.shield" : "video.slash") .font(.system(size: 40)) .foregroundColor(.white.opacity(0.6)) Text(hasCamera ? "Camera access required" : "No camera available") .foregroundColor(.white.opacity(0.8)) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) VStack(spacing: 6) { Text(statusText.isEmpty ? L.scanHint : statusText) .font(.system(size: 12, design: .monospaced)) .foregroundColor(statusColor.opacity(0.9)) .multilineTextAlignment(.center) .lineLimit(3) .truncationMode(.middle) .padding(.horizontal) } .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.black) } .background(Color.black) .onAppear(perform: checkPermission) } private func checkPermission() { hasCamera = AVCaptureDevice.default(for: .video) != nil guard hasCamera else { return } switch AVCaptureDevice.authorizationStatus(for: .video) { case .authorized: isAuthorized = true case .notDetermined: AVCaptureDevice.requestAccess(for: .video) { granted in DispatchQueue.main.async { isAuthorized = granted } } default: isAuthorized = false } } private func handleCode(_ code: String) { statusText = code // 解析 JSON 载荷(含频道信息)或纯 URL → 入库 + 播放 if bridge.handleScannedCode(code) { statusColor = .green DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { onDismiss() } } else { statusColor = .red } } } // MARK: - UIKit 摄像头预览桥接(AVCaptureVideoPreviewLayer) private struct QRCaptureRepresentable: UIViewRepresentable { let onCode: (String) -> Void func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } func makeUIView(context: Context) -> QRCaptureView { let v = QRCaptureView() v.coordinator = context.coordinator v.configure() return v } func updateUIView(_ uiView: QRCaptureView, context: Context) {} static func dismantleUIView(_ uiView: QRCaptureView, coordinator: Coordinator) { coordinator.stop() } final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { let onCode: (String) -> Void private var lastCode: String? private var lastTime = Date.distantPast init(onCode: @escaping (String) -> Void) { self.onCode = onCode } func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard let m = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let code = m.stringValue else { return } let now = Date() if code == lastCode, now.timeIntervalSince(lastTime) < 3 { return } lastCode = code lastTime = now DispatchQueue.main.async { self.onCode(code) } } func stop() {} } /// 承载 AVCaptureVideoPreviewLayer 的 UIView final class QRCaptureView: UIView { weak var coordinator: Coordinator? private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? func configure() { // 摄像头配置是同步硬件 I/O,必须放到后台线程,否则主线程冻结 DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self else { return } self.session.sessionPreset = .high guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) ?? AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), self.session.canAddInput(input) else { return } self.session.addInput(input) let output = AVCaptureMetadataOutput() guard self.session.canAddOutput(output) else { return } self.session.addOutput(output) output.setMetadataObjectsDelegate(self.coordinator, queue: .main) self.session.startRunning() // availableMetadataObjectTypes 在 startRunning 之后才非空 if output.availableMetadataObjectTypes.contains(.qr) { output.metadataObjectTypes = [.qr] } DispatchQueue.main.async { [weak self] in guard let self else { return } let pl = AVCaptureVideoPreviewLayer(session: self.session) pl.videoGravity = .resizeAspectFill pl.frame = self.bounds self.layer.addSublayer(pl) self.previewLayer = pl } } } override func layoutSubviews() { super.layoutSubviews() previewLayer?.frame = bounds } deinit { session.stopRunning() } } } #endif