MiniPlayer/Sources/PlayerRecorder.swift
yumoqing 04990674cc fix: save dialog not appearing after recording on macOS/iOS
macOS:
- Replace deprecated NSSavePanel.begin(completionHandler:) with async begin() API
- The old API has been deprecated since macOS 12 and may silently fail on macOS 26
- Added diagnostic logging throughout recording completion flow

iOS:
- Added diagnostic logging to track onRecordingSaved → pendingExportURL → sheet chain
- Logs file existence, size after recording completes

Diagnostic logging added to help verify the save dialog flow:
- macOS: file size check, showSaveDialog entry, NSSavePanel response
- iOS: recordStream output verification, onRecordingSaved call, sheet binding activation
2026-07-17 20:27:49 +08:00

176 lines
6.7 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
#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<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: %@, file exists: %d", tempURL.path, FileManager.default.fileExists(atPath: tempURL.path))
let attrs = try? FileManager.default.attributesOfItem(atPath: tempURL.path)
NSLog("[Recorder] Output file size: %lld", (attrs?[.size] as? Int64) ?? 0)
await MainActor.run {
RecorderState.isFinalizing = false
self?.stopDurationTimer()
NSLog("[Recorder] Calling showSaveDialog...")
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) {
NSLog("[Recorder] showSaveDialog called for: %@", tempURL.lastPathComponent)
let panel = NSSavePanel()
panel.allowedContentTypes = [.mpeg4Movie]
// 默认文件名去掉 temp_ 前缀
let defaultName = tempURL.lastPathComponent.replacingOccurrences(of: "temp_", with: "")
panel.nameFieldStringValue = defaultName
panel.directoryURL = saveDirectory
// NSSavePanel.begin(completionHandler:) 已被 macOS 12 废弃,macOS 26 上可能静默失效
// 使用现代 async begin() API
Task { @MainActor [weak self] in
NSLog("[Recorder] NSSavePanel.begin() starting...")
let response = await panel.begin()
NSLog("[Recorder] NSSavePanel response: %d", response.rawValue)
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