MiniPlayer/Sources/QRScannerSheet.swift
yumoqing dd64a21404 feat: QR code for current media with channel info, cross-platform
- 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
2026-10-04 12:07:00 +08:00

182 lines
6.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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