import Foundation #if os(macOS) import StreamRecorderKit import AppKit // MARK: - RecorderState(应用退出保护) enum RecorderState { static nonisolated(unsafe) var isFinalizing: Bool = false static nonisolated(unsafe) var pendingTerminate: Bool = false } // MARK: - PlayerRecorder(基于 StreamRecorderKit FFmpegRecorder) /// 使用 FFmpeg 直接从流 URL 下载录制,不再使用 AVAssetWriter + MTAudioProcessingTap 实时捕获方案。 /// 优势: /// - 直接从源流复制/转码,质量更好 /// - 代码量从 883 行降至 ~150 行 /// - 不再依赖 AVPlayerItemVideoOutput / copyPixelBuffer /// - 录制与播放完全解耦(暂停播放不影响录制) @MainActor final class PlayerRecorder: NSObject { private var recorder: FFmpegRecorder? private var recordingTask: Task? private var durationTimer: Timer? private var startDate: Date? @Published var isRecording = false @Published var durationText = "00:00" var onRecordingSaved: ((URL) -> Void)? var onError: ((String) -> Void)? 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(url: URL) { guard !isRecording else { return } let recorder = FFmpegRecorder() self.recorder = recorder let tempFilename = "temp_recording_\(Int(Date().timeIntervalSince1970)).mp4" let tempURL = saveDirectory.appendingPathComponent(tempFilename) try? FileManager.default.removeItem(at: tempURL) isRecording = true startDate = Date() startDurationTimer() NSLog("[Recorder] Starting FFmpeg recording: %@ → %@", url.lastPathComponent, tempFilename) recordingTask = Task { [weak self] in do { try await recorder.start(url: url, outputPath: tempURL.path, duration: 0) // duration=0 表示无限录制,直到 stop() 被调用 // start 在 stop() 后会正常返回 NSLog("[Recorder] ✓ Recording completed: %@", tempURL.path) await MainActor.run { RecorderState.isFinalizing = false self?.stopDurationTimer() self?.showSaveDialog(tempURL: tempURL) } } catch { NSLog("[Recorder] ✗ Recording failed: %@", error.localizedDescription) await MainActor.run { RecorderState.isFinalizing = false self?.stopDurationTimer() // 如果文件存在且有内容,仍然允许保存 let attrs = try? FileManager.default.attributesOfItem(atPath: tempURL.path) let fileSize = (attrs?[.size] as? Int64) ?? 0 NSLog("[Recorder] File exists: %d, size: %lld", FileManager.default.fileExists(atPath: tempURL.path), fileSize) if fileSize > 0 { NSLog("[Recorder] File exists with size %lld, offering save dialog", fileSize) self?.showSaveDialog(tempURL: tempURL) } else { self?.onError?(error.localizedDescription) try? FileManager.default.removeItem(at: tempURL) self?.checkPendingTerminate() } } } } } // MARK: - 停止录制 func stopRecording() { guard isRecording else { return } isRecording = false RecorderState.isFinalizing = true stopDurationTimer() NSLog("[Recorder] Stopping recording...") recorder?.stop() } // MARK: - 录制时长显示 private func startDurationTimer() { startDate = Date() durationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.updateDuration() } } } private func stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil } private func updateDuration() { guard let start = startDate else { return } let elapsed = Int(Date().timeIntervalSince(start)) let min = elapsed / 60 let sec = elapsed % 60 durationText = String(format: "%02d:%02d", min, sec) } // MARK: - 保存对话框 private func showSaveDialog(tempURL: URL) { let panel = NSSavePanel() panel.allowedContentTypes = [.mpeg4Movie] // 默认文件名去掉 temp_ 前缀 let defaultName = tempURL.lastPathComponent.replacingOccurrences(of: "temp_", with: "") panel.nameFieldStringValue = defaultName panel.directoryURL = saveDirectory panel.begin { [weak self] response in Task { @MainActor in if response == .OK, let url = panel.url { do { if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } try FileManager.default.moveItem(at: tempURL, to: url) NSLog("[Recorder] ✓ Saved: %@", url.path) self?.onRecordingSaved?(url) } catch { NSLog("[Recorder] ✗ Save failed: %@", error.localizedDescription) self?.onError?("Save failed: \(error.localizedDescription)") } } else { try? FileManager.default.removeItem(at: tempURL) } self?.checkPendingTerminate() } } } private func checkPendingTerminate() { if RecorderState.pendingTerminate { RecorderState.pendingTerminate = false NSApp.reply(toApplicationShouldTerminate: true) } } } #endif