169 lines
6.1 KiB
Swift
169 lines
6.1 KiB
Swift
import Foundation
|
||
import StreamRecorderKit
|
||
#if os(macOS)
|
||
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<Void, Never>?
|
||
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
|