iOS: PiP画中画、AirPlay投屏、录制保存弹窗、播放记录持久化恢复
This commit is contained in:
parent
ce5878957e
commit
bf7d3de9ba
@ -30,7 +30,8 @@ let package = Package(
|
||||
.executableTarget(
|
||||
name: "MiniPlayeriOS",
|
||||
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 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
|
||||
|
||||
@ -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() }
|
||||
|
||||
|
||||
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
|
||||
#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()
|
||||
|
||||
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