372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
#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<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
|
||
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)
|
||
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?("Recording error: \(error.localizedDescription)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func stopRecording() {
|
||
// 不 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<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
|
||
}
|
||
|
||
if stopRequested && !downloadedSegments.isEmpty {
|
||
break
|
||
}
|
||
|
||
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
||
if !stopRequested {
|
||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
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 downloadedSegments {
|
||
let segData = try Data(contentsOf: segFile)
|
||
output.write(segData)
|
||
}
|
||
output.closeFile()
|
||
|
||
// 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: 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
|
||
}
|
||
|
||
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) {
|
||
NSLog("[MiniPlayer] showExportSheet: \(url.path)")
|
||
|
||
// 1. 保存到相册(使用 PHPhotoLibrary,有回调通知结果)
|
||
if url.pathExtension.lowercased() == "mp4" || url.pathExtension.lowercased() == "mov" {
|
||
saveToPhotoLibrary(url: url)
|
||
}
|
||
|
||
// 2. 延迟弹出系统分享面板(等相册保存启动后再弹)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||
self?.presentActivitySheet(url: 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) {
|
||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
|
||
NSLog("[MiniPlayer] No window scene found")
|
||
return
|
||
}
|
||
|
||
// 找最顶层可见的 VC
|
||
var topVC = windowScene.windows.first?.rootViewController
|
||
while let presented = topVC?.presentedViewController {
|
||
topVC = presented
|
||
}
|
||
|
||
guard let vc = topVC else {
|
||
NSLog("[MiniPlayer] No root view controller found")
|
||
return
|
||
}
|
||
|
||
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, y: vc.view.bounds.midY, width: 0, height: 0)
|
||
popover.permittedArrowDirections = []
|
||
}
|
||
|
||
vc.present(activityVC, animated: true) {
|
||
NSLog("[MiniPlayer] Share sheet presented")
|
||
}
|
||
}
|
||
|
||
// 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
|