X-Git-Url: https://git.r.bdr.sh/rbdr/captura/blobdiff_plain/e834022c9b363804d36045892a305204f2019216..c9b9e1d654ea697afad9f6427d94623bfdf55cce:/Captura/CapturaApp.swift?ds=inline diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift index 933e00b..4f1ceff 100644 --- a/Captura/CapturaApp.swift +++ b/Captura/CapturaApp.swift @@ -1,6 +1,8 @@ import SwiftUI import SwiftData import Cocoa +import Combine +import AVFoundation @main struct CapturaApp: App { @@ -9,123 +11,306 @@ struct CapturaApp: App { var body: some Scene { WindowGroup { - PreferencesWindow() - .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesWindow"), allowing: Set(arrayLiteral: "*")) + PreferencesScreen() + .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*")) .frame(width: 650, height: 450) } - .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesWindow")) - .modelContainer(for: Item.self) + .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen")) + .modelContainer(for: CapturaRemoteFile.self) } } -class CapturaAppDelegate: NSObject, NSApplicationDelegate { +class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { - @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() - } + @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 captureSession: CapturaCaptureSession? = nil + var images: [CGImage] = [] + var outputFile: CapturaFile? = nil + var gifCallbackTimer = ContinuousClock.now + var fps = CapturaSettings.frameRate + var pixelDensity: CGFloat = 1.0 + var stopTimer: DispatchWorkItem? - // MARK: - Setup Functions + 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() - } + + 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) + } - // MARK: - UI Event Handlers - - @objc private func onClickStartRecording() { - NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil) + private func closeWindow() { + if let window = NSApplication.shared.windows.first { + window.close() } + } + + // MARK: - UI Event Handlers - @objc private func onOpenPreferences() { - print("Preferences pressed") + func menuWillOpen(_ menu: NSMenu) { + if captureState != .idle { + menu.cancelTracking() + if captureState == .recording { + NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil) + } } + } + + @objc private func onClickStartRecording() { + NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil) + } - @objc private func onQuit() { - NSApplication.shared.terminate(self) + @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) + } - // MARK: - App State Event Listeners + // 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 + @objc func didReceiveNotification(_ notification: Notification) { + switch(notification.name) { + case .startAreaSelection: + startAreaSelection() + case .startRecording: + startRecording() + case .stopRecording: + stopRecording() + case .finalizeRecording: + DispatchQueue.main.async { + self.finalizeRecording() } -/* - if let data = notification.userInfo?["data"] as? String { - print("Data received: \(data)") + case .reset: + reset() + case .failedToStart: + failedToStart() + case .receivedFrame: + if let frame = notification.userInfo?["frame"] { + receivedFrame(frame as! CVImageBuffer) } - */ + default: + return } + } + + + 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) + 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 + updateImage() + fps = CapturaSettings.frameRate + outputFile = nil + images = []; + pixelDensity = recordingWindow?.pixelDensity ?? 1.0 + 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!) + + outputFile = CapturaFile() + if CapturaSettings.shouldSaveMp4 { + captureSession.startRecording(to: outputFile!.mp4URL) + } else { + captureSession.startRunning() + } + return + } + } + } + NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil) + } - @objc func startAreaSelection() { - if captureState != .selectingArea { - captureState = .selectingArea - recordingWindow = RecordingWindow() + func stopRecording() { + captureState = .uploading + updateImage() + stop() + + if !CapturaSettings.shouldSaveMp4 { + NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) + return + } + + Task.detached { + 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) } } + } - func startRecording() { - captureState = .recording + func finalizeRecording() { + captureState = .uploaded + copyToClipboard() + updateImage() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) } + } + + func reset() { + captureState = .idle + updateImage() + stop() + } - func stopRecording() { - captureState = .uploading + func receivedFrame(_ frame: CVImageBuffer) { + let now = ContinuousClock.now + + if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) { + gifCallbackTimer = now + DispatchQueue.main.async { + if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) { + self.images.append(cgImage) + } + } } + } - func finalizeRecording() { - captureState = .uploaded + 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 + - func reset() { - captureState = .idle - recordingWindow?.close() - self.recordingWindow = nil + 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) + self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.popover?.performClose(nil) + } + } + } + + 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 + } + + 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) + } } + } }