From: Ruben Beltran del Rio Date: Fri, 28 Jul 2023 06:53:26 +0000 (+0200) Subject: Separate some logic out of main app X-Git-Tag: 1.0.0~11 X-Git-Url: https://git.r.bdr.sh/rbdr/captura/commitdiff_plain/f5d16c1c8c259966338b23afd407d142f72784aa?ds=inline;hp=a4e804275517af683afa1733e2db7c383c306f2b Separate some logic out of main app --- diff --git a/Captura.xcodeproj/project.pbxproj b/Captura.xcodeproj/project.pbxproj index 98480e1..a506dc5 100644 --- a/Captura.xcodeproj/project.pbxproj +++ b/Captura.xcodeproj/project.pbxproj @@ -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 = ""; }; B5278B222A71C140009F6462 /* PreferencesWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesWindow.swift; sourceTree = ""; }; B5278B242A71CA80009F6462 /* AdvancedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedSettings.swift; sourceTree = ""; }; + B5278B272A739871009F6462 /* CGImage+resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+resize.swift"; sourceTree = ""; }; + B5278B292A73992D009F6462 /* GifRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifRenderer.swift; sourceTree = ""; }; + B5278B2B2A739B3A009F6462 /* CapturaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaFile.swift; sourceTree = ""; }; B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppEvents.swift"; sourceTree = ""; }; B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWindow.swift; sourceTree = ""; }; B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureState.swift; sourceTree = ""; }; @@ -131,8 +137,9 @@ isa = PBXGroup; children = ( B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */, - B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */, B5278B202A71BFC3009F6462 /* SettingsStructs.swift */, + B5278B292A73992D009F6462 /* GifRenderer.swift */, + B5278B2B2A739B3A009F6462 /* CapturaFile.swift */, ); path = Data; sourceTree = ""; @@ -146,6 +153,15 @@ path = Settings; sourceTree = ""; }; + B5278B262A739862009F6462 /* Core Extensions */ = { + isa = PBXGroup; + children = ( + B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */, + B5278B272A739871009F6462 /* CGImage+resize.swift */, + ); + path = "Core Extensions"; + sourceTree = ""; + }; B5F915452A6EF80D007ECE8E = { isa = PBXGroup; children = ( @@ -169,6 +185,7 @@ B5F915502A6EF80D007ECE8E /* Captura */ = { isa = PBXGroup; children = ( + B5278B262A739862009F6462 /* Core Extensions */, B5278B1C2A71BD3C009F6462 /* Data */, B5278B182A71BD10009F6462 /* Presentation */, B5F915552A6EF80E007ECE8E /* Assets.xcassets */, @@ -335,7 +352,10 @@ 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 index 0000000..110e85b --- /dev/null +++ b/Captura/Core Extensions/CGImage+resize.swift @@ -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/Notification+AppEvents.swift b/Captura/Core Extensions/Notification+AppEvents.swift similarity index 100% rename from Captura/Data/Notification+AppEvents.swift rename to Captura/Core Extensions/Notification+AppEvents.swift diff --git a/Captura/Data/CapturaFile.swift b/Captura/Data/CapturaFile.swift new file mode 100644 index 0000000..da1f2fb --- /dev/null +++ b/Captura/Data/CapturaFile.swift @@ -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 index 0000000..208d7db --- /dev/null +++ b/Captura/Data/GifRenderer.swift @@ -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) + } + } +} diff --git a/Captura/Presentation/Windows/CapturaApp.swift b/Captura/Presentation/Windows/CapturaApp.swift index 87e2560..6cf7044 100644 --- a/Captura/Presentation/Windows/CapturaApp.swift +++ b/Captura/Presentation/Windows/CapturaApp.swift @@ -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?) {} }