#if os(iOS) import Foundation import AVFoundation import UIKit /// iOS HLS 录制器 — 下载 HLS 流片段并合并为 MP4 @MainActor final class HLSRecorder: ObservableObject { @Published var isRecording = false @Published var durationText = "00:00" var onRecordingSaved: ((URL) -> Void)? var onError: ((String) -> Void)? private var recordTask: Task? private var startTime: Date? private var timer: Timer? func startRecording(url: URL) { guard !isRecording else { return } isRecording = true startTime = Date() timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in Task { @MainActor in guard let self, let start = self.startTime else { return } let elapsed = Int(Date().timeIntervalSince(start)) self.durationText = String(format: "%02d:%02d:%02d", elapsed/3600, (elapsed%3600)/60, elapsed%60) } } recordTask = Task { do { let outputURL = try await downloadAndMergeHLS(url: url) await MainActor.run { self.isRecording = false self.timer?.invalidate() self.onRecordingSaved?(outputURL) } } catch { await MainActor.run { self.isRecording = false self.timer?.invalidate() self.onError?(error.localizedDescription) } } } } func stopRecording() { recordTask?.cancel() recordTask = nil isRecording = false timer?.invalidate() } private func downloadAndMergeHLS(url: URL) async throws -> URL { // 1. Download m3u8 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 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 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() } else { mediaPlaylist = playlist baseURL = url.deletingLastPathComponent() } // 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 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) } // 5. Merge segments into single MPEG-TS file let mergedTS = tempDir.appendingPathComponent("merged.ts") let output = try FileHandle(forWritingTo: mergedTS) for segFile in segmentFiles { let segData = try Data(contentsOf: segFile) output.write(segData) } output.closeFile() // 6. Remux TS → MP4 using AVAssetExportSession 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") } // 7. Clean up temp try? FileManager.default.removeItem(at: tempDir) return outputMP4 } 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