diff --git a/.gitignore b/.gitignore index e1853cb..01fc0da 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ MiniPlayer.app/ project.yml .swiftpm/ MiniPlayer.ipa +build/ +devops diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..38ef9fa Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Sources/Localization.swift b/Sources/Localization.swift index 4ca1dd0..83eb026 100644 --- a/Sources/Localization.swift +++ b/Sources/Localization.swift @@ -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: "") diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index f19e55a..ec9c8eb 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -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( + 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) diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 945c719..b0887ff 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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) diff --git a/Sources/QRDisplayView.swift b/Sources/QRDisplayView.swift new file mode 100644 index 0000000..c48a137 --- /dev/null +++ b/Sources/QRDisplayView.swift @@ -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 diff --git a/Sources/QRMediaPayload.swift b/Sources/QRMediaPayload.swift new file mode 100644 index 0000000..78e2c96 --- /dev/null +++ b/Sources/QRMediaPayload.swift @@ -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 + } +} diff --git a/Sources/QRScannerSheet.swift b/Sources/QRScannerSheet.swift new file mode 100644 index 0000000..3e231ad --- /dev/null +++ b/Sources/QRScannerSheet.swift @@ -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 diff --git a/Sources/QRScannerView.swift b/Sources/QRScannerView.swift index 66cb004..b6af637 100644 --- a/Sources/QRScannerView.swift +++ b/Sources/QRScannerView.swift @@ -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 { diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index d7ef7b0..020c48a 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -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)"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index 626acff..6472725 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -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" = "分组(可选)"; diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 956b1fc..f348bbc 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -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