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