forked from MeloNX/MeloNX
Compare commits
6 Commits
9db3659abd
...
cb71eadb53
Author | SHA1 | Date | |
---|---|---|---|
cb71eadb53 | |||
b3bbd8963f | |||
f61287562a | |||
5d5f6c330f | |||
6934b56007 | |||
c54e1f298f |
@ -0,0 +1,237 @@
|
||||
//
|
||||
// NativeController.swift
|
||||
// MeloNX
|
||||
//
|
||||
// Created by XITRIX on 15/02/2025.
|
||||
//
|
||||
|
||||
import CoreHaptics
|
||||
import GameController
|
||||
|
||||
class NativeController: Hashable {
|
||||
private var instanceID: SDL_JoystickID = -1
|
||||
private var controller: OpaquePointer?
|
||||
private var nativeController: GCController
|
||||
private let controllerHaptics: CHHapticEngine?
|
||||
|
||||
public var controllername: String { "GC - \(nativeController.vendorName ?? "Unknown")" }
|
||||
|
||||
init(_ controller: GCController) {
|
||||
nativeController = controller
|
||||
controllerHaptics = nativeController.haptics?.createEngine(withLocality: .default)
|
||||
try? controllerHaptics?.start()
|
||||
setupHandheldController()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
private func setupHandheldController() {
|
||||
if SDL_WasInit(Uint32(SDL_INIT_GAMECONTROLLER)) == 0 {
|
||||
SDL_InitSubSystem(Uint32(SDL_INIT_GAMECONTROLLER))
|
||||
}
|
||||
|
||||
var joystickDesc = SDL_VirtualJoystickDesc(
|
||||
version: UInt16(SDL_VIRTUAL_JOYSTICK_DESC_VERSION),
|
||||
type: Uint16(SDL_JOYSTICK_TYPE_GAMECONTROLLER.rawValue),
|
||||
naxes: 6,
|
||||
nbuttons: 15,
|
||||
nhats: 1,
|
||||
vendor_id: 0,
|
||||
product_id: 0,
|
||||
padding: 0,
|
||||
button_mask: 0,
|
||||
axis_mask: 0,
|
||||
name: (controllername as NSString).utf8String,
|
||||
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
||||
Update: { userdata in
|
||||
// Update joystick state here
|
||||
},
|
||||
SetPlayerIndex: { userdata, playerIndex in
|
||||
print("Player index set to \(playerIndex)")
|
||||
},
|
||||
Rumble: { userdata, lowFreq, highFreq in
|
||||
print("Rumble with \(lowFreq), \(highFreq)")
|
||||
guard let userdata else { return 0 }
|
||||
let _self = Unmanaged<NativeController>.fromOpaque(userdata).takeUnretainedValue()
|
||||
VirtualController.rumble(lowFreq: Float(lowFreq), highFreq: Float(highFreq), engine: _self.controllerHaptics)
|
||||
return 0
|
||||
},
|
||||
RumbleTriggers: { userdata, leftRumble, rightRumble in
|
||||
print("Trigger rumble with \(leftRumble), \(rightRumble)")
|
||||
return 0
|
||||
},
|
||||
SetLED: { userdata, red, green, blue in
|
||||
print("Set LED to RGB(\(red), \(green), \(blue))")
|
||||
return 0
|
||||
},
|
||||
SendEffect: { userdata, data, size in
|
||||
print("Effect sent with size \(size)")
|
||||
return 0
|
||||
}
|
||||
)
|
||||
|
||||
instanceID = SDL_JoystickAttachVirtualEx(&joystickDesc)// SDL_JoystickAttachVirtual(SDL_JoystickType(SDL_JOYSTICK_TYPE_GAMECONTROLLER.rawValue), 6, 15, 1)
|
||||
if instanceID < 0 {
|
||||
print("Failed to create virtual joystick: \(String(cString: SDL_GetError()))")
|
||||
return
|
||||
}
|
||||
|
||||
// Open a game controller for the virtual joystick
|
||||
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||
controller = SDL_GameControllerOpen(Int32(instanceID))
|
||||
|
||||
if controller == nil {
|
||||
print("Failed to create virtual controller: \(String(cString: SDL_GetError()))")
|
||||
return
|
||||
}
|
||||
|
||||
if #available(iOS 16, *) {
|
||||
guard let gamepad = nativeController.extendedGamepad
|
||||
else { return }
|
||||
|
||||
setupButtonChangeListener(gamepad.buttonA, for: .B)
|
||||
setupButtonChangeListener(gamepad.buttonB, for: .A)
|
||||
setupButtonChangeListener(gamepad.buttonX, for: .Y)
|
||||
setupButtonChangeListener(gamepad.buttonY, for: .X)
|
||||
|
||||
setupButtonChangeListener(gamepad.dpad.up, for: .dPadUp)
|
||||
setupButtonChangeListener(gamepad.dpad.down, for: .dPadDown)
|
||||
setupButtonChangeListener(gamepad.dpad.left, for: .dPadLeft)
|
||||
setupButtonChangeListener(gamepad.dpad.right, for: .dPadRight)
|
||||
|
||||
setupButtonChangeListener(gamepad.leftShoulder, for: .leftShoulder)
|
||||
setupButtonChangeListener(gamepad.rightShoulder, for: .rightShoulder)
|
||||
gamepad.leftThumbstickButton.map { setupButtonChangeListener($0, for: .leftStick) }
|
||||
gamepad.rightThumbstickButton.map { setupButtonChangeListener($0, for: .rightStick) }
|
||||
|
||||
setupButtonChangeListener(gamepad.buttonMenu, for: .start)
|
||||
gamepad.buttonOptions.map { setupButtonChangeListener($0, for: .back) }
|
||||
|
||||
setupStickChangeListener(gamepad.leftThumbstick, for: .left)
|
||||
setupStickChangeListener(gamepad.rightThumbstick, for: .right)
|
||||
|
||||
setupTriggerChangeListener(gamepad.leftTrigger, for: .left)
|
||||
setupTriggerChangeListener(gamepad.rightTrigger, for: .right)
|
||||
}
|
||||
}
|
||||
|
||||
func setupButtonChangeListener(_ button: GCControllerButtonInput, for key: VirtualControllerButton) {
|
||||
button.valueChangedHandler = { [unowned self] _, _, pressed in
|
||||
setButtonState(pressed ? 1 : 0, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
func setupStickChangeListener(_ button: GCControllerDirectionPad, for key: ThumbstickType) {
|
||||
button.valueChangedHandler = { [unowned self] _, xValue, yValue in
|
||||
let scaledX = Sint16(xValue * 32767.0)
|
||||
let scaledY = -Sint16(yValue * 32767.0)
|
||||
|
||||
switch key {
|
||||
case .left:
|
||||
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTX.rawValue))
|
||||
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTY.rawValue))
|
||||
case .right:
|
||||
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTX.rawValue))
|
||||
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTY.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupTriggerChangeListener(_ button: GCControllerButtonInput, for key: ThumbstickType) {
|
||||
button.valueChangedHandler = { [unowned self] _, value, pressed in
|
||||
// print("Value: \(value), Is pressed: \(pressed)")
|
||||
let axis: SDL_GameControllerAxis = (key == .left) ? SDL_CONTROLLER_AXIS_TRIGGERLEFT : SDL_CONTROLLER_AXIS_TRIGGERRIGHT
|
||||
let scaledValue = Sint16(value * 32767.0)
|
||||
updateAxisValue(value: scaledValue, forAxis: axis)
|
||||
}
|
||||
}
|
||||
|
||||
static func rumble(lowFreq: Float, highFreq: Float) {
|
||||
do {
|
||||
// Low-frequency haptic pattern
|
||||
let lowFreqPattern = try CHHapticPattern(events: [
|
||||
CHHapticEvent(eventType: .hapticTransient, parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: lowFreq),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
|
||||
], relativeTime: 0, duration: 0.2)
|
||||
], parameters: [])
|
||||
|
||||
// High-frequency haptic pattern
|
||||
let highFreqPattern = try CHHapticPattern(events: [
|
||||
CHHapticEvent(eventType: .hapticTransient, parameters: [
|
||||
CHHapticEventParameter(parameterID: .hapticIntensity, value: highFreq),
|
||||
CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
|
||||
], relativeTime: 0.2, duration: 0.2)
|
||||
], parameters: [])
|
||||
|
||||
// Create and start the haptic engine
|
||||
let engine = try CHHapticEngine()
|
||||
try engine.start()
|
||||
|
||||
// Create and play the low-frequency player
|
||||
let lowFreqPlayer = try engine.makePlayer(with: lowFreqPattern)
|
||||
try lowFreqPlayer.start(atTime: 0)
|
||||
|
||||
// Create and play the high-frequency player after a short delay
|
||||
let highFreqPlayer = try engine.makePlayer(with: highFreqPattern)
|
||||
try highFreqPlayer.start(atTime: 0.2)
|
||||
|
||||
} catch {
|
||||
print("Error creating haptic patterns: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func updateAxisValue(value: Sint16, forAxis axis: SDL_GameControllerAxis) {
|
||||
guard controller != nil else { return }
|
||||
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||
SDL_JoystickSetVirtualAxis(joystick, axis.rawValue, value)
|
||||
}
|
||||
|
||||
func thumbstickMoved(_ stick: ThumbstickType, x: Double, y: Double) {
|
||||
let scaleFactor = 32767.0 / 160
|
||||
|
||||
let scaledX = Int16(min(32767.0, max(-32768.0, x * scaleFactor)))
|
||||
let scaledY = Int16(min(32767.0, max(-32768.0, y * scaleFactor)))
|
||||
|
||||
if stick == .right {
|
||||
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTX.rawValue))
|
||||
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_RIGHTY.rawValue))
|
||||
} else { // ThumbstickType.left
|
||||
updateAxisValue(value: scaledX, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTX.rawValue))
|
||||
updateAxisValue(value: scaledY, forAxis: SDL_GameControllerAxis(SDL_CONTROLLER_AXIS_LEFTY.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
func setButtonState(_ state: Uint8, for button: VirtualControllerButton) {
|
||||
guard controller != nil else { return }
|
||||
|
||||
// print("Button: \(button.rawValue) {state: \(state)}")
|
||||
if (button == .leftTrigger || button == .rightTrigger) && (state == 1 || state == 0) {
|
||||
let axis: SDL_GameControllerAxis = (button == .leftTrigger) ? SDL_CONTROLLER_AXIS_TRIGGERLEFT : SDL_CONTROLLER_AXIS_TRIGGERRIGHT
|
||||
let value: Int = (state == 1) ? 32767 : 0
|
||||
updateAxisValue(value: Sint16(value), forAxis: axis)
|
||||
} else {
|
||||
let joystick = SDL_JoystickFromInstanceID(instanceID)
|
||||
SDL_JoystickSetVirtualButton(joystick, Int32(button.rawValue), state)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanup() {
|
||||
if let controller {
|
||||
SDL_JoystickDetachVirtual(instanceID)
|
||||
SDL_GameControllerClose(controller)
|
||||
self.controller = nil
|
||||
}
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(nativeController)
|
||||
}
|
||||
|
||||
static func == (lhs: NativeController, rhs: NativeController) -> Bool {
|
||||
lhs.nativeController == rhs.nativeController
|
||||
}
|
||||
}
|
@ -78,7 +78,7 @@ class VirtualController {
|
||||
}
|
||||
}
|
||||
|
||||
static func rumble(lowFreq: Float, highFreq: Float) {
|
||||
static func rumble(lowFreq: Float, highFreq: Float, engine: CHHapticEngine? = nil) {
|
||||
do {
|
||||
// Low-frequency haptic pattern
|
||||
let lowFreqPattern = try CHHapticPattern(events: [
|
||||
@ -96,9 +96,23 @@ class VirtualController {
|
||||
], relativeTime: 0.2, duration: 0.2)
|
||||
], parameters: [])
|
||||
|
||||
// Create and start the haptic engine
|
||||
let engine = try CHHapticEngine()
|
||||
try engine.start()
|
||||
// Mutable engine
|
||||
var engine = engine
|
||||
|
||||
// If no engine passed, use device engine
|
||||
if engine == nil {
|
||||
// Create and start the haptic engine
|
||||
if hapticEngine == nil {
|
||||
hapticEngine = try CHHapticEngine()
|
||||
try hapticEngine?.start()
|
||||
}
|
||||
|
||||
engine = hapticEngine
|
||||
}
|
||||
|
||||
guard let engine else {
|
||||
return print("Error creating haptic patterns: hapticEngine is nil")
|
||||
}
|
||||
|
||||
// Create and play the low-frequency player
|
||||
let lowFreqPlayer = try engine.makePlayer(with: lowFreqPattern)
|
||||
@ -113,6 +127,8 @@ class VirtualController {
|
||||
}
|
||||
}
|
||||
|
||||
private static var hapticEngine: CHHapticEngine?
|
||||
|
||||
|
||||
func updateAxisValue(value: Sint16, forAxis axis: SDL_GameControllerAxis) {
|
||||
guard controller != nil else { return }
|
||||
|
@ -26,6 +26,7 @@ struct ContentView: View {
|
||||
@State private var controllersList: [Controller] = []
|
||||
@State private var currentControllers: [Controller] = []
|
||||
@State var onscreencontroller: Controller = Controller(id: "", name: "")
|
||||
@State var nativeControllers: [GCController: NativeController] = [:]
|
||||
@State private var isVirtualControllerActive: Bool = false
|
||||
@AppStorage("isVirtualController") var isVCA: Bool = true
|
||||
|
||||
@ -152,6 +153,7 @@ struct ContentView: View {
|
||||
queue: .main) { notification in
|
||||
if let controller = notification.object as? GCController {
|
||||
print("Controller connected: \(controller.productCategory)")
|
||||
nativeControllers[controller] = .init(controller)
|
||||
refreshControllersList()
|
||||
}
|
||||
}
|
||||
@ -163,6 +165,8 @@ struct ContentView: View {
|
||||
queue: .main) { notification in
|
||||
if let controller = notification.object as? GCController {
|
||||
print("Controller disconnected: \(controller.productCategory)")
|
||||
nativeControllers[controller]?.cleanup()
|
||||
nativeControllers[controller] = nil
|
||||
refreshControllersList()
|
||||
}
|
||||
}
|
||||
@ -306,7 +310,8 @@ struct ContentView: View {
|
||||
self.onscreencontroller = onscreen
|
||||
}
|
||||
|
||||
controllersList.removeAll(where: { $0.id == "0"})
|
||||
controllersList.removeAll(where: { $0.id == "0" || (!$0.name.starts(with: "GC - ") && $0 != onscreencontroller) })
|
||||
controllersList.mutableForEach { $0.name = $0.name.replacingOccurrences(of: "GC - ", with: "") }
|
||||
|
||||
currentControllers = []
|
||||
|
||||
@ -397,3 +402,10 @@ func loadSettings() -> Ryujinx.Configuration? {
|
||||
}
|
||||
}
|
||||
|
||||
extension Array {
|
||||
@inlinable public mutating func mutableForEach(_ body: (inout Element) throws -> Void) rethrows {
|
||||
for index in self.indices {
|
||||
try body(&self[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,45 +14,51 @@ struct GameInfoSheet: View {
|
||||
|
||||
var body: some View {
|
||||
iOSNav {
|
||||
VStack {
|
||||
if let icon = game.icon {
|
||||
Image(uiImage: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 250, height: 250)
|
||||
.cornerRadius(10)
|
||||
.padding()
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(icon, nil, nil, nil)
|
||||
} label: {
|
||||
Label("Save to Photos", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
List {
|
||||
Section {}
|
||||
header: {
|
||||
VStack(alignment: .center) {
|
||||
if let icon = game.icon {
|
||||
Image(uiImage: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 250, height: 250)
|
||||
.cornerRadius(10)
|
||||
.padding()
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIImageWriteToSavedPhotosAlbum(icon, nil, nil, nil)
|
||||
} label: {
|
||||
Label("Save to Photos", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 150, height: 150)
|
||||
.padding()
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 150, height: 150)
|
||||
.padding()
|
||||
VStack(alignment: .center) {
|
||||
Text("**\(game.titleName)** | \(game.titleId.capitalized)")
|
||||
Text(game.developer)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("**\(game.titleName)** | \(game.titleId.capitalized)")
|
||||
Text(game.developer)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Section {
|
||||
HStack {
|
||||
Text("**Version**")
|
||||
Spacer()
|
||||
Text(game.version)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Information")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
|
||||
Text("**Version:** \(game.version)")
|
||||
Text("**Title ID:** \(game.titleId)")
|
||||
HStack {
|
||||
Text("**Title ID**")
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = game.titleId
|
||||
@ -60,15 +66,32 @@ struct GameInfoSheet: View {
|
||||
Text("Copy Title ID")
|
||||
}
|
||||
}
|
||||
Text("**Game Size:** \(fetchFileSize(for: game.fileURL) ?? 0) bytes")
|
||||
Text("**File Type:** .\(getFileType(game.fileURL))")
|
||||
Text("**Game URL:** \(trimGameURL(game.fileURL))")
|
||||
Spacer()
|
||||
Text(game.titleId)
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("**Game Size**")
|
||||
Spacer()
|
||||
Text("\(fetchFileSize(for: game.fileURL) ?? 0) bytes")
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("**File Type**")
|
||||
Spacer()
|
||||
Text(getFileType(game.fileURL))
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("**Game URL**")
|
||||
Text(trimGameURL(game.fileURL))
|
||||
.foregroundStyle(Color.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Information")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.headerProminence(.increased)
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.navigationTitle(game.titleName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@ -103,10 +126,6 @@ struct GameInfoSheet: View {
|
||||
}
|
||||
|
||||
func getFileType(_ url: URL) -> String {
|
||||
let path = url.path
|
||||
if let range = path.range(of: ".") {
|
||||
return String(path[range.upperBound...])
|
||||
}
|
||||
return "Unknown"
|
||||
url.pathExtension
|
||||
}
|
||||
}
|
||||
|
@ -42,72 +42,54 @@ struct GameLibraryView: View {
|
||||
}
|
||||
return Ryujinx.shared.games.filter {
|
||||
$0.titleName.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.developer.localizedCaseInsensitiveContains(searchText)
|
||||
$0.developer.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
iOSNav {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 20) {
|
||||
if !isSearching {
|
||||
Text("Games")
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
List {
|
||||
if Ryujinx.shared.games.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
.padding(.top, 60)
|
||||
Text("No Games Found")
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.primary)
|
||||
Text("Add ROM, Keys and Firmware to get started")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if Ryujinx.shared.games.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
.padding(.top, 60)
|
||||
Text("No Games Found")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
if !isSearching && !recentGames.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Recent")
|
||||
.font(.title2.bold())
|
||||
.foregroundColor(.primary)
|
||||
Text("Add ROM, Keys and Firmware to get started")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
if !isSearching && !recentGames.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Recent")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 16) {
|
||||
ForEach(recentGames) { game in
|
||||
RecentGameCard(game: game, startemu: $startemu)
|
||||
.onTapGesture {
|
||||
addToRecentGames(game)
|
||||
startemu = game
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("All Games")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(filteredGames) { game in
|
||||
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, gameInfo: $gameInfo)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 16) {
|
||||
ForEach(recentGames) { game in
|
||||
RecentGameCard(game: game, startemu: $startemu)
|
||||
.onTapGesture {
|
||||
addToRecentGames(game)
|
||||
startemu = game
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("All Games")
|
||||
.font(.title2.bold())
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(filteredGames) { game in
|
||||
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, gameInfo: $gameInfo)
|
||||
@ -117,35 +99,44 @@ struct GameLibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadRecentGames()
|
||||
|
||||
let firmware = Ryujinx.shared.fetchFirmwareVersion()
|
||||
firmwareversion = (firmware == "" ? "0" : firmware)
|
||||
}
|
||||
.fileImporter(isPresented: $firmwareInstaller, allowedContentTypes: [.item]) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
do {
|
||||
let fun = url.startAccessingSecurityScopedResource()
|
||||
let path = url.path
|
||||
|
||||
Ryujinx.shared.installFirmware(firmwarePath: path)
|
||||
|
||||
firmwareversion = (Ryujinx.shared.fetchFirmwareVersion() == "" ? "0" : Ryujinx.shared.fetchFirmwareVersion())
|
||||
if fun {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
} else {
|
||||
ForEach(filteredGames) { game in
|
||||
GameListRow(game: game, startemu: $startemu, games: games, isViewingGameInfo: $isViewingGameInfo, isSelectingGameUpdate: $isSelectingGameUpdate, gameInfo: $gameInfo)
|
||||
.onTapGesture {
|
||||
addToRecentGames(game)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Games")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
loadRecentGames()
|
||||
|
||||
let firmware = Ryujinx.shared.fetchFirmwareVersion()
|
||||
firmwareversion = (firmware == "" ? "0" : firmware)
|
||||
}
|
||||
.fileImporter(isPresented: $firmwareInstaller, allowedContentTypes: [.item]) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
do {
|
||||
let fun = url.startAccessingSecurityScopedResource()
|
||||
let path = url.path
|
||||
|
||||
Ryujinx.shared.installFirmware(firmwarePath: path)
|
||||
|
||||
firmwareversion = (Ryujinx.shared.fetchFirmwareVersion() == "" ? "0" : Ryujinx.shared.fetchFirmwareVersion())
|
||||
if fun {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
isSelectingGameFile.toggle()
|
||||
} label: {
|
||||
@ -215,7 +206,6 @@ struct GameLibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.searchable(text: $searchText)
|
||||
.onChange(of: searchText) { _ in
|
||||
isSearching = !searchText.isEmpty
|
||||
@ -296,7 +286,6 @@ struct GameLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func addToRecentGames(_ game: Game) {
|
||||
recentGames.removeAll { $0.id == game.id }
|
||||
|
||||
@ -329,8 +318,7 @@ struct GameLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Delete Game Function
|
||||
// MARK: - Delete Game Function
|
||||
func deleteGame(game: Game) {
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
@ -343,7 +331,7 @@ struct GameLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -Game Model
|
||||
// MARK: - Game Model
|
||||
extension Game: Codable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case titleName, titleId, developer, version, fileURL
|
||||
@ -372,7 +360,7 @@ extension Game: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -Recent Game Card
|
||||
// MARK: - Recent Game Card
|
||||
struct RecentGameCard: View {
|
||||
let game: Game
|
||||
@Binding var startemu: Game?
|
||||
@ -393,7 +381,7 @@ struct RecentGameCard: View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(colorScheme == .dark ?
|
||||
Color(.systemGray5) : Color(.systemGray6))
|
||||
Color(.systemGray5) : Color(.systemGray6))
|
||||
.frame(width: 140, height: 140)
|
||||
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
@ -419,7 +407,7 @@ struct RecentGameCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -Game List Item
|
||||
// MARK: - Game List Item
|
||||
struct GameListRow: View {
|
||||
let game: Game
|
||||
@Binding var startemu: Game?
|
||||
@ -447,7 +435,7 @@ struct GameListRow: View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(colorScheme == .dark ?
|
||||
Color(.systemGray5) : Color(.systemGray6))
|
||||
Color(.systemGray5) : Color(.systemGray6))
|
||||
.frame(width: 45, height: 45)
|
||||
|
||||
Image(systemName: "gamecontroller.fill")
|
||||
@ -474,9 +462,6 @@ struct GameListRow: View {
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(0.8)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemBackground))
|
||||
.contextMenu {
|
||||
Section {
|
||||
Button {
|
||||
@ -533,4 +518,3 @@ struct GameListRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,36 +18,42 @@ struct UpdateManagerSheet: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
List(paths, id: \..self) { item in
|
||||
Button(action: {
|
||||
selectItem(item.lastPathComponent)
|
||||
}) {
|
||||
HStack {
|
||||
Text(item.lastPathComponent)
|
||||
if selectedItem == "\(game!.titleId)/\(item.lastPathComponent)" {
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
removeUpdate(item)
|
||||
} label: {
|
||||
Text("Remove Update")
|
||||
List(paths, id: \..self, selection: $selectedItem) { item in
|
||||
Button(action: {
|
||||
selectItem(item.lastPathComponent)
|
||||
}) {
|
||||
HStack {
|
||||
Text(item.lastPathComponent)
|
||||
.foregroundStyle(Color(uiColor: .label))
|
||||
Spacer()
|
||||
if selectedItem == "\(game!.titleId)/\(item.lastPathComponent)" {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.font(.system(size: 24))
|
||||
} else {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
||||
.font(.system(size: 24))
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
removeUpdate(item)
|
||||
} label: {
|
||||
Text("Remove Update")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
.onAppear {
|
||||
print(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
||||
|
||||
loadJSON(URL.documentsDirectory.appendingPathComponent("games").appendingPathComponent(game!.titleId).appendingPathComponent("updates.json"))
|
||||
}
|
||||
.navigationTitle("\(game!.titleName) Updates")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
Button("+") {
|
||||
Button("Add", systemImage: "plus") {
|
||||
isSelectingGameUpdate = true
|
||||
}
|
||||
}
|
||||
@ -80,7 +86,8 @@ struct UpdateManagerSheet: View {
|
||||
let destinationURL = romUpdatedDirectory.appendingPathComponent(url.lastPathComponent)
|
||||
try? fileManager.copyItem(at: url, to: destinationURL)
|
||||
|
||||
Ryujinx.shared.setTitleUpdate(titleId: gameInfo.titleId, updatePath: "\(gameInfo.titleId)/" + url.lastPathComponent)
|
||||
items.append(gameInfo.titleId + "/" + url.lastPathComponent)
|
||||
selectItem(url.lastPathComponent)
|
||||
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||
loadJSON(jsonURL!)
|
||||
} catch {
|
||||
@ -108,6 +115,7 @@ struct UpdateManagerSheet: View {
|
||||
}
|
||||
|
||||
saveJSON(selectedItem: selectedItem ?? "")
|
||||
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||
}
|
||||
|
||||
func saveJSON(selectedItem: String) {
|
||||
@ -122,7 +130,6 @@ struct UpdateManagerSheet: View {
|
||||
}
|
||||
|
||||
func loadJSON(_ json: URL) {
|
||||
|
||||
self.jsonURL = json
|
||||
print("Failed to read JSO")
|
||||
|
||||
@ -132,16 +139,17 @@ struct UpdateManagerSheet: View {
|
||||
do {
|
||||
let data = try Data(contentsOf: jsonURL)
|
||||
if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let list = jsonDict["paths"] as? [String] {
|
||||
let list = jsonDict["paths"] as? [String]
|
||||
{
|
||||
var urls: [URL] = []
|
||||
|
||||
for path in list {
|
||||
urls.append(URL.documentsDirectory.appendingPathComponent("updates").appendingPathComponent(path))
|
||||
}
|
||||
|
||||
self.items = list
|
||||
self.paths = urls
|
||||
self.selectedItem = jsonDict["selected"] as? String
|
||||
items = list
|
||||
paths = urls
|
||||
selectedItem = jsonDict["selected"] as? String
|
||||
}
|
||||
} catch {
|
||||
print("Failed to read JSON: \(error)")
|
||||
@ -155,8 +163,8 @@ struct UpdateManagerSheet: View {
|
||||
do {
|
||||
let newData = try JSONSerialization.data(withJSONObject: defaultData, options: .prettyPrinted)
|
||||
try newData.write(to: jsonURL)
|
||||
self.items = []
|
||||
self.selectedItem = ""
|
||||
items = []
|
||||
selectedItem = ""
|
||||
} catch {
|
||||
print("Failed to create default JSON: \(error)")
|
||||
}
|
||||
@ -183,9 +191,9 @@ struct UpdateManagerSheet: View {
|
||||
|
||||
let newData = try JSONSerialization.data(withJSONObject: jsonDict, options: .prettyPrinted)
|
||||
try newData.write(to: jsonURL)
|
||||
Ryujinx.shared.games = Ryujinx.shared.loadGames()
|
||||
} catch {
|
||||
print("Failed to update JSON: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -751,7 +751,8 @@ namespace Ryujinx.Headless.SDL2
|
||||
|
||||
if (File.Exists(titleUpdateMetadataPath))
|
||||
{
|
||||
updatePath = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
|
||||
string updatePathRelative = JsonHelper.DeserializeFromFile(titleUpdateMetadataPath, _titleSerializerContext.TitleUpdateMetadata).Selected;
|
||||
updatePath = Path.Combine(AppDataManager.BaseDirPath, "updates", updatePathRelative);
|
||||
|
||||
if (File.Exists(updatePath))
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user