diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 3f3945c..5bb7c06 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -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? 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 = [] + 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)!