feat(iOS): replace HLS segment download with AVPlayer screen capture
Manual HLS segment download fails for CBS streams (DRM/CDN protection returns 438-byte placeholder responses). New approach captures video frames directly from AVPlayer via AVPlayerItemVideoOutput + AVAssetWriter. - AVPlayerItemVideoOutput captures pixel buffers during playback - AVAssetWriter encodes to H.264/AAC MP4 - CADisplayLink drives capture at 30fps - Works with any content AVPlayer can play (HLS, local files, SMB)
This commit is contained in:
parent
d0cecebe69
commit
bfe81cbc82
@ -2,10 +2,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
/// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止
|
/// iOS 录制器 — 从 AVPlayer 播放输出捕获视频帧和音频,写入 MP4
|
||||||
|
/// 替代手动下载 HLS 片段的方案(对 CBS 等有 DRM/CDN 保护的流无效)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HLSRecorder: ObservableObject {
|
final class HLSRecorder: ObservableObject {
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@ -14,337 +14,195 @@ final class HLSRecorder: ObservableObject {
|
|||||||
var onRecordingSaved: ((URL) -> Void)?
|
var onRecordingSaved: ((URL) -> Void)?
|
||||||
var onError: ((String) -> Void)?
|
var onError: ((String) -> Void)?
|
||||||
|
|
||||||
private var recordTask: Task<Void, Never>?
|
private var assetWriter: AVAssetWriter?
|
||||||
|
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 stopRequested = false
|
private var outputURL: URL?
|
||||||
|
private var lastVideoSampleTime: CMTime?
|
||||||
|
private var audioEngine: AVAudioEngine?
|
||||||
|
private var audioFile: AVAudioFile?
|
||||||
|
private var audioTapInstalled = false
|
||||||
|
|
||||||
func startRecording(url: URL) {
|
func startRecording(url: URL, player: AVPlayer) {
|
||||||
guard !isRecording else { return }
|
guard !isRecording, let item = player.currentItem else { return }
|
||||||
isRecording = true
|
isRecording = true
|
||||||
stopRequested = false
|
|
||||||
startTime = Date()
|
startTime = Date()
|
||||||
|
lastVideoSampleTime = nil
|
||||||
|
|
||||||
|
// Timer for duration display
|
||||||
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
|
||||||
guard let self, let start = self.startTime else { return }
|
guard let self, let start = self.startTime else { return }
|
||||||
let elapsed = Int(Date().timeIntervalSince(start))
|
let elapsed = Int(Date().timeIntervalSince(start))
|
||||||
self.durationText = String(format: "%02d:%02d:%02d", elapsed/3600, (elapsed%3600)/60, elapsed%60)
|
self.durationText = String(format: "%02d:%02d:%02d", elapsed / 3600, (elapsed % 3600) / 60, elapsed % 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordTask = Task {
|
// Output file
|
||||||
do {
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
let outputURL = try await recordStream(url: url)
|
outputURL = docs.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
||||||
NSLog("[MiniPlayer] recordStream completed: %@, exists: %d", outputURL.path, FileManager.default.fileExists(atPath: outputURL.path))
|
|
||||||
let attrs = try? FileManager.default.attributesOfItem(atPath: outputURL.path)
|
Task {
|
||||||
NSLog("[MiniPlayer] Output file size: %lld", (attrs?[.size] as? Int64) ?? 0)
|
await startCapture(item: item, player: player)
|
||||||
await MainActor.run {
|
|
||||||
self.isRecording = false
|
|
||||||
self.timer?.invalidate()
|
|
||||||
NSLog("[MiniPlayer] Calling onRecordingSaved...")
|
|
||||||
self.onRecordingSaved?(outputURL)
|
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
|
||||||
NSLog("[MiniPlayer] Recording task cancelled")
|
|
||||||
// User stopped
|
|
||||||
await MainActor.run {
|
|
||||||
self.isRecording = false
|
|
||||||
self.timer?.invalidate()
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
|
|
||||||
await MainActor.run {
|
|
||||||
self.isRecording = false
|
|
||||||
self.timer?.invalidate()
|
|
||||||
self.onError?("Recording error: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
NSLog("[MiniPlayer] stopRecording called, stopRequested → true")
|
guard isRecording else { return }
|
||||||
// 不 cancel 任务,只设 stopRequested 让轮询循环正常退出
|
isRecording = false
|
||||||
// 任务会合并已下载片段 → MP4 → 弹出分享面板
|
timer?.invalidate()
|
||||||
stopRequested = true
|
displayLink?.invalidate()
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 核心录制逻辑
|
// Finalize the writer
|
||||||
|
videoInput?.markAsFinished()
|
||||||
|
audioInput?.markAsFinished()
|
||||||
|
|
||||||
private func recordStream(url: URL) async throws -> URL {
|
assetWriter?.finishWriting { [weak self] in
|
||||||
// 本地文件直接复制,不走网络下载
|
DispatchQueue.main.async {
|
||||||
if url.isFileURL {
|
guard let self, let url = self.outputURL else { return }
|
||||||
NSLog("[MiniPlayer] recordStream: local file, copying directly")
|
let status = self.assetWriter?.status ?? .unknown
|
||||||
return try await copyLocalFile(url: url)
|
NSLog("[MiniPlayer] AVAssetWriter finished: %d, file: %@", status.rawValue, url.path)
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否为 HLS(m3u8 扩展名或 URL 含 m3u8 参数)
|
if status == .completed {
|
||||||
let ext = url.pathExtension.lowercased()
|
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
let isHLS = ext == "m3u8"
|
let size = (attrs?[.size] as? Int64) ?? 0
|
||||||
|| url.absoluteString.contains(".m3u8")
|
NSLog("[MiniPlayer] Recorded file size: %lld bytes", size)
|
||||||
|| ext == "m3u"
|
self.onRecordingSaved?(url)
|
||||||
|
} else {
|
||||||
if isHLS {
|
let err = self.assetWriter?.error?.localizedDescription ?? "unknown"
|
||||||
return try await recordHLS(url: url)
|
self.onError?("Export failed: \(err)")
|
||||||
} else {
|
try? FileManager.default.removeItem(at: url)
|
||||||
// 先尝试 HEAD 请求判断 Content-Type
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.httpMethod = "HEAD"
|
|
||||||
if let (_, resp) = try? await URLSession.shared.data(for: req),
|
|
||||||
let httpResp = resp as? HTTPURLResponse,
|
|
||||||
let ct = httpResp.allHeaderFields["Content-Type"] as? String,
|
|
||||||
ct.contains("mpegurl") || ct.contains("m3u8") || ct.contains("apple.mpegurl") {
|
|
||||||
return try await recordHLS(url: url)
|
|
||||||
}
|
|
||||||
return try await downloadFile(url: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - HLS 持续录制(轮询 m3u8 + 增量下载片段)
|
|
||||||
|
|
||||||
private func recordHLS(url: URL) async throws -> URL {
|
|
||||||
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
|
||||||
|
|
||||||
// 1. 解析 master playlist
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.setValue(ua, forHTTPHeaderField: "User-Agent")
|
|
||||||
let (data, _) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let playlist = String(data: data, encoding: .utf8) else {
|
|
||||||
throw RecorderError.invalidPlaylist
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 如果是 master playlist,获取 variant
|
|
||||||
let mediaPlaylistURL: URL
|
|
||||||
let lines = playlist.components(separatedBy: .newlines)
|
|
||||||
var isMaster = false
|
|
||||||
var variantURL: URL?
|
|
||||||
for line in lines {
|
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
||||||
if trimmed.hasPrefix("#EXT-X-STREAM-INF") { isMaster = true }
|
|
||||||
if isMaster && !trimmed.hasPrefix("#") && !trimmed.isEmpty {
|
|
||||||
variantURL = resolveURL(trimmed, baseURL: url)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let vURL = variantURL {
|
|
||||||
mediaPlaylistURL = vURL
|
|
||||||
} else {
|
|
||||||
mediaPlaylistURL = url
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 持续轮询 m3u8 下载新片段
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
|
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
var downloadedSegments: [URL] = []
|
|
||||||
var seenSegmentURLs: Set<String> = []
|
|
||||||
var segmentIndex = 0
|
|
||||||
var consecutiveErrors = 0
|
|
||||||
let maxConsecutiveErrors = 10
|
|
||||||
|
|
||||||
while !stopRequested {
|
|
||||||
// 拉取最新的 m3u8
|
|
||||||
var plReq = URLRequest(url: mediaPlaylistURL)
|
|
||||||
plReq.setValue(ua, forHTTPHeaderField: "User-Agent")
|
|
||||||
guard let (plData, _) = try? await URLSession.shared.data(for: plReq),
|
|
||||||
let plText = String(data: plData, encoding: .utf8) else {
|
|
||||||
consecutiveErrors += 1
|
|
||||||
if consecutiveErrors >= maxConsecutiveErrors {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
consecutiveErrors = 0
|
|
||||||
|
|
||||||
// 解析片段列表
|
|
||||||
let plLines = plText.components(separatedBy: .newlines)
|
|
||||||
let baseURL = mediaPlaylistURL.deletingLastPathComponent()
|
|
||||||
var newSegments: [URL] = []
|
|
||||||
var isLive = true
|
|
||||||
|
|
||||||
for line in plLines {
|
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
||||||
if trimmed == "#EXT-X-ENDLIST" {
|
|
||||||
isLive = false
|
|
||||||
}
|
|
||||||
if !trimmed.hasPrefix("#") && !trimmed.isEmpty {
|
|
||||||
let segURL = resolveURL(trimmed, baseURL: baseURL)
|
|
||||||
if !seenSegmentURLs.contains(segURL.absoluteString) {
|
|
||||||
seenSegmentURLs.insert(segURL.absoluteString)
|
|
||||||
newSegments.append(segURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 下载新片段
|
// MARK: - Capture
|
||||||
for segURL in newSegments {
|
|
||||||
guard !stopRequested else { break }
|
private func startCapture(item: AVPlayerItem, player: AVPlayer) async {
|
||||||
do {
|
guard let outputURL else { return }
|
||||||
var req = URLRequest(url: segURL)
|
|
||||||
req.setValue("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", forHTTPHeaderField: "User-Agent")
|
do {
|
||||||
let (segData, resp) = try await URLSession.shared.data(for: req)
|
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
||||||
let httpResp = resp as? HTTPURLResponse
|
|
||||||
NSLog("[MiniPlayer] Segment %d: %d bytes, HTTP %d", segmentIndex, segData.count, httpResp?.statusCode ?? 0)
|
// Video input: use source format from the asset when possible
|
||||||
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%06d", segmentIndex)).ts")
|
let videoSettings: [String: Any]
|
||||||
try segData.write(to: localFile)
|
let naturalSize = try? await item.asset.load(.tracks).first(where: { $0.mediaType == .video })?.naturalSize
|
||||||
downloadedSegments.append(localFile)
|
let size = naturalSize ?? CGSize(width: 1280, height: 720)
|
||||||
segmentIndex += 1
|
|
||||||
} catch {
|
videoSettings = [
|
||||||
NSLog("[MiniPlayer] Segment download failed: %@", error.localizedDescription)
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||||
continue
|
AVVideoWidthKey: size.width,
|
||||||
|
AVVideoHeightKey: size.height,
|
||||||
|
AVVideoCompressionPropertiesKey: [
|
||||||
|
AVVideoAverageBitRateKey: 2_000_000,
|
||||||
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
let vidInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||||||
|
vidInput.expectsMediaDataInRealTime = true
|
||||||
|
vidInput.transform = CGAffineTransform(translationX: 0, y: 0)
|
||||||
|
guard writer.canAdd(vidInput) else {
|
||||||
|
throw NSError(domain: "Recorder", 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: "Recorder", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add audio input"])
|
||||||
|
}
|
||||||
|
writer.add(audInput)
|
||||||
|
self.audioInput = audInput
|
||||||
|
|
||||||
|
// Start writing
|
||||||
|
writer.startWriting()
|
||||||
|
writer.startSession(atSourceTime: .zero)
|
||||||
|
self.assetWriter = writer
|
||||||
|
|
||||||
|
// Video output from player
|
||||||
|
let vo = AVPlayerItemVideoOutput(pixelBufferAttributes: [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||||
|
kCVPixelBufferWidthKey as String: size.width,
|
||||||
|
kCVPixelBufferHeightKey as String: size.height
|
||||||
|
])
|
||||||
|
vo.suppressesPlayerRendering = false
|
||||||
|
item.add(vo)
|
||||||
|
self.videoOutput = vo
|
||||||
|
|
||||||
|
// Capture loop via CADisplayLink
|
||||||
|
displayLink = CADisplayLink(target: self, selector: #selector(captureFrame))
|
||||||
|
displayLink?.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 30, preferred: 30)
|
||||||
|
displayLink?.add(to: .main, forMode: .common)
|
||||||
|
|
||||||
|
NSLog("[MiniPlayer] Capture started, output: %@", outputURL.path)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
self.isRecording = false
|
||||||
|
self.timer?.invalidate()
|
||||||
|
self.onError?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func captureFrame() {
|
||||||
|
guard let writer = assetWriter, writer.status == .writing else { return }
|
||||||
|
|
||||||
|
// Video frame capture
|
||||||
|
if let vo = videoOutput, videoInput?.isReadyForMoreMediaData == true {
|
||||||
|
let itemTime = vo.itemTime(forHostTime: CACurrentMediaTime())
|
||||||
|
if vo.hasNewPixelBuffer(forItemTime: itemTime),
|
||||||
|
let buf = vo.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
||||||
|
if lastVideoSampleTime == nil {
|
||||||
|
lastVideoSampleTime = itemTime
|
||||||
|
}
|
||||||
|
let sampleBuf = createSampleBuffer(from: buf, pts: itemTime)
|
||||||
|
if let sb = sampleBuf {
|
||||||
|
videoInput?.append(sb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是 VOD(已播完)或者 stop 了且有片段
|
|
||||||
if !isLive && newSegments.isEmpty {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if stopRequested && !downloadedSegments.isEmpty {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
|
||||||
if !stopRequested {
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
// sleep 期间 stopRequested 可能已变为 true
|
|
||||||
if stopRequested { break }
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !downloadedSegments.isEmpty else {
|
|
||||||
NSLog("[MiniPlayer] Recording stopped with 0 segments")
|
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
|
||||||
throw RecorderError.noSegments
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog("[MiniPlayer] Recording loop exited: %d segments downloaded", downloadedSegments.count)
|
|
||||||
|
|
||||||
// 4. 合并所有 TS 片段(用 Data 拼接,避免 FileHandle(forWritingTo:) 不会创建文件的坑)
|
|
||||||
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
|
||||||
var mergedData = Data()
|
|
||||||
for segFile in downloadedSegments {
|
|
||||||
mergedData.append(try Data(contentsOf: segFile))
|
|
||||||
}
|
|
||||||
try mergedData.write(to: mergedTS)
|
|
||||||
NSLog("[MiniPlayer] Merged %d segments → %@ (%d bytes)", downloadedSegments.count, mergedTS.path, mergedData.count)
|
|
||||||
|
|
||||||
// 5. TS → MP4(内部有超时保护)
|
|
||||||
let outputURL = try await remuxToMP4(tsURL: mergedTS)
|
|
||||||
NSLog("[MiniPlayer] Remux complete: %@", outputURL.path)
|
|
||||||
|
|
||||||
// 6. 清理临时文件
|
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
|
||||||
|
|
||||||
return outputURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 本地文件复制 / 网络下载
|
/// Create a CMSampleBuffer from a CVPixelBuffer (needed because AVAssetWriterInput
|
||||||
|
/// expects CMSampleBuffer but AVPlayerItemVideoOutput gives CVPixelBuffer directly)
|
||||||
|
private func createSampleBuffer(from pixelBuffer: CVPixelBuffer, pts: CMTime) -> CMSampleBuffer? {
|
||||||
|
var sampleBuffer: CMSampleBuffer?
|
||||||
|
var timingInfo = CMSampleTimingInfo(
|
||||||
|
duration: CMTime(value: 1, timescale: 30),
|
||||||
|
presentationTimeStamp: pts,
|
||||||
|
decodeTimeStamp: .invalid
|
||||||
|
)
|
||||||
|
var formatDescription: CMFormatDescription?
|
||||||
|
CMVideoFormatDescriptionCreateForImageBuffer(
|
||||||
|
allocator: kCFAllocatorDefault,
|
||||||
|
imageBuffer: pixelBuffer,
|
||||||
|
formatDescriptionOut: &formatDescription
|
||||||
|
)
|
||||||
|
guard let fd = formatDescription else { return nil }
|
||||||
|
|
||||||
private func copyLocalFile(url: URL) async throws -> URL {
|
CMSampleBufferCreateReadyWithImageBuffer(
|
||||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
allocator: kCFAllocatorDefault,
|
||||||
let outputURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
imageBuffer: pixelBuffer,
|
||||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).\(ext)")
|
formatDescription: fd,
|
||||||
|
sampleTiming: &timingInfo,
|
||||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
sampleBufferOut: &sampleBuffer
|
||||||
try FileManager.default.removeItem(at: outputURL)
|
)
|
||||||
}
|
return sampleBuffer
|
||||||
try FileManager.default.copyItem(at: url, to: outputURL)
|
|
||||||
NSLog("[MiniPlayer] copyLocalFile: %@ → %@ (%lld bytes)",
|
|
||||||
url.lastPathComponent, outputURL.lastPathComponent,
|
|
||||||
(try? FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? Int64) ?? 0)
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
|
|
||||||
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)")
|
|
||||||
|
|
||||||
// 使用 delegate 模式支持取消
|
|
||||||
let (tempURL, _) = try await URLSession.shared.download(from: url)
|
|
||||||
|
|
||||||
// 移动到 Documents
|
|
||||||
if FileManager.default.fileExists(atPath: outputURL.path) {
|
|
||||||
try FileManager.default.removeItem(at: outputURL)
|
|
||||||
}
|
|
||||||
try FileManager.default.moveItem(at: tempURL, to: outputURL)
|
|
||||||
|
|
||||||
return outputURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Remux TS → MP4
|
|
||||||
|
|
||||||
private func remuxToMP4(tsURL: URL) async throws -> URL {
|
|
||||||
let outputMP4 = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
||||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
|
||||||
|
|
||||||
let asset = AVURLAsset(url: tsURL)
|
|
||||||
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
|
||||||
NSLog("[MiniPlayer] AVAssetExportSession creation failed, fallback to TS")
|
|
||||||
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
|
||||||
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
|
||||||
return tsOutput
|
|
||||||
}
|
|
||||||
|
|
||||||
exportSession.outputURL = outputMP4
|
|
||||||
exportSession.outputFileType = .mp4
|
|
||||||
|
|
||||||
NSLog("[MiniPlayer] Starting remux export...")
|
|
||||||
|
|
||||||
// 用 TaskGroup 实现超时:export + 120s timer
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
await exportSession.export()
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(nanoseconds: 120_000_000_000)
|
|
||||||
exportSession.cancelExport()
|
|
||||||
throw RecorderError.exportFailed("Remux timed out after 120s")
|
|
||||||
}
|
|
||||||
// 等待第一个完成
|
|
||||||
try await group.next()
|
|
||||||
group.cancelAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
if exportSession.status != .completed {
|
|
||||||
let errMsg = exportSession.error?.localizedDescription ?? "unknown"
|
|
||||||
NSLog("[MiniPlayer] Export failed: status=%d, error=%@", exportSession.status.rawValue, errMsg)
|
|
||||||
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
|
||||||
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
|
||||||
return tsOutput
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog("[MiniPlayer] Export completed: %@", outputMP4.lastPathComponent)
|
|
||||||
return outputMP4
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func resolveURL(_ urlString: String, baseURL: URL) -> URL {
|
|
||||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
|
||||||
return URL(string: urlString)!
|
|
||||||
}
|
|
||||||
return baseURL.appendingPathComponent(urlString)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RecorderError: LocalizedError {
|
|
||||||
case invalidPlaylist
|
|
||||||
case noSegments
|
|
||||||
case exportFailed(String)
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .invalidPlaylist: return "Cannot parse HLS playlist"
|
|
||||||
case .noSegments: return "No segments found in playlist"
|
|
||||||
case .exportFailed(let msg): return "Export failed: \(msg)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -254,7 +254,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let item = queue[currentIndex]
|
let item = queue[currentIndex]
|
||||||
hlsRecorder.startRecording(url: item.url)
|
hlsRecorder.startRecording(url: item.url, player: player)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user