diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 3968412..bb8e2aa 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -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") } } diff --git a/Sources/PiPController.swift b/Sources/PiPController.swift index 8cac3b1..234a171 100644 --- a/Sources/PiPController.swift +++ b/Sources/PiPController.swift @@ -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 } }