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 Permalink 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