- 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: 排除构建产物
169 lines
6.4 KiB
Swift
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
|