]> git.r.bdr.sh - rbdr/captura/commitdiff
Use framerate, control stop
authorRuben Beltran del Rio <redacted>
Thu, 27 Jul 2023 20:32:10 +0000 (22:32 +0200)
committerRuben Beltran del Rio <redacted>
Thu, 27 Jul 2023 20:32:10 +0000 (22:32 +0200)
15 files changed:
Captura.xcodeproj/project.pbxproj
Captura.xcodeproj/xcuserdata/rbdr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist [new file with mode: 0644]
Captura/Captura.entitlements
Captura/CapturaApp.swift [deleted file]
Captura/Data/CaptureState.swift [moved from Captura/CaptureState.swift with 100% similarity]
Captura/Data/Notification+AppEvents.swift [moved from Captura/Notification+AppEvents.swift with 100% similarity]
Captura/Data/SettingsStructs.swift [new file with mode: 0644]
Captura/PreferencesWindow.swift [deleted file]
Captura/Presentation/Popovers/HelpPopoverViewController.swift [new file with mode: 0644]
Captura/Presentation/Screens/PreferencesScreen.swift [new file with mode: 0644]
Captura/Presentation/Settings/AdvancedSettings.swift [new file with mode: 0644]
Captura/Presentation/Settings/OutputSettings.swift [new file with mode: 0644]
Captura/Presentation/Windows/CapturaApp.swift [new file with mode: 0644]
Captura/Presentation/Windows/PreferencesWindow.swift [new file with mode: 0644]
Captura/Presentation/Windows/RecordingWindow.swift [moved from Captura/RecordingWindow.swift with 77% similarity]

index a796c6af5993ac25927b6eed0b2a1775c1cf6362..98480e19a6a24087a3e0b739fe98412690096bf3 100644 (file)
@@ -7,11 +7,16 @@
        objects = {
 
 /* Begin PBXBuildFile section */
+               B5278B172A71528F009F6462 /* HelpPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B162A71528F009F6462 /* HelpPopoverViewController.swift */; };
+               B5278B1F2A71BD9B009F6462 /* OutputSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B1E2A71BD9B009F6462 /* OutputSettings.swift */; };
+               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 */; };
                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 */; };
                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915512A6EF80D007ECE8E /* CapturaApp.swift */; };
-               B5F915542A6EF80D007ECE8E /* PreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */; };
+               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 */; };
 /* End PBXContainerItemProxy section */
 
 /* Begin PBXFileReference section */
+               B5278B162A71528F009F6462 /* HelpPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpPopoverViewController.swift; sourceTree = "<group>"; };
+               B5278B1E2A71BD9B009F6462 /* OutputSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputSettings.swift; sourceTree = "<group>"; };
+               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>"; };
                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>"; };
                B5F9154E2A6EF80D007ECE8E /* Captura.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Captura.app; sourceTree = BUILT_PRODUCTS_DIR; };
                B5F915512A6EF80D007ECE8E /* CapturaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaApp.swift; sourceTree = "<group>"; };
-               B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = "<group>"; };
+               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>"; };
 /* End PBXFrameworksBuildPhase section */
 
 /* Begin PBXGroup section */
+               B5278B182A71BD10009F6462 /* Presentation */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B5278B1D2A71BD8A009F6462 /* Settings */,
+                               B5278B1B2A71BD2E009F6462 /* Popovers */,
+                               B5278B1A2A71BD1F009F6462 /* Windows */,
+                               B5278B192A71BD1A009F6462 /* Screens */,
+                       );
+                       path = Presentation;
+                       sourceTree = "<group>";
+               };
+               B5278B192A71BD1A009F6462 /* Screens */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */,
+                       );
+                       path = Screens;
+                       sourceTree = "<group>";
+               };
+               B5278B1A2A71BD1F009F6462 /* Windows */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
+                               B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */,
+                               B5278B222A71C140009F6462 /* PreferencesWindow.swift */,
+                       );
+                       path = Windows;
+                       sourceTree = "<group>";
+               };
+               B5278B1B2A71BD2E009F6462 /* Popovers */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B5278B162A71528F009F6462 /* HelpPopoverViewController.swift */,
+                       );
+                       path = Popovers;
+                       sourceTree = "<group>";
+               };
+               B5278B1C2A71BD3C009F6462 /* Data */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
+                               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
+                               B5278B202A71BFC3009F6462 /* SettingsStructs.swift */,
+                       );
+                       path = Data;
+                       sourceTree = "<group>";
+               };
+               B5278B1D2A71BD8A009F6462 /* Settings */ = {
+                       isa = PBXGroup;
+                       children = (
+                               B5278B1E2A71BD9B009F6462 /* OutputSettings.swift */,
+                               B5278B242A71CA80009F6462 /* AdvancedSettings.swift */,
+                       );
+                       path = Settings;
+                       sourceTree = "<group>";
+               };
                B5F915452A6EF80D007ECE8E = {
                        isa = PBXGroup;
                        children = (
                B5F915502A6EF80D007ECE8E /* Captura */ = {
                        isa = PBXGroup;
                        children = (
-                               B5F915512A6EF80D007ECE8E /* CapturaApp.swift */,
-                               B5F915532A6EF80D007ECE8E /* PreferencesWindow.swift */,
+                               B5278B1C2A71BD3C009F6462 /* Data */,
+                               B5278B182A71BD10009F6462 /* Presentation */,
                                B5F915552A6EF80E007ECE8E /* Assets.xcassets */,
                                B5F9155A2A6EF80E007ECE8E /* Item.swift */,
                                B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */,
                                B5F915572A6EF80E007ECE8E /* Preview Content */,
-                               B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */,
-                               B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */,
-                               B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */,
                        );
                        path = Captura;
                        sourceTree = "<group>";
                        isa = PBXSourcesBuildPhase;
                        buildActionMask = 2147483647;
                        files = (
-                               B5F915542A6EF80D007ECE8E /* PreferencesWindow.swift in Sources */,
+                               B5F915542A6EF80D007ECE8E /* PreferencesScreen.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 */,
+                               B5278B232A71C140009F6462 /* PreferencesWindow.swift in Sources */,
+                               B5278B172A71528F009F6462 /* HelpPopoverViewController.swift in Sources */,
                                B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */,
+                               B5278B252A71CA80009F6462 /* AdvancedSettings.swift in Sources */,
                                B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */,
                        );
                        runOnlyForDeploymentPostprocessing = 0;
diff --git a/Captura.xcodeproj/xcuserdata/rbdr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Captura.xcodeproj/xcuserdata/rbdr.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
new file mode 100644 (file)
index 0000000..a710259
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Bucket
+   uuid = "64A0C242-D356-455C-8377-13FD423E21EF"
+   type = "1"
+   version = "2.0">
+</Bucket>
index fe097f1d5c7be5f19c0f2f7e1cd1b717c314438f..b288fda0a667f7cffc7e81497e29fe3c12400bfd 100644 (file)
@@ -7,11 +7,11 @@
        <key>com.apple.developer.icloud-container-identifiers</key>
        <array/>
        <key>com.apple.developer.icloud-services</key>
-       <array>
-               <string>CloudKit</string>
-       </array>
+       <array/>
        <key>com.apple.security.app-sandbox</key>
        <true/>
+       <key>com.apple.security.assets.pictures.read-write</key>
+       <true/>
        <key>com.apple.security.files.user-selected.read-only</key>
        <true/>
 </dict>
diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift
deleted file mode 100644 (file)
index 933e00b..0000000
+++ /dev/null
@@ -1,131 +0,0 @@
-import SwiftUI
-import SwiftData
-import Cocoa
-
-@main
-struct CapturaApp: App {
-  
-    @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
-
-    var body: some Scene {
-        WindowGroup {
-            PreferencesWindow()
-              .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesWindow"), allowing: Set(arrayLiteral: "*"))
-              .frame(width: 650, height: 450)
-        }
-        .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesWindow"))
-        .modelContainer(for: Item.self)
-      }
-}
-
-class CapturaAppDelegate: NSObject, NSApplicationDelegate {
-    
-    @Environment(\.openURL) var openURL
-    var statusItem: NSStatusItem!
-    var captureState: CaptureState = .idle
-    var recordingWindow: RecordingWindow? = nil
-
-    func applicationDidFinishLaunching(_ notification: Notification) {
-      setupMenu()
-      NotificationCenter.default.addObserver(
-        self,
-        selector: #selector(self.didReceiveNotification(_:)),
-        name: nil,
-        object: nil)
-      closeWindow()
-    }
-  
-    // MARK: - Setup Functions
-  
-  
-    private func setupMenu() {
-      statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
-
-      statusItem.button!.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
-      statusItem.isVisible = true
-      statusItem.menu = NSMenu()
-      
-      let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6")
-      recordItem.keyEquivalentModifierMask = [.command, .shift]
-      
-      statusItem.menu?.addItem(recordItem)
-      statusItem.menu?.addItem(NSMenuItem.separator())
-      
-      let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "")
-      statusItem.menu?.addItem(preferencesItem)
-      
-      let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "")
-      statusItem.menu?.addItem(quitItem)
-    }
-  
-    private func closeWindow() {
-      if let window = NSApplication.shared.windows.first {
-          window.close()
-      }
-    }
-  
-    // MARK: - UI Event Handlers
-
-    @objc private func onClickStartRecording() {
-      NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
-    }
-  
-    @objc private func onOpenPreferences() {
-      print("Preferences pressed")
-    }
-  
-    @objc private func onQuit() {
-      NSApplication.shared.terminate(self)
-    }
-  
-  
-    // MARK: - App State Event Listeners
-  
-    @objc func didReceiveNotification(_ notification: Notification) {
-      switch(notification.name) {
-      case .startAreaSelection:
-        startAreaSelection()
-      case .startRecording:
-        startRecording()
-      case .stopRecording:
-        stopRecording()
-      case .finalizeRecording:
-        finalizeRecording()
-      case .reset:
-        reset()
-      default:
-        return
-      }
-/*
-      if let data = notification.userInfo?["data"] as? String {
-          print("Data received: \(data)")
-      }
- */
-    }
-
-  
-    @objc func startAreaSelection() {
-      if captureState != .selectingArea {
-        captureState = .selectingArea
-        recordingWindow = RecordingWindow()
-      }
-    }
-  
-    func startRecording() {
-      captureState = .recording
-    }
-  
-    func stopRecording() {
-      captureState = .uploading
-    }
-  
-    func finalizeRecording() {
-      captureState = .uploaded
-    }
-  
-    func reset() {
-      captureState = .idle
-      recordingWindow?.close()
-      self.recordingWindow = nil
-    }
-}
diff --git a/Captura/Data/SettingsStructs.swift b/Captura/Data/SettingsStructs.swift
new file mode 100644 (file)
index 0000000..99f407c
--- /dev/null
@@ -0,0 +1,15 @@
+import Foundation
+
+enum OutputFormatSetting: Int {
+  case gifOnly = 0
+  case mp4Only = 1
+  case all = 2
+  
+  func shouldSaveGif() -> Bool {
+    return self == .gifOnly || self == .all
+  }
+  
+  func shouldSaveMp4() -> Bool {
+    return self == .mp4Only || self == .all
+  }
+}
diff --git a/Captura/PreferencesWindow.swift b/Captura/PreferencesWindow.swift
deleted file mode 100644 (file)
index fdb3a6c..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-import SwiftUI
-import SwiftData
-
-struct PreferencesWindow: View {
-    @Environment(\.modelContext) private var modelContext
-    @Query private var items: [Item]
-    
-    var body: some View {
-        NavigationView {
-            List {
-                ForEach(items) { item in
-                    NavigationLink {
-                        Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
-                    } label: {
-                        Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
-                    }
-                }
-                .onDelete(perform: deleteItems)
-            }
-            .toolbar {
-                ToolbarItem {
-                    Button(action: addItem) {
-                        Label("Add Item", systemImage: "plus")
-                    }
-                }
-            }
-            Text("Select an item")
-        }
-    }
-
-    private func addItem() {
-        withAnimation {
-            let newItem = Item(timestamp: Date())
-            modelContext.insert(newItem)
-        }
-    }
-
-    private func deleteItems(offsets: IndexSet) {
-        withAnimation {
-            for index in offsets {
-                modelContext.delete(items[index])
-            }
-        }
-    }
-}
-
-#Preview {
-  PreferencesWindow()
-        .modelContainer(for: Item.self, inMemory: true)
-}
diff --git a/Captura/Presentation/Popovers/HelpPopoverViewController.swift b/Captura/Presentation/Popovers/HelpPopoverViewController.swift
new file mode 100644 (file)
index 0000000..8a5d515
--- /dev/null
@@ -0,0 +1,36 @@
+import Cocoa
+
+class HelpPopoverViewController: NSViewController {
+  
+    var labelString: String = "Captura"
+    let textField = NSTextField()
+  
+    override func loadView() {
+      self.view = NSView()
+      self.view.frame = NSRect(x: 0, y: 0, width: 250, height: 40)
+
+      textField.stringValue = labelString
+      textField.font = NSFont(name: "Hiragino Mincho ProN", size: 12)
+      textField.isEditable = false
+      textField.isBezeled = false
+      textField.isSelectable = false
+      textField.backgroundColor = NSColor.clear
+      textField.sizeToFit()
+      
+      let x = (view.frame.width - textField.frame.width) / 2
+      let y = (view.frame.height - textField.frame.height) / 2
+      textField.frame.origin = NSPoint(x: x, y: y)
+
+      self.view.addSubview(textField)
+    }
+  
+  func updateLabel(_ newLabel: String) {
+    labelString = newLabel
+    textField.stringValue = labelString
+    textField.sizeToFit()
+
+    let x = (view.frame.width - textField.frame.width) / 2
+    let y = (view.frame.height - textField.frame.height) / 2
+    textField.frame.origin = NSPoint(x: x, y: y)
+  }
+}
diff --git a/Captura/Presentation/Screens/PreferencesScreen.swift b/Captura/Presentation/Screens/PreferencesScreen.swift
new file mode 100644 (file)
index 0000000..f809502
--- /dev/null
@@ -0,0 +1,23 @@
+import SwiftUI
+import SwiftData
+
+struct PreferencesScreen: View {
+    @Environment(\.modelContext) private var modelContext
+    @Query private var items: [Item]
+    
+    var body: some View {
+      TabView {
+        OutputSettings().tabItem {
+          Label("Output", systemImage: "video.fill")
+        }.padding(8.0)
+        AdvancedSettings().tabItem {
+          Label("Advanced", systemImage: "gear")
+        }.padding(8.0)
+      }.padding(16.0)
+    }
+}
+
+#Preview {
+  PreferencesScreen()
+        .modelContainer(for: Item.self, inMemory: true)
+}
diff --git a/Captura/Presentation/Settings/AdvancedSettings.swift b/Captura/Presentation/Settings/AdvancedSettings.swift
new file mode 100644 (file)
index 0000000..7ed78d9
--- /dev/null
@@ -0,0 +1,32 @@
+import SwiftUI
+
+struct AdvancedSettings: View {
+  
+  @AppStorage("backendUrl") var backendUrl: String = ""
+  @AppStorage("keepFiles") var keepFiles = true
+  
+  var body: some View {
+    Form {
+      VStack (alignment: .leading) {
+        LabeledContent("Backend URL") {
+          TextField("", text: $backendUrl)
+        }.font(.headline)
+        LabeledContent("Keep files after remote upload") {
+          Toggle("", isOn: $keepFiles)
+        }.font(.headline)
+        HStack {
+          Text("These settings can break things!")
+          Button {
+            print("Not yet!")
+          } label: {
+            Image(systemName: "info.circle")
+          }.buttonStyle(.borderless)
+        }
+      }
+    }
+  }
+}
+
+#Preview {
+  OutputSettings()
+}
diff --git a/Captura/Presentation/Settings/OutputSettings.swift b/Captura/Presentation/Settings/OutputSettings.swift
new file mode 100644 (file)
index 0000000..3b8e01c
--- /dev/null
@@ -0,0 +1,42 @@
+import SwiftUI
+
+struct OutputSettings: View {
+  
+  @AppStorage("outputFormats") var outputFormats: OutputFormatSetting = .all
+  @AppStorage("frameRate") var frameRate = 10.0
+  
+  var body: some View {
+    Form {
+      VStack (alignment: .leading) {
+        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")
+          }
+        }.font(.headline)
+        Picker(selection: $outputFormats, label: Text("Output Formats").font(.headline)) {
+          Text("GIF & MP4")
+            .tag(OutputFormatSetting.all)
+            .padding(.horizontal, 4.0)
+            .padding(.vertical, 2.0)
+          Text("Only GIF")
+            .tag(OutputFormatSetting.gifOnly)
+            .padding(.horizontal, 4.0)
+            .padding(.vertical, 2.0)
+          
+          Text("Only MP4")
+            .tag(OutputFormatSetting.mp4Only)
+            .padding(.horizontal, 4.0)
+            .padding(.vertical, 2.0)
+        }.pickerStyle(.radioGroup)
+      }
+    }
+  }
+}
+
+#Preview {
+  OutputSettings()
+}
diff --git a/Captura/Presentation/Windows/CapturaApp.swift b/Captura/Presentation/Windows/CapturaApp.swift
new file mode 100644 (file)
index 0000000..87e2560
--- /dev/null
@@ -0,0 +1,374 @@
+import SwiftUI
+import SwiftData
+import Cocoa
+import Combine
+import ReplayKit
+
+@main
+struct CapturaApp: App {
+  
+    @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
+
+    var body: some Scene {
+        WindowGroup {
+          PreferencesScreen()
+              .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*"))
+              .frame(width: 650, height: 450)
+        }
+        .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
+        .modelContainer(for: Item.self)
+      }
+}
+
+class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate {
+    
+  @Environment(\.openURL) var openURL
+  var statusItem: NSStatusItem!
+  var captureState: CaptureState = .idle
+  var recordingWindow: RecordingWindow? = nil
+  var preferencesWindow: PreferencesWindow? = nil
+  var boxListener: AnyCancellable? = nil
+  var popover: NSPopover? = nil
+  var helpShown = false
+  var receivedFrames = false
+  var captureSession: AVCaptureSession? = nil
+  var images: [CGImage] = []
+  var outputURL: URL? = nil
+  var gifCallbackTimer = ContinuousClock.now
+  var fps = UserDefaults.standard.integer(forKey: "frameRate")
+  var pixelDensity: CGFloat = 1.0
+  var stopTimer: DispatchWorkItem?
+  
+  func applicationDidFinishLaunching(_ notification: Notification) {
+    setupMenu()
+    NotificationCenter.default.addObserver(
+      self,
+      selector: #selector(self.didReceiveNotification(_:)),
+      name: nil,
+      object: nil)
+    closeWindow()
+  }
+  
+  // MARK: - Setup Functions
+  
+  
+  private func setupMenu() {
+    statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+    
+    if let button = statusItem.button {
+      button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
+    }
+    
+    statusItem.isVisible = true
+    statusItem.menu = NSMenu()
+    statusItem.menu?.delegate = self
+    
+    // Create the Popover
+    popover = NSPopover()
+    popover?.contentViewController = HelpPopoverViewController()
+    popover?.behavior = .transient
+    
+    
+    let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6")
+    recordItem.keyEquivalentModifierMask = [.command, .shift]
+    statusItem.menu?.addItem(recordItem)
+    statusItem.menu?.addItem(NSMenuItem.separator())
+    
+    let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "")
+    statusItem.menu?.addItem(preferencesItem)
+    
+    let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "")
+    statusItem.menu?.addItem(quitItem)
+  }
+  
+  private func closeWindow() {
+    if let window = NSApplication.shared.windows.first {
+      window.close()
+    }
+  }
+  
+  // MARK: - UI Event Handlers
+  
+  func menuWillOpen(_ menu: NSMenu) {
+    if captureState != .idle {
+      menu.cancelTracking()
+      if captureState == .recording {
+        stopRecording()
+      }
+    }
+  }
+  
+  @objc private func onClickStartRecording() {
+    NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
+  }
+  
+  @objc private func onOpenPreferences() {
+    NSApp.activate(ignoringOtherApps: true)
+    if preferencesWindow == nil {
+      preferencesWindow =  PreferencesWindow()
+    } else {
+      preferencesWindow?.makeKeyAndOrderFront(nil)
+    }
+  }
+  
+  @objc private func onQuit() {
+    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) {
+    switch(notification.name) {
+    case .startAreaSelection:
+      startAreaSelection()
+    case .startRecording:
+      startRecording()
+    case .stopRecording:
+      stopRecording()
+    case .finalizeRecording:
+      finalizeRecording()
+    case .reset:
+      reset()
+    default:
+      return
+    }
+    /*
+     if let data = notification.userInfo?["data"] as? String {
+     print("Data received: \(data)")
+     }
+     */
+  }
+  
+  
+  @objc func startAreaSelection() {
+    helpShown = false
+    NSApp.activate(ignoringOtherApps: true)
+    if captureState != .selectingArea {
+      captureState = .selectingArea
+      if let button = statusItem.button {
+        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.")
+                }
+              }
+            }
+        }
+      }
+    }
+  }
+  
+  func startRecording() {
+    captureState = .recording
+    fps = UserDefaults.standard.integer(forKey: "frameRate")
+    outputURL = 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()
+          
+          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: "stop.circle", 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")
+            let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
+            if outputFormatsSetting.shouldSaveMp4() {
+              movieFileOutput.startRecording(to: outputURL!, recordingDelegate: self)
+            }
+            
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
+              if !self.receivedFrames {
+                self.requestPermission()
+              }
+            }
+          }
+          
+          
+        } else {
+          print("Should error")
+        }
+      }
+    }
+  }
+  
+  func stopRecording() {
+    stopTimer?.cancel()
+    captureState = .uploading
+    captureSession?.stopRunning()
+    captureSession = nil
+    Task.detached {
+      if let outputURL = self.outputURL {
+        await self.createGif(url: outputURL.deletingPathExtension().appendingPathExtension("gif"))
+      }
+    }
+    reset()
+  }
+  
+  func finalizeRecording() {
+    captureState = .uploaded
+    // Stopping the recording
+  }
+  
+  func reset() {
+    if let button = statusItem.button {
+      button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
+    }
+    captureState = .idle
+    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)
+    }
+  }
+  
+  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+    receivedFrames = true
+    
+    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 = self.resize(image: cgImage, by: self.pixelDensity) {
+            self.images.append(cgImage)
+          }
+        }
+      }
+    }
+  }
+  
+  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)
+      self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
+      DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+        self.popover?.performClose(nil)
+      }
+    }
+  }
+  
+  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)
+    }
+  }
+  
+  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()
+  }
+
+}
diff --git a/Captura/Presentation/Windows/PreferencesWindow.swift b/Captura/Presentation/Windows/PreferencesWindow.swift
new file mode 100644 (file)
index 0000000..40eac0d
--- /dev/null
@@ -0,0 +1,20 @@
+import Cocoa
+import SwiftUI
+
+import Foundation
+
+class PreferencesWindow: NSWindow {
+  
+  init() {
+    super.init(
+      contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
+      styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
+      backing: .buffered,
+      defer: false)
+    super.center()
+    self.isReleasedWhenClosed = false
+    super.setFrameAutosaveName("Preferences Window")
+    super.contentView = NSHostingView(rootView: PreferencesScreen())
+    super.makeKeyAndOrderFront(nil)
+  }
+}
similarity index 77%
rename from Captura/RecordingWindow.swift
rename to Captura/Presentation/Windows/RecordingWindow.swift
index d737dec3461a11f8f713b751dbd6f34fc9e7716a..2bb9928067f7accd8caf361630909974dfc98a59 100644 (file)
@@ -1,8 +1,13 @@
 import Cocoa
+import Combine
 
 class RecordingWindow: NSWindow {
   
-  init() {
+  var pixelDensity: CGFloat {
+    self.screen?.backingScaleFactor ?? 1.0
+  }
+  
+  init(_ button: NSRect?) {
     
     let screens = NSScreen.screens
     var boundingBox = NSZeroRect
@@ -17,21 +22,26 @@ class RecordingWindow: NSWindow {
       defer: false)
 
     self.isReleasedWhenClosed = false
+    self.collectionBehavior = [.canJoinAllSpaces]
     self.center()
     self.isMovableByWindowBackground = false
     self.isMovable = false
     self.titlebarAppearsTransparent = true
     self.setFrame(boundingBox, display: true)
     self.titleVisibility = .hidden
-    self.contentView = RecordingContentView()
+    let recordingView = RecordingContentView()
+    recordingView.frame = boundingBox
+    recordingView.button = button
+    self.contentView = recordingView
     self.backgroundColor = NSColor(white: 1.0, alpha: 0.001)
     self.level = .screenSaver
     self.isOpaque = false
     self.hasShadow = false
     self.makeKeyAndOrderFront(nil)
-    self.makeFirstResponder(nil)
   }
   
+  // MARK: - Window Behavior Overrides
+  
   override func resetCursorRects() {
     super.resetCursorRects()
     let cursor = NSCursor.crosshair
@@ -48,34 +58,71 @@ class RecordingWindow: NSWindow {
   
   override func resignMain() {
     super.resignMain()
-    self.ignoresMouseEvents = false
+    if (self.contentView as? RecordingContentView)?.state != .recording {
+      self.ignoresMouseEvents = false
+    }
   }
   
   override func becomeMain() {
     super.becomeMain()
-    (self.contentView as? RecordingContentView)?.state = .idle
+    if (self.contentView as? RecordingContentView)?.state != .recording {
+      (self.contentView as? RecordingContentView)?.state = .idle
+    }
   }
 }
 
 enum RecordingWindowState {
-  case passthrough, idle, drawing, moving, resizing;
+  case passthrough, idle, drawing, moving, resizing, recording;
 }
 
 class RecordingContentView: NSView {
   
-  var state: RecordingWindowState = .idle
-  var box: NSRect? = nil
-  var mouseLocation: NSPoint = NSPoint()
-  var origin: NSPoint = NSPoint()
-  var boxOrigin: NSPoint = NSPoint()
+  public var button: NSRect? = nil
+  @Published public var box: NSRect? = nil
+  public var state: RecordingWindowState = .idle
+  private var mouseLocation: NSPoint = NSPoint()
+  private var origin: NSPoint = NSPoint()
+  private var boxOrigin: NSPoint = NSPoint()
   
-  var resizeBox: NSRect? {
+  private var resizeBox: NSRect? {
     if let box {
       return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
     }
     return nil
   }
   
+  private var shouldPassthrough: Bool {
+    state == .recording || state == .passthrough
+  }
+  
+  // MARK: - State changing API
+  
+  public func startRecording() {
+    state = .recording
+    window?.ignoresMouseEvents = true
+  }
+  
+  public func stopRecording() {
+    
+  }
+  
+  public func reset() {
+    state = .idle
+    window?.ignoresMouseEvents = false
+  }
+  
+  public func startPassthrough() {
+    state = .passthrough
+    window?.ignoresMouseEvents = true
+  }
+  
+  public func stopPassthrough() {
+    state = .idle
+    window?.ignoresMouseEvents = false
+  }
+  
+  // MARK: - View Behavior Overrides
+  
   override func updateTrackingAreas() {
     super.updateTrackingAreas()
     
@@ -92,20 +139,28 @@ class RecordingContentView: NSView {
     
     self.mouseLocation = self.convert(event.locationInWindow, from: nil)
     
-    if let box {
-      if resizeBox!.contains(mouseLocation) {
-        NSCursor.arrow.set()
-      } else {
-        if box.contains(mouseLocation) {
-          NSCursor.openHand.set()
+    if shouldPassthrough {
+      NSCursor.arrow.set()
+    } else {
+      if let box {
+        if resizeBox!.contains(mouseLocation) {
+          NSCursor.arrow.set()
         } else {
-          NSCursor.crosshair.set()
+          if box.contains(mouseLocation) {
+            NSCursor.openHand.set()
+          } else {
+            NSCursor.crosshair.set()
+          }
         }
+        if let button {
+          if button.contains(mouseLocation) {
+            NSCursor.arrow.set()
+          }
+        }
+      } else {
+        NSCursor.crosshair.set()
       }
-    } else {
-      NSCursor.crosshair.set()
     }
-    
 
     self.setNeedsDisplay(self.bounds)
   }
@@ -146,7 +201,7 @@ class RecordingContentView: NSView {
   }
 
   override func hitTest(_ point: NSPoint) -> NSView? {
-    return state == .passthrough ? nil : self
+    return shouldPassthrough ? nil : self
   }
       
   override var acceptsFirstResponder: Bool {
@@ -156,6 +211,14 @@ class RecordingContentView: NSView {
   override func mouseDown(with event: NSEvent) {
     self.origin = self.convert(event.locationInWindow, from: nil)
     if let box {
+      
+      if let button {
+        if button.contains(origin) {
+          NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
+          return
+        }
+      }
+      
       if resizeBox!.contains(origin) {
         self.origin = NSPoint(x: box.minX, y: box.maxY)
         state = .resizing
@@ -172,11 +235,12 @@ class RecordingContentView: NSView {
   }
 
   override func mouseUp(with event: NSEvent) {
-    state = .idle
+    if state != .recording {
+      state = .idle
+    }
   }
   
   override func keyDown(with event: NSEvent) {
-    print("key down")
     switch event.keyCode {
       case 53:  // Escape key
         NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
@@ -186,17 +250,17 @@ class RecordingContentView: NSView {
   }
   
   override func flagsChanged(with event: NSEvent) {
+    if state == .idle {
       if event.modifierFlags.contains(.shift) {
-        state = .passthrough
-        window?.ignoresMouseEvents = true
+        startPassthrough()
       } else {
-        state = .idle
-        window?.ignoresMouseEvents = false
+        stopPassthrough()
       }
+    }
   }
   
   override func draw(_ dirtyRect: NSRect) {
-    if state == .passthrough {
+    if shouldPassthrough {
       NSColor.clear.setFill()
     } else {
       NSColor(white: 1.0, alpha: 0.001).setFill()
@@ -261,6 +325,10 @@ class RecordingContentView: NSView {
       NSColor.white.setStroke()
       whiteBox.stroke()
       
+      if state == .recording {
+        return
+      }
+      
       if let resizeBox {
         let clearBox = NSBezierPath()
         clearBox.move(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
@@ -296,6 +364,8 @@ class RecordingContentView: NSView {
     drawText(string, mouseLocation)
   }
   
+  // MARK: - Utilities
+  
   private func drawText(_ text: NSString, _ location: NSPoint, _ isBottomRight: Bool = false) {
     
     let textAttributes = [