- Add QR generation of the currently playing URL (macOS window / iOS sheet) - Encode channel metadata (name/type/group/logo) in the QR payload as compact JSON; fall back to a plain URL when the payload is too long for a reliably scannable QR - Scanning saves the entry into the local library with its channel metadata and starts playback; legacy plain-URL QR codes still work - Add iOS QR scanner sheet (AVCaptureMetadataOutput) so scanning is no longer macOS-only; both scanners share handleScannedCode - Add optional channel name / group fields to the Add URL dialog and thread group/logo through addURL/addItem into the library - Restore session (queue + position) on launch and save on quit for macOS, not just iOS - Bundle app icons: generate AppIcon.icns from the macOS iconset when missing, and compile Assets.xcassets into the iOS app
182 lines
6.7 KiB
Swift
182 lines
6.7 KiB
Swift
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
|