diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 286b92e..92871dc 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ /* Begin PBXBuildFile section */ 1FA4ABA79AB72914FE414A61 /* libPods-Delta.a in Frameworks */ = {isa = PBXBuildFile; fileRef = DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */; }; 87343D7B985519A5890A61C6 /* libPods-DeltaPreviews.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E3E5A45AB20C8A87754453B /* libPods-DeltaPreviews.a */; }; + ACF7E30D29F73D03000FE071 /* GameScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACF7E30C29F73D03000FE071 /* GameScreenshots.swift */; }; + ACF7E30F29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */; }; BF00BEA625B758AA00C8607D /* SystemBIOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF00BEA525B758AA00C8607D /* SystemBIOS.swift */; }; BF02D5DA1DDEBB3000A5E131 /* openvgdb.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */; }; BF04E6FF1DB8625C000F35D3 /* ControllerSkinsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF04E6FE1DB8625C000F35D3 /* ControllerSkinsViewController.swift */; }; @@ -257,6 +259,8 @@ 8ECE6641DE30D01EA30FE7F6 /* Pods-DeltaPreviews.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DeltaPreviews.release.xcconfig"; path = "Pods/Target Support Files/Pods-DeltaPreviews/Pods-DeltaPreviews.release.xcconfig"; sourceTree = ""; }; A01281C7023C0041B25963BE /* Pods-DeltaPreviews.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DeltaPreviews.debug.xcconfig"; path = "Pods/Target Support Files/Pods-DeltaPreviews/Pods-DeltaPreviews.debug.xcconfig"; sourceTree = ""; }; A19FF50F55441BC2B2248241 /* Pods-Delta.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Delta.release.xcconfig"; path = "Pods/Target Support Files/Pods-Delta/Pods-Delta.release.xcconfig"; sourceTree = ""; }; + ACF7E30C29F73D03000FE071 /* GameScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameScreenshots.swift; sourceTree = ""; }; + ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHPhotoLibrary+Authorization.swift"; sourceTree = ""; }; BF00BEA525B758AA00C8607D /* SystemBIOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemBIOS.swift; sourceTree = ""; }; BF02D5D91DDEBB3000A5E131 /* openvgdb.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = openvgdb.sqlite; sourceTree = ""; }; BF0418131D01E93400E85BCF /* GBADeltaCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = GBADeltaCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -526,6 +530,7 @@ D524F4A2273DE9C000D500B2 /* ProcessInfo+JIT.swift */, D524F4A4273DEBB400D500B2 /* ServerManager+Delta.swift */, D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */, + ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */, ); path = Extensions; sourceTree = ""; @@ -1068,6 +1073,7 @@ isa = PBXGroup; children = ( D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */, + ACF7E30C29F73D03000FE071 /* GameScreenshots.swift */, ); path = Features; sourceTree = ""; @@ -1554,6 +1560,8 @@ D5A9C00329DDED6D00A8D610 /* ExperimentalFeaturesView.swift in Sources */, BF1F45BF21AF676F00EF9895 /* Box.swift in Sources */, BF7AE80A1C2E8C7600B1B5BC /* UIColor+Delta.swift in Sources */, + ACF7E30F29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift in Sources */, + ACF7E30D29F73D03000FE071 /* GameScreenshots.swift in Sources */, BF797A2D1C2D339F00F1A000 /* UILabel+FontSize.swift in Sources */, BF80E1D21F13117000847008 /* ControllerInputsViewController.swift in Sources */, BF5942641E09BBB10051894B /* LoadControllerSkinImageOperation.swift in Sources */, diff --git a/Delta/Emulation/GameViewController.swift b/Delta/Emulation/GameViewController.swift index da88100..955948c 100644 --- a/Delta/Emulation/GameViewController.swift +++ b/Delta/Emulation/GameViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import Photos import DeltaCore import GBADeltaCore @@ -412,6 +413,9 @@ extension GameViewController pauseViewController.fastForwardItem?.action = { [unowned self] item in self.performFastForwardAction(activate: item.isSelected) } + pauseViewController.screenshotItem?.action = { [unowned self] item in + self.performScreenshotAction() + } pauseViewController.sustainButtonsItem?.isSelected = gameController.sustainedInputs.count > 0 pauseViewController.sustainButtonsItem?.action = { [unowned self, unowned pauseViewController] item in @@ -1078,6 +1082,73 @@ extension GameViewController emulatorCore.rate = emulatorCore.deltaCore.supportedRates.lowerBound } } + + func performScreenshotAction() + { + guard let snapshot = self.emulatorCore?.videoManager.snapshot() else { return } + + let imageScale = ExperimentalFeatures.shared.gameScreenshots.size?.rawValue ?? 1.0 + let imageSize = CGSize(width: snapshot.size.width * imageScale, height: snapshot.size.height * imageScale) + + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: imageSize, format: format) + + let scaledSnapshot = renderer.image { (context) in + context.cgContext.interpolationQuality = .none + snapshot.draw(in: CGRect(origin: .zero, size: imageSize)) + } + + if ExperimentalFeatures.shared.gameScreenshots.saveToPhotos + { + PHPhotoLibrary.runIfAuthorized + { + PHPhotoLibrary.saveUIImage(image: scaledSnapshot) + } + } + + if ExperimentalFeatures.shared.gameScreenshots.saveToFiles + { + guard let data = scaledSnapshot.pngData() else { return } + + let screenshotsDirectory = FileManager.default.documentsDirectory.appendingPathComponent("Screenshots") + + do + { + try FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true, attributes: nil) + } + catch + { + print(error) + } + + let date = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + + let fileName: URL + if let game = self.game as? Game + { + let filename = game.name + "_" + dateFormatter.string(from: date) + ".png" + fileName = screenshotsDirectory.appendingPathComponent(filename) + } + else + { + fileName = screenshotsDirectory.appendingPathComponent(dateFormatter.string(from: date) + ".png") + } + + do + { + try data.write(to: fileName) + } + catch + { + print(error) + } + } + + self.pauseViewController?.screenshotItem?.isSelected = false + } } //MARK: - GameViewControllerDelegate - diff --git a/Delta/Experimental Features/ExperimentalFeatures.swift b/Delta/Experimental Features/ExperimentalFeatures.swift index 40e2ac9..2d13cba 100644 --- a/Delta/Experimental Features/ExperimentalFeatures.swift +++ b/Delta/Experimental Features/ExperimentalFeatures.swift @@ -21,6 +21,11 @@ struct ExperimentalFeatures: FeatureContainer description: "Enable to show the Status Bar during gameplay.") var showStatusBar + @Feature(name: "Game Screenshots", + description: "When enabled, a Screenshot button will appear in the Pause Menu, allowing you to save a screenshot of your game. You can choose to save the screenshot to Photos or Files.", + options: GameScreenshotsOptions()) + var gameScreenshots + private init() { self.prepareFeatures() diff --git a/Delta/Experimental Features/Features/GameScreenshots.swift b/Delta/Experimental Features/Features/GameScreenshots.swift new file mode 100644 index 0000000..dfaa49f --- /dev/null +++ b/Delta/Experimental Features/Features/GameScreenshots.swift @@ -0,0 +1,54 @@ +// +// GameScreenshots.swift +// Delta +// +// Created by Chris Rittenhouse on 4/24/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI + +import DeltaFeatures + +enum ScreenshotSize: Double, CaseIterable, CustomStringConvertible +{ + case x5 = 5 + case x4 = 4 + case x3 = 3 + case x2 = 2 + + var description: String { + if #available(iOS 15, *) + { + let formattedText = self.rawValue.formatted(.number.decimalSeparator(strategy: .automatic)) + return "\(formattedText)x Size" + } + else + { + return "\(self.rawValue)x Size" + } + } +} + +extension ScreenshotSize: LocalizedOptionValue +{ + var localizedDescription: Text { + Text(self.description) + } + + static var localizedNilDescription: Text { + Text("Original Size") + } +} + +struct GameScreenshotsOptions +{ + @Option(name: "Save to Files", description: "Save the screenshot to the app's directory in Files.") + var saveToFiles: Bool = true + + @Option(name: "Save to Photos", description: "Save the screenshot to the Photo Library.") + var saveToPhotos: Bool = false + + @Option(name: "Image Size", description: "Choose the size of screenshots. This only increases the export size, it does not increase the quality.", values: ScreenshotSize.allCases) + var size: ScreenshotSize? +} diff --git a/Delta/Extensions/PHPhotoLibrary+Authorization.swift b/Delta/Extensions/PHPhotoLibrary+Authorization.swift new file mode 100644 index 0000000..f9f90be --- /dev/null +++ b/Delta/Extensions/PHPhotoLibrary+Authorization.swift @@ -0,0 +1,46 @@ +// +// PHPhotoLibrary+Authorization.swift +// Delta +// +// Created by Chris Rittenhouse on 4/24/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import Photos + +extension PHPhotoLibrary +{ + static func runIfAuthorized(code: @escaping () -> Void) + { + PHPhotoLibrary.requestAuthorization(for: .addOnly, handler: { success in + switch success + { + case .authorized: + code() + + case .denied, .restricted, .notDetermined, .limited: + break + } + }) + } + + static func saveUIImage(image: UIImage) + { + // Save the image to the Photos app + PHPhotoLibrary.shared().performChanges({ + PHAssetChangeRequest.creationRequestForAsset(from: image) + }, completionHandler: { success, error in + if success + { + // Image saved successfully + print("Image saved to Photos app.") + } + else + { + // Error saving image + print("Error saving image: \(error?.localizedDescription ?? "Unknown error")") + } + }) + } +} diff --git a/Delta/Pause Menu/PauseViewController.swift b/Delta/Pause Menu/PauseViewController.swift index 9a8df82..69d6fa4 100644 --- a/Delta/Pause Menu/PauseViewController.swift +++ b/Delta/Pause Menu/PauseViewController.swift @@ -19,7 +19,7 @@ class PauseViewController: UIViewController, PauseInfoProviding } var pauseItems: [MenuItem] { - return [self.saveStateItem, self.loadStateItem, self.cheatCodesItem, self.fastForwardItem, self.sustainButtonsItem].compactMap { $0 } + return [self.saveStateItem, self.loadStateItem, self.cheatCodesItem, self.fastForwardItem, self.sustainButtonsItem, self.screenshotItem].compactMap { $0 } } /// Pause Items @@ -28,6 +28,7 @@ class PauseViewController: UIViewController, PauseInfoProviding var cheatCodesItem: MenuItem? var fastForwardItem: MenuItem? var sustainButtonsItem: MenuItem? + var screenshotItem: MenuItem? /// PauseInfoProviding var pauseText: String? @@ -160,6 +161,7 @@ private extension PauseViewController self.cheatCodesItem = nil self.sustainButtonsItem = nil self.fastForwardItem = nil + self.screenshotItem = nil guard self.emulatorCore != nil else { return } @@ -179,6 +181,11 @@ private extension PauseViewController self.fastForwardItem = MenuItem(text: NSLocalizedString("Fast Forward", comment: ""), image: #imageLiteral(resourceName: "FastForward"), action: { _ in }) self.sustainButtonsItem = MenuItem(text: NSLocalizedString("Hold Buttons", comment: ""), image: #imageLiteral(resourceName: "SustainButtons"), action: { _ in }) + + if ExperimentalFeatures.shared.gameScreenshots.isEnabled + { + self.screenshotItem = MenuItem(text: NSLocalizedString("Screenshot", comment: ""), image: #imageLiteral(resourceName: "Screenshot"), action: { _ in }) + } } func updateSafeAreaInsets() diff --git a/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/Contents.json b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/Contents.json new file mode 100644 index 0000000..78687fb --- /dev/null +++ b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "screenshot.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "screenshot@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "screenshot@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot.png b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot.png new file mode 100644 index 0000000..0bb9952 Binary files /dev/null and b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot.png differ diff --git a/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@2x.png b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@2x.png new file mode 100644 index 0000000..51f5d0e Binary files /dev/null and b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@2x.png differ diff --git a/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@3x.png b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@3x.png new file mode 100644 index 0000000..229a704 Binary files /dev/null and b/Resources/Assets.xcassets/Pause Icons/Screenshot.imageset/screenshot@3x.png differ