- KVO观察playerItem.tracks变化(HLS tracks异步加载) - 轮询保底(20秒超时),KVO不触发时仍能捕获 - 诊断日志:打印每个track的mediaType/trackID - tap回调改进:始终先GetSourceAudio保证管道不阻塞 - 窗口标题随toolbar联动
595 lines
22 KiB
Swift
595 lines
22 KiB
Swift
import AVFoundation
|
||
import CoreMedia
|
||
import MediaToolbox
|
||
import Foundation
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
// MARK: - 全局音频上下文(C 回调无法访问 Swift 对象,用全局变量)
|
||
private nonisolated(unsafe) var gAudioContext: AudioCaptureContext?
|
||
|
||
class AudioCaptureContext {
|
||
var writerInput: AVAssetWriterInput?
|
||
nonisolated(unsafe) var isRunning: Bool = true
|
||
nonisolated(unsafe) var formatDescription: CMAudioFormatDescription?
|
||
nonisolated(unsafe) var appendCount: Int = 0
|
||
nonisolated(unsafe) var firstAppendLogged: Bool = false
|
||
nonisolated(unsafe) var processCallCount: Int = 0
|
||
}
|
||
|
||
// MARK: - MTAudioProcessingTap C 回调
|
||
|
||
private func tapInitCallback(
|
||
_ tap: MTAudioProcessingTap,
|
||
_ clientInfo: UnsafeMutableRawPointer?,
|
||
_ tapStorageOut: UnsafeMutablePointer<UnsafeMutableRawPointer?>
|
||
) {
|
||
// tapStorage 不需要额外分配,context 通过全局变量访问
|
||
tapStorageOut.pointee = clientInfo
|
||
}
|
||
|
||
private func tapFinalizeCallback(_ tap: MTAudioProcessingTap) {
|
||
// 清理由 stopRecording 负责
|
||
}
|
||
|
||
private func tapPrepareCallback(
|
||
_ tap: MTAudioProcessingTap,
|
||
_ maxFrames: CMItemCount,
|
||
_ processingFormat: UnsafePointer<AudioStreamBasicDescription>
|
||
) {
|
||
guard let ctx = gAudioContext else { return }
|
||
var asbd = processingFormat.pointee
|
||
var fmtDesc: CMAudioFormatDescription?
|
||
CMAudioFormatDescriptionCreate(
|
||
allocator: kCFAllocatorDefault,
|
||
asbd: &asbd,
|
||
layoutSize: 0, layout: nil,
|
||
magicCookieSize: 0, magicCookie: nil,
|
||
extensions: nil,
|
||
formatDescriptionOut: &fmtDesc
|
||
)
|
||
ctx.formatDescription = fmtDesc
|
||
NSLog("[Recorder] tapPrepare: %.1fHz, %uch", asbd.mSampleRate, asbd.mChannelsPerFrame)
|
||
}
|
||
|
||
private func tapUnprepareCallback(_ tap: MTAudioProcessingTap) {
|
||
// Nothing to clean up
|
||
}
|
||
|
||
private func tapProcessCallback(
|
||
_ tap: MTAudioProcessingTap,
|
||
_ numberFrames: CMItemCount,
|
||
_ flags: MTAudioProcessingTapFlags,
|
||
_ bufferListInOut: UnsafeMutablePointer<AudioBufferList>,
|
||
_ numberFramesOut: UnsafeMutablePointer<CMItemCount>,
|
||
_ flagsOut: UnsafeMutablePointer<MTAudioProcessingTapFlags>
|
||
) {
|
||
// 始终先获取源音频(保证音频管道正常运行)
|
||
var timeRange = CMTimeRange()
|
||
var srcFlags: MTAudioProcessingTapFlags = 0
|
||
var actualFrames: CMItemCount = 0
|
||
|
||
let status = MTAudioProcessingTapGetSourceAudio(
|
||
tap, numberFrames, bufferListInOut,
|
||
&srcFlags, &timeRange, &actualFrames
|
||
)
|
||
|
||
// 必须始终设置输出帧数和标志(否则音频引擎可能阻塞)
|
||
numberFramesOut.pointee = actualFrames
|
||
flagsOut.pointee = srcFlags
|
||
|
||
guard status == noErr, actualFrames > 0 else { return }
|
||
|
||
// 通过全局变量获取 context — 未就绪时仅跳过录制,不影响播放
|
||
guard let ctx = gAudioContext, ctx.isRunning,
|
||
let writerInput = ctx.writerInput,
|
||
writerInput.isReadyForMoreMediaData else { return }
|
||
|
||
ctx.processCallCount += 1
|
||
|
||
// 定期记录回调活跃状态(每500次调用打一次日志)
|
||
if ctx.processCallCount == 1 || ctx.processCallCount % 500 == 0 {
|
||
NSLog("[Recorder] tapProcess #%d: frames=%ld, timescale=%d",
|
||
ctx.processCallCount, actualFrames, timeRange.duration.timescale)
|
||
}
|
||
|
||
let bufferList = bufferListInOut.pointee
|
||
guard bufferList.mNumberBuffers > 0 else { return }
|
||
|
||
let buf = bufferList.mBuffers
|
||
let dataSize = Int(buf.mDataByteSize)
|
||
guard dataSize > 0, let srcData = buf.mData else { return }
|
||
|
||
// 创建 CMBlockBuffer
|
||
var blockBuffer: CMBlockBuffer?
|
||
let blockStatus = CMBlockBufferCreateWithMemoryBlock(
|
||
allocator: kCFAllocatorDefault, memoryBlock: nil,
|
||
blockLength: dataSize, blockAllocator: kCFAllocatorDefault,
|
||
customBlockSource: nil, offsetToData: 0,
|
||
dataLength: dataSize,
|
||
flags: kCMBlockBufferAssureMemoryNowFlag,
|
||
blockBufferOut: &blockBuffer
|
||
)
|
||
guard blockStatus == kCMBlockBufferNoErr, let bb = blockBuffer else { return }
|
||
|
||
let replaceStatus = CMBlockBufferReplaceDataBytes(
|
||
with: srcData, blockBuffer: bb,
|
||
offsetIntoDestination: 0, dataLength: dataSize
|
||
)
|
||
guard replaceStatus == kCMBlockBufferNoErr else { return }
|
||
|
||
// 获取 format description
|
||
guard let fd = ctx.formatDescription else {
|
||
if ctx.processCallCount == 1 {
|
||
NSLog("[Recorder] ⚠️ formatDescription nil on first process call")
|
||
}
|
||
return
|
||
}
|
||
|
||
// PTS 基于累积帧数(与视频一样从0开始)
|
||
let sampleRate = Double(timeRange.duration.timescale) > 0
|
||
? Double(timeRange.duration.timescale) : 44100.0
|
||
let ptsValue = Double(ctx.appendCount) / sampleRate
|
||
let pts = CMTime(seconds: ptsValue, preferredTimescale: Int32(sampleRate))
|
||
|
||
var sampleBuffer: CMSampleBuffer?
|
||
let createStatus = CMAudioSampleBufferCreateReadyWithPacketDescriptions(
|
||
allocator: kCFAllocatorDefault,
|
||
dataBuffer: bb,
|
||
formatDescription: fd,
|
||
sampleCount: actualFrames,
|
||
presentationTimeStamp: pts,
|
||
packetDescriptions: nil,
|
||
sampleBufferOut: &sampleBuffer
|
||
)
|
||
guard createStatus == noErr, let sb = sampleBuffer else {
|
||
if ctx.processCallCount <= 3 {
|
||
NSLog("[Recorder] ⚠️ CMAudioSampleBufferCreate failed: %d", createStatus)
|
||
}
|
||
return
|
||
}
|
||
|
||
if writerInput.append(sb) {
|
||
ctx.appendCount += actualFrames
|
||
if !ctx.firstAppendLogged {
|
||
ctx.firstAppendLogged = true
|
||
NSLog("[Recorder] ✓ First audio appended: pts=%.2fs, frames=%ld", pts.seconds, actualFrames)
|
||
}
|
||
} else if ctx.processCallCount <= 3 {
|
||
NSLog("[Recorder] ⚠️ writerInput.append failed (input ready=%d)", writerInput.isReadyForMoreMediaData)
|
||
}
|
||
}
|
||
|
||
// MARK: - PlayerRecorder
|
||
|
||
/// 视频源录制器:AVPlayerItemVideoOutput 抓帧 + MTAudioProcessingTap 捕获音频
|
||
/// 边录边写文件,录制结束后仅改名和移动
|
||
@MainActor
|
||
final class PlayerRecorder: NSObject {
|
||
|
||
private var writer: AVAssetWriter?
|
||
private var videoInput: AVAssetWriterInput?
|
||
private var audioInput: AVAssetWriterInput?
|
||
private var captureTimer: Timer?
|
||
private var durationTimer: Timer?
|
||
private var startDate: Date?
|
||
private weak var weakOutput: AVPlayerItemVideoOutput?
|
||
nonisolated(unsafe) private var isRunning = false
|
||
|
||
// 视频:缓存上一帧(处理静态画面)
|
||
private var lastPixelBuffer: CVPixelBuffer?
|
||
private var captureFrameCount: Int = 0
|
||
|
||
// 音频 (MTAudioProcessingTap)
|
||
private var audioTap: MTAudioProcessingTap?
|
||
private var audioContext: AudioCaptureContext?
|
||
private weak var currentPlayerItem: AVPlayerItem?
|
||
private var tracksObservation: NSKeyValueObservation?
|
||
|
||
// 录制起始时间(用于计算视频相对时间戳)
|
||
nonisolated(unsafe) private var recordStartTime: CMTime = .zero
|
||
|
||
@Published var isRecording = false
|
||
@Published var durationText = "00:00"
|
||
|
||
var onRecordingSaved: ((URL) -> Void)?
|
||
var onError: ((String) -> Void)?
|
||
|
||
/// 录制临时文件路径(边录边写到此文件)
|
||
private var tempURL: URL?
|
||
|
||
/// 最终保存目录
|
||
private var saveDirectory: URL {
|
||
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first!
|
||
let dir = movies.appendingPathComponent("MiniPlayer", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
return dir
|
||
}
|
||
|
||
// MARK: - 开始录制
|
||
|
||
func startRecording(from output: AVPlayerItemVideoOutput, playerItem: AVPlayerItem, startTime: CMTime) {
|
||
guard !isRecording else { return }
|
||
|
||
weakOutput = output
|
||
currentPlayerItem = playerItem
|
||
lastPixelBuffer = nil
|
||
captureFrameCount = 0
|
||
isRunning = false
|
||
recordStartTime = startTime
|
||
|
||
// 临时文件(边录边写,结束仅改名移动)
|
||
let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4"
|
||
let url = saveDirectory.appendingPathComponent(tempFilename)
|
||
// 删除旧文件
|
||
try? FileManager.default.removeItem(at: url)
|
||
tempURL = url
|
||
|
||
do {
|
||
writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||
} catch {
|
||
onError?("Cannot create writer: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
|
||
guard let writer = writer else { return }
|
||
|
||
// 视频输入(H264)
|
||
let videoSettings: [String: Any] = [
|
||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||
AVVideoWidthKey: 1920,
|
||
AVVideoHeightKey: 1080
|
||
]
|
||
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||
vInput.expectsMediaDataInRealTime = true
|
||
if writer.canAdd(vInput) {
|
||
writer.add(vInput)
|
||
videoInput = vInput
|
||
}
|
||
|
||
// 音频输入(AAC)
|
||
let audioSettings: [String: Any] = [
|
||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||
AVSampleRateKey: 44100,
|
||
AVNumberOfChannelsKey: 2,
|
||
AVEncoderBitRateKey: 128000
|
||
]
|
||
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||
aInput.expectsMediaDataInRealTime = true
|
||
if writer.canAdd(aInput) {
|
||
writer.add(aInput)
|
||
audioInput = aInput
|
||
}
|
||
|
||
guard writer.startWriting() else {
|
||
NSLog("[Recorder] startWriting failed: %@", writer.error?.localizedDescription ?? "unknown")
|
||
onError?("Cannot start writer: \(writer.error?.localizedDescription ?? "unknown")")
|
||
return
|
||
}
|
||
|
||
// session 起点为 0,视频和音频都用相对时间戳
|
||
writer.startSession(atSourceTime: .zero)
|
||
NSLog("[Recorder] Writer started, writing to: %@", url.lastPathComponent)
|
||
|
||
// 启动视频抓帧(30fps)
|
||
isRecording = true
|
||
isRunning = true
|
||
startCaptureLoop()
|
||
startDurationTimer()
|
||
|
||
// 启动音频捕获(MTAudioProcessingTap)
|
||
setupAudioTap(playerItem: playerItem, audioWriterInput: aInput)
|
||
}
|
||
|
||
// MARK: - 停止录制
|
||
|
||
func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
isRunning = false
|
||
captureTimer?.invalidate()
|
||
captureTimer = nil
|
||
stopDurationTimer()
|
||
|
||
// 停止音频 tap
|
||
audioContext?.isRunning = false
|
||
tracksObservation = nil
|
||
let totalAudioFrames = audioContext?.appendCount ?? 0
|
||
let processCalls = audioContext?.processCallCount ?? 0
|
||
NSLog("[Recorder] Stopping: videoFrames=%d, audioProcessCalls=%d, audioAppended=%d",
|
||
captureFrameCount, processCalls, totalAudioFrames)
|
||
|
||
// 移除 audioMix
|
||
currentPlayerItem?.audioMix = nil
|
||
currentPlayerItem = nil
|
||
|
||
// 释放 tap 和 context
|
||
audioTap = nil
|
||
audioContext = nil
|
||
gAudioContext = nil
|
||
|
||
videoInput?.markAsFinished()
|
||
audioInput?.markAsFinished()
|
||
|
||
writer?.finishWriting { [weak self] in
|
||
Task { @MainActor in
|
||
guard let self = self, let w = self.writer else { return }
|
||
let vFrames = self.captureFrameCount
|
||
NSLog("[Recorder] finishWriting: status=%d, error=%@, videoFrames=%d, audioFrames=%d",
|
||
w.status.rawValue,
|
||
w.error?.localizedDescription ?? "none",
|
||
vFrames, totalAudioFrames)
|
||
|
||
if w.status == .completed, let tempURL = self.tempURL {
|
||
// 弹出保存对话框,用户可改名
|
||
self.showSaveDialog(tempURL: tempURL)
|
||
} else {
|
||
self.onError?("Recording failed: \(w.error?.localizedDescription ?? "unknown (status=\(w.status.rawValue))")")
|
||
if let url = self.tempURL {
|
||
try? FileManager.default.removeItem(at: url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
lastPixelBuffer = nil
|
||
}
|
||
|
||
// MARK: - 音频 Tap 设置
|
||
|
||
private func setupAudioTap(playerItem: AVPlayerItem, audioWriterInput: AVAssetWriterInput) {
|
||
// HLS 流的 tracks 是异步加载的
|
||
// 策略:先同步检查 → KVO 观察 → 定时轮询保底
|
||
|
||
// 先尝试直接获取(本地文件场景)
|
||
if let audioTrack = self.findAudioTrackID(in: playerItem) {
|
||
NSLog("[Recorder] ✓ Audio track immediately available: trackID=%d", audioTrack)
|
||
self.installTap(on: playerItem, trackID: audioTrack, audioWriterInput: audioWriterInput)
|
||
return
|
||
}
|
||
|
||
// tracks 尚未加载 — 用 KVO 观察变化
|
||
NSLog("[Recorder] ⏳ Audio tracks not yet loaded, setting up KVO + polling...")
|
||
|
||
let observation = playerItem.observe(\.tracks, options: [.new]) { [weak self] item, _ in
|
||
guard let self, self.isRunning, self.audioTap == nil else { return }
|
||
|
||
if let trackID = self.findAudioTrackID(in: item) {
|
||
NSLog("[Recorder] ✓ Audio track via KVO: trackID=%d", trackID)
|
||
Task { @MainActor in
|
||
guard self.isRunning, self.audioTap == nil else { return }
|
||
self.installTap(on: item, trackID: trackID, audioWriterInput: audioWriterInput)
|
||
}
|
||
self.tracksObservation = nil
|
||
}
|
||
}
|
||
self.tracksObservation = observation
|
||
|
||
// 保底:在视频抓帧循环中定期检查(HLS KVO 可能不触发)
|
||
// 超时保护:20秒后放弃
|
||
Task {
|
||
var attempts = 0
|
||
while attempts < 200 && self.isRunning && self.audioTap == nil { // 20秒
|
||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1秒
|
||
attempts += 1
|
||
|
||
if let trackID = self.findAudioTrackID(in: playerItem) {
|
||
NSLog("[Recorder] ✓ Audio track via polling (attempt %d): trackID=%d", attempts, trackID)
|
||
await MainActor.run {
|
||
guard self.isRunning, self.audioTap == nil else { return }
|
||
self.installTap(on: playerItem, trackID: trackID, audioWriterInput: audioWriterInput)
|
||
}
|
||
self.tracksObservation = nil
|
||
return
|
||
}
|
||
}
|
||
if self.audioTap == nil {
|
||
NSLog("[Recorder] ❌ No audio track found after %d polling attempts", attempts)
|
||
self.tracksObservation = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 从 playerItem.tracks 查找音频 trackID
|
||
private func findAudioTrackID(in playerItem: AVPlayerItem) -> CMPersistentTrackID? {
|
||
let itemTracks = playerItem.tracks
|
||
guard !itemTracks.isEmpty else { return nil }
|
||
|
||
for (i, track) in itemTracks.enumerated() {
|
||
guard let assetTrack = track.assetTrack else { continue }
|
||
NSLog("[Recorder] track[%d]: mediaType=%@, trackID=%d, isEnabled=%d",
|
||
i, assetTrack.mediaType.rawValue as NSString, assetTrack.trackID, track.isEnabled)
|
||
if assetTrack.mediaType == .audio {
|
||
return assetTrack.trackID
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func installTap(on playerItem: AVPlayerItem, trackID: CMPersistentTrackID, audioWriterInput: AVAssetWriterInput) {
|
||
// 创建音频捕获上下文
|
||
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
|
||
|
||
// 关键:用 playerItem 的 trackID 创建 audioMix input parameters
|
||
let params = AVMutableAudioMixInputParameters()
|
||
params.trackID = trackID
|
||
params.audioTapProcessor = audioTap
|
||
let audioMix = AVMutableAudioMix()
|
||
audioMix.inputParameters = [params]
|
||
|
||
playerItem.audioMix = audioMix
|
||
NSLog("[Recorder] ✓ Audio tap installed on playerItem (trackID=%d)", trackID)
|
||
}
|
||
|
||
// MARK: - 视频抓帧 (30fps Timer)
|
||
|
||
private func startCaptureLoop() {
|
||
captureTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
self?.captureVideoFrame()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func captureVideoFrame() {
|
||
guard isRunning, let output = weakOutput, let input = videoInput,
|
||
input.isReadyForMoreMediaData else { return }
|
||
|
||
let hostTime = CACurrentMediaTime()
|
||
let itemTime = output.itemTime(forHostTime: hostTime)
|
||
|
||
// 尝试获取新帧
|
||
if let pb = output.copyPixelBuffer(forItemTime: itemTime, itemTimeForDisplay: nil) {
|
||
lastPixelBuffer = pb
|
||
}
|
||
|
||
// 使用缓存帧(处理静态画面)
|
||
guard let pb = lastPixelBuffer else { return }
|
||
|
||
// 计算相对时间戳
|
||
let relativeTime = CMTimeSubtract(itemTime, recordStartTime)
|
||
guard relativeTime.seconds >= 0 else { return }
|
||
|
||
// 创建 sample buffer 并写入
|
||
if let sb = Self.createSampleBuffer(from: pb, time: relativeTime) {
|
||
if input.append(sb) {
|
||
captureFrameCount += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func createSampleBuffer(from pixelBuffer: CVPixelBuffer, time: CMTime) -> CMSampleBuffer? {
|
||
var info = CMSampleTimingInfo()
|
||
info.presentationTimeStamp = time
|
||
info.duration = CMTime(value: 1, timescale: 30)
|
||
info.decodeTimeStamp = .invalid
|
||
|
||
var formatDescription: CMFormatDescription?
|
||
CMVideoFormatDescriptionCreateForImageBuffer(
|
||
allocator: kCFAllocatorDefault,
|
||
imageBuffer: pixelBuffer,
|
||
formatDescriptionOut: &formatDescription
|
||
)
|
||
|
||
guard let fd = formatDescription else { return nil }
|
||
|
||
var sampleBuffer: CMSampleBuffer?
|
||
CMSampleBufferCreateReadyWithImageBuffer(
|
||
allocator: kCFAllocatorDefault,
|
||
imageBuffer: pixelBuffer,
|
||
formatDescription: fd,
|
||
sampleTiming: &info,
|
||
sampleBufferOut: &sampleBuffer
|
||
)
|
||
return sampleBuffer
|
||
}
|
||
|
||
// MARK: - 计时器
|
||
|
||
private func startDurationTimer() {
|
||
startDate = Date()
|
||
durationText = "00:00"
|
||
durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self = self, let start = self.startDate else { return }
|
||
let elapsed = Int(Date().timeIntervalSince(start))
|
||
let m = elapsed / 60
|
||
let s = elapsed % 60
|
||
let h = m / 60
|
||
if h > 0 {
|
||
self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s)
|
||
} else {
|
||
self.durationText = String(format: "%02d:%02d", m, s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopDurationTimer() {
|
||
durationTimer?.invalidate()
|
||
durationTimer = nil
|
||
startDate = nil
|
||
durationText = "00:00"
|
||
}
|
||
|
||
// MARK: - 文件命名
|
||
|
||
private func generateFinalFilename() -> String {
|
||
let formatter = DateFormatter()
|
||
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
|