MiniPlayer/Sources/QRScannerView.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

243 lines
8.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(macOS)
import AppKit
// MARK: - 扫码窗口控制器(独立 NSWindow)
class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var window: NSWindow?
var session: AVCaptureSession?
weak var bridge: PlayerBridge?
private var lastDetected: String?
private var lastDetectedTime: Date = .distantPast
private weak var resultLabel: NSTextField?
private weak var previewHost: NSView?
private weak var loadingLabel: NSTextField?
init(bridge: PlayerBridge) {
self.bridge = bridge
super.init()
}
func show() {
if let win = window, win.isVisible {
win.makeKeyAndOrderFront(nil)
return
}
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 380),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
win.title = L.scanQRCode
win.isReleasedWhenClosed = false
win.center()
let container = NSView(frame: win.contentView!.bounds)
container.autoresizingMask = [.width, .height]
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
// 摄像头预览区域(占位)
let previewHost = NSView(frame: NSRect(x: 20, y: 80, width: 360, height: 240))
previewHost.wantsLayer = true
previewHost.layer?.backgroundColor = NSColor.darkGray.cgColor
previewHost.autoresizingMask = [.width, .height]
container.addSubview(previewHost)
self.previewHost = previewHost
// "正在初始化摄像头..." 提示
let loadingLabel = NSTextField(labelWithString: "Initializing camera...")
loadingLabel.font = NSFont.systemFont(ofSize: 12)
loadingLabel.textColor = .secondaryLabelColor
loadingLabel.frame = NSRect(x: 0, y: 100, width: 360, height: 40)
loadingLabel.alignment = .center
previewHost.addSubview(loadingLabel)
self.loadingLabel = loadingLabel
// 提示文字
let hintLabel = NSTextField(wrappingLabelWithString: L.scanHint)
hintLabel.font = NSFont.systemFont(ofSize: 11)
hintLabel.textColor = .secondaryLabelColor
hintLabel.frame = NSRect(x: 20, y: 20, width: 360, height: 40)
hintLabel.alignment = .center
container.addSubview(hintLabel)
// 检测结果
let resultLabel = NSTextField(labelWithString: "")
resultLabel.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
resultLabel.textColor = .labelColor
resultLabel.frame = NSRect(x: 20, y: 330, width: 360, height: 30)
resultLabel.alignment = .center
resultLabel.lineBreakMode = .byTruncatingMiddle
resultLabel.tag = 100
container.addSubview(resultLabel)
self.resultLabel = resultLabel
win.contentView = container
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
self.window = win
// 后台初始化摄像头(不阻塞主线程)
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.setupCaptureOnBackground(previewHost: previewHost)
}
}
func close() {
session?.stopRunning()
DispatchQueue.main.async { [weak self] in
self?.window?.close()
}
}
// 在后台线程初始化摄像头
private func setupCaptureOnBackground(previewHost: NSView) {
// 先检查摄像头权限
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
if granted {
self?.setupCaptureOnBackground(previewHost: previewHost)
} else {
DispatchQueue.main.async {
self?.showError("Camera permission denied")
}
}
}
return
case .denied, .restricted:
DispatchQueue.main.async { [weak self] in
self?.showError("Camera permission denied")
}
return
case .authorized:
break
@unknown default:
break
}
let session = AVCaptureSession()
session.sessionPreset = .high
// 优先选内置摄像头,避免 Continuity Camera 不支持 metadata
var device: AVCaptureDevice?
if let builtIn = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .unspecified) {
device = builtIn
} else {
device = AVCaptureDevice.default(for: .video)
}
guard let device = device else {
DispatchQueue.main.async { [weak self] in
self?.showError("No camera available")
}
return
}
guard let input = try? AVCaptureDeviceInput(device: device) else {
DispatchQueue.main.async { [weak self] in
self?.showError("Cannot access camera")
}
return
}
if session.canAddInput(input) {
session.addInput(input)
}
let output = AVCaptureMetadataOutput()
if session.canAddOutput(output) {
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
}
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
self.session = session
// 回到主线程更新 UI
DispatchQueue.main.async { [weak self] in
guard let self = self, previewHost.window != nil else {
session.stopRunning()
return
}
// 移除 loading 提示
self.loadingLabel?.removeFromSuperview()
// 添加预览层
previewLayer.frame = previewHost.bounds
previewHost.layer?.addSublayer(previewLayer)
// 启动会话(后台启动避免阻塞)
DispatchQueue.global(qos: .userInitiated).async {
session.startRunning()
// session 启动后再设置 metadata types(启动前 availableMetadataObjectTypes 可能为空)
if session.canAddOutput(output) == false,
output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else {
// 尝试设置所有支持的类型
let available = output.availableMetadataObjectTypes
if available.contains(.qr) {
output.metadataObjectTypes = [.qr]
} else if !available.isEmpty {
output.metadataObjectTypes = available
} else {
DispatchQueue.main.async { [weak self] in
self?.showError("Camera does not support QR scanning. Check System Settings → Privacy → Camera.")
}
}
}
}
}
}
private func showError(_ msg: String) {
resultLabel?.stringValue = msg
resultLabel?.textColor = .systemRed
}
// MARK: - AVCaptureMetadataOutputObjectsDelegate
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let code = metadata.stringValue else { return }
let now = Date()
if code == lastDetected, now.timeIntervalSince(lastDetectedTime) < 3 {
return
}
lastDetected = code
lastDetectedTime = now
// 显示检测结果
resultLabel?.stringValue = code
resultLabel?.textColor = .systemGreen
// 解析:JSON 载荷(含频道信息)或纯 URL → 入库并播放
DispatchQueue.main.async { [weak self] in
guard let self = self, let bridge = self.bridge else { return }
if !bridge.handleScannedCode(code) {
self.resultLabel?.textColor = .systemRed
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.close()
}
}
deinit {
session?.stopRunning()
}
}
#endif