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 @Published var isRecording = false var onRecordingStopped: ((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 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 // 请求屏幕录制权限(macOS 10.15+) // CGRequestScreenCaptureAccess 在首次调用时触发权限弹窗 #if canImport(ScreenCaptureKit) if !CGPreflightScreenCaptureAccess() { CGRequestScreenCaptureAccess() } #endif return true } // MARK: - AVCaptureFileOutputRecordingDelegate nonisolated func fileOutputRecordingDidStart(_ output: AVCaptureFileOutput) { Task { @MainActor in self.isRecording = true } } nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { Task { @MainActor in self.isRecording = false if let error = error { self.onError?("Recording failed: \(error.localizedDescription)") } else { self.onRecordingStopped?(outputFileURL) } // 停止 session 释放资源 self.session?.stopRunning() } } deinit { session?.stopRunning() } } #endif