fix(iOS): simplify recorder to local file only, add app icon
- Reverted broken AVPlayer capture code back to simple local file copy + URLSession download - Stream recording shows clear error message recommending local files - Added Assets.car compilation and CFBundleIcons to Info.plist for app icon - Stopped wasting time on iOS stream capture — needs ffmpeg iOS static lib
This commit is contained in:
parent
1832672081
commit
d74543e3ed
@ -2,9 +2,8 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import MediaToolbox
|
||||
|
||||
/// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧 + 音频,写入 MP4
|
||||
/// iOS 录制器 — 录制本地文件,流媒体暂不支持(CBS 有 DRM 保护,需 ffmpeg iOS)
|
||||
@MainActor
|
||||
final class HLSRecorder: ObservableObject {
|
||||
@Published var isRecording = false
|
||||
@ -13,22 +12,14 @@ final class HLSRecorder: ObservableObject {
|
||||
var onRecordingSaved: ((URL) -> Void)?
|
||||
var onError: ((String) -> Void)?
|
||||
|
||||
private var assetWriter: AVAssetWriter?
|
||||
private var videoInput: AVAssetWriterInput?
|
||||
private var audioInput: AVAssetWriterInput?
|
||||
private var videoOutput: AVPlayerItemVideoOutput?
|
||||
private var displayLink: CADisplayLink?
|
||||
private var recordTask: Task<Void, Never>?
|
||||
private var startTime: Date?
|
||||
private var timer: Timer?
|
||||
private var outputURL: URL?
|
||||
private var firstPTS: CMTime?
|
||||
private var audioTapID: MTAudioProcessingTapID?
|
||||
|
||||
func startRecording(url: URL, player: AVPlayer) {
|
||||
guard !isRecording, let item = player.currentItem else { return }
|
||||
guard !isRecording else { return }
|
||||
isRecording = true
|
||||
startTime = Date()
|
||||
firstPTS = nil
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
@ -38,268 +29,80 @@ final class HLSRecorder: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
||||
recordTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let outputURL: URL
|
||||
if url.isFileURL {
|
||||
outputURL = try await self.copyLocalFile(url: url)
|
||||
} else {
|
||||
// 流媒体:当前 iOS 不支持直接录制 HLS 流
|
||||
// CBS 等有 DRM/CDN 保护,需 ffmpeg iOS 静态库
|
||||
// 先用 URLSession 下载尝试(仅对无保护的 HTTP 流有效)
|
||||
outputURL = try await self.downloadFile(url: url)
|
||||
}
|
||||
|
||||
Task { await startCapture(item: item, player: player) }
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||
let size = (attrs?[.size] as? Int64) ?? 0
|
||||
NSLog("[MiniPlayer] Recording complete: %@ (%lld bytes)", outputURL.path, size)
|
||||
|
||||
await MainActor.run {
|
||||
self.isRecording = false
|
||||
self.timer?.invalidate()
|
||||
if size > 1024 {
|
||||
self.onRecordingSaved?(outputURL)
|
||||
} else {
|
||||
self.onError?("Recording too small (\(size) bytes) — stream may be DRM-protected. Try recording a local file.")
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
|
||||
await MainActor.run {
|
||||
self.isRecording = false
|
||||
self.timer?.invalidate()
|
||||
self.onError?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
guard isRecording else { return }
|
||||
// 对于本地文件复制,立即完成;对于网络下载,取消任务
|
||||
recordTask?.cancel()
|
||||
isRecording = false
|
||||
timer?.invalidate()
|
||||
displayLink?.invalidate()
|
||||
videoOutput = nil
|
||||
|
||||
videoInput?.markAsFinished()
|
||||
audioInput?.markAsFinished()
|
||||
|
||||
assetWriter?.finishWriting { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, let url = self.outputURL else { return }
|
||||
let status = self.assetWriter?.status ?? .unknown
|
||||
NSLog("[MiniPlayer] AVAssetWriter finished: %d", status.rawValue)
|
||||
|
||||
if status == .completed {
|
||||
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||
let size = (attrs?[.size] as? Int64) ?? 0
|
||||
NSLog("[MiniPlayer] Recorded: %lld bytes", size)
|
||||
self.onRecordingSaved?(url)
|
||||
} else {
|
||||
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
|
||||
self.onError?("Export: \(err)")
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Capture setup
|
||||
// MARK: - 本地文件复制
|
||||
|
||||
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
|
||||
guard let outputURL else { return }
|
||||
private func copyLocalFile(url: URL) async throws -> URL {
|
||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
|
||||
|
||||
do {
|
||||
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
||||
|
||||
// Natural size from asset
|
||||
var size = CGSize(width: 1280, height: 720)
|
||||
if let tracks = try? await item.asset.load(.tracks),
|
||||
let videoTrack = tracks.first(where: { $0.mediaType == .video }) {
|
||||
let ns = try? await videoTrack.load(.naturalSize)
|
||||
if let ns, ns.width > 0 { size = ns }
|
||||
}
|
||||
|
||||
// Video input
|
||||
let videoSettings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: size.width,
|
||||
AVVideoHeightKey: size.height,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: 2_000_000,
|
||||
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
||||
]
|
||||
]
|
||||
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||||
vidInput.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(vidInput) else {
|
||||
throw NSError(domain: "Rec", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot add video input"])
|
||||
}
|
||||
writer.add(vidInput)
|
||||
self.videoInput = vidInput
|
||||
|
||||
// Audio input
|
||||
let audioSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 44100,
|
||||
AVNumberOfChannelsKey: 2,
|
||||
AVEncoderBitRateKey: 128_000
|
||||
]
|
||||
let audInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||||
audInput.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(audInput) else {
|
||||
throw NSError(domain: "Rec", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
|
||||
}
|
||||
writer.add(audInput)
|
||||
self.audioInput = audInput
|
||||
|
||||
// MTAudioProcessingTap on the player item's audio track
|
||||
installAudioTap(on: item)
|
||||
|
||||
// Video output from player
|
||||
let pixAttrs: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: size.width,
|
||||
kCVPixelBufferHeightKey as String: size.height
|
||||
]
|
||||
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: pixAttrs)
|
||||
vo.suppressesPlayerRendering = false
|
||||
item.add(vo)
|
||||
self.videoOutput = vo
|
||||
|
||||
writer.startWriting()
|
||||
// session start will be set on first frame
|
||||
self.assetWriter = writer
|
||||
|
||||
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
|
||||
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 15, maximum: 30, preferred: 30)
|
||||
displayLink?.add(to: .main, forMode: .common)
|
||||
|
||||
NSLog("[MiniPlayer] Capture started: %@", outputURL.path)
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isRecording = false
|
||||
self.timer?.invalidate()
|
||||
self.onError?(error.localizedDescription)
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: outputURL)
|
||||
return outputURL
|
||||
}
|
||||
|
||||
// MARK: - Audio tap
|
||||
// MARK: - 网络流下载(仅对无保护 HTTP 流有效)
|
||||
|
||||
private func installAudioTap(on item: AVPlayerItem) {
|
||||
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else {
|
||||
NSLog("[MiniPlayer] No audio track found")
|
||||
return
|
||||
private func downloadFile(url: URL) async throws -> URL {
|
||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
|
||||
|
||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
var callbacks = MTAudioProcessingTapCallbacks(
|
||||
version: kMTAudioProcessingTapCallbacksVersion_0,
|
||||
clientInfo: Unmanaged.passUnretained(self).toOpaque(),
|
||||
init: { tap, clientInfo, tapStorageOut in
|
||||
tapStorageOut?.pointee = clientInfo
|
||||
},
|
||||
finalize: { _ in },
|
||||
prepare: { _, _, _ in },
|
||||
unprepare: { _, _, _ in },
|
||||
process: { tap, numberFrames, flags, bufferListInOut, numberFramesOut, flagsOut in
|
||||
guard let clientInfo = MTAudioProcessingTapGetStorage(tap) else {
|
||||
numberFramesOut.pointee = 0
|
||||
return
|
||||
}
|
||||
let recorder = Unmanaged<HLSRecorder>.fromOpaque(clientInfo).takeUnretainedValue()
|
||||
recorder.processAudio(tap: tap, numberFrames: numberFrames, flags: flags,
|
||||
bufferListInOut: bufferListInOut,
|
||||
numberFramesOut: numberFramesOut, flagsOut: flagsOut)
|
||||
}
|
||||
)
|
||||
|
||||
var tap: Unmanaged<MTAudioProcessingTap>?
|
||||
let status = MTAudioProcessingTapCreate(kCFAllocatorDefault, &callbacks,
|
||||
kMTAudioProcessingTapCreationFlag_PreEffects, &tap)
|
||||
guard status == noErr, let audioTap = tap?.takeRetainedValue() else {
|
||||
NSLog("[MiniPlayer] MTAudioProcessingTapCreate failed: %d", status)
|
||||
return
|
||||
}
|
||||
|
||||
let inputParams = AVMutableAudioMixInputParameters(track: audioTrack)
|
||||
inputParams.audioTapProcessor = audioTap
|
||||
|
||||
let audioMix = AVMutableAudioMix()
|
||||
audioMix.inputParameters = [inputParams]
|
||||
item.audioMix = audioMix
|
||||
|
||||
NSLog("[MiniPlayer] Audio tap installed")
|
||||
}
|
||||
|
||||
private func processAudio(tap: MTAudioProcessingTap, numberFrames: CMItemCount, flags: MTAudioProcessingTapFlags,
|
||||
bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||||
numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||||
flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>) {
|
||||
// Get source audio
|
||||
var sourceFlags = flags
|
||||
var timeRange = CMTimeRange()
|
||||
let status = MTAudioProcessingTapGetSourceAudio(tap, numberFrames, bufferListInOut,
|
||||
flagsOut, &timeRange, numberFramesOut)
|
||||
guard status == noErr, let input = audioInput, input.isReadyForMoreMediaData else {
|
||||
flagsOut.pointee = flags
|
||||
return
|
||||
}
|
||||
|
||||
// Create CMSampleBuffer from the audio buffer list
|
||||
let pts = timeRange.start
|
||||
var asbd = AudioStreamBasicDescription()
|
||||
MTAudioProcessingTapGetStreamDescription(tap, &asbd)
|
||||
|
||||
var formatDesc: CMAudioFormatDescription?
|
||||
CMAudioFormatDescriptionCreate(allocator: kCFAllocatorDefault, asbd: &asbd,
|
||||
layoutSize: 0, layout: nil, 0, nil, nil,
|
||||
formatDescriptionOut: &formatDesc)
|
||||
guard let fd = formatDesc else { return }
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
let numSamples = numberFramesOut.pointee
|
||||
|
||||
var timing = CMSampleTimingInfo(duration: CMTime(value: CMTimeValue(numSamples), timescale: CMTimeScale(asbd.mSampleRate)),
|
||||
presentationTimeStamp: pts, decodeTimeStamp: .invalid)
|
||||
|
||||
CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false,
|
||||
makeDataReadyCallback: nil, refcon: nil, formatDescription: fd,
|
||||
sampleCount: numSamples, sampleTimingEntryCount: 1,
|
||||
sampleTimingArray: &timing, sampleSizeEntryCount: 0,
|
||||
sampleSizeArray: nil, sampleBufferOut: &sampleBuffer)
|
||||
|
||||
guard var sb = sampleBuffer else { return }
|
||||
|
||||
// Copy audio data into the sample buffer
|
||||
let bufList = bufferListInOut.pointee
|
||||
var blockBuffer: CMBlockBuffer?
|
||||
CMBlockBufferCreateWithMemoryBlock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
memoryBlock: nil,
|
||||
blockLength: Int(bufList.mBuffers.mDataByteSize),
|
||||
blockAllocator: kCFAllocatorDefault,
|
||||
customBlockSource: nil,
|
||||
offsetToData: 0,
|
||||
dataLength: Int(bufList.mBuffers.mDataByteSize),
|
||||
flags: 0,
|
||||
blockBufferOut: &blockBuffer
|
||||
)
|
||||
if let bb = blockBuffer {
|
||||
CMBlockBufferReplaceDataBytes(with: bufList.mBuffers.mData!, blockBuffer: bb,
|
||||
offsetIntoDestination: 0, dataLength: Int(bufList.mBuffers.mDataByteSize))
|
||||
CMSampleBufferSetDataBuffer(&sb, newValue: bb)
|
||||
input.append(sb)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Video frame capture
|
||||
|
||||
@objc private func captureFrame() {
|
||||
guard let writer = assetWriter, let vo = videoOutput, let vidInput = videoInput else { return }
|
||||
|
||||
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
|
||||
guard vo.hasNewPixelBuffer(forItemTime: itemTime),
|
||||
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil)
|
||||
else { return }
|
||||
|
||||
// Start session on first frame
|
||||
if writer.status == .unknown {
|
||||
writer.startSession(atSourceTime: itemTime)
|
||||
firstPTS = itemTime
|
||||
NSLog("[MiniPlayer] Session started at PTS: %.3f", itemTime.seconds)
|
||||
}
|
||||
|
||||
guard vidInput.isReadyForMoreMediaData else { return }
|
||||
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: 30),
|
||||
presentationTimeStamp: itemTime,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
var formatDesc: CMFormatDescription?
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault,
|
||||
imageBuffer: buf, formatDescriptionOut: &formatDesc)
|
||||
guard let fd = formatDesc else { return }
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: buf,
|
||||
formatDescription: fd, sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer)
|
||||
if let sb = sampleBuffer {
|
||||
vidInput.append(sb)
|
||||
}
|
||||
let (tempURL, _) = try await URLSession.shared.download(from: url)
|
||||
try FileManager.default.moveItem(at: tempURL, to: outputURL)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -2,65 +2,66 @@
|
||||
<!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>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon60x60</string>
|
||||
</array>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MiniPlayer</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIcons</key>
|
||||
<dict>
|
||||
<key>CFBundlePrimaryIcon</key>
|
||||
<dict>
|
||||
<key>CFBundleIconFiles</key>
|
||||
<array>
|
||||
<string>AppIcon60x60</string>
|
||||
<string>AppIcon60x60</string>
|
||||
</array>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<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>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>
|
||||
<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>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user