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
This commit is contained in:
parent
bf7d3de9ba
commit
bfb7631714
@ -36,6 +36,7 @@ final class HLSRecorder: ObservableObject {
|
||||
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()
|
||||
@ -43,12 +44,14 @@ final class HLSRecorder: ObservableObject {
|
||||
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()
|
||||
@ -59,6 +62,7 @@ final class HLSRecorder: ObservableObject {
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
NSLog("[MiniPlayer] stopRecording called, stopRequested → true")
|
||||
// 不 cancel 任务,只设 stopRequested 让轮询循环正常退出
|
||||
// 任务会合并已下载片段 → MP4 → 弹出分享面板
|
||||
stopRequested = true
|
||||
@ -189,16 +193,21 @@ final class HLSRecorder: ObservableObject {
|
||||
// 等待 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)
|
||||
@ -207,9 +216,11 @@ final class HLSRecorder: ObservableObject {
|
||||
output.write(segData)
|
||||
}
|
||||
output.closeFile()
|
||||
NSLog("[MiniPlayer] Merged %d segments → %@", downloadedSegments.count, mergedTS.path)
|
||||
|
||||
// 5. TS → MP4
|
||||
// 5. TS → MP4(内部有超时保护)
|
||||
let outputURL = try await remuxToMP4(tsURL: mergedTS)
|
||||
NSLog("[MiniPlayer] Remux complete: %@", outputURL.path)
|
||||
|
||||
// 6. 清理临时文件
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
@ -244,7 +255,7 @@ final class HLSRecorder: ObservableObject {
|
||||
|
||||
let asset = AVURLAsset(url: tsURL)
|
||||
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
||||
// Fallback: 直接保存 TS
|
||||
NSLog("[MiniPlayer] AVAssetExportSession creation failed, fallback to TS")
|
||||
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
||||
return tsOutput
|
||||
@ -252,15 +263,33 @@ final class HLSRecorder: ObservableObject {
|
||||
|
||||
exportSession.outputURL = outputMP4
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
|
||||
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 {
|
||||
// Fallback: 保存为 TS
|
||||
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
|
||||
}
|
||||
|
||||
@ -316,33 +345,48 @@ final class HLSRecorder: ObservableObject {
|
||||
}
|
||||
|
||||
private func presentActivitySheet(url: URL) {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {
|
||||
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
|
||||
var topVC = windowScene.windows.first?.rootViewController
|
||||
// 找最顶层可见的 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 root view controller found")
|
||||
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, y: vc.view.bounds.midY, width: 0, height: 0)
|
||||
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")
|
||||
NSLog("[MiniPlayer] Share sheet presented successfully")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,9 +13,13 @@ final class PiPController: NSObject, ObservableObject {
|
||||
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private weak var playerLayer: AVPlayerLayer?
|
||||
private var pipPossibleObservation: NSKeyValueObservation?
|
||||
|
||||
/// 用 AVPlayerLayer 初始化 PiP 控制器
|
||||
func setup(with playerLayer: AVPlayerLayer) {
|
||||
// 避免重复初始化
|
||||
guard pipController == nil else { return }
|
||||
|
||||
self.playerLayer = playerLayer
|
||||
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||
@ -26,11 +30,25 @@ final class PiPController: NSObject, ObservableObject {
|
||||
|
||||
isPiPSupported = true
|
||||
|
||||
let pip = AVPictureInPictureController(playerLayer: playerLayer)
|
||||
pip?.delegate = self
|
||||
guard let pip = AVPictureInPictureController(playerLayer: playerLayer) else {
|
||||
NSLog("[MiniPlayer] PiP controller creation returned nil")
|
||||
isPiPSupported = false
|
||||
return
|
||||
}
|
||||
pip.delegate = self
|
||||
self.pipController = pip
|
||||
|
||||
NSLog("[MiniPlayer] PiP controller initialized")
|
||||
// KVO 观察 isPictureInPicturePossible 变化
|
||||
pipPossibleObservation = pip.observe(\.isPictureInPicturePossible, options: [.new]) { [weak self] controller, _ in
|
||||
let possible = controller.isPictureInPicturePossible
|
||||
NSLog("[MiniPlayer] PiP possible changed: %d", possible)
|
||||
Task { @MainActor in
|
||||
// 触发 UI 刷新(按钮状态可能需要更新)
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
NSLog("[MiniPlayer] PiP controller initialized, possible=%d", pip.isPictureInPicturePossible)
|
||||
}
|
||||
|
||||
/// 切换画中画状态
|
||||
@ -40,12 +58,14 @@ final class PiPController: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("[MiniPlayer] togglePiP: active=%d, possible=%d", pip.isPictureInPictureActive, pip.isPictureInPicturePossible)
|
||||
|
||||
if pip.isPictureInPictureActive {
|
||||
pip.stopPictureInPicture()
|
||||
} else if pip.isPictureInPicturePossible {
|
||||
pip.startPictureInPicture()
|
||||
} else {
|
||||
NSLog("[MiniPlayer] PiP not possible right now (player may not be ready)")
|
||||
NSLog("[MiniPlayer] PiP not possible — check: 1) player has content 2) Xcode project has 'Audio, AirPlay, and Picture in Picture' background mode enabled in Signing & Capabilities")
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +75,7 @@ final class PiPController: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
deinit {
|
||||
pipPossibleObservation = nil
|
||||
pipController?.delegate = nil
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user