feat: 录制完成后弹出保存对话框,支持用户改名

- 添加 showSaveDialog 方法,使用 NSSavePanel
- 默认文件名为 Recording_时间戳.mp4
- 用户可修改文件名或选择保存位置
- 点击取消(X)时删除临时文件
- 保存成功后调用 onRecordingSaved 回调
This commit is contained in:
yumoqing 2026-06-25 08:56:47 +08:00
parent ff20ffe07f
commit fc95254500

View File

@ -305,16 +305,8 @@ final class PlayerRecorder: NSObject {
vFrames, totalAudioFrames) vFrames, totalAudioFrames)
if w.status == .completed, let tempURL = self.tempURL { if w.status == .completed, let tempURL = self.tempURL {
// //
let finalFilename = self.generateFinalFilename() self.showSaveDialog(tempURL: tempURL)
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)")
}
} else { } else {
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")") self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
if let url = self.tempURL { if let url = self.tempURL {
@ -330,69 +322,71 @@ final class PlayerRecorder: NSObject {
// MARK: - Tap // MARK: - Tap
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) { private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
let asset = playerItem.asset // 使 playerItem.tracks asset.loadTracks
// HLS tracks
Task { Task {
do { // tracks
let audioTracks = try await asset.loadTracks(withMediaType: .audio) var attempts = 0
guard let audioTrack = audioTracks.first else { while attempts < 50 { // 5
NSLog("[Recorder] ❌ No audio track for tap") guard self.isRunning else { return }
return
} let tracks = try await playerItem.asset.loadTracks(withMediaType: .audio)
guard self.isRunning else { if let audioTrack = tracks.first {
NSLog("[Recorder] ❌ isRunning=false, aborting audio tap setup") 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 return
} }
NSLog("[Recorder] Audio track found: %d, setting up tap...", audioTrack.trackID) attempts += 1
try await Task.sleep(nanoseconds: 100_000_000) // 0.1
//
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)
} }
NSLog("[Recorder] ❌ Timeout waiting for audio track")
} }
} }
@ -494,6 +488,43 @@ final class PlayerRecorder: NSObject {
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
return "Recording_\(formatter.string(from: Date())).mp4" 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 #endif