#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" @Published var pendingExportURL: URL? 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) self.showExportSheet(url: 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: - 导出/分享 private func showExportSheet(url: URL) { NSLog("[MiniPlayer] showExportSheet: \(url.path)") // 1. 保存到相册(使用 PHPhotoLibrary,有回调通知结果) if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" { saveToPhotoLibrary(url: url) } // 2. 通过 SwiftUI sheet 弹出分享面板(而非直接 present UIViewController) DispatchQueue.main.async { [weak self] in self?.pendingExportURL = url } } private func saveToPhotoLibrary(url: URL) { let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) if status == .authorized || status == .limited { performSave(url: url) } else if status == .notDetermined { PHPhotoLibrary.requestAuthorization(for: .addOnly) { [weak self] newStatus in DispatchQueue.main.async { if newStatus == .authorized || newStatus == .limited { self?.performSave(url: url) } else { self?.onError?("Photo library access denied") } } } } else { onError?("Photo library access denied. Please enable in Settings.") } } private func performSave(url: URL) { PHPhotoLibrary.shared().performChanges { PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil) } completionHandler: { [weak self] success, error in DispatchQueue.main.async { if success { self?.onRecordingSaved?(url) NSLog("[MiniPlayer] Video saved to Photos") } else { self?.onError?("Save to Photos failed: \(error?.localizedDescription ?? "unknown")") } } } } private func presentActivitySheet(url: URL) { NSLog("[MiniPlayer] presentActivitySheet called for: %@", url.lastPathComponent) guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }) ?? UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first else { NSLog("[MiniPlayer] No window scene found") return } // 找最顶层可见的 VC:先用 keyWindow,再 fallback 到任意 window var topVC: UIViewController? if let keyWindow = windowScene.keyWindow { topVC = keyWindow.rootViewController } if topVC == nil { for window in windowScene.windows where !window.isHidden && window.bounds.size != .zero { topVC = window.rootViewController if topVC != nil { break } } } if topVC == nil { topVC = windowScene.windows.first?.rootViewController } // 沿着 presentedViewController 链找到最顶层 while let presented = topVC?.presentedViewController { topVC = presented } guard let vc = topVC else { NSLog("[MiniPlayer] No view controller found to present share sheet") return } NSLog("[MiniPlayer] Presenting from VC: %@", String(describing: type(of: vc))) let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil) // iPad 适配 if let popover = activityVC.popoverPresentationController { popover.sourceView = vc.view popover.sourceRect = CGRect(x: vc.view?.bounds.midX ?? 0, y: vc.view?.bounds.midY ?? 0, width: 0, height: 0) popover.permittedArrowDirections = [] } vc.present(activityVC, animated: true) { NSLog("[MiniPlayer] Share sheet presented successfully") } } // 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