- 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
165 lines
5.4 KiB
Swift
165 lines
5.4 KiB
Swift
import SwiftUI
|
||
import CoreImage
|
||
import CoreImage.CIFilterBuiltins
|
||
|
||
// MARK: - QR 码生成器(macOS + iOS 通用)
|
||
|
||
/// 把任意文本编码成 QR 码图片。
|
||
public enum QRCodeGenerator {
|
||
private static let context = CIContext()
|
||
|
||
/// 生成 QR 码位图。返回 nil 表示编码失败。
|
||
public static func image(from string: String, size: CGFloat = 260) -> PlatformImage? {
|
||
guard !string.isEmpty else { return nil }
|
||
let data = Data(string.utf8)
|
||
|
||
let filter = CIFilter.qrCodeGenerator()
|
||
filter.message = data
|
||
// 容错等级 H:即使二维码中间叠 logo 也能扫出,同时更耐屏幕反光/压缩
|
||
filter.correctionLevel = "H"
|
||
|
||
guard let output = filter.outputImage else { return nil }
|
||
|
||
// 原始 QR 码每模块 1px,放大到目标尺寸
|
||
let scale = size / output.extent.width
|
||
let scaled = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||
|
||
#if os(macOS)
|
||
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||
return NSImage(cgImage: cg, size: NSSize(width: size, height: size))
|
||
#else
|
||
guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
|
||
return UIImage(cgImage: cg)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#if os(macOS)
|
||
public typealias PlatformImage = NSImage
|
||
#else
|
||
public typealias PlatformImage = UIImage
|
||
#endif
|
||
|
||
// MARK: - 二维码弹窗内容视图(macOS 窗口 / iOS sheet 共用)
|
||
|
||
public struct QRDisplayView: View {
|
||
let urlString: String // 实际编码进二维码的内容(可能是 JSON 载荷)
|
||
let displayText: String? // 界面上给用户看的明文(通常是纯 URL)
|
||
let onDismiss: () -> Void
|
||
|
||
@State private var copied = false
|
||
|
||
public init(urlString: String, displayText: String? = nil, onDismiss: @escaping () -> Void) {
|
||
self.urlString = urlString
|
||
self.displayText = displayText
|
||
self.onDismiss = onDismiss
|
||
}
|
||
|
||
/// 界面上展示的文本:优先用明文 URL,没有则展示编码内容
|
||
private var shownText: String { displayText ?? urlString }
|
||
|
||
public var body: some View {
|
||
VStack(spacing: 18) {
|
||
Text(L.showQRCode)
|
||
.font(.headline)
|
||
|
||
Group {
|
||
if let img = QRCodeGenerator.image(from: urlString, size: 260) {
|
||
#if os(macOS)
|
||
Image(nsImage: img)
|
||
.interpolation(.none)
|
||
.resizable()
|
||
.frame(width: 260, height: 260)
|
||
.padding(10)
|
||
.background(Color.white)
|
||
.cornerRadius(8)
|
||
#else
|
||
Image(uiImage: img)
|
||
.interpolation(.none)
|
||
.resizable()
|
||
.frame(width: 260, height: 260)
|
||
.padding(10)
|
||
.background(Color.white)
|
||
.cornerRadius(8)
|
||
#endif
|
||
} else {
|
||
Text(L.qrGenFailed)
|
||
.foregroundColor(.red)
|
||
.frame(width: 260, height: 260)
|
||
}
|
||
}
|
||
|
||
// URL 明文:方便长按/复制/手动核对
|
||
Text(shownText)
|
||
.font(.system(size: 12, design: .monospaced))
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(4)
|
||
.truncationMode(.middle)
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: 300)
|
||
|
||
HStack(spacing: 12) {
|
||
Button(copied ? L.copied : L.copyURL) {
|
||
copyToClipboard(shownText)
|
||
copied = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
|
||
}
|
||
Button(L.done, action: onDismiss)
|
||
.keyboardShortcut(.defaultAction)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.frame(minWidth: 340)
|
||
}
|
||
|
||
private func copyToClipboard(_ s: String) {
|
||
#if os(macOS)
|
||
let pb = NSPasteboard.general
|
||
pb.clearContents()
|
||
pb.setString(s, forType: .string)
|
||
#else
|
||
UIPasteboard.general.string = s
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#if os(macOS)
|
||
/// macOS:独立窗口控制器,复用与扫码窗口一致的 NSWindow 模式
|
||
public final class QRDisplayWindowController {
|
||
private var window: NSWindow?
|
||
private let onClose: () -> Void
|
||
|
||
public init(onClose: @escaping () -> Void = {}) {
|
||
self.onClose = onClose
|
||
}
|
||
|
||
public func show(urlString: String, displayText: String? = nil) {
|
||
if let win = window, win.isVisible {
|
||
win.makeKeyAndOrderFront(nil)
|
||
return
|
||
}
|
||
let win = NSWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 380, height: 460),
|
||
styleMask: [.titled, .closable],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
win.title = L.showQRCode
|
||
win.isReleasedWhenClosed = false
|
||
win.center()
|
||
win.contentView = NSHostingView(rootView: QRDisplayView(urlString: urlString, displayText: displayText) { [weak self] in
|
||
self?.close()
|
||
})
|
||
win.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
self.window = win
|
||
}
|
||
|
||
public func close() {
|
||
window?.close()
|
||
window = nil
|
||
onClose()
|
||
}
|
||
}
|
||
#endif
|