MiniPlayer/Sources/PlayerRecorder.swift
yumoqing 221075ce8a refactor: replace AVAssetWriter+Tap recording with StreamRecorderKit FFmpegRecorder
- PlayerRecorder.swift: 883 lines → ~150 lines
- Recording now uses FFmpeg to download stream directly from URL
- No longer depends on AVPlayerItemVideoOutput/copyPixelBuffer/MTAudioProcessingTap
- Recording decoupled from playback (pausing playback doesn't affect recording)
- Better quality: direct stream copy/transcode instead of re-encoding captured frames
- Added StreamRecorderKit as local SPM dependency
2026-06-28 18:19:10 +08:00

158 lines
5.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
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]
panel.nameFieldStringValue = tempURL.lastPathComponent
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