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:
yumoqing 2026-07-01 23:18:39 +08:00
parent 44708f39ef
commit 88f1ecbe9c

View File

@ -2,8 +2,9 @@
import Foundation
import AVFoundation
import UIKit
import Combine
/// iOS HLS HLS MP4
/// iOS HLS /
@MainActor
final class HLSRecorder: ObservableObject {
@Published var isRecording = false
@ -15,10 +16,12 @@ final class HLSRecorder: ObservableObject {
private var recordTask: Task<Void, Never>?
private var startTime: Date?
private var timer: Timer?
private var stopRequested = false
func startRecording(url: URL) {
guard !isRecording else { return }
isRecording = true
stopRequested = false
startTime = Date()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
@ -31,37 +34,62 @@ final class HLSRecorder: ObservableObject {
recordTask = Task {
do {
let outputURL = try await downloadAndMergeHLS(url: url)
let outputURL = try await recordStream(url: url)
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
self.onRecordingSaved?(outputURL)
self.showExportSheet(url: outputURL)
}
} catch is CancellationError {
// User stopped
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
}
} catch {
await MainActor.run {
self.isRecording = false
self.timer?.invalidate()
self.onError?(error.localizedDescription)
self.onError?("Recording error: \(error.localizedDescription)")
}
}
}
}
func stopRecording() {
stopRequested = true
recordTask?.cancel()
recordTask = nil
isRecording = false
timer?.invalidate()
}
private func downloadAndMergeHLS(url: URL) async throws -> URL {
// 1. Download m3u8
// MARK: -
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)
guard let playlist = String(data: data, encoding: .utf8) else {
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)
var isMaster = false
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 {
let (vData, _) = try await URLSession.shared.data(from: vURL)
guard let vStr = String(data: vData, encoding: .utf8) else { throw RecorderError.invalidPlaylist }
mediaPlaylist = vStr
baseURL = vURL.deletingLastPathComponent()
mediaPlaylistURL = vURL
} else {
mediaPlaylist = playlist
baseURL = url.deletingLastPathComponent()
mediaPlaylistURL = url
}
// 3. Parse segment URLs
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
// 3. m3u8
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("MiniPlayer_rec_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
var segmentFiles: [URL] = []
for (i, segURL) in segmentURLs.enumerated() {
let localFile = tempDir.appendingPathComponent("seg_\(String(format: "%04d", i)).ts")
let (segData, _) = try await URLSession.shared.data(from: segURL)
try segData.write(to: localFile)
segmentFiles.append(localFile)
var downloadedSegments: [URL] = []
var seenSegmentURLs: Set<String> = []
var segmentIndex = 0
var consecutiveErrors = 0
let maxConsecutiveErrors = 10
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 output = try FileHandle(forWritingTo: mergedTS)
for segFile in segmentFiles {
for segFile in downloadedSegments {
let segData = try Data(contentsOf: segFile)
output.write(segData)
}
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]
.appendingPathComponent("MiniPlayer_Recording_\(Int(Date().timeIntervalSince1970)).mp4")
let asset = AVURLAsset(url: mergedTS)
if let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) {
exportSession.outputURL = outputMP4
exportSession.outputFileType = .mp4
await exportSession.export()
if exportSession.status != .completed {
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")
let asset = AVURLAsset(url: tsURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
// Fallback: TS
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
return tsOutput
}
// 7. Clean up temp
try? FileManager.default.removeItem(at: tempDir)
exportSession.outputURL = outputMP4
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
}
// 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 {
if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") {
return URL(string: urlString)!