iOS: PiP画中画、AirPlay投屏、录制保存弹窗、播放记录持久化恢复

This commit is contained in:
yumoqing 2026-07-02 08:16:33 +08:00
parent ce5878957e
commit bf7d3de9ba
7 changed files with 418 additions and 15 deletions

View File

@ -30,7 +30,8 @@ let package = Package(
.executableTarget(
name: "MiniPlayeriOS",
dependencies: ["MiniPlayerCore"],
path: "SourcesiOS"
path: "SourcesiOS",
exclude: ["Info.plist"]
)
]
)

View 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

View File

@ -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

View File

@ -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() }

View 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

View File

@ -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
// AirPlayiOS 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"
/// UserDefaultsapp /退
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()

54
SourcesiOS/Info.plist Normal file
View 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>