feat: 录制完成后弹出保存对话框,支持用户改名
- 添加 showSaveDialog 方法,使用 NSSavePanel - 默认文件名为 Recording_时间戳.mp4 - 用户可修改文件名或选择保存位置 - 点击取消(X)时删除临时文件 - 保存成功后调用 onRecordingSaved 回调
This commit is contained in:
parent
ff20ffe07f
commit
fc95254500
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user