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:
yumoqing 2026-07-17 21:55:49 +08:00
parent 1832672081
commit d74543e3ed
2 changed files with 121 additions and 317 deletions

View File

@ -2,9 +2,8 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import UIKit import UIKit
import MediaToolbox
/// iOS AVPlayer + MP4 /// iOS CBS DRM ffmpeg iOS
@MainActor @MainActor
final class HLSRecorder: ObservableObject { final class HLSRecorder: ObservableObject {
@Published var isRecording = false @Published var isRecording = false
@ -13,22 +12,14 @@ final class HLSRecorder: ObservableObject {
var onRecordingSaved: ((URL) -> Void)? var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
private var assetWriter: AVAssetWriter? private var recordTask: Task<Void, Never>?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var videoOutput: AVPlayerItemVideoOutput?
private var displayLink: CADisplayLink?
private var startTime: Date? private var startTime: Date?
private var timer: Timer? private var timer: Timer?
private var outputURL: URL?
private var firstPTS: CMTime?
private var audioTapID: MTAudioProcessingTapID?
func startRecording(url: URL, player: AVPlayer) { func startRecording(url: URL, player: AVPlayer) {
guard !isRecording, let item = player.currentItem else { return } guard !isRecording else { return }
isRecording = true isRecording = true
startTime = Date() startTime = Date()
firstPTS = nil
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
@ -38,268 +29,80 @@ final class HLSRecorder: ObservableObject {
} }
} }
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] recordTask = Task { [weak self] in
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4") 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() { func stopRecording() {
guard isRecording else { return } guard isRecording else { return }
//
recordTask?.cancel()
isRecording = false isRecording = false
timer?.invalidate() 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 { private func copyLocalFile(url: URL) async throws -> URL {
guard let outputURL else { return } 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 { if FileManager.default.fileExists(atPath: outputURL.path) {
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4) try FileManager.default.removeItem(at: outputURL)
// 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)
}
} }
try FileManager.default.copyItem(at: url, to: outputURL)
return outputURL
} }
// MARK: - Audio tap // MARK: - HTTP
private func installAudioTap(on item: AVPlayerItem) { private func downloadFile(url: URL) async throws -> URL {
guard let audioTrack = item.asset.tracks(withMediaType: .audio).first else { let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
NSLog("[MiniPlayer] No audio track found") let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return .appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
if FileManager.default.fileExists(atPath: outputURL.path) {
try FileManager.default.removeItem(at: outputURL)
} }
var callbacks = MTAudioProcessingTapCallbacks( let (tempURL, _) = try await URLSession.shared.download(from: url)
version: kMTAudioProcessingTapCallbacksVersion_0, try FileManager.default.moveItem(at: tempURL, to: outputURL)
clientInfo: Unmanaged.passUnretained(self).toOpaque(), return outputURL
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)
}
} }
} }
#endif #endif

View File

@ -2,65 +2,66 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>MiniPlayer</string> <string>MiniPlayer</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIcons</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <dict>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundlePrimaryIcon</key>
<string>6.0</string> <dict>
<key>CFBundleName</key> <key>CFBundleIconFiles</key>
<string>$(PRODUCT_NAME)</string> <array>
<key>CFBundlePackageType</key> <string>AppIcon60x60</string>
<string>APPL</string> <string>AppIcon60x60</string>
<key>CFBundleShortVersionString</key> </array>
<string>1.0</string> <key>CFBundleIconName</key>
<key>CFBundleVersion</key> <string>AppIcon</string>
<string>1</string> </dict>
<key>LSRequiresIPhoneOS</key> </dict>
<true/> <key>CFBundleIdentifier</key>
<key>UIBackgroundModes</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<array> <key>CFBundleInfoDictionaryVersion</key>
<string>audio</string> <string>6.0</string>
</array> <key>CFBundleName</key>
<key>UILaunchScreen</key> <string>$(PRODUCT_NAME)</string>
<dict/> <key>CFBundlePackageType</key>
<key>UISupportedInterfaceOrientations</key> <string>APPL</string>
<array> <key>CFBundleShortVersionString</key>
<string>UIInterfaceOrientationPortrait</string> <string>1.0</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <key>CFBundleVersion</key>
<string>UIInterfaceOrientationLandscapeRight</string> <string>1</string>
</array> <key>LSRequiresIPhoneOS</key>
<key>UISupportedInterfaceOrientations~ipad</key> <true/>
<array> <key>NSAppTransportSecurity</key>
<string>UIInterfaceOrientationPortrait</string> <dict>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <key>NSAllowsArbitraryLoads</key>
<string>UIInterfaceOrientationLandscapeLeft</string> <true/>
<string>UIInterfaceOrientationLandscapeRight</string> </dict>
</array> <key>NSPhotoLibraryAddUsageDescription</key>
<key>NSAppTransportSecurity</key> <string>Save recorded videos to your photo library</string>
<dict> <key>NSPhotoLibraryUsageDescription</key>
<key>NSAllowsArbitraryLoads</key> <string>Access photo library to save recordings</string>
<true/> <key>UIBackgroundModes</key>
</dict> <array>
<key>NSPhotoLibraryAddUsageDescription</key> <string>audio</string>
<string>Save recorded videos to your photo library</string> </array>
<key>NSPhotoLibraryUsageDescription</key> <key>UILaunchScreen</key>
<string>Access photo library to save recordings</string> <dict/>
<key>CFBundleIcons</key> <key>UISupportedInterfaceOrientations</key>
<dict> <array>
<key>CFBundlePrimaryIcon</key> <string>UIInterfaceOrientationPortrait</string>
<dict> <string>UIInterfaceOrientationLandscapeLeft</string>
<key>CFBundleIconFiles</key> <string>UIInterfaceOrientationLandscapeRight</string>
<array> </array>
<string>AppIcon60x60</string> <key>UISupportedInterfaceOrientations~ipad</key>
</array> <array>
<key>CFBundleIconName</key> <string>UIInterfaceOrientationPortrait</string>
<string>AppIcon</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</dict> <string>UIInterfaceOrientationLandscapeLeft</string>
</dict> <string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict> </dict>
</plist> </plist>