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

View File

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