From bf7d3de9bad2ced4d196e59ac2a8527886eabba3 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 2 Jul 2026 08:16:33 +0800 Subject: [PATCH] =?UTF-8?q?iOS:=20PiP=E7=94=BB=E4=B8=AD=E7=94=BB=E3=80=81A?= =?UTF-8?q?irPlay=E6=8A=95=E5=B1=8F=E3=80=81=E5=BD=95=E5=88=B6=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=BC=B9=E7=AA=97=E3=80=81=E6=92=AD=E6=94=BE=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=8C=81=E4=B9=85=E5=8C=96=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.swift | 3 +- Sources/AirPlayButton.swift | 25 ++++++++ Sources/HLSRecorder.swift | 78 ++++++++++++++++++++---- Sources/MiniPlayerApp.swift | 63 +++++++++++++++++++- Sources/PiPController.swift | 94 +++++++++++++++++++++++++++++ Sources/PlayerBridge.swift | 116 +++++++++++++++++++++++++++++++++++- SourcesiOS/Info.plist | 54 +++++++++++++++++ 7 files changed, 418 insertions(+), 15 deletions(-) create mode 100644 Sources/AirPlayButton.swift create mode 100644 Sources/PiPController.swift create mode 100644 SourcesiOS/Info.plist diff --git a/Package.swift b/Package.swift index ff6f257..dc3dc69 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,8 @@ let package = Package( .executableTarget( name: "MiniPlayeriOS", dependencies: ["MiniPlayerCore"], - path: "SourcesiOS" + path: "SourcesiOS", + exclude: ["Info.plist"] ) ] ) diff --git a/Sources/AirPlayButton.swift b/Sources/AirPlayButton.swift new file mode 100644 index 0000000..beda74a --- /dev/null +++ b/Sources/AirPlayButton.swift @@ -0,0 +1,25 @@ +import SwiftUI +import AVKit + +#if os(iOS) +/// AirPlay 投屏按钮 — 封装 AVRoutePickerView +/// 点击后弹出系统 AirPlay 设备选择面板 +struct AirPlayButton: UIViewRepresentable { + + /// 按钮颜色(默认白色) + var tintColor: UIColor = .white + + func makeUIView(context: Context) -> AVRoutePickerView { + let picker = AVRoutePickerView() + picker.tintColor = tintColor + picker.activeTintColor = UIColor.systemBlue + // 使按钮在深色背景下可见 + picker.backgroundColor = .clear + return picker + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + uiView.tintColor = tintColor + } +} +#endif diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 71eddb1..3968412 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -3,6 +3,7 @@ import Foundation import AVFoundation import UIKit import Combine +import Photos /// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止 @MainActor @@ -266,30 +267,83 @@ final class HLSRecorder: ObservableObject { // MARK: - 导出/分享 private func showExportSheet(url: URL) { - // 保存到相册(如果是视频) + NSLog("[MiniPlayer] showExportSheet: \(url.path)") + + // 1. 保存到相册(使用 PHPhotoLibrary,有回调通知结果) if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" { - UISaveVideoAtPathToSavedPhotosAlbum(url.path, nil, nil, nil) + saveToPhotoLibrary(url: url) } - // 同时弹出系统分享面板 - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController else { return } + // 2. 延迟弹出系统分享面板(等相册保存启动后再弹) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + self?.presentActivitySheet(url: url) + } + } + + private func saveToPhotoLibrary(url: URL) { + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + + if status == .authorized || status == .limited { + performSave(url: url) + } else if status == .notDetermined { + PHPhotoLibrary.requestAuthorization(for: .addOnly) { [weak self] newStatus in + DispatchQueue.main.async { + if newStatus == .authorized || newStatus == .limited { + self?.performSave(url: url) + } else { + self?.onError?("Photo library access denied") + } + } + } + } else { + onError?("Photo library access denied. Please enable in Settings.") + } + } + + private func performSave(url: URL) { + PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil) + } completionHandler: { [weak self] success, error in + DispatchQueue.main.async { + if success { + self?.onRecordingSaved?(url) + NSLog("[MiniPlayer] Video saved to Photos") + } else { + self?.onError?("Save to Photos failed: \(error?.localizedDescription ?? "unknown")") + } + } + } + } + + private func presentActivitySheet(url: URL) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + NSLog("[MiniPlayer] No window scene found") + return + } + + // 找最顶层可见的 VC + var topVC = windowScene.windows.first?.rootViewController + while let presented = topVC?.presentedViewController { + topVC = presented + } + + guard let vc = topVC else { + NSLog("[MiniPlayer] No root view controller found") + return + } let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil) // iPad 适配 if let popover = activityVC.popoverPresentationController { - popover.sourceView = rootVC.view - popover.sourceRect = CGRect(x: rootVC.view.bounds.midX, y: rootVC.view.bounds.midY, width: 0, height: 0) + popover.sourceView = vc.view + popover.sourceRect = CGRect(x: vc.view.bounds.midX, y: vc.view.bounds.midY, width: 0, height: 0) popover.permittedArrowDirections = [] } - // 找到最顶层的 VC - var topVC = rootVC - while let presented = topVC.presentedViewController { - topVC = presented + vc.present(activityVC, animated: true) { + NSLog("[MiniPlayer] Share sheet presented") } - topVC.present(activityVC, animated: true) } // MARK: - Helpers diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index ea038fc..ff7f7f6 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -70,6 +70,8 @@ public struct MiniPlayerApp: App { #endif @StateObject private var bridge = PlayerBridge() @StateObject private var engine = BricksEngine() + @Environment(\.scenePhase) private var scenePhase + @State private var hasRestored = false public init() {} @@ -83,6 +85,29 @@ public struct MiniPlayerApp: App { bridge.setup() setupBridgeEvents() loadI18n() + // iOS: 首次启动时恢复上次播放状态 + #if os(iOS) + if !hasRestored { + hasRestored = true + // 延迟恢复,等 setup 和 KVO 就绪 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + bridge.restoreSessionState() + } + } + #endif + } + .onChange(of: scenePhase) { _, newPhase in + #if os(iOS) + switch newPhase { + case .background, .inactive: + // App 进后台/挂起:保存当前队列和播放位置 + bridge.saveSessionState() + case .active: + break + @unknown default: + break + } + #endif } } #if os(macOS) @@ -215,6 +240,12 @@ public struct MiniPlayerApp: App { #if os(macOS) struct PlayerLayerView: NSViewRepresentable { let player: AVPlayer + let onPlayerLayerReady: ((AVPlayerLayer) -> Void)? + + init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) { + self.player = player + self.onPlayerLayerReady = onPlayerLayerReady + } func makeNSView(context: Context) -> NSView { let view = PlayerLayerHostView() @@ -253,10 +284,21 @@ class PlayerLayerHostView: NSView { #if os(iOS) struct PlayerLayerView: UIViewRepresentable { let player: AVPlayer + let onPlayerLayerReady: ((AVPlayerLayer) -> Void)? + + init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) { + self.player = player + self.onPlayerLayerReady = onPlayerLayerReady + } + func makeUIView(context: Context) -> PlayerLayerHostViewiOS { let view = PlayerLayerHostViewiOS() view.playerLayer.player = player view.playerLayer.videoGravity = .resizeAspect + // 通知外部 playerLayer 已就绪(用于 PiP 初始化) + DispatchQueue.main.async { + onPlayerLayerReady?(view.playerLayer) + } return view } func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) { @@ -333,7 +375,12 @@ struct PlayerContentView: View { ZStack(alignment: .topLeading) { Color.black.ignoresSafeArea() // 直接用 AVPlayerLayer 渲染视频(彻底绕过 AVPlayerView 内置控制栏) - PlayerLayerView(player: bridge.player) + PlayerLayerView(player: bridge.player) { playerLayer in + #if os(iOS) + // PlayerLayer 就绪后初始化 PiP 控制器 + bridge.pipController.setup(with: playerLayer) + #endif + } .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { bridge.toggleFullscreen() @@ -586,6 +633,20 @@ struct ControlToolbar: View { // 全屏 TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() } + #if os(iOS) + // 画中画(小屏播放) + if bridge.pipController.isPiPSupported { + TB(icon: bridge.pipController.isPiPActive ? "pip.exit" : "pip.enter") { + bridge.pipController.togglePiP() + onTouch() + } + } + + // 投屏(AirPlay) + AirPlayButton() + .frame(width: 28, height: 28) + #endif + // 播放列表 TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() } diff --git a/Sources/PiPController.swift b/Sources/PiPController.swift new file mode 100644 index 0000000..8cac3b1 --- /dev/null +++ b/Sources/PiPController.swift @@ -0,0 +1,94 @@ +import Foundation +import AVKit +import Combine + +#if os(iOS) +/// 画中画(Picture-in-Picture)控制器 +/// 封装 AVPictureInPictureController,管理 PiP 生命周期 +@MainActor +final class PiPController: NSObject, ObservableObject { + + @Published var isPiPActive = false + @Published var isPiPSupported = false + + private var pipController: AVPictureInPictureController? + private weak var playerLayer: AVPlayerLayer? + + /// 用 AVPlayerLayer 初始化 PiP 控制器 + func setup(with playerLayer: AVPlayerLayer) { + self.playerLayer = playerLayer + + guard AVPictureInPictureController.isPictureInPictureSupported() else { + NSLog("[MiniPlayer] PiP not supported on this device") + isPiPSupported = false + return + } + + isPiPSupported = true + + let pip = AVPictureInPictureController(playerLayer: playerLayer) + pip?.delegate = self + self.pipController = pip + + NSLog("[MiniPlayer] PiP controller initialized") + } + + /// 切换画中画状态 + func togglePiP() { + guard let pip = pipController else { + NSLog("[MiniPlayer] PiP controller not initialized") + return + } + + if pip.isPictureInPictureActive { + pip.stopPictureInPicture() + } else if pip.isPictureInPicturePossible { + pip.startPictureInPicture() + } else { + NSLog("[MiniPlayer] PiP not possible right now (player may not be ready)") + } + } + + /// 当前是否可以启动 PiP + var canStartPiP: Bool { + pipController?.isPictureInPicturePossible ?? false + } + + deinit { + pipController?.delegate = nil + } +} + +// MARK: - AVPictureInPictureControllerDelegate +extension PiPController: AVPictureInPictureControllerDelegate { + + nonisolated func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("[MiniPlayer] PiP will start") + Task { @MainActor in self.isPiPActive = true } + } + + nonisolated func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("[MiniPlayer] PiP did start") + } + + nonisolated func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("[MiniPlayer] PiP will stop") + } + + nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + NSLog("[MiniPlayer] PiP did stop") + Task { @MainActor in self.isPiPActive = false } + } + + nonisolated func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + NSLog("[MiniPlayer] PiP failed to start: \(error.localizedDescription)") + } + + nonisolated func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + // 用户点击 PiP 窗口的恢复按钮时调用 + // 返回 true 表示我们已处理恢复(回到 app 内播放) + NSLog("[MiniPlayer] PiP restore requested") + completionHandler(true) + } +} +#endif diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 21d4312..e1d9225 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -3,6 +3,8 @@ import AVFoundation import AVKit #if os(macOS) import AppKit +#elseif os(iOS) +import UIKit #endif /// 播放队列项 @@ -119,6 +121,7 @@ final class PlayerBridge: ObservableObject { #if os(iOS) let hlsRecorder = HLSRecorder() + let pipController = PiPController() #endif // MARK: - 初始化 @@ -126,10 +129,16 @@ final class PlayerBridge: ObservableObject { #if os(macOS) // 设置为正常 GUI 应用,显示在 Dock 中 NSApp.setActivationPolicy(.regular) + #elseif os(iOS) + // iOS: 配置音频会话(支持后台播放 + PiP) + configureAudioSession() #endif player.volume = volume + // 启用 AirPlay(iOS 和 macOS 通用) + player.allowsExternalPlayback = true + setupTimeObserver() setupEndObserver() @@ -152,6 +161,20 @@ final class PlayerBridge: ObservableObject { #endif } + #if os(iOS) + /// 配置 AVAudioSession,支持后台音频和 PiP + private func configureAudioSession() { + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback) + try session.setActive(true) + NSLog("[MiniPlayer] AVAudioSession configured: playback + moviePlayback") + } catch { + NSLog("[MiniPlayer] AVAudioSession setup failed: \(error.localizedDescription)") + } + } + #endif + // MARK: - 录制 func toggleRecording() { #if os(macOS) @@ -754,13 +777,104 @@ final class PlayerBridge: ObservableObject { // MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate) + // MARK: - 队列持久化(跨启动恢复) + + private static let queueKey = "MiniPlayer.savedQueue" + private static let indexKey = "MiniPlayer.savedIndex" + private static let positionKey = "MiniPlayer.savedPosition" + private static let repeatModeKey = "MiniPlayer.savedRepeatMode" + + /// 保存当前队列和播放位置到 UserDefaults(app 进后台/退出时调用) + func saveSessionState() { + guard !queue.isEmpty, currentIndex >= 0 else { return } + + let items = queue.map { item -> [String: String] in + [ + "url": item.url.absoluteString, + "name": item.name, + "type": item.mediaType, + "libraryID": item.libraryID ?? "" + ] + } + + let position = player.currentTime().seconds + + UserDefaults.standard.set(items, forKey: Self.queueKey) + UserDefaults.standard.set(currentIndex, forKey: Self.indexKey) + UserDefaults.standard.set(position.isFinite ? position : 0, forKey: Self.positionKey) + UserDefaults.standard.set(repeatMode.rawValue, forKey: Self.repeatModeKey) + UserDefaults.standard.synchronize() + + // 同时保存媒体库位置 + library.saveSync() + + NSLog("[MiniPlayer] Session saved: \(queue.count) items, index=\(currentIndex), position=\(position)") + } + + /// 从 UserDefaults 恢复队列和播放位置(app 启动时调用) + func restoreSessionState() { + guard let items = UserDefaults.standard.array(forKey: Self.queueKey) as? [[String: String]], + !items.isEmpty else { + NSLog("[MiniPlayer] No saved session to restore") + return + } + + let savedIndex = UserDefaults.standard.integer(forKey: Self.indexKey) + let savedPosition = UserDefaults.standard.double(forKey: Self.positionKey) + + if let modeStr = UserDefaults.standard.string(forKey: Self.repeatModeKey), + let mode = RepeatMode(rawValue: modeStr) { + repeatMode = mode + } + + // 重建队列 + var restoredQueue: [MediaItem] = [] + for dict in items { + guard let urlStr = dict["url"], let url = URL(string: urlStr) else { continue } + let name = dict["name"] ?? url.lastPathComponent + let type = dict["type"] ?? "url" + let libID = dict["libraryID"]?.isEmpty == false ? dict["libraryID"] : nil + restoredQueue.append(MediaItem(id: UUID().uuidString, url: url, name: name, + mediaType: type, libraryID: libID)) + } + + guard !restoredQueue.isEmpty else { + NSLog("[MiniPlayer] Restored queue is empty after URL parsing") + return + } + + queue = restoredQueue + let idx = min(savedIndex, restoredQueue.count - 1) + + NSLog("[MiniPlayer] Restoring session: \(restoredQueue.count) items, index=\(idx), position=\(savedPosition)") + + // 播放并 seek 到上次位置 + playIndex(idx) + + // seek 需要等 item readyToPlay,用延迟方式 + if savedPosition > 1.0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self else { return } + let dur = self.cachedDuration + if dur > 0 && savedPosition < dur - 5 { + self.player.seek(to: CMTime(seconds: savedPosition, preferredTimescale: 600)) + self.showToast("Resumed at \(self.formatTime(savedPosition))") + NSLog("[MiniPlayer] Resumed at \(savedPosition)s") + } + } + } + } + // MARK: - 清理 /// 主动清理所有观察者,在 view onDisappear 时调用 /// 必须在 PlayerBridge 被释放之前调用 func cleanup() { isTearingDown = true - // 强制保存播放位置(退出前记忆,不等debounce) + // 保存队列和播放位置(跨启动恢复) + saveSessionState() + + // 强制保存播放位置到媒体库(退出前记忆,不等debounce) saveCurrentPositionImmediate() player.pause() diff --git a/SourcesiOS/Info.plist b/SourcesiOS/Info.plist new file mode 100644 index 0000000..310008a --- /dev/null +++ b/SourcesiOS/Info.plist @@ -0,0 +1,54 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + MiniPlayer + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSPhotoLibraryAddUsageDescription + Save recorded videos to your photo library + NSPhotoLibraryUsageDescription + Access photo library to save recordings + +