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
This commit is contained in:
parent
d74543e3ed
commit
dd64a21404
2
.gitignore
vendored
2
.gitignore
vendored
@ -12,3 +12,5 @@ MiniPlayer.app/
|
||||
project.yml
|
||||
.swiftpm/
|
||||
MiniPlayer.ipa
|
||||
build/
|
||||
devops
|
||||
|
||||
BIN
Resources/AppIcon.icns
Normal file
BIN
Resources/AppIcon.icns
Normal file
Binary file not shown.
@ -55,6 +55,16 @@ enum L {
|
||||
static let scanQRCode = NSLocalizedString("scan_qr_code", value: "Scan QR Code", comment: "")
|
||||
static let scanHint = NSLocalizedString("scan_hint", value: "Point camera at a QR code containing a media URL", comment: "")
|
||||
static let detectedCode = NSLocalizedString("detected_code", value: "Detected:", comment: "")
|
||||
|
||||
// 二维码显示
|
||||
static let showQRCode = NSLocalizedString("show_qr_code", value: "QR Code", comment: "")
|
||||
static let qrGenFailed = NSLocalizedString("qr_gen_failed", value: "Could not generate QR code", comment: "")
|
||||
static let copyURL = NSLocalizedString("copy_url", value: "Copy URL", comment: "")
|
||||
static let copied = NSLocalizedString("copied", value: "Copied", comment: "")
|
||||
static let done = NSLocalizedString("done", value: "Done", comment: "")
|
||||
static let noCurrentURL = NSLocalizedString("no_current_url", value: "No video is currently playing", comment: "")
|
||||
static let channelNameField = NSLocalizedString("channel_name_field", value: "Channel name (optional)", comment: "")
|
||||
static let channelGroupField = NSLocalizedString("channel_group_field", value: "Group (optional)", comment: "")
|
||||
|
||||
// 媒体库
|
||||
static let mediaLibrary = NSLocalizedString("media_library", value: "Media Library", comment: "")
|
||||
|
||||
@ -10,10 +10,14 @@ import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
extension Notification.Name {
|
||||
/// 应用即将退出时发送,用于在终止前保存播放状态
|
||||
static let miniPlayerWillTerminate = Notification.Name("MiniPlayer.willTerminate")
|
||||
}
|
||||
|
||||
// 主窗口关闭时退出应用
|
||||
class AppDel: NSObject, NSApplicationDelegate {
|
||||
private var closeObserver: Any?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// 监听所有窗口关闭事件
|
||||
closeObserver = NotificationCenter.default.addObserver(
|
||||
@ -34,6 +38,8 @@ class AppDel: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
// 退出前保存播放状态,供下次启动恢复
|
||||
NotificationCenter.default.post(name: .miniPlayerWillTerminate, object: nil)
|
||||
if RecorderState.isFinalizing {
|
||||
NSLog("[MiniPlayer] ⏳ Termination blocked: recording is finalizing")
|
||||
RecorderState.pendingTerminate = true
|
||||
@ -85,8 +91,7 @@ public struct MiniPlayerApp: App {
|
||||
bridge.setup()
|
||||
setupBridgeEvents()
|
||||
loadI18n()
|
||||
// iOS: 首次启动时恢复上次播放状态
|
||||
#if os(iOS)
|
||||
// 首次启动时恢复上次播放状态(macOS + iOS 均适用)
|
||||
if !hasRestored {
|
||||
hasRestored = true
|
||||
// 延迟恢复,等 setup 和 KVO 就绪
|
||||
@ -94,10 +99,8 @@ public struct MiniPlayerApp: App {
|
||||
bridge.restoreSessionState()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
#if os(iOS)
|
||||
switch newPhase {
|
||||
case .background, .inactive:
|
||||
// App 进后台/挂起:保存当前队列和播放位置
|
||||
@ -107,8 +110,13 @@ public struct MiniPlayerApp: App {
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .miniPlayerWillTerminate)) { _ in
|
||||
// macOS 退出前保存播放状态
|
||||
bridge.saveSessionState()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
@ -596,6 +604,19 @@ struct BricksAppView: View {
|
||||
NetworkFilePanel(bridge: bridge, isPresented: $bridge.showNetworkBrowser)
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: Binding<Bool>(
|
||||
get: { bridge.qrDisplayURL != nil },
|
||||
set: { if !$0 { bridge.qrDisplayURL = nil } }
|
||||
)) {
|
||||
if let urlStr = bridge.qrDisplayURL {
|
||||
QRDisplayView(urlString: urlStr) { bridge.qrDisplayURL = nil }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $bridge.showQRScanner) {
|
||||
QRScannerSheet(bridge: bridge) { bridge.showQRScanner = false }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.fileImporter(
|
||||
isPresented: $bridge.showFileImporter,
|
||||
allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio, .plainText, .item],
|
||||
@ -696,9 +717,8 @@ struct ControlToolbar: View {
|
||||
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
|
||||
TB(icon: "network") { bridge.toggleNetworkBrowser(); onTouch() }
|
||||
TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
|
||||
#if os(macOS)
|
||||
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() }
|
||||
#endif
|
||||
TB(icon: "qrcode") { bridge.showCurrentQRCode(); onTouch() }
|
||||
TB(icon: "backward.fill") { bridge.playPrev(); onTouch() }
|
||||
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
|
||||
TB(icon: "forward.fill") { bridge.playNext(); onTouch() }
|
||||
@ -871,6 +891,8 @@ struct PlaylistWindowView: View {
|
||||
struct URLInputDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@State private var url = ""
|
||||
@State private var name = ""
|
||||
@State private var group = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@ -878,11 +900,20 @@ struct URLInputDialog: View {
|
||||
Text(L.addMediaURL).font(.headline)
|
||||
TextField("https://example.com/video.m3u8", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField(L.channelNameField, text: $name)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField(L.channelGroupField, text: $group)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button(L.cancel) { dismiss() }
|
||||
Spacer()
|
||||
Button(L.add) {
|
||||
if !url.isEmpty { bridge.addURL(url); dismiss() }
|
||||
if !url.isEmpty {
|
||||
bridge.addURL(url,
|
||||
name: name.isEmpty ? nil : name,
|
||||
group: group.isEmpty ? nil : group)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(url.isEmpty)
|
||||
|
||||
@ -80,7 +80,10 @@ final class PlayerBridge: ObservableObject {
|
||||
@Published var showNetworkBrowser = false
|
||||
#if os(macOS)
|
||||
var qrScannerController: QRScannerWindowController?
|
||||
var qrDisplayController: QRDisplayWindowController?
|
||||
#endif
|
||||
/// iOS: 当前待显示的二维码 URL(非 nil 时弹出 sheet)
|
||||
@Published var qrDisplayURL: String?
|
||||
@Published var toastMessage: String?
|
||||
@Published var availableTracks: [String] = []
|
||||
@Published var currentTrackIndex: Int = 0
|
||||
@ -714,17 +717,18 @@ final class PlayerBridge: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
func addURL(_ urlString: String) {
|
||||
func addURL(_ urlString: String, name: String? = nil, group: String? = nil, logo: String? = nil) {
|
||||
let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
print("[MiniPlayer] addURL called: \(urlString)")
|
||||
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
|
||||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||||
let resolvedName = (name?.isEmpty == false ? name! :
|
||||
(url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent))
|
||||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||||
print("[MiniPlayer] Adding item: name=\(name), type=\(type)")
|
||||
addItem(url: url, name: name, type: type)
|
||||
print("[MiniPlayer] Adding item: name=\(resolvedName), type=\(type)")
|
||||
addItem(url: url, name: resolvedName, type: type, group: group, logoURL: logo)
|
||||
}
|
||||
|
||||
func addItem(url: URL, name: String, type: String) {
|
||||
func addItem(url: URL, name: String, type: String, group: String? = nil, logoURL: String? = nil) {
|
||||
let urlString = url.absoluteString
|
||||
print("[MiniPlayer] addItem: name=\(name), type=\(type), scheme=\(url.scheme ?? "nil")")
|
||||
// 检查队列是否已有相同URL
|
||||
@ -734,7 +738,8 @@ final class PlayerBridge: ObservableObject {
|
||||
return
|
||||
}
|
||||
// 自动入库(库内已自动去重)
|
||||
let libItem = library.addItem(url: urlString, name: name, type: type)
|
||||
let libItem = library.addItem(url: urlString, name: name, type: type,
|
||||
group: group, logoURL: logoURL)
|
||||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
|
||||
mediaType: type, libraryID: libItem.id))
|
||||
print("[MiniPlayer] addItem: added to queue, count=\(queue.count)")
|
||||
@ -777,8 +782,84 @@ final class PlayerBridge: ObservableObject {
|
||||
qrScannerController = QRScannerWindowController(bridge: self)
|
||||
}
|
||||
qrScannerController?.show()
|
||||
#else
|
||||
showQRScanner = true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - 当前播放 URL 的二维码
|
||||
/// 当前正在播放的媒体 URL(无播放内容时为 nil)
|
||||
var currentURLString: String? {
|
||||
guard currentIndex >= 0, currentIndex < queue.count else { return nil }
|
||||
let s = queue[currentIndex].url.absoluteString
|
||||
return s.isEmpty ? nil : s
|
||||
}
|
||||
|
||||
/// 当前播放条目对应的媒体库条目(用于取频道分组/台标)
|
||||
var currentLibraryItem: LibraryItem? {
|
||||
guard currentIndex >= 0, currentIndex < queue.count,
|
||||
let libID = queue[currentIndex].libraryID else { return nil }
|
||||
return library.items.first { $0.id == libID }
|
||||
}
|
||||
|
||||
/// 把当前播放的视频 URL + 频道信息编码成二维码载荷
|
||||
func currentQRPayload() -> QRMediaPayload? {
|
||||
guard let urlString = currentURLString else { return nil }
|
||||
let item = queue[currentIndex]
|
||||
let lib = currentLibraryItem
|
||||
return QRMediaPayload(
|
||||
url: urlString,
|
||||
name: item.name,
|
||||
type: item.mediaType,
|
||||
group: lib?.group,
|
||||
logo: lib?.logoURL,
|
||||
from: "MiniPlayer"
|
||||
)
|
||||
}
|
||||
|
||||
/// 把当前播放的视频 URL(含频道信息)生成二维码并弹窗显示
|
||||
func showCurrentQRCode() {
|
||||
guard let payload = currentQRPayload() else {
|
||||
showToast(L.noCurrentURL)
|
||||
return
|
||||
}
|
||||
let text = payload.encoded()
|
||||
#if os(macOS)
|
||||
if qrDisplayController == nil {
|
||||
qrDisplayController = QRDisplayWindowController()
|
||||
}
|
||||
qrDisplayController?.show(urlString: text, displayText: payload.url)
|
||||
#else
|
||||
qrDisplayURL = text
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 处理扫码/粘贴得到的二维码内容:解析频道信息 → 入库 → 播放
|
||||
/// 返回是否成功识别(无效内容返回 false,调用方决定提示)
|
||||
@discardableResult
|
||||
func handleScannedCode(_ code: String) -> Bool {
|
||||
guard let payload = QRMediaPayload.decode(code),
|
||||
let url = URL(string: payload.url) else { return false }
|
||||
|
||||
let name = (payload.name?.isEmpty == false ? payload.name! :
|
||||
(url.lastPathComponent.isEmpty ? (url.host ?? payload.url) : url.lastPathComponent))
|
||||
let type = payload.type?.isEmpty == false ? payload.type! :
|
||||
(payload.url.contains(".m3u8") ? "stream" : "url")
|
||||
|
||||
// 入库:带上频道分组/台标;已存在则返回原有条目
|
||||
let libItem = library.addItem(url: payload.url, name: name, type: type,
|
||||
group: payload.group, logoURL: payload.logo)
|
||||
|
||||
// 已在队列则直接跳到该条播放
|
||||
if let idx = queue.firstIndex(where: { $0.url.absoluteString == payload.url }) {
|
||||
playIndex(idx)
|
||||
return true
|
||||
}
|
||||
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
|
||||
mediaType: type, libraryID: libItem.id))
|
||||
playIndex(queue.count - 1)
|
||||
return true
|
||||
}
|
||||
|
||||
func togglePlaylistWindow() {
|
||||
#if os(macOS)
|
||||
|
||||
164
Sources/QRDisplayView.swift
Normal file
164
Sources/QRDisplayView.swift
Normal file
@ -0,0 +1,164 @@
|
||||
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
|
||||
80
Sources/QRMediaPayload.swift
Normal file
80
Sources/QRMediaPayload.swift
Normal file
@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - 二维码载荷(当前播放视频 URL + 频道信息)
|
||||
//
|
||||
// 扫码后直接把该条目保存进扫码方的媒体库,并带上频道/分组/台标等元数据。
|
||||
//
|
||||
// 编码格式:优先 JSON(携带频道信息),
|
||||
// 若编码后过长(> ~900 字符,QR 版本会过大影响识别率),
|
||||
// 退化为纯 URL —— 扫码方仍能添加,只是没有频道元数据。
|
||||
|
||||
public struct QRMediaPayload: Codable, Equatable {
|
||||
/// 媒体 URL(必填)
|
||||
public var url: String
|
||||
/// 频道/节目名称
|
||||
public var name: String?
|
||||
/// 类型:"stream" / "url" / "file"
|
||||
public var type: String?
|
||||
/// 频道分组(M3U group-title)
|
||||
public var group: String?
|
||||
/// 台标 URL(M3U tvg-logo)
|
||||
public var logo: String?
|
||||
/// 发送方标识,便于扫码方区分来源
|
||||
public var from: String?
|
||||
|
||||
public init(url: String, name: String? = nil, type: String? = nil,
|
||||
group: String? = nil, logo: String? = nil, from: String? = nil) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.group = group
|
||||
self.logo = logo
|
||||
self.from = from
|
||||
}
|
||||
|
||||
/// 短键名,压缩二维码体积
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url = "u"
|
||||
case name = "n"
|
||||
case type = "t"
|
||||
case group = "g"
|
||||
case logo = "l"
|
||||
case from = "f"
|
||||
}
|
||||
|
||||
/// 二维码中实际使用的文本:短负载用 JSON,超长退回纯 URL
|
||||
public static let maxJSONLength = 900
|
||||
|
||||
public func encoded() -> String {
|
||||
let enc = JSONEncoder()
|
||||
enc.outputFormatting = [.withoutEscapingSlashes]
|
||||
if let data = try? enc.encode(self),
|
||||
let json = String(data: data, encoding: .utf8),
|
||||
json.count <= Self.maxJSONLength {
|
||||
return json
|
||||
}
|
||||
return url // 退化:纯 URL
|
||||
}
|
||||
|
||||
/// 解析扫码结果:支持 JSON 载荷,也兼容纯 URL(旧二维码/手输)
|
||||
public static func decode(_ text: String) -> QRMediaPayload? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
if trimmed.hasPrefix("{") {
|
||||
if let data = trimmed.data(using: .utf8),
|
||||
let p = try? JSONDecoder().decode(QRMediaPayload.self, from: data),
|
||||
!p.url.isEmpty {
|
||||
return p
|
||||
}
|
||||
return nil // 是 JSON 但解析失败 → 不当成 URL 处理
|
||||
}
|
||||
|
||||
// 纯 URL 回退
|
||||
if let u = URL(string: trimmed), let scheme = u.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" || scheme == "file" {
|
||||
return QRMediaPayload(url: trimmed, name: u.lastPathComponent.isEmpty ? u.host : u.lastPathComponent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
181
Sources/QRScannerSheet.swift
Normal file
181
Sources/QRScannerSheet.swift
Normal file
@ -0,0 +1,181 @@
|
||||
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
|
||||
@ -222,15 +222,16 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
|
||||
resultLabel?.stringValue = code
|
||||
resultLabel?.textColor = .systemGreen
|
||||
|
||||
// 有效 URL → 添加并播放
|
||||
if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.bridge?.addURL(code)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||||
self?.close()
|
||||
// 解析: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 {
|
||||
|
||||
@ -29,3 +29,14 @@
|
||||
"invalid_url" = "Invalid URL";
|
||||
"open" = "Open";
|
||||
"list" = "List";
|
||||
"scan_qr_code" = "Scan QR Code";
|
||||
"scan_hint" = "Point camera at a QR code containing a media URL";
|
||||
"detected_code" = "Detected:";
|
||||
"show_qr_code" = "QR Code";
|
||||
"qr_gen_failed" = "Could not generate QR code";
|
||||
"copy_url" = "Copy URL";
|
||||
"copied" = "Copied";
|
||||
"done" = "Done";
|
||||
"no_current_url" = "No video is currently playing";
|
||||
"channel_name_field" = "Channel name (optional)";
|
||||
"channel_group_field" = "Group (optional)";
|
||||
|
||||
@ -29,3 +29,14 @@
|
||||
"invalid_url" = "无效URL";
|
||||
"open" = "打开";
|
||||
"list" = "列表";
|
||||
"scan_qr_code" = "扫码";
|
||||
"scan_hint" = "将摄像头对准包含媒体URL的二维码";
|
||||
"detected_code" = "检测到:";
|
||||
"show_qr_code" = "二维码";
|
||||
"qr_gen_failed" = "无法生成二维码";
|
||||
"copy_url" = "复制URL";
|
||||
"copied" = "已复制";
|
||||
"done" = "完成";
|
||||
"no_current_url" = "当前没有正在播放的视频";
|
||||
"channel_name_field" = "频道名称(可选)";
|
||||
"channel_group_field" = "分组(可选)";
|
||||
|
||||
@ -50,11 +50,32 @@ cp "$PROJECT_DIR/Sources/Resources/i18n/zh.json" "$APP_BUNDLE/Contents/Resources
|
||||
cp "$PROJECT_DIR/Sources/Resources/zh-Hans.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/zh-Hans.lproj/"
|
||||
cp "$PROJECT_DIR/Sources/Resources/en.lproj/Localizable.strings" "$APP_BUNDLE/Contents/Resources/en.lproj/"
|
||||
|
||||
# App 图标(如果有)
|
||||
# App 图标:若缺 .icns 则从 iconset 自动生成
|
||||
if [ ! -f "$PROJECT_DIR/Resources/AppIcon.icns" ] && [ -d "$PROJECT_DIR/Icons_macOS/MiniPlayer.appiconset" ]; then
|
||||
echo " → 生成 AppIcon.icns..."
|
||||
ICONSET_SRC="$PROJECT_DIR/Icons_macOS/MiniPlayer.appiconset"
|
||||
ICONSET_TMP="$(mktemp -d)/AppIcon.iconset"
|
||||
mkdir -p "$ICONSET_TMP"
|
||||
cp "$ICONSET_SRC/icon_16.png" "$ICONSET_TMP/icon_16x16.png"
|
||||
cp "$ICONSET_SRC/icon_16@2x.png" "$ICONSET_TMP/icon_16x16@2x.png"
|
||||
cp "$ICONSET_SRC/icon_32.png" "$ICONSET_TMP/icon_32x32.png"
|
||||
cp "$ICONSET_SRC/icon_32@2x.png" "$ICONSET_TMP/icon_32x32@2x.png"
|
||||
cp "$ICONSET_SRC/icon_128.png" "$ICONSET_TMP/icon_128x128.png"
|
||||
cp "$ICONSET_SRC/icon_128@2x.png" "$ICONSET_TMP/icon_128x128@2x.png"
|
||||
cp "$ICONSET_SRC/icon_256.png" "$ICONSET_TMP/icon_256x256.png"
|
||||
cp "$ICONSET_SRC/icon_256@2x.png" "$ICONSET_TMP/icon_256x256@2x.png"
|
||||
cp "$ICONSET_SRC/icon_512.png" "$ICONSET_TMP/icon_512x512.png"
|
||||
cp "$ICONSET_SRC/icon_512@2x.png" "$ICONSET_TMP/icon_512x512@2x.png"
|
||||
mkdir -p "$PROJECT_DIR/Resources"
|
||||
iconutil -c icns "$ICONSET_TMP" -o "$PROJECT_DIR/Resources/AppIcon.icns" 2>/dev/null || true
|
||||
rm -rf "$(dirname "$ICONSET_TMP")"
|
||||
fi
|
||||
|
||||
if [ -f "$PROJECT_DIR/Resources/AppIcon.icns" ]; then
|
||||
cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/"
|
||||
ICON_FILE="AppIcon.icns"
|
||||
else
|
||||
echo " ⚠ 未找到/无法生成 AppIcon.icns,应用将没有图标"
|
||||
ICON_FILE=""
|
||||
fi
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user