diff --git a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..5f080c7a7 --- /dev/null +++ b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "fedf09a893a63378a2e53f631cd833ae83a0c9ee7338eb8d153b04fd34aaf805", + "pins" : [ + { + "identity" : "swiftsvg", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mchoe/SwiftSVG", + "state" : { + "branch" : "master", + "revision" : "88b9ee086b29019e35f6f49c8e30e5552eb8fa9d" + } + } + ], + "version" : 3 +} diff --git a/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 000000000..6ae07b7b3 Binary files /dev/null and b/src/MeloNX/MeloNX.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h b/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h index 752e409dd..97835afee 100644 --- a/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h +++ b/src/MeloNX/MeloNX/App/Core/Headers/Ryujinx-Header.h @@ -50,7 +50,9 @@ char* installed_firmware_version(); void set_native_window(void *layerPtr); -void stop_emulation(bool shouldPause); +void pause_emulation(bool shouldPause); + +void stop_emulation(); void initialize(); diff --git a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift index 7a634ec2b..6efb4edfe 100644 --- a/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift +++ b/src/MeloNX/MeloNX/App/Core/Ryujinx/Ryujinx.swift @@ -119,7 +119,7 @@ struct iOSNav: View { class Ryujinx : ObservableObject { - private var isRunning = false + @Published var isRunning = false let virtualController = VirtualController() @@ -147,6 +147,22 @@ class Ryujinx : ObservableObject { self.games = loadGames() } + func runloop(_ cool: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: "runOnMainThread") { + RunLoop.main.perform { + cool() + } + } else { + thread = Thread { + cool() + } + + thread.qualityOfService = .userInteractive + thread.name = "MeloNX" + thread.start() + } + } + public struct Configuration : Codable, Equatable { var gamepath: String var inputids: [String] @@ -238,7 +254,7 @@ class Ryujinx : ObservableObject { self.config = config - thread = Thread { [self] in + runloop { [self] in isRunning = true @@ -299,10 +315,6 @@ class Ryujinx : ObservableObject { } } } - - thread.qualityOfService = .userInteractive - thread.name = "MeloNX" - thread.start() } func saveArrayAsTextFile(strings: [String], filePath: String) { @@ -374,10 +386,6 @@ class Ryujinx : ObservableObject { thread.cancel() } - var running: Bool { - return isRunning - } - func loadGames() -> [Game] { let fileManager = FileManager.default @@ -468,6 +476,7 @@ class Ryujinx : ObservableObject { args.append(contentsOf: ["--aspect-ratio", config.aspectRatio.rawValue]) + if config.nintendoinput { args.append("--correct-controller") } diff --git a/src/MeloNX/MeloNX/App/Models/ToggleButtonsState.swift b/src/MeloNX/MeloNX/App/Models/ToggleButtonsState.swift new file mode 100644 index 000000000..08e6d9310 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Models/ToggleButtonsState.swift @@ -0,0 +1,27 @@ +// +// ToggleButtonsState.swift +// MeloNX +// +// Created by Stossy11 on 12/04/2025. +// + + +struct ToggleButtonsState: Codable, Equatable { + var toggle1: Bool + var toggle2: Bool + var toggle3: Bool + var toggle4: Bool + + init() { + self = .default + } + + init(toggle1: Bool, toggle2: Bool, toggle3: Bool, toggle4: Bool) { + self.toggle1 = toggle1 + self.toggle2 = toggle2 + self.toggle3 = toggle3 + self.toggle4 = toggle4 + } + + static let `default` = ToggleButtonsState(toggle1: false, toggle2: false, toggle3: false, toggle4: false) +} diff --git a/src/MeloNX/MeloNX/App/Views/Extensions/AppCodableStorage.swift b/src/MeloNX/MeloNX/App/Views/Extensions/AppCodableStorage.swift new file mode 100644 index 000000000..a85f19f94 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Views/Extensions/AppCodableStorage.swift @@ -0,0 +1,47 @@ +// +// AppCodableStorage.swift +// MeloNX +// +// Created by Stossy11 on 12/04/2025. +// + +import SwiftUI + +@propertyWrapper +struct AppCodableStorage: DynamicProperty { + @State private var value: Value + + private let key: String + private let defaultValue: Value + private let storage: UserDefaults + + init(wrappedValue defaultValue: Value, _ key: String, store: UserDefaults = .standard) { + self._value = State(initialValue: { + if let data = store.data(forKey: key), + let decoded = try? JSONDecoder().decode(Value.self, from: data) { + return decoded + } + return defaultValue + }()) + self.key = key + self.defaultValue = defaultValue + self.storage = store + } + + var wrappedValue: Value { + get { value } + nonmutating set { + value = newValue + if let data = try? JSONEncoder().encode(newValue) { + storage.set(data, forKey: key) + } + } + } + + var projectedValue: Binding { + Binding( + get: { self.wrappedValue }, + set: { newValue in self.wrappedValue = newValue } + ) + } +} diff --git a/src/MeloNX/MeloNX/App/Views/Main/Elements/FileImporter.swift b/src/MeloNX/MeloNX/App/Views/Main/Elements/FileImporter.swift new file mode 100644 index 000000000..bfae480a2 --- /dev/null +++ b/src/MeloNX/MeloNX/App/Views/Main/Elements/FileImporter.swift @@ -0,0 +1,125 @@ +// +// FileImporter.swift +// MeloNX +// +// Created by Stossy11 on 17/04/2025. +// + + +import SwiftUI +import UniformTypeIdentifiers + +class FileImporterManager: ObservableObject { + static let shared = FileImporterManager() + + private init() {} + + func importFiles(types: [UTType], allowMultiple: Bool = false, completion: @escaping (Result<[URL], Error>) -> Void) { + let id = "\(Unmanaged.passUnretained(completion as AnyObject).toOpaque())" + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .importFiles, + object: nil, + userInfo: [ + "id": id, + "types": types, + "allowMultiple": allowMultiple, + "completion": completion + ] + ) + } + } +} + +extension Notification.Name { + static let importFiles = Notification.Name("importFiles") +} + +struct FileImporterView: ViewModifier { + @State private var isImporterPresented: [String: Bool] = [:] + @State private var activeImporters: [String: ImporterConfig] = [:] + + struct ImporterConfig { + let types: [UTType] + let allowMultiple: Bool + let completion: (Result<[URL], Error>) -> Void + } + + func body(content: Content) -> some View { + content + .background( + ForEach(Array(activeImporters.keys), id: \.self) { id in + if let config = activeImporters[id] { + FileImporterWrapper( + isPresented: Binding( + get: { isImporterPresented[id] ?? false }, + set: { isImporterPresented[id] = $0 } + ), + id: id, + config: config, + onCompletion: { success in + if success { + DispatchQueue.main.async { + activeImporters.removeValue(forKey: id) + } + } + } + ) + } + } + ) + .onReceive(NotificationCenter.default.publisher(for: .importFiles)) { notification in + guard let userInfo = notification.userInfo, + let id = userInfo["id"] as? String, + let types = userInfo["types"] as? [UTType], + let allowMultiple = userInfo["allowMultiple"] as? Bool, + let completion = userInfo["completion"] as? ((Result<[URL], Error>) -> Void) else { + return + } + + let config = ImporterConfig( + types: types, + allowMultiple: allowMultiple, + completion: completion + ) + + activeImporters[id] = config + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isImporterPresented[id] = true + } + } + } +} + +struct FileImporterWrapper: View { + @Binding var isPresented: Bool + let id: String + let config: FileImporterView.ImporterConfig + let onCompletion: (Bool) -> Void + + var body: some View { + Text("wow") + .hidden() + .fileImporter( + isPresented: $isPresented, + allowedContentTypes: config.types, + allowsMultipleSelection: config.allowMultiple + ) { result in + switch result { + case .success(let urls): + config.completion(.success(urls)) + case .failure(let error): + config.completion(.failure(error)) + } + onCompletion(true) + } + } +} + +extension View { + func withFileImporter() -> some View { + self.modifier(FileImporterView()) + } +} diff --git a/src/MeloNX/MeloNX/App/Views/Main/ControllerView/ControllerView.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/ControllerView.swift similarity index 62% rename from src/MeloNX/MeloNX/App/Views/Main/ControllerView/ControllerView.swift rename to src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/ControllerView.swift index 3a40027d6..13c9829c0 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/ControllerView/ControllerView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/ControllerView.swift @@ -70,11 +70,11 @@ struct ControllerView: View { HStack { ButtonView(button: .leftStick) .padding() - ButtonView(button: .start) + ButtonView(button: .back) } HStack { - ButtonView(button: .back) + ButtonView(button: .start) ButtonView(button: .rightStick) .padding() } @@ -257,148 +257,180 @@ struct ABXYView: View { } } + struct ButtonView: View { var button: VirtualControllerButton - @State private var width: CGFloat = 45 - @State private var height: CGFloat = 45 - @State private var isPressed = false + @AppStorage("onscreenhandheld") var onscreenjoy: Bool = false - @Environment(\.presentationMode) var presentationMode @AppStorage("On-ScreenControllerScale") var controllerScale: Double = 1.0 - @State private var debounceTimer: Timer? + @Environment(\.presentationMode) var presentationMode + + @AppCodableStorage("toggleButtons") var toggleButtons = ToggleButtonsState() + @State private var istoggle = false + + @State private var isPressed = false + @State private var toggleState = false + + @State private var size: CGSize = .zero var body: some View { - Image(systemName: buttonText) - .resizable() - .scaledToFit() - .frame(width: width, height: height) - .foregroundColor(true ? Color.white.opacity(0.5) : Color.black.opacity(0.5)) + Circle() + .foregroundStyle(.clear.opacity(0)) + .overlay { + Image(systemName: buttonConfig.iconName) + .resizable() + .scaledToFit() + .frame(width: size.width, height: size.height) + .foregroundStyle(.white) + .opacity(isPressed ? 0.6 : 1.0) + .allowsHitTesting(false) + } + .frame(width: size.width, height: size.height) .background( - Group { - if !button.isTrigger && button != .leftStick && button != .rightStick { - Circle() - .fill(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3)) - .frame(width: width * 1.25, height: height * 1.25) - } else if button == .leftStick || button == .rightStick { - Image(systemName: buttonText) - .resizable() - .scaledToFit() - .frame(width: width * 1.25, height: height * 1.25) - .foregroundColor(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3)) - } else if button.isTrigger { - Image(systemName: "" + String(turntobutton(buttonText))) - .resizable() - .scaledToFit() - .frame(width: width * 1.25, height: height * 1.25) - .foregroundColor(true ? Color.gray.opacity(0.4) : Color.gray.opacity(0.3)) - } - } + buttonBackground ) - .opacity(isPressed ? 0.6 : 1.0) .gesture( DragGesture(minimumDistance: 0) - .onChanged { _ in - handleButtonPress() - } - .onEnded { _ in - handleButtonRelease() - } + .onChanged { _ in handleButtonPress() } + .onEnded { _ in handleButtonRelease() } ) .onAppear { - configureSizeForButton() + istoggle = (toggleButtons.toggle1 && button == .A) || (toggleButtons.toggle2 && button == .B) || (toggleButtons.toggle3 && button == .X) || (toggleButtons.toggle4 && button == .Y) + size = calculateButtonSize() + } + .onChange(of: controllerScale) { _ in + size = calculateButtonSize() } } - private func turntobutton(_ string: String) -> String { - var sting = string - if string.hasPrefix("zl") || string.hasPrefix("zr") { - sting = String(string.dropFirst(3)) - } else { - sting = String(string.dropFirst(2)) + private var buttonBackground: some View { + Group { + if !button.isTrigger && button != .leftStick && button != .rightStick { + Circle() + .fill(Color.gray.opacity(0.4)) + .frame(width: size.width * 1.25, height: size.height * 1.25) + } else if button == .leftStick || button == .rightStick { + Image(systemName: buttonConfig.iconName) + .resizable() + .scaledToFit() + .frame(width: size.width * 1.25, height: size.height * 1.25) + .foregroundColor(Color.gray.opacity(0.4)) + } else if button.isTrigger { + Image(systemName: convertTriggerIconToButton(buttonConfig.iconName)) + .resizable() + .scaledToFit() + .frame(width: size.width * 1.25, height: size.height * 1.25) + .foregroundColor(Color.gray.opacity(0.4)) + } + } + } + + private func convertTriggerIconToButton(_ iconName: String) -> String { + if iconName.hasPrefix("zl") || iconName.hasPrefix("zr") { + var converted = String(iconName.dropFirst(3)) + converted = converted.replacingOccurrences(of: "rectangle", with: "button") + converted = converted.replacingOccurrences(of: ".fill", with: ".horizontal.fill") + return converted + } else { + var converted = String(iconName.dropFirst(2)) + converted = converted.replacingOccurrences(of: "rectangle", with: "button") + converted = converted.replacingOccurrences(of: ".fill", with: ".horizontal.fill") + return converted } - sting = sting.replacingOccurrences(of: "rectangle", with: "button") - sting = sting.replacingOccurrences(of: ".fill", with: ".horizontal.fill") - - return sting } private func handleButtonPress() { - if !isPressed { + guard !isPressed || istoggle else { return } + + if istoggle { + toggleState.toggle() + isPressed = toggleState + let value = toggleState ? 1 : 0 + Ryujinx.shared.virtualController.setButtonState(Uint8(value), for: button) + Haptics.shared.play(.medium) + } else { isPressed = true - - debounceTimer?.invalidate() - Ryujinx.shared.virtualController.setButtonState(1, for: button) - Haptics.shared.play(.medium) } } private func handleButtonRelease() { - if isPressed { - isPressed = false - - debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false) { _ in - Ryujinx.shared.virtualController.setButtonState(0, for: button) - } + if istoggle { return } + + guard isPressed else { return } + + isPressed = false + DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 0.05) { + Ryujinx.shared.virtualController.setButtonState(0, for: button) } } - private func configureSizeForButton() { + private func calculateButtonSize() -> CGSize { + let baseWidth: CGFloat + let baseHeight: CGFloat + if button.isTrigger { - width = 70 - height = 40 + baseWidth = 70 + baseHeight = 40 } else if button.isSmall { - width = 35 - height = 35 + baseWidth = 35 + baseHeight = 35 + } else { + baseWidth = 45 + baseHeight = 45 } - // Adjust for iPad - if UIDevice.current.systemName.contains("iPadOS") { - width *= 1.2 - height *= 1.2 - } + let deviceMultiplier = UIDevice.current.userInterfaceIdiom == .pad ? 1.2 : 1.0 + let scaleMultiplier = CGFloat(controllerScale) - width *= CGFloat(controllerScale) - height *= CGFloat(controllerScale) + return CGSize( + width: baseWidth * deviceMultiplier * scaleMultiplier, + height: baseHeight * deviceMultiplier * scaleMultiplier + ) } - private var buttonText: String { + // Centralized button configuration + private var buttonConfig: ButtonConfiguration { switch button { case .A: - return "a.circle.fill" + return ButtonConfiguration(iconName: "a.circle.fill") case .B: - return "b.circle.fill" + return ButtonConfiguration(iconName: "b.circle.fill") case .X: - return "x.circle.fill" + return ButtonConfiguration(iconName: "x.circle.fill") case .Y: - return "y.circle.fill" + return ButtonConfiguration(iconName: "y.circle.fill") case .leftStick: - return "l.joystick.press.down.fill" + return ButtonConfiguration(iconName: "l.joystick.press.down.fill") case .rightStick: - return "r.joystick.press.down.fill" + return ButtonConfiguration(iconName: "r.joystick.press.down.fill") case .dPadUp: - return "arrowtriangle.up.circle.fill" + return ButtonConfiguration(iconName: "arrowtriangle.up.circle.fill") case .dPadDown: - return "arrowtriangle.down.circle.fill" + return ButtonConfiguration(iconName: "arrowtriangle.down.circle.fill") case .dPadLeft: - return "arrowtriangle.left.circle.fill" + return ButtonConfiguration(iconName: "arrowtriangle.left.circle.fill") case .dPadRight: - return "arrowtriangle.right.circle.fill" + return ButtonConfiguration(iconName: "arrowtriangle.right.circle.fill") case .leftTrigger: - return "zl.rectangle.roundedtop.fill" + return ButtonConfiguration(iconName: "zl.rectangle.roundedtop.fill") case .rightTrigger: - return "zr.rectangle.roundedtop.fill" + return ButtonConfiguration(iconName: "zr.rectangle.roundedtop.fill") case .leftShoulder: - return "l.rectangle.roundedbottom.fill" + return ButtonConfiguration(iconName: "l.rectangle.roundedbottom.fill") case .rightShoulder: - return "r.rectangle.roundedbottom.fill" + return ButtonConfiguration(iconName: "r.rectangle.roundedbottom.fill") case .start: - return "plus.circle.fill" + return ButtonConfiguration(iconName: "plus.circle.fill") case .back: - return "minus.circle.fill" + return ButtonConfiguration(iconName: "minus.circle.fill") case .guide: - return "house.circle.fill" + return ButtonConfiguration(iconName: "house.circle.fill") } } + + struct ButtonConfiguration { + let iconName: String + } } diff --git a/src/MeloNX/MeloNX/App/Views/Main/ControllerView/Haptics/Haptics.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Haptics/Haptics.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/ControllerView/Haptics/Haptics.swift rename to src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Haptics/Haptics.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/ControllerView/Joystick/Joystick.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Joystick/Joystick.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/ControllerView/Joystick/Joystick.swift rename to src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Joystick/Joystick.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/ControllerView/Joystick/JoystickView.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Joystick/JoystickView.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/ControllerView/Joystick/JoystickView.swift rename to src/MeloNX/MeloNX/App/Views/Main/Emulation/ControllerView/Joystick/JoystickView.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/Emulation/EmulationView/EmulationView.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/EmulationView/EmulationView.swift index 3114ad459..796154736 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/Emulation/EmulationView/EmulationView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/Emulation/EmulationView/EmulationView.swift @@ -65,28 +65,27 @@ struct EmulationView: View { Spacer() } - Spacer() if ssb { HStack { - Button { - if let screenshot = Ryujinx.shared.emulationUIView?.screenshot() { - UIImageWriteToSavedPhotosAlbum(screenshot, nil, nil, nil) + Image(systemName: "arrow.left.circle") + .resizable() + .frame(width: 50, height: 50) + .onTapGesture { + startgame = nil + stop_emulation() + try? Ryujinx.shared.stop() } - } label: { - Image(systemName: "square.and.arrow.up") - } - .frame(width: UIDevice.current.systemName.contains("iPadOS") ? 60 * 1.2 : 45, height: UIDevice.current.systemName.contains("iPadOS") ? 60 * 1.2 : 45) - .padding() + .padding() Spacer() - - - + } } + Spacer() + } } } @@ -102,13 +101,13 @@ struct EmulationView: View { .onChange(of: scenePhase) { newPhase in // Detect when the app enters the background if newPhase == .background { - stop_emulation(true) + pause_emulation(true) isInBackground = true } else if newPhase == .active { - stop_emulation(false) + pause_emulation(false) isInBackground = false } else if newPhase == .inactive { - stop_emulation(true) + pause_emulation(true) isInBackground = true } } diff --git a/src/MeloNX/MeloNX/App/Views/Main/Emulation/MetalView/MeloMTKView.swift b/src/MeloNX/MeloNX/App/Views/Main/Emulation/MetalView/MeloMTKView.swift index 95d5f66a3..442065c85 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/Emulation/MetalView/MeloMTKView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/Emulation/MetalView/MeloMTKView.swift @@ -87,40 +87,48 @@ class MeloMTKView: MTKView { override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) + let disabled = UserDefaults.standard.bool(forKey: "disableTouch") + setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9) - for touch in touches { - let location = touch.location(in: self) - if scaleToTargetResolution(location) == nil { - ignoredTouches.insert(touch) - continue + if !disabled { + for touch in touches { + let location = touch.location(in: self) + if scaleToTargetResolution(location) == nil { + ignoredTouches.insert(touch) + continue + } + + activeTouches.append(touch) + let index = activeTouches.firstIndex(of: touch)! + + let scaledLocation = scaleToTargetResolution(location)! + // // print("Touch began at: \(scaledLocation) and \(self.aspectRatio)") + touch_began(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index)) } - - activeTouches.append(touch) - let index = activeTouches.firstIndex(of: touch)! - - let scaledLocation = scaleToTargetResolution(location)! - // // print("Touch began at: \(scaledLocation) and \(self.aspectRatio)") - touch_began(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index)) } } override func touchesEnded(_ touches: Set, with event: UIEvent?) { super.touchesEnded(touches, with: event) + let disabled = UserDefaults.standard.bool(forKey: "disableTouch") + setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9) - for touch in touches { - if ignoredTouches.contains(touch) { - ignoredTouches.remove(touch) - continue - } - - if let index = activeTouches.firstIndex(of: touch) { - activeTouches.remove(at: index) + if !disabled { + for touch in touches { + if ignoredTouches.contains(touch) { + ignoredTouches.remove(touch) + continue + } - // // print("Touch ended for index \(index)") - touch_ended(Int32(index)) + if let index = activeTouches.firstIndex(of: touch) { + activeTouches.remove(at: index) + + // // print("Touch ended for index \(index)") + touch_ended(Int32(index)) + } } } } @@ -128,26 +136,30 @@ class MeloMTKView: MTKView { override func touchesMoved(_ touches: Set, with event: UIEvent?) { super.touchesMoved(touches, with: event) + let disabled = UserDefaults.standard.bool(forKey: "disableTouch") + setAspectRatio(Ryujinx.shared.config?.aspectRatio ?? .fixed16x9) - for touch in touches { - if ignoredTouches.contains(touch) { - continue - } - - let location = touch.location(in: self) - guard let scaledLocation = scaleToTargetResolution(location) else { - if let index = activeTouches.firstIndex(of: touch) { - activeTouches.remove(at: index) - // // print("Touch left active area, removed index \(index)") - touch_ended(Int32(index)) + if !disabled { + for touch in touches { + if ignoredTouches.contains(touch) { + continue + } + + let location = touch.location(in: self) + guard let scaledLocation = scaleToTargetResolution(location) else { + if let index = activeTouches.firstIndex(of: touch) { + activeTouches.remove(at: index) + // // print("Touch left active area, removed index \(index)") + touch_ended(Int32(index)) + } + continue + } + + if let index = activeTouches.firstIndex(of: touch) { + // // print("Touch moved to: \(scaledLocation)") + touch_moved(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index)) } - continue - } - - if let index = activeTouches.firstIndex(of: touch) { - // // print("Touch moved to: \(scaledLocation)") - touch_moved(Float(scaledLocation.x), Float(scaledLocation.y), Int32(index)) } } } diff --git a/src/MeloNX/MeloNX/App/Views/Main/ContentView.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift similarity index 99% rename from src/MeloNX/MeloNX/App/Views/Main/ContentView.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift index d03fcc3ae..f3f195d1f 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/ContentView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/UI/ContentView.swift @@ -45,7 +45,7 @@ struct ContentView: View { @AppStorage("quit") var quit: Bool = false @State var quits: Bool = false @AppStorage("MVK_CONFIG_PREFILL_METAL_COMMAND_BUFFERS") var mVKPreFillBuffer: Bool = true - @AppStorage("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS") var syncqsubmits: Bool = true + @AppStorage("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS") var syncqsubmits: Bool = false @AppStorage("ignoreJIT") var ignoreJIT: Bool = false // Loading Animation diff --git a/src/MeloNX/MeloNX/App/Views/Main/GamesList/GameCompatibility/GameCompatibilityCache.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameCompatibility/GameCompatibilityCache.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/GamesList/GameCompatibility/GameCompatibilityCache.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameCompatibility/GameCompatibilityCache.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/GamesList/GameInfoSheet.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameInfoSheet.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/GamesList/GameInfoSheet.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameInfoSheet.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/GamesList/GameListView.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameListView.swift similarity index 92% rename from src/MeloNX/MeloNX/App/Views/Main/GamesList/GameListView.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameListView.swift index c4a27beb4..029b1ca63 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/GamesList/GameListView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/UI/GamesList/GameListView.swift @@ -78,10 +78,7 @@ struct GameLibraryView: View { // Game list if Ryujinx.shared.games.isEmpty { - EmptyGameLibraryView( - isSelectingGameFile: $isSelectingGameFile, - isImporting: $isImporting - ) + EmptyGameLibraryView(isSelectingGameFile: $isSelectingGameFile) } else { gameListView .animation(.easeInOut(duration: 0.3), value: searchText) @@ -174,11 +171,29 @@ struct GameLibraryView: View { .onChange(of: searchText) { _ in isSearching = !searchText.isEmpty } - .fileImporter(isPresented: $isImporting, allowedContentTypes: [.folder, .nsp, .xci, .zip, .item]) { result in - handleFileImport(result: result) + .onChange(of: isImporting) { newValue in + if newValue { + FileImporterManager.shared.importFiles(types: [.nsp, .xci, .item]) { result in + isImporting = false + handleRunningGame(result: result) + } + } } - .fileImporter(isPresented: $firmwareInstaller, allowedContentTypes: [.item]) { result in - handleFirmwareImport(result: result) + .onChange(of: isSelectingGameFile) { newValue in + if newValue { + FileImporterManager.shared.importFiles(types: [.nsp, .xci, .item]) { result in + isImporting = false + handleAddingGame(result: result) + } + } + } + .onChange(of: firmwareInstaller) { newValue in + if newValue { + FileImporterManager.shared.importFiles(types: [.folder, .zip]) { result in + isImporting = false + handleFirmwareImport(result: result) + } + } } .sheet(isPresented: $isSelectingGameUpdate) { UpdateManagerSheet(game: $gameInfo) @@ -361,68 +376,72 @@ struct GameLibraryView: View { // MARK: - Import Handlers - private func handleFileImport(result: Result) { - if isSelectingGameFile { - switch result { - case .success(let url): - guard url.startAccessingSecurityScopedResource() else { - // print("Failed to access security-scoped resource") - return - } - defer { url.stopAccessingSecurityScopedResource() } - - do { - let fileManager = FileManager.default - let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! - let romsDirectory = documentsDirectory.appendingPathComponent("roms") - - if !fileManager.fileExists(atPath: romsDirectory.path) { - try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil) - } - - let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent) - try fileManager.copyItem(at: url, to: destinationURL) - - Ryujinx.shared.games = Ryujinx.shared.loadGames() - } catch { - // print("Error copying game file: \(error)") - } - case .failure(let err): - print("File import failed: \(err.localizedDescription)") + private func handleAddingGame(result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first, url.startAccessingSecurityScopedResource() else { + // print("Failed to access security-scoped resource") + return } - } else { - switch result { - case .success(let url): - guard url.startAccessingSecurityScopedResource() else { - // print("Failed to access security-scoped resource") - return + defer { url.stopAccessingSecurityScopedResource() } + + do { + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let romsDirectory = documentsDirectory.appendingPathComponent("roms") + + if !fileManager.fileExists(atPath: romsDirectory.path) { + try fileManager.createDirectory(at: romsDirectory, withIntermediateDirectories: true, attributes: nil) } - do { - let handle = try FileHandle(forReadingFrom: url) - let fileExtension = (url.pathExtension as NSString).utf8String - let extensionPtr = UnsafeMutablePointer(mutating: fileExtension) - - let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr) - - let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url) - - DispatchQueue.main.async { - startemu = game - } - } catch { - // print(error) - } + let destinationURL = romsDirectory.appendingPathComponent(url.lastPathComponent) + try fileManager.copyItem(at: url, to: destinationURL) - case .failure(let err): - print("File import failed: \(err.localizedDescription)") + Ryujinx.shared.games = Ryujinx.shared.loadGames() + } catch { + // print("Error copying game file: \(error)") } + case .failure(let err): + print("File import failed: \(err.localizedDescription)") } } - private func handleFirmwareImport(result: Result) { + private func handleRunningGame(result: Result<[URL], Error>) { + switch result { + case .success(let urls): + guard let url = urls.first, url.startAccessingSecurityScopedResource() else { + // print("Failed to access security-scoped resource") + return + } + + do { + let handle = try FileHandle(forReadingFrom: url) + let fileExtension = (url.pathExtension as NSString).utf8String + let extensionPtr = UnsafeMutablePointer(mutating: fileExtension) + + let gameInfo = get_game_info(handle.fileDescriptor, extensionPtr) + + let game = Game.convertGameInfoToGame(gameInfo: gameInfo, url: url) + + DispatchQueue.main.async { + startemu = game + } + } catch { + // print(error) + } + + case .failure(let err): + print("File import failed: \(err.localizedDescription)") + } + } + + private func handleFirmwareImport(result: Result<[URL], Error>) { switch result { case .success(let url): + guard let url = url.first else { + return + } + do { let fun = url.startAccessingSecurityScopedResource() let path = url.path @@ -527,7 +546,6 @@ extension Game: Codable { // MARK: - Empty Library View struct EmptyGameLibraryView: View { @Binding var isSelectingGameFile: Bool - @Binding var isImporting: Bool var body: some View { VStack(spacing: 24) { @@ -550,7 +568,6 @@ struct EmptyGameLibraryView: View { Button { isSelectingGameFile = true - isImporting = true } label: { Label("Add Game", systemImage: "plus") .font(.headline) diff --git a/src/MeloNX/MeloNX/App/Views/Main/JIT/JITPopover.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/JIT/JITPopover.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/JIT/JITPopover.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/JIT/JITPopover.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/Logging/Logs.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/Logging/Logs.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/Logging/Logs.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/Logging/Logs.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/SettingsView/SettingsView.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/SettingsView/SettingsView.swift similarity index 96% rename from src/MeloNX/MeloNX/App/Views/Main/SettingsView/SettingsView.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/SettingsView/SettingsView.swift index 5cc2b4bdb..9e1c7303b 100644 --- a/src/MeloNX/MeloNX/App/Views/Main/SettingsView/SettingsView.swift +++ b/src/MeloNX/MeloNX/App/Views/Main/UI/SettingsView/SettingsView.swift @@ -57,6 +57,13 @@ struct SettingsView: View { @AppStorage("HideButtons") var hideButtonsJoy = false @AppStorage("checkForUpdate") var checkForUpdate: Bool = true + + @AppStorage("disableTouch") var disableTouch = false + + @AppStorage("runOnMainThread") var runOnMainThread = false + + @AppCodableStorage("toggleButtons") var toggleButtons = ToggleButtonsState() + @State private var showResolutionInfo = false @State private var showAnisotropicInfo = false @@ -480,6 +487,16 @@ struct SettingsView: View { Divider() SettingsToggle(isOn: $swapBandA, icon: "rectangle.2.swap", label: "Swap Face Buttons (Physical Controller)") + + Divider() + + DisclosureGroup("Toggle Buttons") { + SettingsToggle(isOn: $toggleButtons.toggle1, icon: "circle.grid.cross.right.filled", label: "Toggle A") + SettingsToggle(isOn: $toggleButtons.toggle2, icon: "circle.grid.cross.down.filled", label: "Toggle B") + SettingsToggle(isOn: $toggleButtons.toggle3, icon: "circle.grid.cross.up.filled", label: "Toggle X") + SettingsToggle(isOn: $toggleButtons.toggle4, icon: "circle.grid.cross.left.filled", label: "Toggle Y") + } + .padding(.vertical, 6) } } @@ -727,6 +744,10 @@ struct SettingsView: View { // Advanced toggles card SettingsCard { VStack(spacing: 4) { + SettingsToggle(isOn: $runOnMainThread, icon: "square.stack.3d.up", label: "Run Core on Main Thread") + + Divider() + SettingsToggle(isOn: $config.dfsIntegrityChecks, icon: "checkmark.shield", label: "Disable FS Integrity Checks") Divider() @@ -837,8 +858,13 @@ struct SettingsView: View { SettingsSection(title: "Miscellaneous Options") { SettingsCard { VStack(spacing: 4) { + // Disable Touch card + SettingsToggle(isOn: $disableTouch, icon: "rectangle.and.hand.point.up.left.filled", label: "Disable Touch") + + Divider() + // Screenshot button card - SettingsToggle(isOn: $ssb, icon: "square.and.arrow.up", label: "Screenshot Button") + SettingsToggle(isOn: $ssb, icon: "arrow.left.circle", label: "Exit Button") Divider() diff --git a/src/MeloNX/MeloNX/App/Views/Main/TabView/TabView.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/TabView/TabView.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/TabView/TabView.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/TabView/TabView.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/Updates/App/MeloNXUpdateSheet.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/Updates/App/MeloNXUpdateSheet.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/Updates/App/MeloNXUpdateSheet.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/Updates/App/MeloNXUpdateSheet.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/Updates/Games/GameDLCManagerSheet.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/Updates/Games/GameDLCManagerSheet.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/Updates/Games/GameDLCManagerSheet.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/Updates/Games/GameDLCManagerSheet.swift diff --git a/src/MeloNX/MeloNX/App/Views/Main/Updates/Games/GameUpdateManagerSheet.swift b/src/MeloNX/MeloNX/App/Views/Main/UI/Updates/Games/GameUpdateManagerSheet.swift similarity index 100% rename from src/MeloNX/MeloNX/App/Views/Main/Updates/Games/GameUpdateManagerSheet.swift rename to src/MeloNX/MeloNX/App/Views/Main/UI/Updates/Games/GameUpdateManagerSheet.swift diff --git a/src/MeloNX/MeloNX/App/Views/MeloNXApp.swift b/src/MeloNX/MeloNX/App/Views/MeloNXApp.swift index 209b6fde6..eadc076e0 100644 --- a/src/MeloNX/MeloNX/App/Views/MeloNXApp.swift +++ b/src/MeloNX/MeloNX/App/Views/MeloNXApp.swift @@ -34,10 +34,15 @@ struct MeloNXApp: App { @AppStorage("location-enabled") var locationenabled: Bool = false @AppStorage("checkForUpdate") var checkForUpdate: Bool = true + @AppStorage("runOnMainThread") var runOnMainThread = false + + @AppStorage("autoJIT") var autoJIT = false + var body: some Scene { WindowGroup { if finishedStorage { ContentView() + .withFileImporter() .onAppear { if checkForUpdate { checkLatestVersion() @@ -59,10 +64,8 @@ struct MeloNXApp: App { } else { SetupView(finished: $finished) .onChange(of: finished) { newValue in - withAnimation { - withAnimation { - finishedStorage = newValue - } + withAnimation(.easeOut) { + finishedStorage = newValue } } } diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs index bf649541b..ecbba6c6b 100644 --- a/src/Ryujinx.Headless.SDL2/Program.cs +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -286,16 +286,6 @@ namespace Ryujinx.Headless.SDL2 { _contentManager = new ContentManager(_virtualFileSystem); } - - if (_accountManager == null) - { - _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, ""); - } - - if (_userChannelPersistence == null) - { - _userChannelPersistence = new UserChannelPersistence(); - } } static void Main(string[] args) @@ -402,8 +392,8 @@ namespace Ryujinx.Headless.SDL2 return String.Empty; } - [UnmanagedCallersOnly(EntryPoint = "stop_emulation")] - public static void StopEmulation(bool shouldPause) + [UnmanagedCallersOnly(EntryPoint = "pause_emulation")] + public static void PauseEmulation(bool shouldPause) { if (_window != null) { @@ -422,6 +412,15 @@ namespace Ryujinx.Headless.SDL2 } } + [UnmanagedCallersOnly(EntryPoint = "stop_emulation")] + public static void StopEmulation() + { + if (_window != null) + { + _window.Exit(); + } + } + [UnmanagedCallersOnly(EntryPoint = "get_game_info")] public static GameInfoNative GetGameInfoNative(int descriptor, IntPtr extensionPtr) { @@ -1134,42 +1133,22 @@ namespace Ryujinx.Headless.SDL2 static void Load(Options option) { + _libHacHorizonManager = new LibHacHorizonManager(); + _libHacHorizonManager.InitializeFsServer(_virtualFileSystem); + _libHacHorizonManager.InitializeArpServer(); + _libHacHorizonManager.InitializeBcatServer(); + _libHacHorizonManager.InitializeSystemClients(); - if (_virtualFileSystem == null) + _contentManager = new ContentManager(_virtualFileSystem); + + _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile); + + _userChannelPersistence = new UserChannelPersistence(); + + _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver()); + + if (OperatingSystem.IsIOS()) { - _virtualFileSystem = VirtualFileSystem.CreateInstance(); - } - - if (_libHacHorizonManager == null) - { - _libHacHorizonManager = new LibHacHorizonManager(); - _libHacHorizonManager.InitializeFsServer(_virtualFileSystem); - _libHacHorizonManager.InitializeArpServer(); - _libHacHorizonManager.InitializeBcatServer(); - _libHacHorizonManager.InitializeSystemClients(); - } - - if (_contentManager == null) - { - _contentManager = new ContentManager(_virtualFileSystem); - } - - if (_accountManager == null) - { - _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile); - } - - if (_userChannelPersistence == null) - { - _userChannelPersistence = new UserChannelPersistence(); - } - - if (_inputManager == null) - { - _inputManager = new InputManager(new SDL2KeyboardDriver(), new SDL2GamepadDriver()); - } - - if (OperatingSystem.IsIOS()) { Logger.Info?.Print(LogClass.Application, $"Current Device: {option.DisplayName} ({option.DeviceModel}) {Environment.OSVersion.Version}"); Logger.Info?.Print(LogClass.Application, $"Increased Memory Limit: {option.MemoryEnt}"); }