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:
yumoqing 2026-10-04 12:07:00 +08:00
parent d74543e3ed
commit dd64a21404
12 changed files with 616 additions and 23 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ MiniPlayer.app/
project.yml project.yml
.swiftpm/ .swiftpm/
MiniPlayer.ipa MiniPlayer.ipa
build/
devops

BIN
Resources/AppIcon.icns Normal file

Binary file not shown.

View File

@ -56,6 +56,16 @@ enum L {
static let scanHint = NSLocalizedString("scan_hint", value: "Point camera at a QR code containing a media URL", 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 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: "") static let mediaLibrary = NSLocalizedString("media_library", value: "Media Library", comment: "")
static let allMedia = NSLocalizedString("all_media", value: "All Media", comment: "") static let allMedia = NSLocalizedString("all_media", value: "All Media", comment: "")

View File

@ -10,10 +10,14 @@ import UniformTypeIdentifiers
#endif #endif
#if os(macOS) #if os(macOS)
extension Notification.Name {
/// 应用即将退出时发送,用于在终止前保存播放状态
static let miniPlayerWillTerminate = Notification.Name("MiniPlayer.willTerminate")
}
// 主窗口关闭时退出应用 // 主窗口关闭时退出应用
class AppDel: NSObject, NSApplicationDelegate { class AppDel: NSObject, NSApplicationDelegate {
private var closeObserver: Any? private var closeObserver: Any?
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// 监听所有窗口关闭事件 // 监听所有窗口关闭事件
closeObserver = NotificationCenter.default.addObserver( closeObserver = NotificationCenter.default.addObserver(
@ -34,6 +38,8 @@ class AppDel: NSObject, NSApplicationDelegate {
} }
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
// 退出前保存播放状态,供下次启动恢复
NotificationCenter.default.post(name: .miniPlayerWillTerminate, object: nil)
if RecorderState.isFinalizing { if RecorderState.isFinalizing {
NSLog("[MiniPlayer] ⏳ Termination blocked: recording is finalizing") NSLog("[MiniPlayer] ⏳ Termination blocked: recording is finalizing")
RecorderState.pendingTerminate = true RecorderState.pendingTerminate = true
@ -85,8 +91,7 @@ public struct MiniPlayerApp: App {
bridge.setup() bridge.setup()
setupBridgeEvents() setupBridgeEvents()
loadI18n() loadI18n()
// iOS: 首次启动时恢复上次播放状态 // 首次启动时恢复上次播放状态(macOS + iOS 均适用)
#if os(iOS)
if !hasRestored { if !hasRestored {
hasRestored = true hasRestored = true
// 延迟恢复,等 setup 和 KVO 就绪 // 延迟恢复,等 setup 和 KVO 就绪
@ -94,10 +99,8 @@ public struct MiniPlayerApp: App {
bridge.restoreSessionState() bridge.restoreSessionState()
} }
} }
#endif
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { _, newPhase in
#if os(iOS)
switch newPhase { switch newPhase {
case .background, .inactive: case .background, .inactive:
// App 进后台/挂起:保存当前队列和播放位置 // App 进后台/挂起:保存当前队列和播放位置
@ -107,8 +110,13 @@ public struct MiniPlayerApp: App {
@unknown default: @unknown default:
break break
} }
#endif
} }
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: .miniPlayerWillTerminate)) { _ in
// macOS 退出前保存播放状态
bridge.saveSessionState()
}
#endif
} }
#if os(macOS) #if os(macOS)
.commands { .commands {
@ -596,6 +604,19 @@ struct BricksAppView: View {
NetworkFilePanel(bridge: bridge, isPresented: $bridge.showNetworkBrowser) NetworkFilePanel(bridge: bridge, isPresented: $bridge.showNetworkBrowser)
} }
#if os(iOS) #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( .fileImporter(
isPresented: $bridge.showFileImporter, isPresented: $bridge.showFileImporter,
allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio, .plainText, .item], 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: "folder") { bridge.openFileDialog(); onTouch() }
TB(icon: "network") { bridge.toggleNetworkBrowser(); onTouch() } TB(icon: "network") { bridge.toggleNetworkBrowser(); onTouch() }
TB(icon: "link") { bridge.showURLDialog = true; onTouch() } TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
#if os(macOS)
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() } TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() }
#endif TB(icon: "qrcode") { bridge.showCurrentQRCode(); onTouch() }
TB(icon: "backward.fill") { bridge.playPrev(); onTouch() } TB(icon: "backward.fill") { bridge.playPrev(); onTouch() }
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() } TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
TB(icon: "forward.fill") { bridge.playNext(); onTouch() } TB(icon: "forward.fill") { bridge.playNext(); onTouch() }
@ -871,6 +891,8 @@ struct PlaylistWindowView: View {
struct URLInputDialog: View { struct URLInputDialog: View {
@ObservedObject var bridge: PlayerBridge @ObservedObject var bridge: PlayerBridge
@State private var url = "" @State private var url = ""
@State private var name = ""
@State private var group = ""
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@ -878,11 +900,20 @@ struct URLInputDialog: View {
Text(L.addMediaURL).font(.headline) Text(L.addMediaURL).font(.headline)
TextField("https://example.com/video.m3u8", text: $url) TextField("https://example.com/video.m3u8", text: $url)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
TextField(L.channelNameField, text: $name)
.textFieldStyle(.roundedBorder)
TextField(L.channelGroupField, text: $group)
.textFieldStyle(.roundedBorder)
HStack { HStack {
Button(L.cancel) { dismiss() } Button(L.cancel) { dismiss() }
Spacer() Spacer()
Button(L.add) { 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) .keyboardShortcut(.defaultAction)
.disabled(url.isEmpty) .disabled(url.isEmpty)

View File

@ -80,7 +80,10 @@ final class PlayerBridge: ObservableObject {
@Published var showNetworkBrowser = false @Published var showNetworkBrowser = false
#if os(macOS) #if os(macOS)
var qrScannerController: QRScannerWindowController? var qrScannerController: QRScannerWindowController?
var qrDisplayController: QRDisplayWindowController?
#endif #endif
/// iOS: 当前待显示的二维码 URL(非 nil 时弹出 sheet)
@Published var qrDisplayURL: String?
@Published var toastMessage: String? @Published var toastMessage: String?
@Published var availableTracks: [String] = [] @Published var availableTracks: [String] = []
@Published var currentTrackIndex: Int = 0 @Published var currentTrackIndex: Int = 0
@ -714,17 +717,18 @@ final class PlayerBridge: ObservableObject {
#endif #endif
} }
func addURL(_ urlString: String) { func addURL(_ urlString: String, name: String? = nil, group: String? = nil, logo: String? = nil) {
let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines) let urlString = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
print("[MiniPlayer] addURL called: \(urlString)") print("[MiniPlayer] addURL called: \(urlString)")
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return } 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" let type = urlString.contains(".m3u8") ? "stream" : "url"
print("[MiniPlayer] Adding item: name=\(name), type=\(type)") print("[MiniPlayer] Adding item: name=\(resolvedName), type=\(type)")
addItem(url: url, name: name, 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 let urlString = url.absoluteString
print("[MiniPlayer] addItem: name=\(name), type=\(type), scheme=\(url.scheme ?? "nil")") print("[MiniPlayer] addItem: name=\(name), type=\(type), scheme=\(url.scheme ?? "nil")")
// 检查队列是否已有相同URL // 检查队列是否已有相同URL
@ -734,7 +738,8 @@ final class PlayerBridge: ObservableObject {
return 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, queue.append(MediaItem(id: UUID().uuidString, url: url, name: name,
mediaType: type, libraryID: libItem.id)) mediaType: type, libraryID: libItem.id))
print("[MiniPlayer] addItem: added to queue, count=\(queue.count)") print("[MiniPlayer] addItem: added to queue, count=\(queue.count)")
@ -777,9 +782,85 @@ final class PlayerBridge: ObservableObject {
qrScannerController = QRScannerWindowController(bridge: self) qrScannerController = QRScannerWindowController(bridge: self)
} }
qrScannerController?.show() qrScannerController?.show()
#else
showQRScanner = true
#endif #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() { func togglePlaylistWindow() {
#if os(macOS) #if os(macOS)
if let win = playlistWindow, win.isVisible { if let win = playlistWindow, win.isVisible {

164
Sources/QRDisplayView.swift Normal file
View 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

View 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
}
}

View 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

View File

@ -222,16 +222,17 @@ class QRScannerWindowController: NSObject, AVCaptureMetadataOutputObjectsDelegat
resultLabel?.stringValue = code resultLabel?.stringValue = code
resultLabel?.textColor = .systemGreen resultLabel?.textColor = .systemGreen
// 有效 URL → 添加并播放 // 解析:JSON 载荷(含频道信息)或纯 URL → 入库并播放
if let url = URL(string: code), (url.scheme == "http" || url.scheme == "https") {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.bridge?.addURL(code) 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 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.close() self?.close()
} }
} }
}
deinit { deinit {
session?.stopRunning() session?.stopRunning()

View File

@ -29,3 +29,14 @@
"invalid_url" = "Invalid URL"; "invalid_url" = "Invalid URL";
"open" = "Open"; "open" = "Open";
"list" = "List"; "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)";

View File

@ -29,3 +29,14 @@
"invalid_url" = "无效URL"; "invalid_url" = "无效URL";
"open" = "打开"; "open" = "打开";
"list" = "列表"; "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" = "分组(可选)";

View File

@ -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/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/" 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 if [ -f "$PROJECT_DIR/Resources/AppIcon.icns" ]; then
cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/" cp "$PROJECT_DIR/Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/"
ICON_FILE="AppIcon.icns" ICON_FILE="AppIcon.icns"
else else
echo " ⚠ 未找到/无法生成 AppIcon.icns,应用将没有图标"
ICON_FILE="" ICON_FILE=""
fi fi