MiniPlayer/Sources/ScreenRecorder.swift

200 lines
6.6 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 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