200 lines
6.6 KiB
Swift
200 lines
6.6 KiB
Swift
import AVFoundation
|
||
#if os(macOS)
|
||
import AppKit
|
||
|
||
/// 屏幕录制器:录制屏幕 + 麦克风音频,保存到 ~/Movies/MiniPlayer/
|
||
@MainActor
|
||
final class ScreenRecorder: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||
|
||
private var session: AVCaptureSession?
|
||
private var fileOutput: AVCaptureMovieFileOutput?
|
||
private var isConfigured = false
|
||
private var timer: 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
|
||
}
|
||
|
||
/// 开始录制
|
||
func startRecording() {
|
||
guard !isRecording else { return }
|
||
|
||
// 首次配置 session(避免每次重建)
|
||
if !isConfigured {
|
||
guard configureSession() else { return }
|
||
isConfigured = true
|
||
}
|
||
|
||
guard let fileOutput = fileOutput else {
|
||
onError?("Recording not ready")
|
||
return
|
||
}
|
||
|
||
// 生成文件名:录制_2026-06-24_15-30-45.mp4
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||
let filename = "Recording_\(formatter.string(from: Date())).mp4"
|
||
let outputURL = saveDirectory.appendingPathComponent(filename)
|
||
|
||
// 启动 session 并开始录制
|
||
if let session = session, !session.isRunning {
|
||
session.startRunning()
|
||
}
|
||
|
||
fileOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||
}
|
||
|
||
/// 停止录制 → 弹出保存对话框
|
||
func stopRecording() {
|
||
guard isRecording, let fileOutput = fileOutput else { return }
|
||
fileOutput.stopRecording()
|
||
}
|
||
|
||
private func startTimer() {
|
||
startDate = Date()
|
||
durationText = "00:00"
|
||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||
Task { @MainActor in
|
||
guard let self = self, let start = self.startDate else { return }
|
||
let elapsed = Int(Date().timeIntervalSince(start))
|
||
let m = elapsed / 60
|
||
let s = elapsed % 60
|
||
let h = m / 60
|
||
if h > 0 {
|
||
self.durationText = String(format: "%d:%02d:%02d", h, m % 60, s)
|
||
} else {
|
||
self.durationText = String(format: "%02d:%02d", m, s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopTimer() {
|
||
timer?.invalidate()
|
||
timer = nil
|
||
startDate = nil
|
||
durationText = "00:00"
|
||
}
|
||
|
||
/// 弹出保存对话框
|
||
private func showSaveDialog(tempURL: URL) {
|
||
let panel = NSSavePanel()
|
||
panel.nameFieldStringValue = tempURL.lastPathComponent
|
||
panel.allowedContentTypes = [.mpeg4Movie]
|
||
panel.directoryURL = saveDirectory
|
||
panel.title = "Save Recording"
|
||
|
||
panel.begin { [weak self] response in
|
||
guard let self = self else { return }
|
||
if response == .OK, let destURL = panel.url {
|
||
do {
|
||
// 如果目标已存在先删除
|
||
if FileManager.default.fileExists(atPath: destURL.path) {
|
||
try FileManager.default.removeItem(at: destURL)
|
||
}
|
||
try FileManager.default.moveItem(at: tempURL, to: destURL)
|
||
self.onRecordingSaved?(destURL)
|
||
} catch {
|
||
self.onError?("Save failed: \(error.localizedDescription)")
|
||
}
|
||
} else {
|
||
// 用户取消 → 删除临时文件
|
||
try? FileManager.default.removeItem(at: tempURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func configureSession() -> Bool {
|
||
let session = AVCaptureSession()
|
||
session.sessionPreset = .hd1920x1080
|
||
|
||
// 屏幕输入(主显示器)
|
||
guard let displayID = NSScreen.main?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
|
||
onError?("No display found")
|
||
return false
|
||
}
|
||
|
||
let screenInput = AVCaptureScreenInput(displayID: displayID)
|
||
guard let screenInput = screenInput else {
|
||
onError?("Cannot create screen input")
|
||
return false
|
||
}
|
||
screenInput.capturesMouseClicks = false
|
||
screenInput.capturesCursor = true
|
||
|
||
if session.canAddInput(screenInput) {
|
||
session.addInput(screenInput)
|
||
} else {
|
||
onError?("Cannot add screen input")
|
||
return false
|
||
}
|
||
|
||
// 麦克风输入
|
||
if let audioDevice = AVCaptureDevice.default(for: .audio),
|
||
let audioInput = try? AVCaptureDeviceInput(device: audioDevice) {
|
||
if session.canAddInput(audioInput) {
|
||
session.addInput(audioInput)
|
||
}
|
||
}
|
||
|
||
// 文件输出
|
||
let output = AVCaptureMovieFileOutput()
|
||
if session.canAddOutput(output) {
|
||
session.addOutput(output)
|
||
} else {
|
||
onError?("Cannot add file output")
|
||
return false
|
||
}
|
||
|
||
self.session = session
|
||
self.fileOutput = output
|
||
|
||
// 请求屏幕录制权限
|
||
#if canImport(ScreenCaptureKit)
|
||
if !CGPreflightScreenCaptureAccess() {
|
||
CGRequestScreenCaptureAccess()
|
||
}
|
||
#endif
|
||
|
||
return true
|
||
}
|
||
|
||
// MARK: - AVCaptureFileOutputRecordingDelegate
|
||
nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) {
|
||
Task { @MainActor in
|
||
self.isRecording = true
|
||
self.startTimer()
|
||
}
|
||
}
|
||
|
||
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||
Task { @MainActor in
|
||
self.isRecording = false
|
||
self.stopTimer()
|
||
if let error = error {
|
||
self.onError?("Recording failed: \(error.localizedDescription)")
|
||
} else {
|
||
self.showSaveDialog(tempURL: outputFileURL)
|
||
}
|
||
self.session?.stopRunning()
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
session?.stopRunning()
|
||
}
|
||
}
|
||
|
||
#endif
|