iOS: PiP画中画、AirPlay投屏、录制保存弹窗、播放记录持久化恢复
This commit is contained in:
parent
ce5878957e
commit
bf7d3de9ba
@ -30,7 +30,8 @@ let package = Package(
|
|||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "MiniPlayeriOS",
|
name: "MiniPlayeriOS",
|
||||||
dependencies: ["MiniPlayerCore"],
|
dependencies: ["MiniPlayerCore"],
|
||||||
path: "SourcesiOS"
|
path: "SourcesiOS",
|
||||||
|
exclude: ["Info.plist"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
25
Sources/AirPlayButton.swift
Normal file
25
Sources/AirPlayButton.swift
Normal file
@ -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
|
||||||
@ -3,6 +3,7 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
import Photos
|
||||||
|
|
||||||
/// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止
|
/// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -266,30 +267,83 @@ final class HLSRecorder: ObservableObject {
|
|||||||
// MARK: - 导出/分享
|
// MARK: - 导出/分享
|
||||||
|
|
||||||
private func showExportSheet(url: URL) {
|
private func showExportSheet(url: URL) {
|
||||||
// 保存到相册(如果是视频)
|
NSLog("[MiniPlayer] showExportSheet: \(url.path)")
|
||||||
|
|
||||||
|
// 1. 保存到相册(使用 PHPhotoLibrary,有回调通知结果)
|
||||||
if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" {
|
if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" {
|
||||||
UISaveVideoAtPathToSavedPhotosAlbum(url.path, nil, nil, nil)
|
saveToPhotoLibrary(url: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同时弹出系统分享面板
|
// 2. 延迟弹出系统分享面板(等相册保存启动后再弹)
|
||||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
let rootVC = windowScene.windows.first?.rootViewController else { return }
|
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)
|
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||||
|
|
||||||
// iPad 适配
|
// iPad 适配
|
||||||
if let popover = activityVC.popoverPresentationController {
|
if let popover = activityVC.popoverPresentationController {
|
||||||
popover.sourceView = rootVC.view
|
popover.sourceView = vc.view
|
||||||
popover.sourceRect = CGRect(x: rootVC.view.bounds.midX, y: rootVC.view.bounds.midY, width: 0, height: 0)
|
popover.sourceRect = CGRect(x: vc.view.bounds.midX, y: vc.view.bounds.midY, width: 0, height: 0)
|
||||||
popover.permittedArrowDirections = []
|
popover.permittedArrowDirections = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找到最顶层的 VC
|
vc.present(activityVC, animated: true) {
|
||||||
var topVC = rootVC
|
NSLog("[MiniPlayer] Share sheet presented")
|
||||||
while let presented = topVC.presentedViewController {
|
|
||||||
topVC = presented
|
|
||||||
}
|
}
|
||||||
topVC.present(activityVC, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@ -70,6 +70,8 @@ public struct MiniPlayerApp: App {
|
|||||||
#endif
|
#endif
|
||||||
@StateObject private var bridge = PlayerBridge()
|
@StateObject private var bridge = PlayerBridge()
|
||||||
@StateObject private var engine = BricksEngine()
|
@StateObject private var engine = BricksEngine()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var hasRestored = false
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
@ -83,6 +85,29 @@ public struct MiniPlayerApp: App {
|
|||||||
bridge.setup()
|
bridge.setup()
|
||||||
setupBridgeEvents()
|
setupBridgeEvents()
|
||||||
loadI18n()
|
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)
|
#if os(macOS)
|
||||||
@ -215,6 +240,12 @@ public struct MiniPlayerApp: App {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
struct PlayerLayerView: NSViewRepresentable {
|
struct PlayerLayerView: NSViewRepresentable {
|
||||||
let player: AVPlayer
|
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 {
|
func makeNSView(context: Context) -> NSView {
|
||||||
let view = PlayerLayerHostView()
|
let view = PlayerLayerHostView()
|
||||||
@ -253,10 +284,21 @@ class PlayerLayerHostView: NSView {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
struct PlayerLayerView: UIViewRepresentable {
|
struct PlayerLayerView: UIViewRepresentable {
|
||||||
let player: AVPlayer
|
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 {
|
func makeUIView(context: Context) -> PlayerLayerHostViewiOS {
|
||||||
let view = PlayerLayerHostViewiOS()
|
let view = PlayerLayerHostViewiOS()
|
||||||
view.playerLayer.player = player
|
view.playerLayer.player = player
|
||||||
view.playerLayer.videoGravity = .resizeAspect
|
view.playerLayer.videoGravity = .resizeAspect
|
||||||
|
// 通知外部 playerLayer 已就绪(用于 PiP 初始化)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
onPlayerLayerReady?(view.playerLayer)
|
||||||
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) {
|
func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) {
|
||||||
@ -333,7 +375,12 @@ struct PlayerContentView: View {
|
|||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
// 直接用 AVPlayerLayer 渲染视频(彻底绕过 AVPlayerView 内置控制栏)
|
// 直接用 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)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.onTapGesture(count: 2) {
|
.onTapGesture(count: 2) {
|
||||||
bridge.toggleFullscreen()
|
bridge.toggleFullscreen()
|
||||||
@ -586,6 +633,20 @@ struct ControlToolbar: View {
|
|||||||
// 全屏
|
// 全屏
|
||||||
TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() }
|
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() }
|
TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() }
|
||||||
|
|
||||||
|
|||||||
94
Sources/PiPController.swift
Normal file
94
Sources/PiPController.swift
Normal file
@ -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
|
||||||
@ -3,6 +3,8 @@ import AVFoundation
|
|||||||
import AVKit
|
import AVKit
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
|
#elseif os(iOS)
|
||||||
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// 播放队列项
|
/// 播放队列项
|
||||||
@ -119,6 +121,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
let hlsRecorder = HLSRecorder()
|
let hlsRecorder = HLSRecorder()
|
||||||
|
let pipController = PiPController()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// MARK: - 初始化
|
// MARK: - 初始化
|
||||||
@ -126,10 +129,16 @@ final class PlayerBridge: ObservableObject {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// 设置为正常 GUI 应用,显示在 Dock 中
|
// 设置为正常 GUI 应用,显示在 Dock 中
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
#elseif os(iOS)
|
||||||
|
// iOS: 配置音频会话(支持后台播放 + PiP)
|
||||||
|
configureAudioSession()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
player.volume = volume
|
player.volume = volume
|
||||||
|
|
||||||
|
// 启用 AirPlay(iOS 和 macOS 通用)
|
||||||
|
player.allowsExternalPlayback = true
|
||||||
|
|
||||||
setupTimeObserver()
|
setupTimeObserver()
|
||||||
setupEndObserver()
|
setupEndObserver()
|
||||||
|
|
||||||
@ -152,6 +161,20 @@ final class PlayerBridge: ObservableObject {
|
|||||||
#endif
|
#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: - 录制
|
// MARK: - 录制
|
||||||
func toggleRecording() {
|
func toggleRecording() {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ -754,13 +777,104 @@ final class PlayerBridge: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - 播放状态(已改为在 updateTimeDisplay 中检查 player.rate)
|
// 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: - 清理
|
// MARK: - 清理
|
||||||
/// 主动清理所有观察者,在 view onDisappear 时调用
|
/// 主动清理所有观察者,在 view onDisappear 时调用
|
||||||
/// 必须在 PlayerBridge 被释放之前调用
|
/// 必须在 PlayerBridge 被释放之前调用
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
isTearingDown = true
|
isTearingDown = true
|
||||||
|
|
||||||
// 强制保存播放位置(退出前记忆,不等debounce)
|
// 保存队列和播放位置(跨启动恢复)
|
||||||
|
saveSessionState()
|
||||||
|
|
||||||
|
// 强制保存播放位置到媒体库(退出前记忆,不等debounce)
|
||||||
saveCurrentPositionImmediate()
|
saveCurrentPositionImmediate()
|
||||||
|
|
||||||
player.pause()
|
player.pause()
|
||||||
|
|||||||
54
SourcesiOS/Info.plist
Normal file
54
SourcesiOS/Info.plist
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>MiniPlayer</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>Save recorded videos to your photo library</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Access photo library to save recordings</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Loading…
x
Reference in New Issue
Block a user