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:
yumoqing 2026-07-02 21:13:24 +08:00
parent bf7d3de9ba
commit bfb7631714
2 changed files with 79 additions and 14 deletions

View File

@ -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")
}
}

View File

@ -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
}
}