diff --git a/android/app/src/cpp/android_host_interface.cpp b/android/app/src/cpp/android_host_interface.cpp index 2dd84bb63..fc6958a42 100644 --- a/android/app/src/cpp/android_host_interface.cpp +++ b/android/app/src/cpp/android_host_interface.cpp @@ -1,4 +1,5 @@ #include "android_host_interface.h" +#include "android_controller_interface.h" #include "android_progress_callback.h" #include "common/assert.h" #include "common/audio_stream.h" @@ -503,6 +504,27 @@ std::unique_ptr AndroidHostInterface::CreateAudioStream(AudioBacken return CommonHostInterface::CreateAudioStream(backend); } +void AndroidHostInterface::UpdateControllerInterface() +{ + if (m_controller_interface) + { + m_controller_interface->Shutdown(); + m_controller_interface.reset(); + } + + m_controller_interface = std::make_unique(); + if (!m_controller_interface || !m_controller_interface->Initialize(this)) + { + Log_WarningPrintf("Failed to initialize controller interface, bindings are not possible."); + if (m_controller_interface) + { + m_controller_interface->Shutdown(); + m_controller_interface.reset(); + } + } +} + + void AndroidHostInterface::OnSystemPaused(bool paused) { CommonHostInterface::OnSystemPaused(paused); @@ -637,6 +659,32 @@ void AndroidHostInterface::SetControllerAxisState(u32 index, s32 button_code, fl false); } +void AndroidHostInterface::HandleControllerButtonEvent(u32 controller_index, u32 button_index, bool pressed) +{ + if (!IsEmulationThreadRunning()) + return; + + RunOnEmulationThread( + [this, controller_index, button_index, pressed]() { + AndroidControllerInterface* ci = static_cast(m_controller_interface.get()); + if (ci) + ci->HandleButtonEvent(controller_index, button_index, pressed); + }); +} + +void AndroidHostInterface::HandleControllerAxisEvent(u32 controller_index, u32 axis_index, float value) +{ + if (!IsEmulationThreadRunning()) + return; + + RunOnEmulationThread( + [this, controller_index, axis_index, value]() { + AndroidControllerInterface* ci = static_cast(m_controller_interface.get()); + if (ci) + ci->HandleAxisEvent(controller_index, axis_index, value); + }); +} + void AndroidHostInterface::SetFastForwardEnabled(bool enabled) { m_fast_forward_enabled = enabled; @@ -656,6 +704,7 @@ void AndroidHostInterface::ApplySettings(bool display_osd_messages) LoadAndConvertSettings(); CommonHostInterface::ApplyGameSettings(display_osd_messages); CommonHostInterface::FixIncompatibleSettings(display_osd_messages); + UpdateInputMap(); // Defer renderer changes, the app really doesn't like it. if (System::IsValid() && g_settings.gpu_renderer != old_settings.gpu_renderer) @@ -743,6 +792,45 @@ void AndroidHostInterface::UpdateVibration() SetVibration(vibration_state); } +jobjectArray AndroidHostInterface::GetInputProfileNames(JNIEnv* env) const +{ + const InputProfileList profile_list(GetInputProfileList()); + if (profile_list.empty()) + return nullptr; + + jobjectArray name_array = env->NewObjectArray(static_cast(profile_list.size()), s_String_class, nullptr); + u32 name_array_index = 0; + Assert(name_array != nullptr); + for (const InputProfileEntry& e : profile_list) + { + jstring axis_name_jstr = env->NewStringUTF(e.name.c_str()); + env->SetObjectArrayElement(name_array, name_array_index++, axis_name_jstr); + env->DeleteLocalRef(axis_name_jstr); + } + + return name_array; +} + +bool AndroidHostInterface::ApplyInputProfile(const char *profile_name) +{ + const std::string path(GetInputProfilePath(profile_name)); + if (path.empty()) + return false; + + Assert(!IsEmulationThreadRunning() || IsEmulationThreadPaused()); + CommonHostInterface::ApplyInputProfile(path.c_str(), m_settings_interface); + return true; +} + +bool AndroidHostInterface::SaveInputProfile(const char *profile_name) +{ + const std::string path(GetSavePathForInputProfile(profile_name)); + if (path.empty()) + return false; + + return CommonHostInterface::SaveInputProfile(path.c_str(), m_settings_interface); +} + extern "C" jint JNI_OnLoad(JavaVM* vm, void* reserved) { Log::SetDebugOutputParams(true, nullptr, LOGLEVEL_DEV); @@ -938,6 +1026,82 @@ DEFINE_JNI_ARGS_METHOD(jint, AndroidHostInterface_getControllerAxisCode, jobject return code.value_or(-1); } +DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getControllerButtonNames, jobject unused, jstring controller_type) +{ + std::optional type = + Settings::ParseControllerTypeName(AndroidHelpers::JStringToString(env, controller_type).c_str()); + if (!type) + return nullptr; + + const Controller::ButtonList buttons(Controller::GetButtonNames(type.value())); + if (buttons.empty()) + return nullptr; + + jobjectArray name_array = env->NewObjectArray(static_cast(buttons.size()), s_String_class, nullptr); + u32 name_array_index = 0; + Assert(name_array != nullptr); + for (const auto& [button_name, button_code] : buttons) + { + jstring button_name_jstr = env->NewStringUTF(button_name.c_str()); + env->SetObjectArrayElement(name_array, name_array_index++, button_name_jstr); + env->DeleteLocalRef(button_name_jstr); + } + + return name_array; +} + +DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getControllerAxisNames, jobject unused, jstring controller_type) +{ + std::optional type = + Settings::ParseControllerTypeName(AndroidHelpers::JStringToString(env, controller_type).c_str()); + if (!type) + return nullptr; + + const Controller::AxisList axes(Controller::GetAxisNames(type.value())); + if (axes.empty()) + return nullptr; + + jobjectArray name_array = env->NewObjectArray(static_cast(axes.size()), s_String_class, nullptr); + u32 name_array_index = 0; + Assert(name_array != nullptr); + for (const auto& [axis_name, axis_code, axis_type] : axes) + { + jstring axis_name_jstr = env->NewStringUTF(axis_name.c_str()); + env->SetObjectArrayElement(name_array, name_array_index++, axis_name_jstr); + env->DeleteLocalRef(axis_name_jstr); + } + + return name_array; +} + +DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_handleControllerButtonEvent, jobject obj, jint controller_index, jint button_index, jboolean pressed) +{ + AndroidHelpers::GetNativeClass(env, obj)->HandleControllerButtonEvent(controller_index, button_index, pressed); +} + +DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_handleControllerAxisEvent, jobject obj, jint controller_index, jint axis_index, jfloat value) +{ + AndroidHelpers::GetNativeClass(env, obj)->HandleControllerAxisEvent(controller_index, axis_index, value); +} + +DEFINE_JNI_ARGS_METHOD(jobjectArray, AndroidHostInterface_getInputProfileNames, jobject obj) +{ + AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj); + return hi->GetInputProfileNames(env); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_loadInputProfile, jobject obj, jstring name) +{ + AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj); + return hi->ApplyInputProfile(AndroidHelpers::JStringToString(env, name).c_str()); +} + +DEFINE_JNI_ARGS_METHOD(jboolean, AndroidHostInterface_saveInputProfile, jobject obj, jstring name) +{ + AndroidHostInterface* hi = AndroidHelpers::GetNativeClass(env, obj); + return hi->SaveInputProfile(AndroidHelpers::JStringToString(env, name).c_str()); +} + DEFINE_JNI_ARGS_METHOD(void, AndroidHostInterface_refreshGameList, jobject obj, jboolean invalidate_cache, jboolean invalidate_database, jobject progress_callback) { diff --git a/android/app/src/cpp/android_host_interface.h b/android/app/src/cpp/android_host_interface.h index 59c7c297a..5bdb31fde 100644 --- a/android/app/src/cpp/android_host_interface.h +++ b/android/app/src/cpp/android_host_interface.h @@ -51,6 +51,8 @@ public: void SetControllerType(u32 index, std::string_view type_name); void SetControllerButtonState(u32 index, s32 button_code, bool pressed); void SetControllerAxisState(u32 index, s32 button_code, float value); + void HandleControllerButtonEvent(u32 controller_index, u32 button_index, bool pressed); + void HandleControllerAxisEvent(u32 controller_index, u32 axis_index, float value); void SetFastForwardEnabled(bool enabled); void RefreshGameList(bool invalidate_cache, bool invalidate_database, ProgressCallback* progress_callback); @@ -58,6 +60,10 @@ public: bool ImportPatchCodesFromString(const std::string& str); + jobjectArray GetInputProfileNames(JNIEnv* env) const; + bool ApplyInputProfile(const char* profile_name); + bool SaveInputProfile(const char* profile_name); + protected: void SetUserDirectory() override; void LoadSettings() override; @@ -66,6 +72,7 @@ protected: bool AcquireHostDisplay() override; void ReleaseHostDisplay() override; std::unique_ptr CreateAudioStream(AudioBackend backend) override; + void UpdateControllerInterface() override; void OnSystemPaused(bool paused) override; void OnSystemDestroyed() override; diff --git a/android/app/src/cpp/android_settings_interface.cpp b/android/app/src/cpp/android_settings_interface.cpp index ddc4751d2..79c975448 100644 --- a/android/app/src/cpp/android_settings_interface.cpp +++ b/android/app/src/cpp/android_settings_interface.cpp @@ -308,6 +308,20 @@ std::vector AndroidSettingsInterface::GetStringList(const char* sec env, env->CallObjectMethod(m_java_shared_preferences, m_get_string_set, key_string.Get(), nullptr)); if (env->ExceptionCheck()) { + env->ExceptionClear(); + + // this might just be a string, not a string set + LocalRefHolder string_object( + env, reinterpret_cast(env->CallObjectMethod(m_java_shared_preferences, m_get_string, key_string.Get(), nullptr))); + + if (!env->ExceptionCheck()) { + std::vector ret; + if (string_object) + ret.push_back(AndroidHelpers::JStringToString(env, string_object)); + + return ret; + } + env->ExceptionClear(); return {}; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 214b089c7..0aea123cf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -34,6 +34,15 @@ android:name="android.support.PARENT_ACTIVITY" android:value="com.github.stenzek.duckstation.MainActivity" /> + + + dismiss()); + setButton(BUTTON_NEGATIVE, context.getString(R.string.controller_binding_dialog_clear), (dialogInterface, button) -> { + mCurrentBinding = null; + updateBinding(); + dismiss(); + }); + + setOnKeyListener(new DialogInterface.OnKeyListener() { + @Override + public boolean onKey(DialogInterface dialog, int keyCode, KeyEvent event) { + if (onKeyDown(keyCode, event)) + return true; + + return false; + } + }); + } + + private void updateMessage() { + setMessage(String.format(getContext().getString(R.string.controller_binding_dialog_message), mCurrentBinding)); + } + + private void updateBinding() { + SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(getContext()).edit(); + if (mCurrentBinding != null) { + ArraySet values = new ArraySet<>(); + values.add(mCurrentBinding); + editor.putStringSet(mSettingKey, values); + } else { + try { + editor.remove(mSettingKey); + } catch (Exception e) { + + } + } + + editor.commit(); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (mIsAxis || !EmulationSurfaceView.isDPadOrButtonEvent(event)) + return super.onKeyUp(keyCode, event); + + int buttonIndex = EmulationSurfaceView.getButtonIndexForKeyCode(keyCode); + if (buttonIndex < 0) + return super.onKeyUp(keyCode, event); + + // TODO: Multiple controllers + final int controllerIndex = 0; + mCurrentBinding = String.format("Controller%d/Button%d", controllerIndex, buttonIndex); + updateMessage(); + updateBinding(); + dismiss(); + return true; + } + + private int mUpdatedAxisCode = -1; + + private void setAxisCode(int axisCode, boolean positive) { + final int axisIndex = EmulationSurfaceView.getAxisIndexForAxisCode(axisCode); + if (mUpdatedAxisCode >= 0 || axisIndex < 0) + return; + + mUpdatedAxisCode = axisCode; + + final int controllerIndex = 0; + if (mIsAxis) + mCurrentBinding = String.format("Controller%d/Axis%d", controllerIndex, axisIndex); + else + mCurrentBinding = String.format("Controller%d/%cAxis%d", controllerIndex, (positive) ? '+' : '-', axisIndex); + + updateBinding(); + updateMessage(); + dismiss(); + } + + final static float DETECT_THRESHOLD = 0.25f; + + private HashMap mStartingAxisValues = new HashMap<>(); + + private boolean doAxisDetection(MotionEvent event) { + if ((event.getSource() & (InputDevice.SOURCE_JOYSTICK | InputDevice.SOURCE_GAMEPAD | InputDevice.SOURCE_DPAD)) == 0) + return false; + + final int[] axisCodes = EmulationSurfaceView.getKnownAxisCodes(); + final int deviceId = event.getDeviceId(); + + if (!mStartingAxisValues.containsKey(deviceId)) { + final float[] axisValues = new float[axisCodes.length]; + for (int axisIndex = 0; axisIndex < axisCodes.length; axisIndex++) { + final int axisCode = axisCodes[axisIndex]; + + // these are binary, so start at zero + if (axisCode == MotionEvent.AXIS_HAT_X || axisCode == MotionEvent.AXIS_HAT_Y) + axisValues[axisIndex] = 0.0f; + else + axisValues[axisIndex] = event.getAxisValue(axisCode); + } + + mStartingAxisValues.put(deviceId, axisValues); + } + + final float[] axisValues = mStartingAxisValues.get(deviceId); + for (int axisIndex = 0; axisIndex < axisCodes.length; axisIndex++) { + final float newValue = event.getAxisValue(axisCodes[axisIndex]); + if (Math.abs(newValue - axisValues[axisIndex]) >= DETECT_THRESHOLD) { + setAxisCode(axisCodes[axisIndex], newValue >= 0.0f); + break; + } + } + + return true; + } + + @Override + public boolean onGenericMotionEvent(@NonNull MotionEvent event) { + if (doAxisDetection(event)) + return true; + + return super.onGenericMotionEvent(event); + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/ControllerBindingPreference.java b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerBindingPreference.java new file mode 100644 index 000000000..c40b985ca --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerBindingPreference.java @@ -0,0 +1,148 @@ +package com.github.stenzek.duckstation; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceViewHolder; + +import java.util.Set; + +public class ControllerBindingPreference extends Preference { + private boolean mIsAxis; + private String mBindingName; + private String mValue; + private TextView mValueView; + + private static int getIconForButton(String buttonName) { + if (buttonName.equals("Up")) { + return R.drawable.ic_controller_up_button_pressed; + } else if (buttonName.equals("Right")) { + return R.drawable.ic_controller_right_button_pressed; + } else if (buttonName.equals("Down")) { + return R.drawable.ic_controller_down_button_pressed; + } else if (buttonName.equals("Left")) { + return R.drawable.ic_controller_left_button_pressed; + } else if (buttonName.equals("Triangle")) { + return R.drawable.ic_controller_triangle_button_pressed; + } else if (buttonName.equals("Circle")) { + return R.drawable.ic_controller_circle_button_pressed; + } else if (buttonName.equals("Cross")) { + return R.drawable.ic_controller_cross_button_pressed; + } else if (buttonName.equals("Square")) { + return R.drawable.ic_controller_square_button_pressed; + } else if (buttonName.equals("Start")) { + return R.drawable.ic_controller_start_button_pressed; + } else if (buttonName.equals("Select")) { + return R.drawable.ic_controller_select_button_pressed; + } else if (buttonName.equals("L1")) { + return R.drawable.ic_controller_l1_button_pressed; + } else if (buttonName.equals("L2")) { + return R.drawable.ic_controller_l2_button_pressed; + } else if (buttonName.equals("R1")) { + return R.drawable.ic_controller_r1_button_pressed; + } else if (buttonName.equals("R2")) { + return R.drawable.ic_controller_r2_button_pressed; + } + + return R.drawable.ic_baseline_radio_button_unchecked_24; + } + + private static int getIconForAxis(String axisName) { + return R.drawable.ic_baseline_radio_button_checked_24; + } + + public ControllerBindingPreference(Context context, AttributeSet attrs) { + this(context, attrs, 0); + setWidgetLayoutResource(R.layout.layout_controller_binding_preference); + setIconSpaceReserved(false); + } + + public ControllerBindingPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + setWidgetLayoutResource(R.layout.layout_controller_binding_preference); + setIconSpaceReserved(false); + } + + public ControllerBindingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + setWidgetLayoutResource(R.layout.layout_controller_binding_preference); + setIconSpaceReserved(false); + } + + @Override + public void onBindViewHolder(PreferenceViewHolder holder) { + super.onBindViewHolder(holder); + + ImageView iconView = ((ImageView)holder.findViewById(R.id.controller_binding_icon)); + TextView nameView = ((TextView)holder.findViewById(R.id.controller_binding_name)); + mValueView = ((TextView)holder.findViewById(R.id.controller_binding_value)); + + iconView.setImageDrawable(ContextCompat.getDrawable(getContext(), getIconForButton(mBindingName))); + nameView.setText(mBindingName); + updateValue(); + } + + @Override + protected void onClick() { + ControllerBindingDialog dialog = new ControllerBindingDialog(getContext(), mBindingName, getKey(), mValue, mIsAxis); + dialog.setOnDismissListener((dismissedDialog) -> updateValue()); + dialog.show(); + } + + public void initButton(int controllerIndex, String buttonName) { + mBindingName = buttonName; + mIsAxis = false; + setKey(String.format("Controller%d/Button%s", controllerIndex, buttonName)); + updateValue(); + } + + public void initAxis(int controllerIndex, String axisName) { + mBindingName = axisName; + mIsAxis = true; + setKey(String.format("Controller%d/Axis%s", controllerIndex, axisName)); + updateValue(); + } + + private void updateValue(String value) { + mValue = value; + if (mValueView != null) { + if (value != null) + mValueView.setText(value); + else + mValueView.setText(getContext().getString(R.string.controller_binding_dialog_no_binding)); + } + } + + public void updateValue() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + Set values = PreferenceHelpers.getStringSet(prefs, getKey()); + if (values != null) { + StringBuilder sb = new StringBuilder(); + for (String value : values) { + if (sb.length() > 0) + sb.append(", "); + sb.append(value); + } + + updateValue(sb.toString()); + } else { + updateValue(null); + } + } + + public void clearBinding(SharedPreferences.Editor prefEditor) { + try { + prefEditor.remove(getKey()); + } catch (Exception e) { + + } + + updateValue(null); + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/ControllerMappingActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerMappingActivity.java new file mode 100644 index 000000000..dc703cb44 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/ControllerMappingActivity.java @@ -0,0 +1,239 @@ +package com.github.stenzek.duckstation; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import java.util.ArrayList; + +public class ControllerMappingActivity extends AppCompatActivity { + + private static final int NUM_CONTROLLER_PORTS = 2; + + private ArrayList mPreferences = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.settings_activity); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings, new SettingsCollectionFragment(this)) + .commit(); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.controller_mapping_activity_title); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_controller_mapping, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + + final int id = item.getItemId(); + + //noinspection SimplifiableIfStatement + if (id == R.id.action_load_profile) { + doLoadProfile(); + return true; + } else if (id == R.id.action_save_profile) { + doSaveProfile(); + return true; + } else if (id == R.id.action_clear_bindings) { + doClearBindings(); + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + private void displayError(String text) { + new AlertDialog.Builder(this) + .setTitle(R.string.emulation_activity_error) + .setMessage(text) + .setNegativeButton(R.string.main_activity_ok, ((dialog, which) -> dialog.dismiss())) + .create() + .show(); + } + + private void doLoadProfile() { + final String[] profileNames = AndroidHostInterface.getInstance().getInputProfileNames(); + if (profileNames == null) { + displayError(getString(R.string.controller_mapping_activity_no_profiles_found)); + return; + } + + new AlertDialog.Builder(this) + .setTitle(R.string.controller_mapping_activity_select_input_profile) + .setItems(profileNames, (dialog, choice) -> { + doLoadProfile(profileNames[choice]); + dialog.dismiss(); + }) + .setNegativeButton("Cancel", ((dialog, which) -> dialog.dismiss())) + .create() + .show(); + } + + private void doLoadProfile(String profileName) { + if (!AndroidHostInterface.getInstance().loadInputProfile(profileName)) { + displayError(String.format(getString(R.string.controller_mapping_activity_failed_to_load_profile), profileName)); + return; + } + + updateAllBindings(); + } + + private void doSaveProfile() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + final EditText input = new EditText(this); + builder.setTitle(R.string.controller_mapping_activity_input_profile_name); + builder.setView(input); + builder.setPositiveButton(R.string.controller_mapping_activity_save, (dialog, which) -> { + final String name = input.getText().toString(); + if (name.isEmpty()) { + displayError(getString(R.string.controller_mapping_activity_name_must_be_provided)); + return; + } + + if (!AndroidHostInterface.getInstance().saveInputProfile(name)) { + displayError(getString(R.string.controller_mapping_activity_failed_to_save_input_profile)); + return; + } + + Toast.makeText(ControllerMappingActivity.this, String.format(ControllerMappingActivity.this.getString(R.string.controller_mapping_activity_input_profile_saved), name), + Toast.LENGTH_LONG).show(); + }); + builder.setNegativeButton(R.string.controller_mapping_activity_cancel, (dialog, which) -> dialog.dismiss()); + builder.create().show(); + } + + private void doClearBindings() { + SharedPreferences.Editor prefEdit = PreferenceManager.getDefaultSharedPreferences(this).edit(); + for (ControllerBindingPreference pref : mPreferences) + pref.clearBinding(prefEdit); + prefEdit.commit(); + } + + private void updateAllBindings() { + for (ControllerBindingPreference pref : mPreferences) + pref.updateValue(); + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + private ControllerMappingActivity activity; + private int controllerIndex; + + public SettingsFragment(ControllerMappingActivity activity, int controllerIndex) { + this.activity = activity; + this.controllerIndex = controllerIndex; + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + final SharedPreferences sp = getPreferenceManager().getSharedPreferences(); + String controllerType = sp.getString(String.format("Controller%d/Type", controllerIndex), "None"); + String[] controllerButtons = AndroidHostInterface.getControllerButtonNames(controllerType); + String[] axisButtons = AndroidHostInterface.getControllerAxisNames(controllerType); + + final PreferenceScreen ps = getPreferenceManager().createPreferenceScreen(getContext()); + if (controllerButtons != null) { + for (String buttonName : controllerButtons) { + final ControllerBindingPreference cbp = new ControllerBindingPreference(getContext(), null); + cbp.initButton(controllerIndex, buttonName); + ps.addPreference(cbp); + activity.mPreferences.add(cbp); + } + } + if (axisButtons != null) { + for (String axisName : axisButtons) { + final ControllerBindingPreference cbp = new ControllerBindingPreference(getContext(), null); + cbp.initAxis(controllerIndex, axisName); + ps.addPreference(cbp); + activity.mPreferences.add(cbp); + } + } + + setPreferenceScreen(ps); + } + } + + public static class SettingsCollectionFragment extends Fragment { + private ControllerMappingActivity activity; + private SettingsCollectionAdapter adapter; + private ViewPager2 viewPager; + + public SettingsCollectionFragment(ControllerMappingActivity activity) { + this.activity = activity; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_controller_mapping, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + adapter = new SettingsCollectionAdapter(activity, this); + viewPager = view.findViewById(R.id.view_pager); + viewPager.setAdapter(adapter); + + TabLayout tabLayout = view.findViewById(R.id.tab_layout); + new TabLayoutMediator(tabLayout, viewPager, + (tab, position) -> tab.setText(String.format("Port %d", position + 1)) + ).attach(); + } + } + + public static class SettingsCollectionAdapter extends FragmentStateAdapter { + private ControllerMappingActivity activity; + + public SettingsCollectionAdapter(@NonNull ControllerMappingActivity activity, @NonNull Fragment fragment) { + super(fragment); + this.activity = activity; + } + + @NonNull + @Override + public Fragment createFragment(int position) { + return new SettingsFragment(activity, position + 1); + } + + @Override + public int getItemCount() { + return NUM_CONTROLLER_PORTS; + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationSurfaceView.java b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationSurfaceView.java index dae19de45..1256484a4 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/EmulationSurfaceView.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/EmulationSurfaceView.java @@ -1,6 +1,7 @@ package com.github.stenzek.duckstation; import android.content.Context; +import android.content.SharedPreferences; import android.util.AttributeSet; import android.util.Log; import android.view.InputDevice; @@ -8,7 +9,10 @@ import android.view.KeyEvent; import android.view.MotionEvent; import android.view.SurfaceView; +import androidx.core.util.Pair; + import java.util.ArrayList; +import java.util.HashMap; import java.util.List; public class EmulationSurfaceView extends SurfaceView { @@ -24,7 +28,7 @@ public class EmulationSurfaceView extends SurfaceView { super(context, attrs, defStyle); } - private boolean isDPadOrButtonEvent(KeyEvent event) { + public static boolean isDPadOrButtonEvent(KeyEvent event) { final int source = event.getSource(); return (source & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD || (source & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD || @@ -51,66 +55,65 @@ public class EmulationSurfaceView extends SurfaceView { } } - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (!isDPadOrButtonEvent(event) || isExternalKeyCode(keyCode)) - return false; + private static final int[] buttonKeyCodes = new int[] { + KeyEvent.KEYCODE_BUTTON_A, // 0/Cross + KeyEvent.KEYCODE_BUTTON_B, // 1/Circle + KeyEvent.KEYCODE_BUTTON_X, // 2/Square + KeyEvent.KEYCODE_BUTTON_Y, // 3/Triangle + KeyEvent.KEYCODE_BUTTON_SELECT, // 4/Select + KeyEvent.KEYCODE_BUTTON_MODE, // 5/Analog + KeyEvent.KEYCODE_BUTTON_START, // 6/Start + KeyEvent.KEYCODE_BUTTON_THUMBL, // 7/L3 + KeyEvent.KEYCODE_BUTTON_THUMBR, // 8/R3 + KeyEvent.KEYCODE_BUTTON_L1, // 9/L1 + KeyEvent.KEYCODE_BUTTON_R1, // 10/R1 + KeyEvent.KEYCODE_DPAD_UP, // 11/Up + KeyEvent.KEYCODE_DPAD_DOWN, // 12/Down + KeyEvent.KEYCODE_DPAD_LEFT, // 13/Left + KeyEvent.KEYCODE_DPAD_RIGHT, // 14/Right + KeyEvent.KEYCODE_BUTTON_L2, // 15 + KeyEvent.KEYCODE_BUTTON_R2, // 16 + KeyEvent.KEYCODE_BUTTON_C, // 17 + KeyEvent.KEYCODE_BUTTON_Z, // 18 + }; + private static final int[] axisCodes = new int[] { + MotionEvent.AXIS_X, // 0/LeftX + MotionEvent.AXIS_Y, // 1/LeftY + MotionEvent.AXIS_Z, // 2/RightX + MotionEvent.AXIS_RZ, // 3/RightY + MotionEvent.AXIS_LTRIGGER, // 4/L2 + MotionEvent.AXIS_RTRIGGER, // 5/R2 + MotionEvent.AXIS_RX, // 6 + MotionEvent.AXIS_RY, // 7 + MotionEvent.AXIS_HAT_X, // 8 + MotionEvent.AXIS_HAT_Y, // 9 + }; - if (event.getRepeatCount() == 0) - handleControllerKey(event.getDeviceId(), keyCode, true); - - return true; - } - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - if (!isDPadOrButtonEvent(event) || isExternalKeyCode(keyCode)) - return false; - - if (event.getRepeatCount() == 0) - handleControllerKey(event.getDeviceId(), keyCode, false); - - return true; - } - - @Override - public boolean onGenericMotionEvent(MotionEvent event) { - final int source = event.getSource(); - if ((source & (InputDevice.SOURCE_JOYSTICK | InputDevice.SOURCE_GAMEPAD | InputDevice.SOURCE_DPAD)) == 0) - return false; - - final int deviceId = event.getDeviceId(); - for (AxisMapping mapping : mControllerAxisMapping) { - if (mapping.deviceId != deviceId) - continue; - - final float axisValue = event.getAxisValue(mapping.deviceAxisOrButton); - float emuValue; - - if (mapping.deviceMotionRange != null) { - final float transformedValue = (axisValue - mapping.deviceMotionRange.getMin()) / mapping.deviceMotionRange.getRange(); - emuValue = (transformedValue * 2.0f) - 1.0f; - } else { - emuValue = axisValue; - } - Log.d("EmulationSurfaceView", String.format("axis %d value %f emuvalue %f", mapping.deviceAxisOrButton, axisValue, emuValue)); - - if (mapping.axisMapping >= 0) { - AndroidHostInterface.getInstance().setControllerAxisState(0, mapping.axisMapping, emuValue); - } - - final float DEAD_ZONE = 0.25f; - if (mapping.negativeButton >= 0) { - AndroidHostInterface.getInstance().setControllerButtonState(0, mapping.negativeButton, (emuValue <= -DEAD_ZONE)); - } - if (mapping.positiveButton >= 0) { - AndroidHostInterface.getInstance().setControllerButtonState(0, mapping.positiveButton, (emuValue >= DEAD_ZONE)); - } + public static int getButtonIndexForKeyCode(int keyCode) { + for (int buttonIndex = 0; buttonIndex < buttonKeyCodes.length; buttonIndex++) { + if (buttonKeyCodes[buttonIndex] == keyCode) + return buttonIndex; } - return true; + Log.e("EmulationSurfaceView", String.format("Button code %d not found", keyCode)); + return -1; } + public static int[] getKnownAxisCodes() { + return axisCodes; + } + + public static int getAxisIndexForAxisCode(int axisCode) { + for (int axisIndex = 0; axisIndex < axisCodes.length; axisIndex++) { + if (axisCodes[axisIndex] == axisCode) + return axisIndex; + } + + Log.e("EmulationSurfaceView", String.format("Axis code %d not found", axisCode)); + return -1; + } + + private class ButtonMapping { public ButtonMapping(int deviceId, int deviceButton, int controllerIndex, int button) { this.deviceId = deviceId; @@ -158,16 +161,92 @@ public class EmulationSurfaceView extends SurfaceView { private ArrayList mControllerKeyMapping; private ArrayList mControllerAxisMapping; - private void addControllerKeyMapping(int deviceId, int keyCode, int controllerIndex, String controllerType, String buttonName) { - int mapping = AndroidHostInterface.getControllerButtonCode(controllerType, buttonName); - Log.i("EmulationSurfaceView", String.format("Map %d to %d (%s)", keyCode, mapping, - buttonName)); - if (mapping >= 0) { - mControllerKeyMapping.add(new ButtonMapping(deviceId, keyCode, controllerIndex, mapping)); + private boolean handleControllerKey(int deviceId, int keyCode, boolean pressed) { + boolean result = false; + for (ButtonMapping mapping : mControllerKeyMapping) { + if (mapping.deviceId != deviceId || mapping.deviceAxisOrButton != keyCode) + continue; + + AndroidHostInterface.getInstance().handleControllerButtonEvent(0, mapping.buttonMapping, pressed); + Log.d("EmulationSurfaceView", String.format("handleControllerKey %d -> %d %d", keyCode, mapping.buttonMapping, pressed ? 1 : 0)); + result = true; } + + return result; } - private void addControllerAxisMapping(int deviceId, List motionRanges, int axis, int controllerIndex, String controllerType, String axisName, String negativeButtonName, String positiveButtonName) { + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (!isDPadOrButtonEvent(event) || isExternalKeyCode(keyCode)) + return false; + + if (event.getRepeatCount() == 0) + handleControllerKey(event.getDeviceId(), keyCode, true); + + return true; + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (!isDPadOrButtonEvent(event) || isExternalKeyCode(keyCode)) + return false; + + if (event.getRepeatCount() == 0) + handleControllerKey(event.getDeviceId(), keyCode, false); + + return true; + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + final int source = event.getSource(); + if ((source & (InputDevice.SOURCE_JOYSTICK | InputDevice.SOURCE_GAMEPAD | InputDevice.SOURCE_DPAD)) == 0) + return false; + + final int deviceId = event.getDeviceId(); + for (AxisMapping mapping : mControllerAxisMapping) { + if (mapping.deviceId != deviceId) + continue; + + final float axisValue = event.getAxisValue(mapping.deviceAxisOrButton); + float emuValue; + + if (mapping.deviceMotionRange != null) { + final float transformedValue = (axisValue - mapping.deviceMotionRange.getMin()) / mapping.deviceMotionRange.getRange(); + emuValue = (transformedValue * 2.0f) - 1.0f; + } else { + emuValue = axisValue; + } + Log.d("EmulationSurfaceView", String.format("axis %d value %f emuvalue %f", mapping.deviceAxisOrButton, axisValue, emuValue)); + + if (mapping.axisMapping >= 0) { + AndroidHostInterface.getInstance().handleControllerAxisEvent(0, mapping.axisMapping, emuValue); + } + + final float DEAD_ZONE = 0.25f; + if (mapping.negativeButton >= 0) { + AndroidHostInterface.getInstance().handleControllerButtonEvent(0, mapping.negativeButton, (emuValue <= -DEAD_ZONE)); + } + if (mapping.positiveButton >= 0) { + AndroidHostInterface.getInstance().handleControllerButtonEvent(0, mapping.positiveButton, (emuValue >= DEAD_ZONE)); + } + } + + return true; + } + + private boolean addControllerKeyMapping(int deviceId, int keyCode, int controllerIndex) { + int mapping = getButtonIndexForKeyCode(keyCode); + Log.i("EmulationSurfaceView", String.format("Map %d to %d", keyCode, mapping)); + if (mapping >= 0) { + mControllerKeyMapping.add(new ButtonMapping(deviceId, keyCode, controllerIndex, mapping)); + return true; + } + + return false; + } + + private boolean addControllerAxisMapping(int deviceId, List motionRanges, int axis, int controllerIndex) { InputDevice.MotionRange range = null; for (InputDevice.MotionRange curRange : motionRanges) { if (curRange.getAxis() == axis) { @@ -176,26 +255,26 @@ public class EmulationSurfaceView extends SurfaceView { } } if (range == null) - return; + return false; - if (axisName != null) { - int mapping = AndroidHostInterface.getControllerAxisCode(controllerType, axisName); - Log.i("EmulationSurfaceView", String.format("Map axis %d to %d (%s)", axis, mapping, axisName)); - if (mapping >= 0) { - mControllerAxisMapping.add(new AxisMapping(deviceId, axis, range, controllerIndex, mapping)); - return; - } + int mapping = getAxisIndexForAxisCode(axis); + int negativeButton = -1; + int positiveButton = -1; + + if (mapping >= 0) { + Log.i("EmulationSurfaceView", String.format("Map axis %d to %d", axis, mapping)); + mControllerAxisMapping.add(new AxisMapping(deviceId, axis, range, controllerIndex, mapping)); + return true; } - if (negativeButtonName != null && positiveButtonName != null) { - final int negativeMapping = AndroidHostInterface.getControllerButtonCode(controllerType, negativeButtonName); - final int positiveMapping = AndroidHostInterface.getControllerButtonCode(controllerType, positiveButtonName); - Log.i("EmulationSurfaceView", String.format("Map axis %d to %d %d (button %s %s)", axis, negativeMapping, positiveMapping, - negativeButtonName, positiveButtonName)); - if (negativeMapping >= 0 && positiveMapping >= 0) { - mControllerAxisMapping.add(new AxisMapping(deviceId, axis, range, controllerIndex, positiveMapping, negativeMapping)); - } + if (negativeButton >= 0 && negativeButton >= 0) { + Log.i("EmulationSurfaceView", String.format("Map axis %d to buttons %d %d", axis, negativeButton, positiveButton)); + mControllerAxisMapping.add(new AxisMapping(deviceId, axis, range, controllerIndex, positiveButton, negativeButton)); + return true; } + + Log.w("EmulationSurfaceView", String.format("Axis %d was not mapped", axis)); + return false; } private static boolean isJoystickDevice(int deviceId) { @@ -229,48 +308,18 @@ public class EmulationSurfaceView extends SurfaceView { List motionRanges = device.getMotionRanges(); int controllerIndex = 0; - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_DPAD_UP, controllerIndex, controllerType, "Up"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_DPAD_RIGHT, controllerIndex, controllerType, "Right"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_DPAD_DOWN, controllerIndex, controllerType, "Down"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_DPAD_LEFT, controllerIndex, controllerType, "Left"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_L1, controllerIndex, controllerType, "L1"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_L2, controllerIndex, controllerType, "L2"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_SELECT, controllerIndex, controllerType, "Select"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_START, controllerIndex, controllerType, "Start"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_Y, controllerIndex, controllerType, "Triangle"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_B, controllerIndex, controllerType, "Circle"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_A, controllerIndex, controllerType, "Cross"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_X, controllerIndex, controllerType, "Square"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_R1, controllerIndex, controllerType, "R1"); - addControllerKeyMapping(deviceId, KeyEvent.KEYCODE_BUTTON_R2, controllerIndex, controllerType, "R2"); + for (int keyCode : buttonKeyCodes) { + addControllerKeyMapping(deviceId, keyCode, controllerIndex); + } + if (motionRanges != null) { - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_X, controllerIndex, controllerType, "LeftX", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_Y, controllerIndex, controllerType, "LeftY", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_RX, controllerIndex, controllerType, "RightX", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_RY, controllerIndex, controllerType, "RightY", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_Z, controllerIndex, controllerType, "RightX", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_RZ, controllerIndex, controllerType, "RightY", null, null); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_LTRIGGER, controllerIndex, controllerType, "L2", "L2", "L2"); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_RTRIGGER, controllerIndex, controllerType, "R2", "R2", "R2"); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_HAT_X, controllerIndex, controllerType, null, "Left", "Right"); - addControllerAxisMapping(deviceId, motionRanges, MotionEvent.AXIS_HAT_Y, controllerIndex, controllerType, null, "Up", "Down"); + for (int axisCode : axisCodes) { + addControllerAxisMapping(deviceId, motionRanges, axisCode, controllerIndex); + } } } return !mControllerKeyMapping.isEmpty() || !mControllerKeyMapping.isEmpty(); } - private boolean handleControllerKey(int deviceId, int keyCode, boolean pressed) { - boolean result = false; - for (ButtonMapping mapping : mControllerKeyMapping) { - if (mapping.deviceId != deviceId || mapping.deviceAxisOrButton != keyCode) - continue; - - AndroidHostInterface.getInstance().setControllerButtonState(0, mapping.buttonMapping, pressed); - Log.d("EmulationSurfaceView", String.format("handleControllerKey %d -> %d %d", keyCode, mapping.buttonMapping, pressed ? 1 : 0)); - result = true; - } - - return result; - } } diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java index 8192a5be0..943f593b7 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java @@ -218,6 +218,10 @@ public class MainActivity extends AppCompatActivity { Intent intent = new Intent(this, SettingsActivity.class); startActivityForResult(intent, REQUEST_SETTINGS); return true; + } else if (id == R.id.action_controller_mapping) { + Intent intent = new Intent(this, ControllerMappingActivity.class); + startActivity(intent); + return true; } else if (id == R.id.action_show_version) { showVersion(); return true; diff --git a/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml b/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml new file mode 100644 index 000000000..f58b501e3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_folder_open_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_gamepad_24.xml b/android/app/src/main/res/drawable/ic_baseline_gamepad_24.xml new file mode 100644 index 000000000..fcf518c3c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_gamepad_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_radio_button_checked_24.xml b/android/app/src/main/res/drawable/ic_baseline_radio_button_checked_24.xml new file mode 100644 index 000000000..dc3833728 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_radio_button_checked_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml b/android/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml new file mode 100644 index 000000000..bcb6fc9c5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_radio_button_unchecked_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_save_24.xml b/android/app/src/main/res/drawable/ic_baseline_save_24.xml new file mode 100644 index 000000000..1a8d86d20 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_save_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/activity_controller_mapping.xml b/android/app/src/main/res/layout/activity_controller_mapping.xml new file mode 100644 index 000000000..de6591a20 --- /dev/null +++ b/android/app/src/main/res/layout/activity_controller_mapping.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/fragment_controller_mapping.xml b/android/app/src/main/res/layout/fragment_controller_mapping.xml new file mode 100644 index 000000000..d60ed84ea --- /dev/null +++ b/android/app/src/main/res/layout/fragment_controller_mapping.xml @@ -0,0 +1,21 @@ + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/layout_controller_binding_preference.xml b/android/app/src/main/res/layout/layout_controller_binding_preference.xml new file mode 100644 index 000000000..aa7e90269 --- /dev/null +++ b/android/app/src/main/res/layout/layout_controller_binding_preference.xml @@ -0,0 +1,39 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/menu_controller_mapping.xml b/android/app/src/main/res/menu/menu_controller_mapping.xml new file mode 100644 index 000000000..a6e97e439 --- /dev/null +++ b/android/app/src/main/res/menu/menu_controller_mapping.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/menu/menu_main.xml b/android/app/src/main/res/menu/menu_main.xml index c4533a130..bb9bc871b 100644 --- a/android/app/src/main/res/menu/menu_main.xml +++ b/android/app/src/main/res/menu/menu_main.xml @@ -36,6 +36,11 @@ android:id="@+id/action_discord_server" android:title="@string/menu_main_discord_server" /> + DuckStation Settings + Controller Mapping Settings Console Region Enable TTY Output @@ -160,4 +161,20 @@ Reset Layout Touchscreen controller is not active. Theme + Allows you bind external controller buttons/axises to the emulated controller. + Controller Mapping + Press button on controller to set new binding.\n\nCurrent Binding: %s + ]]> + Cancel + Clear + Controller Mapping + No profiles found. + Select Input Profile + Failed to load profile \'%s\' + Input Profile Name: + Save + A name must be provided. + Failed to save input profile. + Input profile \'%s\' saved. + Cancel diff --git a/android/app/src/main/res/xml/controllers_preferences.xml b/android/app/src/main/res/xml/controllers_preferences.xml index 4e96a8dd4..7f5407222 100644 --- a/android/app/src/main/res/xml/controllers_preferences.xml +++ b/android/app/src/main/res/xml/controllers_preferences.xml @@ -14,7 +14,8 @@ ~ limitations under the License. --> - + + + +