fix(iOS): rewrite HLSRecorder for continuous recording until user stops
- Poll m3u8 every 2s to download new segments (supports live HLS) - For non-HLS URLs, download entire file - Accumulate TS segments incrementally, merge on stop - Remux TS→MP4 via AVAssetExportSession - Show system share sheet + save to Photos on completion - Proper cancellation support via Task.cancel()
This commit is contained in:
parent
44708f39ef
commit
88f1ecbe9c
@ -2,8 +2,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
/// iOS HLS 录制器 — 下载 HLS 流片段并合并为 MP4
|
/// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HLSRecorder: ObservableObject {
|
final class HLSRecorder: ObservableObject {
|
||||||
@Published var isRecording = false
|
@Published var isRecording = false
|
||||||
@ -15,10 +16,12 @@ final class HLSRecorder: ObservableObject {
|
|||||||
private var recordTask: Task<Void, Never>?
|
private var recordTask: Task<Void, Never>?
|
||||||
private var startTime: Date?
|
private var startTime: Date?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
|
private var stopRequested = false
|
||||||
|
|
||||||
func startRecording(url: URL) {
|
func startRecording(url: URL) {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
isRecording = true
|
isRecording = true
|
||||||
|
stopRequested = false
|
||||||
startTime = Date()
|
startTime = Date()
|
||||||
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
@ -31,37 +34,62 @@ final class HLSRecorder: ObservableObject {
|
|||||||
|
|
||||||
recordTask = Task {
|
recordTask = Task {
|
||||||
do {
|
do {
|
||||||
let outputURL = try await downloadAndMergeHLS(url: url)
|
let outputURL = try await recordStream(url: url)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
self.onRecordingSaved?(outputURL)
|
self.onRecordingSaved?(outputURL)
|
||||||
|
self.showExportSheet(url: outputURL)
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
// User stopped
|
||||||
|
await MainActor.run {
|
||||||
|
self.isRecording = false
|
||||||
|
self.timer?.invalidate()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
self.onError?(error.localizedDescription)
|
self.onError?("Recording error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
|
stopRequested = true
|
||||||
recordTask?.cancel()
|
recordTask?.cancel()
|
||||||
recordTask = nil
|
recordTask = nil
|
||||||
isRecording = false
|
isRecording = false
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadAndMergeHLS(url: URL) async throws -> URL {
|
// MARK: - 核心录制逻辑
|
||||||
// 1. Download m3u8
|
|
||||||
|
private func recordStream(url: URL) async throws -> URL {
|
||||||
|
// 判断是否为 HLS
|
||||||
|
let isHLS = url.pathExtension.lowercased() == "m3u8"
|
||||||
|
|| url.absoluteString.contains(".m3u8")
|
||||||
|
|
||||||
|
if isHLS {
|
||||||
|
return try await recordHLS(url: url)
|
||||||
|
} else {
|
||||||
|
return try await downloadFile(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HLS 持续录制(轮询 m3u8 + 增量下载片段)
|
||||||
|
|
||||||
|
private func recordHLS(url: URL) async throws -> URL {
|
||||||
|
// 1. 解析 master playlist
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
guard let playlist = String(data: data, encoding: .utf8) else {
|
guard let playlist = String(data: data, encoding: .utf8) else {
|
||||||
throw RecorderError.invalidPlaylist
|
throw RecorderError.invalidPlaylist
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check if master playlist - if so, pick first variant
|
// 2. 如果是 master playlist,获取 variant
|
||||||
|
let mediaPlaylistURL: URL
|
||||||
let lines = playlist.components(separatedBy: .newlines)
|
let lines = playlist.components(separatedBy: .newlines)
|
||||||
var isMaster = false
|
var isMaster = false
|
||||||
var variantURL: URL?
|
var variantURL: URL?
|
||||||
@ -74,77 +102,183 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If master, re-download variant playlist
|
|
||||||
let mediaPlaylist: String
|
|
||||||
let baseURL: URL
|
|
||||||
if let vURL = variantURL {
|
if let vURL = variantURL {
|
||||||
let (vData, _) = try await URLSession.shared.data(from: vURL)
|
mediaPlaylistURL = vURL
|
||||||
guard let vStr = String(data: vData, encoding: .utf8) else { throw RecorderError.invalidPlaylist }
|
|
||||||
mediaPlaylist = vStr
|
|
||||||
baseURL = vURL.deletingLastPathComponent()
|
|
||||||
} else {
|
} else {
|
||||||
mediaPlaylist = playlist
|
mediaPlaylistURL = url
|
||||||
baseURL = url.deletingLastPathComponent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Parse segment URLs
|
// 3. 持续轮询 m3u8 下载新片段
|
||||||
let mLines = mediaPlaylist.components(separatedBy: .newlines)
|
|
||||||
var segmentURLs: [URL] = []
|
|
||||||
for line in mLines {
|
|
||||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
||||||
if !trimmed.hasPrefix("#") && !trimmed.isEmpty {
|
|
||||||
segmentURLs.append(resolveURL(trimmed, baseURL: baseURL))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !segmentURLs.isEmpty else { throw RecorderError.noSegments }
|
|
||||||
|
|
||||||
// 4. Download all segments to temp dir
|
|
||||||
let tempDir = FileManager.default.temporaryDirectory
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
|
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
var segmentFiles: [URL] = []
|
var downloadedSegments: [URL] = []
|
||||||
for (i, segURL) in segmentURLs.enumerated() {
|
var seenSegmentURLs: Set<String> = []
|
||||||
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%04d", i)).ts")
|
var segmentIndex = 0
|
||||||
let (segData, _) = try await URLSession.shared.data(from: segURL)
|
var consecutiveErrors = 0
|
||||||
try segData.write(to: localFile)
|
let maxConsecutiveErrors = 10
|
||||||
segmentFiles.append(localFile)
|
|
||||||
|
while !stopRequested {
|
||||||
|
// 拉取最新的 m3u8
|
||||||
|
guard let (plData, _) = try? await URLSession.shared.data(from: mediaPlaylistURL),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载新片段
|
||||||
|
for segURL in newSegments {
|
||||||
|
guard !stopRequested else { break }
|
||||||
|
do {
|
||||||
|
let (segData, _) = try await URLSession.shared.data(from: segURL)
|
||||||
|
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%06d", segmentIndex)).ts")
|
||||||
|
try segData.write(to: localFile)
|
||||||
|
downloadedSegments.append(localFile)
|
||||||
|
segmentIndex += 1
|
||||||
|
} catch {
|
||||||
|
// 单个片段下载失败不中断
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是 VOD(已播完)或者已有足够片段且 stop 了
|
||||||
|
if !isLive && newSegments.isEmpty {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
||||||
|
if !stopRequested {
|
||||||
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Merge segments into single MPEG-TS file
|
guard !downloadedSegments.isEmpty else {
|
||||||
|
try? FileManager.default.removeItem(at: tempDir)
|
||||||
|
throw RecorderError.noSegments
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 合并所有 TS 片段
|
||||||
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
||||||
let output = try FileHandle(forWritingTo: mergedTS)
|
let output = try FileHandle(forWritingTo: mergedTS)
|
||||||
for segFile in segmentFiles {
|
for segFile in downloadedSegments {
|
||||||
let segData = try Data(contentsOf: segFile)
|
let segData = try Data(contentsOf: segFile)
|
||||||
output.write(segData)
|
output.write(segData)
|
||||||
}
|
}
|
||||||
output.closeFile()
|
output.closeFile()
|
||||||
|
|
||||||
// 6. Remux TS → MP4 using AVAssetExportSession
|
// 5. TS → MP4
|
||||||
|
let outputURL = try await remuxToMP4(tsURL: mergedTS)
|
||||||
|
|
||||||
|
// 6. 清理临时文件
|
||||||
|
try? FileManager.default.removeItem(at: tempDir)
|
||||||
|
|
||||||
|
return outputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 普通文件下载
|
||||||
|
|
||||||
|
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]
|
let outputMP4 = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
|
||||||
|
|
||||||
let asset = AVURLAsset(url: mergedTS)
|
let asset = AVURLAsset(url: tsURL)
|
||||||
if let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) {
|
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
||||||
exportSession.outputURL = outputMP4
|
// Fallback: 直接保存 TS
|
||||||
exportSession.outputFileType = .mp4
|
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||||
await exportSession.export()
|
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
||||||
if exportSession.status != .completed {
|
return tsOutput
|
||||||
throw RecorderError.exportFailed(exportSession.error?.localizedDescription ?? "Unknown")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try FileManager.default.moveItem(at: mergedTS, to: outputMP4.deletingPathExtension().appendingPathExtension("ts"))
|
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
|
||||||
return outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Clean up temp
|
exportSession.outputURL = outputMP4
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
exportSession.outputFileType = .mp4
|
||||||
|
await exportSession.export()
|
||||||
|
|
||||||
|
if exportSession.status != .completed {
|
||||||
|
// Fallback: 保存为 TS
|
||||||
|
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||||
|
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
||||||
|
return tsOutput
|
||||||
|
}
|
||||||
|
|
||||||
return outputMP4
|
return outputMP4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 导出/分享
|
||||||
|
|
||||||
|
private func showExportSheet(url: URL) {
|
||||||
|
// 保存到相册(如果是视频)
|
||||||
|
if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" {
|
||||||
|
UISaveVideoAtPathToSavedPhotosAlbum(url.path, nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时弹出系统分享面板
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let rootVC = windowScene.windows.first?.rootViewController else { 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.permittedArrowDirections = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最顶层的 VC
|
||||||
|
var topVC = rootVC
|
||||||
|
while let presented = topVC.presentedViewController {
|
||||||
|
topVC = presented
|
||||||
|
}
|
||||||
|
topVC.present(activityVC, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func resolveURL(_ urlString: String, baseURL: URL) -> URL {
|
private func resolveURL(_ urlString: String, baseURL: URL) -> URL {
|
||||||
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
|
||||||
return URL(string: urlString)!
|
return URL(string: urlString)!
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user