diff --git a/Sources/PlayerRecorder.swift b/Sources/PlayerRecorder.swift index 00067f1..dc60ba1 100644 --- a/Sources/PlayerRecorder.swift +++ b/Sources/PlayerRecorder.swift @@ -305,16 +305,8 @@ final class PlayerRecorder: NSObject { vFrames, totalAudioFrames) if w.status == .completed, let tempURL = self.tempURL { - // 边录边写完成,仅做改名 - let finalFilename = self.generateFinalFilename() - let finalURL = self.saveDirectory.appendingPathComponent(finalFilename) - do { - try FileManager.default.moveItem(at: tempURL, to: finalURL) - NSLog("[Recorder] ✓ Saved: %@", finalURL.path) - self.onRecordingSaved?(finalURL) - } catch { - self.onError?("Save failed: \(error.localizedDescription)") - } + // 弹出保存对话框,用户可改名 + self.showSaveDialog(tempURL: tempURL) } else { self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") if let url = self.tempURL { @@ -330,69 +322,71 @@ final class PlayerRecorder: NSObject { // MARK: - 音频 Tap 设置 private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { - let asset = playerItem.asset + // 使用 playerItem.tracks(动态加载)而不是 asset.loadTracks + // HLS 流的 tracks 需要等待加载完成后才可用 Task { - do { - let audioTracks = try await asset.loadTracks(withMediaType: .audio) - guard let audioTrack = audioTracks.first else { - NSLog("[Recorder] ❌ No audio track for tap") - return - } - guard self.isRunning else { - NSLog("[Recorder] ❌ isRunning=false, aborting audio tap setup") + // 等待 tracks 加载 + var attempts = 0 + while attempts < 50 { // 最多等待 5 秒 + guard self.isRunning else { return } + + let tracks = try await playerItem.asset.loadTracks(withMediaType: .audio) + if let audioTrack = tracks.first { + NSLog("[Recorder] ✓ Audio track found: %d, setting up tap...", audioTrack.trackID) + + // 创建音频捕获上下文 + let context = AudioCaptureContext() + context.writerInput = audioWriterInput + context.isRunning = true + self.audioContext = context + + // 全局引用,供 C 回调访问 + gAudioContext = context + + // 创建 MTAudioProcessingTap 回调结构体 + var callbacks = MTAudioProcessingTapCallbacks( + version: kMTAudioProcessingTapCallbacksVersion_0, + clientInfo: nil, + init: tapInitCallback, + finalize: tapFinalizeCallback, + prepare: tapPrepareCallback, + unprepare: tapUnprepareCallback, + process: tapProcessCallback + ) + + var tap: MTAudioProcessingTap? + let status = MTAudioProcessingTapCreate( + kCFAllocatorDefault, + &callbacks, + kMTAudioProcessingTapCreationFlag_PostEffects, + &tap + ) + + guard status == noErr, let audioTap = tap else { + NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status) + gAudioContext = nil + return + } + + self.audioTap = audioTap + + // 创建 audioMix 并挂上 tap + let params = AVMutableAudioMixInputParameters(track: audioTrack) + params.audioTapProcessor = audioTap + let audioMix = AVMutableAudioMix() + audioMix.inputParameters = [params] + + playerItem.audioMix = audioMix + NSLog("[Recorder] ✓ Audio tap installed on playerItem") return } - NSLog("[Recorder] Audio track found: %d, setting up tap...", audioTrack.trackID) - - // 创建音频捕获上下文 - let context = AudioCaptureContext() - context.writerInput = audioWriterInput - context.isRunning = true - self.audioContext = context - - // 全局引用,供 C 回调访问 - gAudioContext = context - - // 创建 MTAudioProcessingTap 回调结构体 - var callbacks = MTAudioProcessingTapCallbacks( - version: kMTAudioProcessingTapCallbacksVersion_0, - clientInfo: nil, - init: tapInitCallback, - finalize: tapFinalizeCallback, - prepare: tapPrepareCallback, - unprepare: tapUnprepareCallback, - process: tapProcessCallback - ) - - var tap: MTAudioProcessingTap? - let status = MTAudioProcessingTapCreate( - kCFAllocatorDefault, - &callbacks, - kMTAudioProcessingTapCreationFlag_PostEffects, - &tap - ) - - guard status == noErr, let audioTap = tap else { - NSLog("[Recorder] ❌ MTAudioProcessingTapCreate failed: %d", status) - gAudioContext = nil - return - } - - self.audioTap = audioTap - - // 创建 audioMix 并挂上 tap - let params = AVMutableAudioMixInputParameters(track: audioTrack) - params.audioTapProcessor = audioTap - let audioMix = AVMutableAudioMix() - audioMix.inputParameters = [params] - - playerItem.audioMix = audioMix - NSLog("[Recorder] ✓ Audio tap installed on playerItem") - } catch { - NSLog("[Recorder] Audio tap setup error: %@", error.localizedDescription) + attempts += 1 + try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒 } + + NSLog("[Recorder] ❌ Timeout waiting for audio track") } } @@ -494,6 +488,43 @@ final class PlayerRecorder: NSObject { formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" return "Recording_\(formatter.string(from: Date())).mp4" } + + // MARK: - 保存对话框 + + private func showSaveDialog(tempURL: URL) { + let panel = NSSavePanel() + panel.nameFieldStringValue = generateFinalFilename() + panel.allowedContentTypes = [.mpeg4Movie] + panel.directoryURL = saveDirectory + panel.title = "保存录制文件" + panel.prompt = "保存" + + panel.begin { [weak self] response in + Task { @MainActor in + guard let self = self else { return } + + if response == .OK, let finalURL = panel.url { + do { + // 如果目标文件已存在,先删除 + if FileManager.default.fileExists(atPath: finalURL.path) { + try FileManager.default.removeItem(at: finalURL) + } + // 移动临时文件到最终位置 + try FileManager.default.moveItem(at: tempURL, to: finalURL) + NSLog("[Recorder] ✓ Saved: %@", finalURL.path) + self.onRecordingSaved?(finalURL) + } catch { + self.onError?("Save failed: \(error.localizedDescription)") + try? FileManager.default.removeItem(at: tempURL) + } + } else { + // 用户取消,删除临时文件 + NSLog("[Recorder] User cancelled save, deleting temp file") + try? FileManager.default.removeItem(at: tempURL) + } + } + } + } } #endif