]> git.r.bdr.sh - rbdr/captura/blob - Captura/Presentation/Windows/CapturaApp.swift
87e25604ce6044ddc4048d22c9ac9ac95e8a5822
[rbdr/captura] / Captura / Presentation / Windows / CapturaApp.swift
1 import SwiftUI
2 import SwiftData
3 import Cocoa
4 import Combine
5 import ReplayKit
6
7 @main
8 struct CapturaApp: App {
9
10 @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
11
12 var body: some Scene {
13 WindowGroup {
14 PreferencesScreen()
15 .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*"))
16 .frame(width: 650, height: 450)
17 }
18 .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
19 .modelContainer(for: Item.self)
20 }
21 }
22
23 class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate {
24
25 @Environment(\.openURL) var openURL
26 var statusItem: NSStatusItem!
27 var captureState: CaptureState = .idle
28 var recordingWindow: RecordingWindow? = nil
29 var preferencesWindow: PreferencesWindow? = nil
30 var boxListener: AnyCancellable? = nil
31 var popover: NSPopover? = nil
32 var helpShown = false
33 var receivedFrames = false
34 var captureSession: AVCaptureSession? = nil
35 var images: [CGImage] = []
36 var outputURL: URL? = nil
37 var gifCallbackTimer = ContinuousClock.now
38 var fps = UserDefaults.standard.integer(forKey: "frameRate")
39 var pixelDensity: CGFloat = 1.0
40 var stopTimer: DispatchWorkItem?
41
42 func applicationDidFinishLaunching(_ notification: Notification) {
43 setupMenu()
44 NotificationCenter.default.addObserver(
45 self,
46 selector: #selector(self.didReceiveNotification(_:)),
47 name: nil,
48 object: nil)
49 closeWindow()
50 }
51
52 // MARK: - Setup Functions
53
54
55 private func setupMenu() {
56 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
57
58 if let button = statusItem.button {
59 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
60 }
61
62 statusItem.isVisible = true
63 statusItem.menu = NSMenu()
64 statusItem.menu?.delegate = self
65
66 // Create the Popover
67 popover = NSPopover()
68 popover?.contentViewController = HelpPopoverViewController()
69 popover?.behavior = .transient
70
71
72 let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6")
73 recordItem.keyEquivalentModifierMask = [.command, .shift]
74 statusItem.menu?.addItem(recordItem)
75 statusItem.menu?.addItem(NSMenuItem.separator())
76
77 let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "")
78 statusItem.menu?.addItem(preferencesItem)
79
80 let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "")
81 statusItem.menu?.addItem(quitItem)
82 }
83
84 private func closeWindow() {
85 if let window = NSApplication.shared.windows.first {
86 window.close()
87 }
88 }
89
90 // MARK: - UI Event Handlers
91
92 func menuWillOpen(_ menu: NSMenu) {
93 if captureState != .idle {
94 menu.cancelTracking()
95 if captureState == .recording {
96 stopRecording()
97 }
98 }
99 }
100
101 @objc private func onClickStartRecording() {
102 NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
103 }
104
105 @objc private func onOpenPreferences() {
106 NSApp.activate(ignoringOtherApps: true)
107 if preferencesWindow == nil {
108 preferencesWindow = PreferencesWindow()
109 } else {
110 preferencesWindow?.makeKeyAndOrderFront(nil)
111 }
112 }
113
114 @objc private func onQuit() {
115 NSApplication.shared.terminate(self)
116 }
117
118 @objc private func onClickStatusBar(_ sender: NSStatusBarButton) {
119 print("CLICK")
120 if captureState == .recording {
121 stopRecording()
122 }
123 }
124
125
126 // MARK: - App State Event Listeners
127
128 @objc func didReceiveNotification(_ notification: Notification) {
129 switch(notification.name) {
130 case .startAreaSelection:
131 startAreaSelection()
132 case .startRecording:
133 startRecording()
134 case .stopRecording:
135 stopRecording()
136 case .finalizeRecording:
137 finalizeRecording()
138 case .reset:
139 reset()
140 default:
141 return
142 }
143 /*
144 if let data = notification.userInfo?["data"] as? String {
145 print("Data received: \(data)")
146 }
147 */
148 }
149
150
151 @objc func startAreaSelection() {
152 helpShown = false
153 NSApp.activate(ignoringOtherApps: true)
154 if captureState != .selectingArea {
155 captureState = .selectingArea
156 if let button = statusItem.button {
157 let rectInWindow = button.convert(button.bounds, to: nil)
158 let rectInScreen = button.window?.convertToScreen(rectInWindow)
159 recordingWindow = RecordingWindow(rectInScreen)
160 if let view = recordingWindow?.contentView as? RecordingContentView {
161 boxListener = view.$box
162 .debounce(for: .seconds(0.3), scheduler: RunLoop.main)
163 .sink { newValue in
164 if newValue != nil {
165 button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura")
166 if !self.helpShown {
167 self.helpShown = true
168 self.showPopoverWithMessage("Click here when you're ready to record.")
169 }
170 }
171 }
172 }
173 }
174 }
175 }
176
177 func startRecording() {
178 captureState = .recording
179 fps = UserDefaults.standard.integer(forKey: "frameRate")
180 outputURL = nil
181 images = [];
182 pixelDensity = recordingWindow?.pixelDensity ?? 1.0
183 if let view = recordingWindow?.contentView as? RecordingContentView {
184 view.startRecording()
185 if let box = view.box {
186 if let screen = NSScreen.main {
187 let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
188 let screenInput = AVCaptureScreenInput(displayID: displayId)
189 screenInput?.cropRect = box.insetBy(dx: 1, dy: 1)
190
191 captureSession = AVCaptureSession()
192
193 if let captureSession {
194
195
196 if captureSession.canAddInput(screenInput!) {
197 captureSession.addInput(screenInput!)
198 }
199
200 let videoOutput = AVCaptureVideoDataOutput()
201 videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: []))
202
203 if captureSession.canAddOutput(videoOutput) {
204 captureSession.addOutput(videoOutput)
205 }
206
207 let movieFileOutput = AVCaptureMovieFileOutput()
208 if captureSession.canAddOutput(movieFileOutput) {
209 captureSession.addOutput(movieFileOutput)
210 }
211
212 stopTimer = DispatchWorkItem {
213 self.stopRecording()
214 }
215 DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
216
217 if let button = statusItem.button {
218 button.image = NSImage(systemSymbolName: "stop.circle", accessibilityDescription: "Captura")
219 }
220
221 receivedFrames = false
222 captureSession.startRunning()
223 guard let picturesDirectoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first else {
224 fatalError("Unable to access user's Pictures directory")
225 }
226
227 outputURL = picturesDirectoryURL.appendingPathComponent("captura/\(filename())").appendingPathExtension("mp4")
228 let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
229 if outputFormatsSetting.shouldSaveMp4() {
230 movieFileOutput.startRecording(to: outputURL!, recordingDelegate: self)
231 }
232
233 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
234 if !self.receivedFrames {
235 self.requestPermission()
236 }
237 }
238 }
239
240
241 } else {
242 print("Should error")
243 }
244 }
245 }
246 }
247
248 func stopRecording() {
249 stopTimer?.cancel()
250 captureState = .uploading
251 captureSession?.stopRunning()
252 captureSession = nil
253 Task.detached {
254 if let outputURL = self.outputURL {
255 await self.createGif(url: outputURL.deletingPathExtension().appendingPathExtension("gif"))
256 }
257 }
258 reset()
259 }
260
261 func finalizeRecording() {
262 captureState = .uploaded
263 // Stopping the recording
264 }
265
266 func reset() {
267 if let button = statusItem.button {
268 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
269 }
270 captureState = .idle
271 boxListener?.cancel()
272 recordingWindow?.close()
273 self.recordingWindow = nil
274 }
275
276 private func requestPermission() {
277 reset()
278 showPopoverWithMessage("Please grant Captura permission to record")
279 if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") {
280 NSWorkspace.shared.open(url)
281 }
282 }
283
284 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
285 receivedFrames = true
286
287 let now = ContinuousClock.now
288
289 if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) {
290 gifCallbackTimer = now
291 DispatchQueue.main.async {
292 // Get the CVImageBuffer from the sample buffer
293 guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
294 let ciImage = CIImage(cvImageBuffer: imageBuffer)
295 let context = CIContext()
296 if let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))) {
297 if let cgImage = self.resize(image: cgImage, by: self.pixelDensity) {
298 self.images.append(cgImage)
299 }
300 }
301 }
302 }
303 }
304
305 func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
306 if let error = error as? NSError {
307 if error.domain == AVFoundationErrorDomain && error.code == -11806 {
308 Task.detached {
309 await self.createGif(url: outputFileURL.deletingPathExtension().appendingPathExtension("gif"))
310 }
311 }
312 }
313 }
314
315 private func showPopoverWithMessage(_ message: String) {
316 if let button = statusItem.button {
317 (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message)
318 self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
319 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
320 self.popover?.performClose(nil)
321 }
322 }
323 }
324
325 func filename() -> String {
326 let dateFormatter = DateFormatter()
327 dateFormatter.dateStyle = .medium
328 dateFormatter.timeStyle = .medium
329 dateFormatter.locale = Locale.current
330 let dateString = dateFormatter.string(from: Date()).replacingOccurrences(of: ":", with: ".")
331
332 return "Captura \(dateString)"
333 }
334
335 func createGif(url: URL) async {
336
337
338 let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
339 if !outputFormatsSetting.shouldSaveGif() {
340 return
341 }
342
343 let framedelay = String(format: "%.3f", 1.0 / Double(fps))
344 let fileProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
345 let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFUnclampedDelayTime as String: framedelay]]
346 let cfURL = url as CFURL
347 if let destination = CGImageDestinationCreateWithURL(cfURL, UTType.gif.identifier as CFString, images.count, nil) {
348 CGImageDestinationSetProperties(destination, fileProperties as CFDictionary?)
349 for image in images {
350 CGImageDestinationAddImage(destination, image, gifProperties as CFDictionary?)
351 }
352 CGImageDestinationFinalize(destination)
353 }
354 }
355
356 private func resize(image: CGImage, by scale: CGFloat) -> CGImage? {
357 let width = Int(CGFloat(image.width) / scale)
358 let height = Int(CGFloat(image.height) / scale)
359
360 let bitsPerComponent = image.bitsPerComponent
361 let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
362 let bitmapInfo = image.bitmapInfo.rawValue
363
364 guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo) else {
365 return nil
366 }
367
368 context.interpolationQuality = .high
369 context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
370
371 return context.makeImage()
372 }
373
374 }