MiniPlayer/Sources/HLSRecorder.swift
yumoqing ce5878957e fix(iOS): fix recording, playback compatibility, and media library UI
Recording:
- stopRecording() now only sets stopRequested=true, no longer cancels task
- Task exits polling loop naturally → merges TS segments → exports MP4
- System share sheet + Photos save shown on completion
- Added HEAD request to detect HLS Content-Type for non-.m3u8 URLs
- Faster exit when stopRequested && segments exist

Playback:
- Copy imported files to app Documents sandbox (security-scoped URLs expire after defer)
- Remove Referer header that blocks some streams; use iPhone UA only
- AVURLAsset options only for http/https schemes

Media Library UI (iOS):
- Replace NavigationSplitView with VStack + horizontal category chips
- Search bar at top, swipe actions for queue/delete
- scrollDismissesKeyboard(.interactively) on list
- Tap gesture sends resignFirstResponder to dismiss keyboard
- Compact rows with context menu, inline play button
- Close button in navigation bar
- presentationDetents(.medium) for tag/playlist sheets
2026-07-02 07:44:09 +08:00

318 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#if os(iOS)
import Foundation
import AVFoundation
import UIKit
import Combine
/// 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 {
// HLSm3u8 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) {
//
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)!
}
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