]> git.r.bdr.sh - rbdr/captura/commitdiff
Separate some logic out of main app
authorRuben Beltran del Rio <redacted>
Fri, 28 Jul 2023 06:53:26 +0000 (08:53 +0200)
committerRuben Beltran del Rio <redacted>
Fri, 28 Jul 2023 06:53:26 +0000 (08:53 +0200)
Captura.xcodeproj/project.pbxproj
Captura/Core Extensions/CGImage+resize.swift [new file with mode: 0644]
Captura/Core Extensions/Notification+AppEvents.swift [moved from Captura/Data/Notification+AppEvents.swift with 100% similarity]
Captura/Data/CapturaFile.swift [new file with mode: 0644]
Captura/Data/GifRenderer.swift [new file with mode: 0644]
Captura/Presentation/Windows/CapturaApp.swift

index 98480e19a6a24087a3e0b739fe98412690096bf3..a506dc534f61daa2db510e58c4f572c46b42d5c1 100644 (file)
@@ -12,6 +12,9 @@
                B5278B212A71BFC3009F6462 /* SettingsStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B202A71BFC3009F6462 /* SettingsStructs.swift */; };
                B5278B232A71C140009F6462 /* PreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B222A71C140009F6462 /* PreferencesWindow.swift */; };
                B5278B252A71CA80009F6462 /* AdvancedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B242A71CA80009F6462 /* AdvancedSettings.swift */; };
+               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 */; };
                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 */; };
@@ -48,6 +51,9 @@
                B5278B202A71BFC3009F6462 /* SettingsStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStructs.swift; sourceTree = "<group>"; };
                B5278B222A71C140009F6462 /* PreferencesWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = "<group>"; };
                B5278B242A71CA80009F6462 /* AdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettings.swift; sourceTree = "<group>"; };
+               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>"; };
                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>"; };
                        isa = PBXGroup;
                        children = (
                                B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
-                               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
                                B5278B202A71BFC3009F6462 /* SettingsStructs.swift */,
+                               B5278B292A73992D009F6462 /* GifRenderer.swift */,
+                               B5278B2B2A739B3A009F6462 /* CapturaFile.swift */,
                        );
                        path = Data;
                        sourceTree = "<group>";
                        path = Settings;
                        sourceTree = "<group>";
                };
+               B5278B262A739862009F6462 /* Core Extensions */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
+                               B5278B272A739871009F6462 /* CGImage+resize.swift */,
+                       );
+                       path = "Core Extensions";
+                       sourceTree = "<group>";
+               };
                B5F915452A6EF80D007ECE8E = {
                        isa = PBXGroup;
                        children = (
                B5F915502A6EF80D007ECE8E /* Captura */ = {
                        isa = PBXGroup;
                        children = (
+                               B5278B262A739862009F6462 /* Core Extensions */,
                                B5278B1C2A71BD3C009F6462 /* Data */,
                                B5278B182A71BD10009F6462 /* Presentation */,
                                B5F915552A6EF80E007ECE8E /* Assets.xcassets */,
                        buildActionMask = 2147483647;
                        files = (
                                B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */,
+                               B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */,
+                               B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */,
                                B5278B1F2A71BD9B009F6462 /* OutputSettings.swift in Sources */,
+                               B5278B2A2A73992D009F6462 /* GifRenderer.swift in Sources */,
                                B5278B212A71BFC3009F6462 /* SettingsStructs.swift in Sources */,
                                B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */,
                                B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */,
diff --git a/Captura/Core Extensions/CGImage+resize.swift b/Captura/Core Extensions/CGImage+resize.swift
new file mode 100644 (file)
index 0000000..110e85b
--- /dev/null
@@ -0,0 +1,21 @@
+import CoreGraphics
+
+extension CGImage {
+  func resize(by scale: CGFloat) -> CGImage? {
+    let width = Int(CGFloat(self.width) / scale)
+    let height = Int(CGFloat(self.height) / scale)
+    
+    let bitsPerComponent = self.bitsPerComponent
+    let colorSpace = self.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
+    let bitmapInfo = self.bitmapInfo.rawValue
+    
+    guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo) else {
+        return nil
+    }
+    
+    context.interpolationQuality = .high
+    context.draw(self, in: CGRect(x: 0, y: 0, width: width, height: height))
+    
+    return context.makeImage()
+  }
+}
diff --git a/Captura/Data/CapturaFile.swift b/Captura/Data/CapturaFile.swift
new file mode 100644 (file)
index 0000000..da1f2fb
--- /dev/null
@@ -0,0 +1,31 @@
+import Foundation
+
+struct CapturaFile {
+  
+  let name: String
+  let baseDirectory: URL
+  let appDirectory: String = "captura"
+  
+  private var baseURL: URL {
+    baseDirectory.appendingPathComponent("\(appDirectory)/\(name)")
+  }
+  
+  var mp4URL: URL {
+    return baseURL.appendingPathExtension("mp4")
+  }
+  
+  var gifURL: URL {
+    return baseURL.appendingPathExtension("gif")
+  }
+  
+  init() {
+    let dateFormatter = DateFormatter()
+    dateFormatter.dateStyle = .medium
+    dateFormatter.timeStyle = .medium
+    dateFormatter.locale = Locale.current
+    let dateString = dateFormatter.string(from: Date()).replacingOccurrences(of: ":", with: ".")
+    
+    self.name = "Captura \(dateString)"
+    self.baseDirectory = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!
+  }
+}
diff --git a/Captura/Data/GifRenderer.swift b/Captura/Data/GifRenderer.swift
new file mode 100644 (file)
index 0000000..208d7db
--- /dev/null
@@ -0,0 +1,19 @@
+import UniformTypeIdentifiers
+import SwiftUI
+import CoreGraphics
+
+struct GifRenderer {
+  static func render(_ images: [CGImage], at fps: Int, to url: URL) async {
+    let framedelay = String(format: "%.3f", 1.0 / Double(fps))
+    let fileProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
+    let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFUnclampedDelayTime as String: framedelay]]
+    let cfURL = url as CFURL
+    if let destination = CGImageDestinationCreateWithURL(cfURL, UTType.gif.identifier as CFString, images.count, nil) {
+      CGImageDestinationSetProperties(destination, fileProperties as CFDictionary?)
+      for image in images {
+        CGImageDestinationAddImage(destination, image, gifProperties as CFDictionary?)
+      }
+      CGImageDestinationFinalize(destination)
+    }
+  }
+}
index 87e25604ce6044ddc4048d22c9ac9ac95e8a5822..6cf70445038f54856ac745ab48b2f46830586477 100644 (file)
@@ -33,7 +33,7 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
   var receivedFrames = false
   var captureSession: AVCaptureSession? = nil
   var images: [CGImage] = []
-  var outputURL: URL? = nil
+  var outputFile: CapturaFile? = nil
   var gifCallbackTimer = ContinuousClock.now
   var fps = UserDefaults.standard.integer(forKey: "frameRate")
   var pixelDensity: CGFloat = 1.0
@@ -93,7 +93,7 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     if captureState != .idle {
       menu.cancelTracking()
       if captureState == .recording {
-        stopRecording()
+        NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil)
       }
     }
   }
@@ -115,14 +115,6 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     NSApplication.shared.terminate(self)
   }
   
-  @objc private func onClickStatusBar(_ sender: NSStatusBarButton) {
-    print("CLICK")
-    if captureState == .recording {
-      stopRecording()
-    }
-  }
-  
-  
   // MARK: - App State Event Listeners
   
   @objc func didReceiveNotification(_ notification: Notification) {
@@ -134,7 +126,9 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     case .stopRecording:
       stopRecording()
     case .finalizeRecording:
-      finalizeRecording()
+      DispatchQueue.main.async {
+        self.finalizeRecording()
+      }
     case .reset:
       reset()
     default:
@@ -177,7 +171,7 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
   func startRecording() {
     captureState = .recording
     fps = UserDefaults.standard.integer(forKey: "frameRate")
-    outputURL = nil
+    outputFile = nil
     images = [];
     pixelDensity = recordingWindow?.pixelDensity ?? 1.0
     if let view = recordingWindow?.contentView as? RecordingContentView {
@@ -215,19 +209,16 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
             DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
             
             if let button = statusItem.button {
-              button.image = NSImage(systemSymbolName: "stop.circle", accessibilityDescription: "Captura")
+              button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura")
             }
             
             receivedFrames = false
             captureSession.startRunning()
-            guard let picturesDirectoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first else {
-              fatalError("Unable to access user's Pictures directory")
-            }
             
-            outputURL = picturesDirectoryURL.appendingPathComponent("captura/\(filename())").appendingPathExtension("mp4")
+            outputFile = CapturaFile()
             let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
             if outputFormatsSetting.shouldSaveMp4() {
-              movieFileOutput.startRecording(to: outputURL!, recordingDelegate: self)
+              movieFileOutput.startRecording(to: outputFile!.mp4URL, recordingDelegate: self)
             }
             
             DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
@@ -246,21 +237,39 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
   }
   
   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
+    
+    let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
+    if !outputFormatsSetting.shouldSaveGif() {
+      NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil)
+      return
+    }
+    
     Task.detached {
-      if let outputURL = self.outputURL {
-        await self.createGif(url: outputURL.deletingPathExtension().appendingPathExtension("gif"))
+      if let outputFile = self.outputFile {
+        await GifRenderer.render(self.images, at: self.fps, to: outputFile.gifURL)
+        NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil)
       }
     }
-    reset()
   }
   
   func finalizeRecording() {
+    if let button = statusItem.button {
+      button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura")
+    }
     captureState = .uploaded
-    // Stopping the recording
+    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+      self.reset()
+    }
   }
   
   func reset() {
@@ -268,6 +277,8 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
       button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
     }
     captureState = .idle
+    stopTimer?.cancel()
+    captureSession?.stopRunning()
     boxListener?.cancel()
     recordingWindow?.close()
     self.recordingWindow = nil
@@ -294,7 +305,7 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
         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 = self.resize(image: cgImage, by: self.pixelDensity) {
+          if let cgImage = cgImage.resize(by: self.pixelDensity) {
             self.images.append(cgImage)
           }
         }
@@ -302,16 +313,6 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     }
   }
   
-  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
-    if let error = error as? NSError {
-      if error.domain == AVFoundationErrorDomain && error.code == -11806 {
-        Task.detached {
-          await self.createGif(url: outputFileURL.deletingPathExtension().appendingPathExtension("gif"))
-        }
-      }
-    }
-  }
-  
   private func showPopoverWithMessage(_ message: String) {
     if let button = statusItem.button {
       (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message)
@@ -322,53 +323,7 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOut
     }
   }
   
-  func filename() -> String {
-      let dateFormatter = DateFormatter()
-      dateFormatter.dateStyle = .medium
-      dateFormatter.timeStyle = .medium
-      dateFormatter.locale = Locale.current
-      let dateString = dateFormatter.string(from: Date()).replacingOccurrences(of: ":", with: ".")
-      
-      return "Captura \(dateString)"
-  }
-  
-  func createGif(url: URL) async {
-    
-    
-    let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
-    if !outputFormatsSetting.shouldSaveGif() {
-      return
-    }
-
-    let framedelay = String(format: "%.3f", 1.0 / Double(fps))
-    let fileProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
-    let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFUnclampedDelayTime as String: framedelay]]
-    let cfURL = url as CFURL
-    if let destination = CGImageDestinationCreateWithURL(cfURL, UTType.gif.identifier as CFString, images.count, nil) {
-      CGImageDestinationSetProperties(destination, fileProperties as CFDictionary?)
-      for image in images {
-        CGImageDestinationAddImage(destination, image, gifProperties as CFDictionary?)
-      }
-      CGImageDestinationFinalize(destination)
-    }
-  }
+  // MARK: - AVCaptureFileOutputRecordingDelegate Implementation
   
-  private func resize(image: CGImage, by scale: CGFloat) -> CGImage? {
-      let width = Int(CGFloat(image.width) / scale)
-      let height = Int(CGFloat(image.height) / scale)
-      
-      let bitsPerComponent = image.bitsPerComponent
-      let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
-      let bitmapInfo = image.bitmapInfo.rawValue
-      
-      guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo) else {
-          return nil
-      }
-      
-      context.interpolationQuality = .high
-      context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
-      
-      return context.makeImage()
-  }
-
+  func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}
 }