]> git.r.bdr.sh - rbdr/captura/commitdiff
Add clipboard
authorRuben Beltran del Rio <redacted>
Fri, 28 Jul 2023 11:48:53 +0000 (13:48 +0200)
committerRuben Beltran del Rio <redacted>
Fri, 28 Jul 2023 11:48:53 +0000 (13:48 +0200)
14 files changed:
Captura.xcodeproj/project.pbxproj
Captura/CapturaApp.swift [moved from Captura/Presentation/Windows/CapturaApp.swift with 54% similarity]
Captura/Core Extensions/CVImageBuffer+cgImage.swift [new file with mode: 0644]
Captura/Core Extensions/Notification+AppEvents.swift
Captura/Data/CapturaRemoteFile.swift [new file with mode: 0644]
Captura/Data/CapturaSettings.swift [new file with mode: 0644]
Captura/Domain/CapturaCaptureSession.swift [new file with mode: 0644]
Captura/Domain/CaptureState.swift [moved from Captura/Data/CaptureState.swift with 88% similarity]
Captura/Item.swift [deleted file]
Captura/Presentation/Screens/PreferencesScreen.swift
Captura/Presentation/Settings/AdvancedSettings.swift
Captura/Presentation/Settings/OutputSettings.swift
Captura/Presentation/Windows/RecordingWindow.swift
README.md [new file with mode: 0644]

index a506dc534f61daa2db510e58c4f572c46b42d5c1..3238749d7765e34628411ccdf095fd9bf86c377a 100644 (file)
@@ -15,6 +15,9 @@
                B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B272A739871009F6462 /* CGImage+resize.swift */; };
                B5278B2A2A73992D009F6462 /* GifRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B292A73992D009F6462 /* GifRenderer.swift */; };
                B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B2B2A739B3A009F6462 /* CapturaFile.swift */; };
+               B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */; };
+               B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */; };
+               B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B372A73D1EE009F6462 /* CapturaSettings.swift */; };
                B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */; };
                B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */; };
                B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */; };
@@ -22,7 +25,7 @@
                B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */; };
                B5F915562A6EF80E007ECE8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915552A6EF80E007ECE8E /* Assets.xcassets */; };
                B5F915592A6EF80E007ECE8E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */; };
-               B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* Item.swift */; };
+               B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */; };
                B5F915662A6EF80E007ECE8E /* CapturaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915652A6EF80E007ECE8E /* CapturaTests.swift */; };
                B5F915702A6EF80E007ECE8E /* CapturaUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9156F2A6EF80E007ECE8E /* CapturaUITests.swift */; };
                B5F915722A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915712A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift */; };
@@ -54,6 +57,9 @@
                B5278B272A739871009F6462 /* CGImage+resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+resize.swift"; sourceTree = "<group>"; };
                B5278B292A73992D009F6462 /* GifRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifRenderer.swift; sourceTree = "<group>"; };
                B5278B2B2A739B3A009F6462 /* CapturaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaFile.swift; sourceTree = "<group>"; };
+               B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CVImageBuffer+cgImage.swift"; sourceTree = "<group>"; };
+               B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaCaptureSession.swift; sourceTree = "<group>"; };
+               B5278B372A73D1EE009F6462 /* CapturaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaSettings.swift; sourceTree = "<group>"; };
                B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppEvents.swift"; sourceTree = "<group>"; };
                B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWindow.swift; sourceTree = "<group>"; };
                B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureState.swift; sourceTree = "<group>"; };
@@ -62,7 +68,7 @@
                B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesScreen.swift; sourceTree = "<group>"; };
                B5F915552A6EF80E007ECE8E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
                B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
-               B5F9155A2A6EF80E007ECE8E /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
+               B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaRemoteFile.swift; sourceTree = "<group>"; };
                B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Captura.entitlements; sourceTree = "<group>"; };
                B5F915612A6EF80E007ECE8E /* CapturaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CapturaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
                B5F915652A6EF80E007ECE8E /* CapturaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaTests.swift; sourceTree = "<group>"; };
                B5278B1A2A71BD1F009F6462 /* Windows */ = {
                        isa = PBXGroup;
                        children = (
-                               B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
                                B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */,
                                B5278B222A71C140009F6462 /* PreferencesWindow.swift */,
                        );
                B5278B1C2A71BD3C009F6462 /* Data */ = {
                        isa = PBXGroup;
                        children = (
-                               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
                                B5278B202A71BFC3009F6462 /* SettingsStructs.swift */,
                                B5278B292A73992D009F6462 /* GifRenderer.swift */,
                                B5278B2B2A739B3A009F6462 /* CapturaFile.swift */,
+                               B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */,
+                               B5278B372A73D1EE009F6462 /* CapturaSettings.swift */,
                        );
                        path = Data;
                        sourceTree = "<group>";
                        children = (
                                B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
                                B5278B272A739871009F6462 /* CGImage+resize.swift */,
+                               B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */,
                        );
                        path = "Core Extensions";
                        sourceTree = "<group>";
                };
+               B5278B322A73AFEC009F6462 /* Domain */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
+                               B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */,
+                       );
+                       path = Domain;
+                       sourceTree = "<group>";
+               };
                B5F915452A6EF80D007ECE8E = {
                        isa = PBXGroup;
                        children = (
                B5F915502A6EF80D007ECE8E /* Captura */ = {
                        isa = PBXGroup;
                        children = (
+                               B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
                                B5278B262A739862009F6462 /* Core Extensions */,
                                B5278B1C2A71BD3C009F6462 /* Data */,
+                               B5278B322A73AFEC009F6462 /* Domain */,
                                B5278B182A71BD10009F6462 /* Presentation */,
                                B5F915552A6EF80E007ECE8E /* Assets.xcassets */,
-                               B5F9155A2A6EF80E007ECE8E /* Item.swift */,
                                B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */,
                                B5F915572A6EF80E007ECE8E /* Preview Content */,
                        );
                        buildActionMask = 2147483647;
                        files = (
                                B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */,
+                               B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */,
                                B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */,
                                B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */,
                                B5278B1F2A71BD9B009F6462 /* OutputSettings.swift in Sources */,
                                B5278B212A71BFC3009F6462 /* SettingsStructs.swift in Sources */,
                                B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */,
                                B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */,
-                               B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */,
+                               B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */,
                                B5278B232A71C140009F6462 /* PreferencesWindow.swift in Sources */,
                                B5278B172A71528F009F6462 /* HelpPopoverViewController.swift in Sources */,
                                B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */,
+                               B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */,
                                B5278B252A71CA80009F6462 /* AdvancedSettings.swift in Sources */,
+                               B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */,
                                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
similarity index 54%
rename from Captura/Presentation/Windows/CapturaApp.swift
rename to Captura/CapturaApp.swift
index 6cf70445038f54856ac745ab48b2f46830586477..4f1ceffe2a5f43a9ebe8accf8c6145654484df0e 100644 (file)
@@ -2,7 +2,7 @@ import SwiftUI
 import SwiftData
 import Cocoa
 import Combine
-import ReplayKit
+import AVFoundation
 
 @main
 struct CapturaApp: App {
@@ -16,11 +16,11 @@ struct CapturaApp: App {
               .frame(width: 650, height: 450)
         }
         .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
-        .modelContainer(for: Item.self)
+        .modelContainer(for: CapturaRemoteFile.self)
       }
 }
 
-class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate {
+class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
     
   @Environment(\.openURL) var openURL
   var statusItem: NSStatusItem!
@@ -30,12 +30,11 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
   var boxListener: AnyCancellable? = nil
   var popover: NSPopover? = nil
   var helpShown = false
-  var receivedFrames = false
-  var captureSession: AVCaptureSession? = nil
+  var captureSession: CapturaCaptureSession? = nil
   var images: [CGImage] = []
   var outputFile: CapturaFile? = nil
   var gifCallbackTimer = ContinuousClock.now
-  var fps = UserDefaults.standard.integer(forKey: "frameRate")
+  var fps = CapturaSettings.frameRate
   var pixelDensity: CGFloat = 1.0
   var stopTimer: DispatchWorkItem?
   
@@ -131,18 +130,19 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
       }
     case .reset:
       reset()
+    case .failedToStart:
+      failedToStart()
+    case .receivedFrame:
+      if let frame = notification.userInfo?["frame"] {
+        receivedFrame(frame as! CVImageBuffer)
+      }
     default:
       return
     }
-    /*
-     if let data = notification.userInfo?["data"] as? String {
-     print("Data received: \(data)")
-     }
-     */
   }
   
   
-  @objc func startAreaSelection() {
+  func startAreaSelection() {
     helpShown = false
     NSApp.activate(ignoringOtherApps: true)
     if captureState != .selectingArea {
@@ -151,105 +151,59 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
         let rectInWindow = button.convert(button.bounds, to: nil)
         let rectInScreen = button.window?.convertToScreen(rectInWindow)
         recordingWindow = RecordingWindow(rectInScreen)
-        if let view = recordingWindow?.contentView as? RecordingContentView {
-          boxListener = view.$box
-            .debounce(for: .seconds(0.3), scheduler: RunLoop.main)
-            .sink { newValue in
-              if newValue != nil {
-                button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura")
-                if !self.helpShown {
-                  self.helpShown = true
-                  self.showPopoverWithMessage("Click here when you're ready to record.")
-                }
+        boxListener = recordingWindow?.recordingContentView.$box
+          .debounce(for: .seconds(0.3), scheduler: RunLoop.main)
+          .sink { newValue in
+            if newValue != nil {
+              self.updateImage()
+              if !self.helpShown {
+                self.helpShown = true
+                self.showPopoverWithMessage("Click here when you're ready to record.")
               }
             }
-        }
+          }
       }
     }
   }
   
   func startRecording() {
     captureState = .recording
-    fps = UserDefaults.standard.integer(forKey: "frameRate")
+    updateImage()
+    fps = CapturaSettings.frameRate
     outputFile = nil
     images = [];
     pixelDensity = recordingWindow?.pixelDensity ?? 1.0
-    if let view = recordingWindow?.contentView as? RecordingContentView {
-      view.startRecording()
-      if let box = view.box {
-        if let screen = NSScreen.main {
-          let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
-          let screenInput = AVCaptureScreenInput(displayID: displayId)
-          screenInput?.cropRect = box.insetBy(dx: 1, dy: 1)
-          
-          captureSession = AVCaptureSession()
+    recordingWindow?.recordingContentView.startRecording()
+    if let box = recordingWindow?.recordingContentView.box {
+      if let screen = recordingWindow?.screen {
+        captureSession = CapturaCaptureSession(screen, box: box)
+        
+        if let captureSession {
+
+          stopTimer = DispatchWorkItem {
+            self.stopRecording()
+          }
+          DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
           
-          if let captureSession {
-            
-            
-            if captureSession.canAddInput(screenInput!) {
-              captureSession.addInput(screenInput!)
-            }
-            
-            let videoOutput = AVCaptureVideoDataOutput()
-            videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: []))
-            
-            if captureSession.canAddOutput(videoOutput) {
-              captureSession.addOutput(videoOutput)
-            }
-            
-            let movieFileOutput = AVCaptureMovieFileOutput()
-            if captureSession.canAddOutput(movieFileOutput) {
-              captureSession.addOutput(movieFileOutput)
-            }
-            
-            stopTimer = DispatchWorkItem {
-              self.stopRecording()
-            }
-            DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
-            
-            if let button = statusItem.button {
-              button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura")
-            }
-            
-            receivedFrames = false
+          outputFile = CapturaFile()
+          if CapturaSettings.shouldSaveMp4 {
+            captureSession.startRecording(to: outputFile!.mp4URL)
+          } else {
             captureSession.startRunning()
-            
-            outputFile = CapturaFile()
-            let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
-            if outputFormatsSetting.shouldSaveMp4() {
-              movieFileOutput.startRecording(to: outputFile!.mp4URL, recordingDelegate: self)
-            }
-            
-            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
-              if !self.receivedFrames {
-                self.requestPermission()
-              }
-            }
           }
-          
-          
-        } else {
-          print("Should error")
+          return
         }
       }
     }
+    NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil)
   }
   
   func stopRecording() {
-    if let button = statusItem.button {
-      button.image = NSImage(systemSymbolName: "dock.arrow.up.rectangle", accessibilityDescription: "Captura")
-    }
-    stopTimer?.cancel()
     captureState = .uploading
-    captureSession?.stopRunning()
-    captureSession = nil
-    boxListener?.cancel()
-    recordingWindow?.close()
-    self.recordingWindow = nil
+    updateImage()
+    stop()
     
-    let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
-    if !outputFormatsSetting.shouldSaveGif() {
+    if !CapturaSettings.shouldSaveMp4 {
       NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil)
       return
     }
@@ -263,56 +217,53 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
   }
   
   func finalizeRecording() {
-    if let button = statusItem.button {
-      button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura")
-    }
     captureState = .uploaded
+    copyToClipboard()
+    updateImage()
     DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
-      self.reset()
+      NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
     }
   }
   
   func reset() {
-    if let button = statusItem.button {
-      button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
-    }
     captureState = .idle
-    stopTimer?.cancel()
-    captureSession?.stopRunning()
-    boxListener?.cancel()
-    recordingWindow?.close()
-    self.recordingWindow = nil
-  }
-  
-  private func requestPermission() {
-    reset()
-    showPopoverWithMessage("Please grant Captura permission to record")
-    if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") {
-      NSWorkspace.shared.open(url)
-    }
+    updateImage()
+    stop()
   }
   
-  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
-    receivedFrames = true
-    
+  func receivedFrame(_ frame: CVImageBuffer) {
     let now = ContinuousClock.now
     
     if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) {
       gifCallbackTimer = now
       DispatchQueue.main.async {
-        // Get the CVImageBuffer from the sample buffer
-        guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
-        let ciImage = CIImage(cvImageBuffer: imageBuffer)
-        let context = CIContext()
-        if let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))) {
-          if let cgImage = cgImage.resize(by: self.pixelDensity) {
-            self.images.append(cgImage)
-          }
+        if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) {
+          self.images.append(cgImage)
         }
       }
     }
   }
   
+  func failedToStart() {
+    captureState = .error
+    updateImage()
+    requestPermissionToRecord()
+    stop()
+    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+      NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
+    }
+  }
+  
+  // MARK: - Presentation Helpers
+  
+  
+  private func requestPermissionToRecord() {
+    showPopoverWithMessage("Please grant Captura permission to record")
+    if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") {
+      NSWorkspace.shared.open(url)
+    }
+  }
+  
   private func showPopoverWithMessage(_ message: String) {
     if let button = statusItem.button {
       (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message)
@@ -323,7 +274,43 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     }
   }
   
-  // MARK: - AVCaptureFileOutputRecordingDelegate Implementation
+  private func updateImage() {
+    if let button = statusItem.button {
+      let image: String = switch captureState {
+      case .idle:
+        "rectangle.dashed.badge.record"
+      case .selectingArea:
+        "circle.rectangle.dashed"
+      case .recording:
+        "checkmark.rectangle"
+      case .uploading:
+        "dock.arrow.up.rectangle"
+      case .uploaded:
+        "checkmark.rectangle.fill"
+      case .error:
+        "xmark.rectangle.fill"
+      }
+      button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura")
+    }
+  }
+  
+  private func stop() {
+    stopTimer?.cancel()
+    captureSession?.stopRunning()
+    captureSession = nil
+    boxListener?.cancel()
+    recordingWindow?.close()
+    recordingWindow = nil
+  }
   
-  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}
+  private func copyToClipboard() {
+    let fileType: NSPasteboard.PasteboardType = .init(rawValue: CapturaSettings.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4")
+    if let url = CapturaSettings.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL {
+      if let data = try? Data(contentsOf: url) {
+        let pasteboard = NSPasteboard.general
+        pasteboard.declareTypes([fileType], owner: nil)
+        pasteboard.setData(data, forType: fileType)
+      }
+    }
+  }
 }
diff --git a/Captura/Core Extensions/CVImageBuffer+cgImage.swift b/Captura/Core Extensions/CVImageBuffer+cgImage.swift
new file mode 100644 (file)
index 0000000..c30cb0c
--- /dev/null
@@ -0,0 +1,11 @@
+import Foundation
+import ReplayKit
+
+extension CVImageBuffer {
+  
+  var cgImage: CGImage? {
+    let ciImage = CIImage(cvImageBuffer: self)
+    let context = CIContext()
+    return context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(self), height: CVPixelBufferGetHeight(self)))
+  }
+}
index 809c00ed19ca88ee5e1c583d8160c8ca7cb2b8b6..5d00937968a6d39187d12674929a2635c35d1a9b 100644 (file)
@@ -6,4 +6,6 @@ extension Notification.Name {
   static let stopRecording = Notification.Name("stopRecording")
   static let finalizeRecording = Notification.Name("finalizeRecording")
   static let reset = Notification.Name("reset")
+  static let failedToStart = Notification.Name("failedToStart")
+  static let receivedFrame = Notification.Name("receivedFrame")
 }
diff --git a/Captura/Data/CapturaRemoteFile.swift b/Captura/Data/CapturaRemoteFile.swift
new file mode 100644 (file)
index 0000000..24d821e
--- /dev/null
@@ -0,0 +1,13 @@
+import Foundation
+import SwiftData
+
+@Model
+final class CapturaRemoteFile {
+    var timestamp: Date
+    var url: URL
+    
+    init(url: URL) {
+      self.timestamp = Date()
+      self.url = url
+    }
+}
diff --git a/Captura/Data/CapturaSettings.swift b/Captura/Data/CapturaSettings.swift
new file mode 100644 (file)
index 0000000..d0d04f2
--- /dev/null
@@ -0,0 +1,29 @@
+import Foundation
+
+struct CapturaSettings {
+  static var frameRate: Int {
+    UserDefaults.standard.integer(forKey: "frameRate")
+  }
+  
+  static var outputFormats: OutputFormatSetting {
+    OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
+  }
+  
+  static var shouldSaveMp4: Bool {
+    outputFormats.shouldSaveMp4()
+  }
+  
+  static var shouldSaveGif: Bool {
+    outputFormats.shouldSaveGif()
+  }
+  
+  
+  static var shouldSendNotifications: Bool {
+    get {
+      UserDefaults.standard.bool(forKey: "shouldSendNotifications")
+    }
+    set {
+      UserDefaults.standard.setValue(newValue, forKey: "shouldSendNotifications")
+    }
+  }
+}
diff --git a/Captura/Domain/CapturaCaptureSession.swift b/Captura/Domain/CapturaCaptureSession.swift
new file mode 100644 (file)
index 0000000..80240e6
--- /dev/null
@@ -0,0 +1,60 @@
+import AppKit
+import AVFoundation
+
+class CapturaCaptureSession: AVCaptureSession, AVCaptureFileOutputRecordingDelegate, AVCaptureVideoDataOutputSampleBufferDelegate {
+
+  let videoOutput = AVCaptureVideoDataOutput()
+  let movieFileOutput = AVCaptureMovieFileOutput()
+  var receivedFrames = false
+  
+  init(_ screen: NSScreen, box: NSRect) {
+    super.init()
+    
+    let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
+    let screenInput = AVCaptureScreenInput(displayID: displayId)
+    screenInput?.cropRect = box.insetBy(dx: 1, dy: 1)
+    
+    if self.canAddInput(screenInput!) {
+      self.addInput(screenInput!)
+    }
+    
+    videoOutput.setSampleBufferDelegate(self, queue: Dispatch.DispatchQueue(label: "sample buffer delegate", attributes: []))
+    
+    if self.canAddOutput(videoOutput) {
+      self.addOutput(videoOutput)
+    }
+    
+    if self.canAddOutput(movieFileOutput) {
+      self.addOutput(movieFileOutput)
+    }
+  }
+  
+  func startRecording() {
+    receivedFrames = false
+    self.startRunning()
+    
+    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+      if !self.receivedFrames {
+        NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil)
+      }
+    }
+  }
+  
+  func startRecording(to url: URL) {
+    self.startRecording()
+    movieFileOutput.startRecording(to: url, recordingDelegate: self)
+  }
+  
+  // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate Implementation
+  
+  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+    receivedFrames = true
+    
+    guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
+    NotificationCenter.default.post(name: .receivedFrame, object: nil, userInfo: ["frame": imageBuffer])
+  }
+  
+  // MARK: - AVCaptureFileOutputRecordingDelegate Implementation
+  
+  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}
+}
similarity index 88%
rename from Captura/Data/CaptureState.swift
rename to Captura/Domain/CaptureState.swift
index be354f64b1403986afe0f961367ccbd13a4548ea..0761b8897efd1703987a9a2b39026240bc95bba1 100644 (file)
@@ -4,4 +4,5 @@ enum CaptureState {
   case recording
   case uploading
   case uploaded
+  case error
 }
diff --git a/Captura/Item.swift b/Captura/Item.swift
deleted file mode 100644 (file)
index 22587d8..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-//
-//  Item.swift
-//  Captura
-//
-//  Created by Ruben Beltran del Rio on 7/24/23.
-//
-
-import Foundation
-import SwiftData
-
-@Model
-final class Item {
-    var timestamp: Date
-    
-    init(timestamp: Date) {
-        self.timestamp = timestamp
-    }
-}
index f80950244b7908a79d8d901c3df3c9af36d8b756..a9c380b8a57fb5ed1f9eb6218f962472d1679b99 100644 (file)
@@ -2,9 +2,6 @@ import SwiftUI
 import SwiftData
 
 struct PreferencesScreen: View {
-    @Environment(\.modelContext) private var modelContext
-    @Query private var items: [Item]
-    
     var body: some View {
       TabView {
         OutputSettings().tabItem {
@@ -13,11 +10,10 @@ struct PreferencesScreen: View {
         AdvancedSettings().tabItem {
           Label("Advanced", systemImage: "gear")
         }.padding(8.0)
-      }.padding(16.0)
+      }.padding(16.0).frame(minWidth: 300, minHeight: 250)
     }
 }
 
 #Preview {
   PreferencesScreen()
-        .modelContainer(for: Item.self, inMemory: true)
 }
index 7ed78d967e2acd01951581f407901661d503a11d..e5b77ce430651896ebc7a2610e5fc43273e72912 100644 (file)
@@ -3,25 +3,45 @@ import SwiftUI
 struct AdvancedSettings: View {
   
   @AppStorage("backendUrl") var backendUrl: String = ""
+  @AppStorage("backendFormat") var outputFormats: OutputFormatSetting = .gifOnly
   @AppStorage("keepFiles") var keepFiles = true
   
+  var parsedBackendUrl: URL? {
+    URL(string: backendUrl)
+  }
+  
   var body: some View {
     Form {
-      VStack (alignment: .leading) {
+      VStack (alignment: .center) {
         LabeledContent("Backend URL") {
-          TextField("", text: $backendUrl)
-        }.font(.headline)
-        LabeledContent("Keep files after remote upload") {
-          Toggle("", isOn: $keepFiles)
+          TextField("", text: $backendUrl).font(.body)
         }.font(.headline)
+        Picker(selection: $outputFormats, label: Text("Backend Format").font(.headline)) {
+          Text("GIF")
+            .tag(OutputFormatSetting.gifOnly)
+            .padding(.horizontal, 4.0)
+            .padding(.vertical, 2.0)
+          Text("MP4")
+            .tag(OutputFormatSetting.mp4Only)
+            .padding(.horizontal, 4.0)
+            .padding(.vertical, 2.0)
+        }
+          .pickerStyle(.radioGroup)
+          .disabled(parsedBackendUrl == nil)
+        Toggle("Keep Local Files", isOn: $keepFiles)
+          .font(.headline)
+          .disabled(parsedBackendUrl == nil)
+          .padding(.vertical, 8.0)
         HStack {
-          Text("These settings can break things!")
+          Text("These settings can break things! Please make sure you understand how to use them before enabling.")
+            .lineLimit(3...10)
           Button {
             print("Not yet!")
           } label: {
             Image(systemName: "info.circle")
           }.buttonStyle(.borderless)
         }
+        Spacer()
       }
     }
   }
index 3b8e01cee8118f12d7a5f3ae2c26832e90893f35..df125419bbd9b945e50f61d5837233b6add7fc06 100644 (file)
@@ -7,14 +7,14 @@ struct OutputSettings: View {
   
   var body: some View {
     Form {
-      VStack (alignment: .leading) {
+      VStack (alignment: .center) {
         LabeledContent("GIF Framerate") {
           Slider(value: $frameRate,  in: 4...10, step: 1) {
             Text("\(Int(frameRate))").font(.body).frame(width: 24)
           } minimumValueLabel: {
             Text("4")
           } maximumValueLabel: {
-            Text("12")
+            Text("10")
           }
         }.font(.headline)
         Picker(selection: $outputFormats, label: Text("Output Formats").font(.headline)) {
@@ -33,6 +33,7 @@ struct OutputSettings: View {
             .padding(.vertical, 2.0)
         }.pickerStyle(.radioGroup)
       }
+      Spacer()
     }
   }
 }
index 2bb9928067f7accd8caf361630909974dfc98a59..b9f212c63633a0b64fe6752ce2d3c5a6b9dfac4c 100644 (file)
@@ -7,6 +7,10 @@ class RecordingWindow: NSWindow {
     self.screen?.backingScaleFactor ?? 1.0
   }
   
+  var recordingContentView: RecordingContentView {
+    self.contentView as! RecordingContentView
+  }
+  
   init(_ button: NSRect?) {
     
     let screens = NSScreen.screens
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..9230a9d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# Captura
+
+Bring your own backend screen recorder
+
+## Bring Your Own Backend (Protocol version 1.0)
+
+Captura allows you to define a URL that will be used to POST the recording.
+
+### The Request
+
+A `POST` will be made to the request, with the payload containing the binary
+contents of the file. You can expect the following headers:
+
+* `User-Agent`: `Captura/1.0`
+* `Content-Type`: `video/mp4` or `image/gif`, depending on the output format
+  selected.
+
+### Authentication / Authorization
+
+We will only do a POST request, so if you need any type of keys or user
+identifiers, include them in the URL itself (eg. via path elements or
+in the query parameters)
+
+### Expected Response
+
+If the upload is successful, the response *MUST* be a JSON object containing
+a key called `url` with a value of type `string` corresponding to the URL
+where the file is available. The status code *MUST* be 201 Created.
+
+Any response code other than 201 Created will be treated as an error. Captura
+will not re-attempt an upload.