#if os(iOS) import Foundation import AVFoundation import UIKit import Combine import Photos /// iOS 录制器 — 持续拉取 HLS 片段 / 下载普通文件,直到用户手动停止 @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? 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 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 recordStream(url: url) NSLog("[MiniPlayer] recordStream completed: %@", outputURL.path) await MainActor.run { self.isRecording = false self.timer?.invalidate() 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() { NSLog("[MiniPlayer] stopRecording called, stopRequested → true") // 不 cancel 任务,只设 stopRequested 让轮询循环正常退出 // 任务会合并已下载片段 → MP4 → 弹出分享面板 stopRequested = true } // MARK: - 核心录制逻辑 private func recordStream(url: URL) async throws -> URL { // 判断是否为 HLS(m3u8 扩展名或 URL 含 m3u8 参数) let ext = url.pathExtension.lowercased() let isHLS = ext == "m3u8" || url.absoluteString.contains(".m3u8") || ext == "m3u" if isHLS { return try await recordHLS(url: url) } else { // 先尝试 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 { // 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. 如果是 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 = [] 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 } 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 片段 let mergedTS = tempDir.appendingPathComponent("merged.ts") let output = try FileHandle(forWritingTo: mergedTS) for segFile in downloadedSegments { let segData = try Data(contentsOf: segFile) output.write(segData) } output.closeFile() NSLog("[MiniPlayer] Merged %d segments → %@", downloadedSegments.count, mergedTS.path) // 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: - 普通文件下载 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