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 {
|
recordTask = Task {
|
||||||
do {
|
do {
|
||||||
let outputURL = try await recordStream(url: url)
|
let outputURL = try await recordStream(url: url)
|
||||||
|
NSLog("[MiniPlayer] recordStream completed: %@", outputURL.path)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
@ -43,12 +44,14 @@ final class HLSRecorder: ObservableObject {
|
|||||||
self.showExportSheet(url: outputURL)
|
self.showExportSheet(url: outputURL)
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
|
NSLog("[MiniPlayer] Recording task cancelled")
|
||||||
// User stopped
|
// User stopped
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
NSLog("[MiniPlayer] Recording error: %@", error.localizedDescription)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRecording = false
|
self.isRecording = false
|
||||||
self.timer?.invalidate()
|
self.timer?.invalidate()
|
||||||
@ -59,6 +62,7 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording() {
|
func stopRecording() {
|
||||||
|
NSLog("[MiniPlayer] stopRecording called, stopRequested → true")
|
||||||
// 不 cancel 任务,只设 stopRequested 让轮询循环正常退出
|
// 不 cancel 任务,只设 stopRequested 让轮询循环正常退出
|
||||||
// 任务会合并已下载片段 → MP4 → 弹出分享面板
|
// 任务会合并已下载片段 → MP4 → 弹出分享面板
|
||||||
stopRequested = true
|
stopRequested = true
|
||||||
@ -189,16 +193,21 @@ final class HLSRecorder: ObservableObject {
|
|||||||
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
// 等待 2 秒再拉取(HLS 典型 segment duration 2-10s)
|
||||||
if !stopRequested {
|
if !stopRequested {
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
// sleep 期间 stopRequested 可能已变为 true
|
||||||
|
if stopRequested { break }
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !downloadedSegments.isEmpty else {
|
guard !downloadedSegments.isEmpty else {
|
||||||
|
NSLog("[MiniPlayer] Recording stopped with 0 segments")
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
try? FileManager.default.removeItem(at: tempDir)
|
||||||
throw RecorderError.noSegments
|
throw RecorderError.noSegments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSLog("[MiniPlayer] Recording loop exited: %d segments downloaded", downloadedSegments.count)
|
||||||
|
|
||||||
// 4. 合并所有 TS 片段
|
// 4. 合并所有 TS 片段
|
||||||
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
let mergedTS = tempDir.appendingPathComponent("merged.ts")
|
||||||
let output = try FileHandle(forWritingTo: mergedTS)
|
let output = try FileHandle(forWritingTo: mergedTS)
|
||||||
@ -207,9 +216,11 @@ final class HLSRecorder: ObservableObject {
|
|||||||
output.write(segData)
|
output.write(segData)
|
||||||
}
|
}
|
||||||
output.closeFile()
|
output.closeFile()
|
||||||
|
NSLog("[MiniPlayer] Merged %d segments → %@", downloadedSegments.count, mergedTS.path)
|
||||||
|
|
||||||
// 5. TS → MP4
|
// 5. TS → MP4(内部有超时保护)
|
||||||
let outputURL = try await remuxToMP4(tsURL: mergedTS)
|
let outputURL = try await remuxToMP4(tsURL: mergedTS)
|
||||||
|
NSLog("[MiniPlayer] Remux complete: %@", outputURL.path)
|
||||||
|
|
||||||
// 6. 清理临时文件
|
// 6. 清理临时文件
|
||||||
try? FileManager.default.removeItem(at: tempDir)
|
try? FileManager.default.removeItem(at: tempDir)
|
||||||
@ -244,7 +255,7 @@ final class HLSRecorder: ObservableObject {
|
|||||||
|
|
||||||
let asset = AVURLAsset(url: tsURL)
|
let asset = AVURLAsset(url: tsURL)
|
||||||
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
|
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")
|
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||||
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
||||||
return tsOutput
|
return tsOutput
|
||||||
@ -252,15 +263,33 @@ final class HLSRecorder: ObservableObject {
|
|||||||
|
|
||||||
exportSession.outputURL = outputMP4
|
exportSession.outputURL = outputMP4
|
||||||
exportSession.outputFileType = .mp4
|
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 {
|
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")
|
let tsOutput = outputMP4.deletingPathExtension().appendingPathExtension("ts")
|
||||||
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
try FileManager.default.moveItem(at: tsURL, to: tsOutput)
|
||||||
return tsOutput
|
return tsOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSLog("[MiniPlayer] Export completed: %@", outputMP4.lastPathComponent)
|
||||||
return outputMP4
|
return outputMP4
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,33 +345,48 @@ final class HLSRecorder: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentActivitySheet(url: URL) {
|
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")
|
NSLog("[MiniPlayer] No window scene found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 找最顶层可见的 VC
|
// 找最顶层可见的 VC(遍历所有 window)
|
||||||
var topVC = windowScene.windows.first?.rootViewController
|
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 {
|
while let presented = topVC?.presentedViewController {
|
||||||
topVC = presented
|
topVC = presented
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let vc = topVC else {
|
guard let vc = topVC else {
|
||||||
NSLog("[MiniPlayer] No root view controller found")
|
NSLog("[MiniPlayer] No view controller found to present share sheet")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSLog("[MiniPlayer] Presenting from VC: %@", String(describing: type(of: vc)))
|
||||||
|
|
||||||
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||||
|
|
||||||
// iPad 适配
|
// iPad 适配
|
||||||
if let popover = activityVC.popoverPresentationController {
|
if let popover = activityVC.popoverPresentationController {
|
||||||
popover.sourceView = vc.view
|
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 = []
|
popover.permittedArrowDirections = []
|
||||||
}
|
}
|
||||||
|
|
||||||
vc.present(activityVC, animated: true) {
|
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 var pipController: AVPictureInPictureController?
|
||||||
private weak var playerLayer: AVPlayerLayer?
|
private weak var playerLayer: AVPlayerLayer?
|
||||||
|
private var pipPossibleObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
/// 用 AVPlayerLayer 初始化 PiP 控制器
|
/// 用 AVPlayerLayer 初始化 PiP 控制器
|
||||||
func setup(with playerLayer: AVPlayerLayer) {
|
func setup(with playerLayer: AVPlayerLayer) {
|
||||||
|
// 避免重复初始化
|
||||||
|
guard pipController == nil else { return }
|
||||||
|
|
||||||
self.playerLayer = playerLayer
|
self.playerLayer = playerLayer
|
||||||
|
|
||||||
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
guard AVPictureInPictureController.isPictureInPictureSupported() else {
|
||||||
@ -26,11 +30,25 @@ final class PiPController: NSObject, ObservableObject {
|
|||||||
|
|
||||||
isPiPSupported = true
|
isPiPSupported = true
|
||||||
|
|
||||||
let pip = AVPictureInPictureController(playerLayer: playerLayer)
|
guard let pip = AVPictureInPictureController(playerLayer: playerLayer) else {
|
||||||
pip?.delegate = self
|
NSLog("[MiniPlayer] PiP controller creation returned nil")
|
||||||
|
isPiPSupported = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pip.delegate = self
|
||||||
self.pipController = pip
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NSLog("[MiniPlayer] togglePiP: active=%d, possible=%d", pip.isPictureInPictureActive, pip.isPictureInPicturePossible)
|
||||||
|
|
||||||
if pip.isPictureInPictureActive {
|
if pip.isPictureInPictureActive {
|
||||||
pip.stopPictureInPicture()
|
pip.stopPictureInPicture()
|
||||||
} else if pip.isPictureInPicturePossible {
|
} else if pip.isPictureInPicturePossible {
|
||||||
pip.startPictureInPicture()
|
pip.startPictureInPicture()
|
||||||
} else {
|
} 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 {
|
deinit {
|
||||||
|
pipPossibleObservation = nil
|
||||||
pipController?.delegate = nil
|
pipController?.delegate = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user