MiniPlayer/Sources/HLSRecorder.swift
yumoqing d22a5037b8 feat: iOS/macOS 双平台打包支持
- Package.swift: 重构为 MiniPlayerCore(共享库) + MiniPlayer(macOS) + MiniPlayeriOS(iOS) 三 target
- 所有源文件添加 #if os(macOS) / #if os(iOS) 条件编译
- HLSRecorder.swift: iOS 纯 Swift HLS 录制器(下载TS+合并+AVAssetExportSession转MP4)
- scripts/build-macos.sh: macOS 打包脚本 (.app + .dmg)
- scripts/build-ios.sh: iOS 打包脚本 (xcodegen + xcodebuild archive → .ipa)
- .gitignore: 排除构建产物
2026-07-01 08:29:16 +08:00

169 lines
6.4 KiB
Swift

#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<Void, Never>?
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