MiniPlayer/Sources/HLSRecorder.swift
yumoqing 4c38b06860 修复四个问题: 录制后弹保存窗/本地文件播放/左上角锁定/音轨循环切换
1. HLSRecorder: 添加 pendingExportURL,录制完成后通过 SwiftUI sheet 弹出分享面板
2. PlayerBridge: AVAudioSession mode 改为 .default 兼容视频+音频,本地文件加载失败增强日志
3. PlayerContentView: iOS 锁屏功能,30秒无操作自动锁定,左上角图标切换锁状态
4. ControlToolbar: 音轨按钮改为直接循环切换(仅多音轨时可用),添加 cycleTrack() 方法
2026-07-02 22:10:55 +08:00

422 lines
16 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
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<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)
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 {
// 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)
// 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