MiniPlayer/Sources/HLSRecorder.swift
yumoqing bfb7631714 fix(iOS): improve PiP KVO observation, fix recording stop flow and export sheet
- PiPController: add KVO on isPictureInPicturePossible, avoid duplicate setup, better logging
- HLSRecorder: add stopRequested check after Task.sleep to ensure recording exits cleanly
- HLSRecorder: add 120s timeout to remuxToMP4 via TaskGroup to prevent hanging
- HLSRecorder: improve presentActivitySheet VC lookup (key window, foregroundActive scene)
- Add detailed logging throughout recording/export flow for debugging
2026-07-02 21:13:24 +08:00

416 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"
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.
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) {
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 window
var topVC: UIViewController?
for window in windowScene.windows where window.isKeyWindow {
topVC = window.rootViewController
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