From 0037544a0078ad7bc2d93799e10a571557f6d202 Mon Sep 17 00:00:00 2001 From: SSimco <37044560+SSimco@users.noreply.github.com> Date: Tue, 3 Sep 2024 19:34:15 +0300 Subject: [PATCH] Refactored game list code & added option to remove & add multiple game paths --- .../main/cpp/AndroidGameIconLoadedCallback.h | 25 ----- .../main/cpp/AndroidGameTitleLoadedCallback.h | 19 +++- src/android/app/src/main/cpp/CMakeLists.txt | 3 - src/android/app/src/main/cpp/EmulationState.h | 34 +++--- .../app/src/main/cpp/GameIconLoader.cpp | 86 --------------- src/android/app/src/main/cpp/GameIconLoader.h | 33 ------ .../app/src/main/cpp/GameTitleLoader.cpp | 70 +++++++----- .../app/src/main/cpp/GameTitleLoader.h | 23 ++-- src/android/app/src/main/cpp/native-lib.cpp | 38 +++---- .../java/info/cemu/Cemu/NativeLibrary.java | 17 ++- .../java/info/cemu/Cemu/gameview/Game.java | 5 +- .../info/cemu/Cemu/gameview/GameAdapter.java | 102 +++++++----------- .../cemu/Cemu/gameview/GameViewModel.java | 50 +++++++++ .../cemu/Cemu/gameview/GamesFragment.java | 71 ++++-------- .../cemu/Cemu/settings/OptionsFragment.java | 69 ------------ .../cemu/Cemu/settings/SettingsFragment.java | 34 ++++++ .../cemu/Cemu/settings/SettingsManager.java | 9 -- .../settings/gamespath/GamePathAdapter.java | 65 +++++++++++ .../settings/gamespath/GamePathsFragment.java | 95 ++++++++++++++++ .../settings/input/InputSettingsFragment.java | 1 - .../app/src/main/res/drawable/ic_add.xml | 10 ++ .../app/src/main/res/drawable/ic_delete.xml | 10 ++ .../src/main/res/layout/fragment_games.xml | 58 +++++----- .../app/src/main/res/layout/layout_game.xml | 5 +- .../src/main/res/layout/layout_game_path.xml | 42 ++++++++ .../app/src/main/res/menu/menu_game_paths.xml | 9 ++ .../res/navigation/nav_settings_graph.xml | 52 +++++---- .../src/main/res/values-w1240dp/integers.xml | 3 + .../app/src/main/res/values/integers.xml | 3 + .../app/src/main/res/values/strings.xml | 7 +- 30 files changed, 565 insertions(+), 483 deletions(-) delete mode 100644 src/android/app/src/main/cpp/AndroidGameIconLoadedCallback.h delete mode 100644 src/android/app/src/main/cpp/GameIconLoader.cpp delete mode 100644 src/android/app/src/main/cpp/GameIconLoader.h create mode 100644 src/android/app/src/main/java/info/cemu/Cemu/gameview/GameViewModel.java delete mode 100644 src/android/app/src/main/java/info/cemu/Cemu/settings/OptionsFragment.java create mode 100644 src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java create mode 100644 src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathAdapter.java create mode 100644 src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathsFragment.java create mode 100644 src/android/app/src/main/res/drawable/ic_add.xml create mode 100644 src/android/app/src/main/res/drawable/ic_delete.xml create mode 100644 src/android/app/src/main/res/layout/layout_game_path.xml create mode 100644 src/android/app/src/main/res/menu/menu_game_paths.xml create mode 100644 src/android/app/src/main/res/values-w1240dp/integers.xml create mode 100644 src/android/app/src/main/res/values/integers.xml diff --git a/src/android/app/src/main/cpp/AndroidGameIconLoadedCallback.h b/src/android/app/src/main/cpp/AndroidGameIconLoadedCallback.h deleted file mode 100644 index e48063eb..00000000 --- a/src/android/app/src/main/cpp/AndroidGameIconLoadedCallback.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "GameIconLoader.h" -#include "JNIUtils.h" - -class AndroidGameIconLoadedCallback : public GameIconLoadedCallback -{ - jmethodID m_onGameIconLoadedMID; - JNIUtils::Scopedjobject m_gameTitleLoadedCallbackObj; - - public: - AndroidGameIconLoadedCallback(jmethodID onGameIconLoadedMID, - jobject gameTitleLoadedCallbackObj) - : m_onGameIconLoadedMID(onGameIconLoadedMID), - m_gameTitleLoadedCallbackObj(gameTitleLoadedCallbackObj) {} - - void onIconLoaded(TitleId titleId, int* colors, int width, int height) override - { - JNIUtils::ScopedJNIENV env; - jintArray jIconData = env->NewIntArray(width * height); - env->SetIntArrayRegion(jIconData, 0, width * height, reinterpret_cast(colors)); - env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameIconLoadedMID, static_cast(titleId), jIconData, width, height); - env->DeleteLocalRef(jIconData); - } -}; \ No newline at end of file diff --git a/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h index 172dc5a5..7d8a543a 100644 --- a/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h +++ b/src/android/app/src/main/cpp/AndroidGameTitleLoadedCallback.h @@ -1,8 +1,10 @@ #pragma once #include "GameTitleLoader.h" +#include "JNIUtils.h" -class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback { +class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback +{ jmethodID m_onGameTitleLoadedMID; JNIUtils::Scopedjobject m_gameTitleLoadedCallbackObj; @@ -11,12 +13,23 @@ class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback { : m_onGameTitleLoadedMID(onGameTitleLoadedMID), m_gameTitleLoadedCallbackObj(gameTitleLoadedCallbackObj) {} - void onTitleLoaded(const Game& game) override + void onTitleLoaded(const Game& game, const std::shared_ptr& icon) override { JNIUtils::ScopedJNIENV env; jstring name = env->NewStringUTF(game.name.c_str()); jlong titleId = static_cast(game.titleId); - env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameTitleLoadedMID, titleId, name); + int width = -1, height = -1; + jintArray jIconData = nullptr; + if (icon) + { + width = icon->m_width; + height = icon->m_height; + jIconData = env->NewIntArray(width * height); + env->SetIntArrayRegion(jIconData, 0, width * height, reinterpret_cast(icon->intColors())); + } + env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameTitleLoadedMID, static_cast(titleId), name, jIconData, width, height); + if (jIconData != nullptr) + env->DeleteLocalRef(jIconData); env->DeleteLocalRef(name); } }; diff --git a/src/android/app/src/main/cpp/CMakeLists.txt b/src/android/app/src/main/cpp/CMakeLists.txt index ac8c60ab..916bf599 100644 --- a/src/android/app/src/main/cpp/CMakeLists.txt +++ b/src/android/app/src/main/cpp/CMakeLists.txt @@ -4,14 +4,11 @@ add_library(CemuAndroid SHARED AndroidEmulatedController.cpp AndroidEmulatedController.h AndroidFilesystemCallbacks.h - AndroidGameIconLoadedCallback.h AndroidGameTitleLoadedCallback.h CMakeLists.txt CafeSystemUtils.cpp CafeSystemUtils.h EmulationState.h - GameIconLoader.cpp - GameIconLoader.h GameTitleLoader.cpp GameTitleLoader.h Image.cpp diff --git a/src/android/app/src/main/cpp/EmulationState.h b/src/android/app/src/main/cpp/EmulationState.h index 108ae306..3bdebc1e 100644 --- a/src/android/app/src/main/cpp/EmulationState.h +++ b/src/android/app/src/main/cpp/EmulationState.h @@ -11,7 +11,6 @@ #include "CafeSystemUtils.h" #include "Cafe/CafeSystem.h" #include "Cemu/GuiSystem/GuiSystem.h" -#include "GameIconLoader.h" #include "GameTitleLoader.h" #include "Utils.h" #include "input/ControllerFactory.h" @@ -23,7 +22,6 @@ void CemuCommonInit(); class EmulationState { - GameIconLoader m_gameIconLoader; GameTitleLoader m_gameTitleLoader; std::unordered_map m_graphicPacks; void fillGraphicPacks() @@ -241,24 +239,28 @@ class EmulationState m_gameTitleLoader.setOnTitleLoaded(onGameTitleLoaded); } - const Image& getGameIcon(TitleId titleId) + void addGamesPath(const std::string& gamePath) { - return m_gameIconLoader.getGameIcon(titleId); + auto& gamePaths = g_config.data().game_paths; + if (std::any_of(gamePaths.begin(), gamePaths.end(), [&](auto path) { return path == gamePath; })) + return; + gamePaths.push_back(gamePath); + g_config.Save(); + CafeTitleList::ClearScanPaths(); + for (auto& it : gamePaths) + CafeTitleList::AddScanPath(it); + CafeTitleList::Refresh(); } - void setOnGameIconLoaded(const std::shared_ptr& onGameIconLoaded) + void removeGamesPath(const std::string& gamePath) { - m_gameIconLoader.setOnIconLoaded(onGameIconLoaded); - } - - void addGamePath(const fs::path& gamePath) - { - m_gameTitleLoader.addGamePath(gamePath); - } - - void requestGameIcon(TitleId titleId) - { - m_gameIconLoader.requestIcon(titleId); + auto& gamePaths = g_config.data().game_paths; + std::erase_if(gamePaths, [&](auto path) { return path == gamePath; }); + g_config.Save(); + CafeTitleList::ClearScanPaths(); + for (auto& it : gamePaths) + CafeTitleList::AddScanPath(it); + CafeTitleList::Refresh(); } void reloadGameTitles() diff --git a/src/android/app/src/main/cpp/GameIconLoader.cpp b/src/android/app/src/main/cpp/GameIconLoader.cpp deleted file mode 100644 index 5915ad03..00000000 --- a/src/android/app/src/main/cpp/GameIconLoader.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "GameIconLoader.h" - -#include "Cafe/TitleList/TitleInfo.h" -#include "Cafe/TitleList/TitleList.h" - -GameIconLoader::GameIconLoader() -{ - m_loaderThread = std::thread(&GameIconLoader::loadGameIcons, this); -} - -GameIconLoader::~GameIconLoader() -{ - m_continueLoading = false; - m_condVar.notify_one(); - m_loaderThread.join(); -} - -const Image& GameIconLoader::getGameIcon(TitleId titleId) -{ - return m_iconCache.at(titleId); -} - -void GameIconLoader::requestIcon(TitleId titleId) -{ - { - std::lock_guard lock(m_threadMutex); - m_iconsToLoad.emplace_front(titleId); - } - m_condVar.notify_one(); -} - -void GameIconLoader::loadGameIcons() -{ - while (m_continueLoading) - { - TitleId titleId = 0; - { - std::unique_lock lock(m_threadMutex); - m_condVar.wait(lock, [this] { return !m_iconsToLoad.empty() || !m_continueLoading; }); - if (!m_continueLoading) - return; - titleId = m_iconsToLoad.front(); - m_iconsToLoad.pop_front(); - } - TitleInfo titleInfo; - if (!CafeTitleList::GetFirstByTitleId(titleId, titleInfo)) - continue; - - if (auto iconIt = m_iconCache.find(titleId); iconIt != m_iconCache.end()) - { - auto& icon = iconIt->second; - if (m_onIconLoaded) - m_onIconLoaded->onIconLoaded(titleId, icon.intColors(), icon.m_width, icon.m_height); - continue; - } - - std::string tempMountPath = TitleInfo::GetUniqueTempMountingPath(); - if (!titleInfo.Mount(tempMountPath, "", FSC_PRIORITY_BASE)) - continue; - auto tgaData = fsc_extractFile((tempMountPath + "/meta/iconTex.tga").c_str()); - if (tgaData && tgaData->size() > 16) - { - Image image(tgaData.value()); - if (image.isOk()) - { - if (m_onIconLoaded) - m_onIconLoaded->onIconLoaded(titleId, image.intColors(), image.m_width, image.m_height); - m_iconCache.emplace(titleId, std::move(image)); - } - } - else - { - cemuLog_log(LogType::Force, "Failed to load icon for title {:016x}", titleId); - } - titleInfo.Unmount(tempMountPath); - } -} - -void GameIconLoader::setOnIconLoaded(const std::shared_ptr& onIconLoaded) -{ - { - std::lock_guard lock(m_threadMutex); - m_onIconLoaded = onIconLoaded; - } - m_condVar.notify_one(); -}; diff --git a/src/android/app/src/main/cpp/GameIconLoader.h b/src/android/app/src/main/cpp/GameIconLoader.h deleted file mode 100644 index bf59ad20..00000000 --- a/src/android/app/src/main/cpp/GameIconLoader.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include "Cafe/TitleList/TitleId.h" -#include "Image.h" - -class GameIconLoadedCallback { - public: - virtual void onIconLoaded(TitleId titleId, int* colors, int width, int height) = 0; -}; - -class GameIconLoader { - std::condition_variable m_condVar; - std::atomic_bool m_continueLoading = true; - std::thread m_loaderThread; - std::mutex m_threadMutex; - std::deque m_iconsToLoad; - std::map m_iconCache; - std::shared_ptr m_onIconLoaded = nullptr; - - public: - GameIconLoader(); - - ~GameIconLoader(); - - const Image& getGameIcon(TitleId titleId); - - void setOnIconLoaded(const std::shared_ptr& onIconLoaded); - - void requestIcon(TitleId titleId); - - private: - void loadGameIcons(); -}; diff --git a/src/android/app/src/main/cpp/GameTitleLoader.cpp b/src/android/app/src/main/cpp/GameTitleLoader.cpp index 1ec61eae..38ba545b 100644 --- a/src/android/app/src/main/cpp/GameTitleLoader.cpp +++ b/src/android/app/src/main/cpp/GameTitleLoader.cpp @@ -1,5 +1,13 @@ #include "GameTitleLoader.h" +std::optional getFirstTitleInfoByTitleId(TitleId titleId) +{ + TitleInfo titleInfo; + if (CafeTitleList::GetFirstByTitleId(titleId, titleInfo)) + return titleInfo; + return {}; +} + GameTitleLoader::GameTitleLoader() { m_loaderThread = std::thread(&GameTitleLoader::loadGameTitles, this); @@ -27,11 +35,14 @@ void GameTitleLoader::reloadGameTitles() { if (m_callbackIdTitleList.has_value()) { - CafeTitleList::Refresh(); CafeTitleList::UnregisterCallback(m_callbackIdTitleList.value()); } m_gameInfos.clear(); - registerCallback(); + CafeTitleList::ClearScanPaths(); + for (auto&& gamePath : g_config.data().game_paths) + CafeTitleList::AddScanPath(gamePath); + CafeTitleList::Refresh(); + m_callbackIdTitleList = CafeTitleList::RegisterCallback([](CafeTitleListCallbackEvent* evt, void* ctx) { static_cast(ctx)->HandleTitleListCallback(evt); }, this); } GameTitleLoader::~GameTitleLoader() @@ -57,11 +68,14 @@ void GameTitleLoader::titleRefresh(TitleId titleId) isNewEntry = true; m_gameInfos[baseTitleId] = Game(); } + Game& game = m_gameInfos[baseTitleId]; + std::optional titleInfo = getFirstTitleInfoByTitleId(titleId); game.titleId = baseTitleId; - game.name = GetNameByTitleId(baseTitleId); + game.name = getNameByTitleId(baseTitleId, titleInfo); game.version = gameInfo.GetVersion(); game.region = gameInfo.GetRegion(); + std::shared_ptr icon = loadIcon(baseTitleId, titleInfo); if (gameInfo.HasAOC()) { game.dlc = gameInfo.GetAOCVersion(); @@ -72,7 +86,7 @@ void GameTitleLoader::titleRefresh(TitleId titleId) if (iosu::pdm::GetStatForGamelist(baseTitleId, playTimeStat)) game.secondsPlayed = playTimeStat.numMinutesPlayed * 60; if (m_gameTitleLoadedCallback) - m_gameTitleLoadedCallback->onTitleLoaded(game); + m_gameTitleLoadedCallback->onTitleLoaded(game, icon); } else { @@ -84,11 +98,10 @@ void GameTitleLoader::loadGameTitles() { while (m_continueLoading) { - TitleId titleId = 0; + TitleId titleId; { std::unique_lock lock(m_threadMutex); - m_condVar.wait(lock, [this] { return (!m_titlesToLoad.empty()) || - !m_continueLoading; }); + m_condVar.wait(lock, [this] { return (!m_titlesToLoad.empty()) || !m_continueLoading; }); if (!m_continueLoading) return; titleId = m_titlesToLoad.front(); @@ -97,29 +110,43 @@ void GameTitleLoader::loadGameTitles() titleRefresh(titleId); } } - -std::string GameTitleLoader::GetNameByTitleId(uint64 titleId) +std::string GameTitleLoader::getNameByTitleId(TitleId titleId, const std::optional& titleInfo) { auto it = m_name_cache.find(titleId); if (it != m_name_cache.end()) return it->second; - TitleInfo titleInfo; - if (!CafeTitleList::GetFirstByTitleId(titleId, titleInfo)) + if (!titleInfo.has_value()) return "Unknown title"; std::string name; if (!GetConfig().GetGameListCustomName(titleId, name)) - name = titleInfo.GetMetaTitleName(); + name = titleInfo.value().GetMetaTitleName(); m_name_cache.emplace(titleId, name); return name; } -void GameTitleLoader::registerCallback() +std::shared_ptr GameTitleLoader::loadIcon(TitleId titleId, const std::optional& titleInfo) { - m_callbackIdTitleList = CafeTitleList::RegisterCallback( - [](CafeTitleListCallbackEvent* evt, void* ctx) { - static_cast(ctx)->HandleTitleListCallback(evt); - }, - this); + if (auto iconIt = m_iconCache.find(titleId); iconIt != m_iconCache.end()) + return iconIt->second; + std::string tempMountPath = TitleInfo::GetUniqueTempMountingPath(); + if (!titleInfo.has_value()) + return {}; + auto titleInfoValue = titleInfo.value(); + if (!titleInfoValue.Mount(tempMountPath, "", FSC_PRIORITY_BASE)) + return {}; + auto tgaData = fsc_extractFile((tempMountPath + "/meta/iconTex.tga").c_str()); + if (!tgaData || tgaData->size() <= 16) + { + cemuLog_log(LogType::Force, "Failed to load icon for title {:016x}", titleId); + titleInfoValue.Unmount(tempMountPath); + return {}; + } + auto image = std::make_shared(tgaData.value()); + titleInfoValue.Unmount(tempMountPath); + if (!image->isOk()) + return {}; + m_iconCache.emplace(titleId, image); + return image; } void GameTitleLoader::HandleTitleListCallback(CafeTitleListCallbackEvent* evt) @@ -129,10 +156,3 @@ void GameTitleLoader::HandleTitleListCallback(CafeTitleListCallbackEvent* evt) queueTitle(evt->titleInfo->GetAppTitleId()); } } - -void GameTitleLoader::addGamePath(const fs::path& path) -{ - CafeTitleList::ClearScanPaths(); - CafeTitleList::AddScanPath(path); - CafeTitleList::Refresh(); -} diff --git a/src/android/app/src/main/cpp/GameTitleLoader.h b/src/android/app/src/main/cpp/GameTitleLoader.h index b4ef2ec2..21709bb2 100644 --- a/src/android/app/src/main/cpp/GameTitleLoader.h +++ b/src/android/app/src/main/cpp/GameTitleLoader.h @@ -3,6 +3,7 @@ #include "Cafe/IOSU/PDM/iosu_pdm.h" #include "Cafe/TitleList/TitleId.h" #include "Cafe/TitleList/TitleList.h" +#include "Image.h" struct Game { @@ -14,12 +15,14 @@ struct Game CafeConsoleRegion region; }; -class GameTitleLoadedCallback { +class GameTitleLoadedCallback +{ public: - virtual void onTitleLoaded(const Game& game) = 0; + virtual void onTitleLoaded(const Game& game, const std::shared_ptr& icon) = 0; }; -class GameTitleLoader { +class GameTitleLoader +{ std::mutex m_threadMutex; std::condition_variable m_condVar; std::thread m_loaderThread; @@ -27,30 +30,22 @@ class GameTitleLoader { std::deque m_titlesToLoad; std::optional m_callbackIdTitleList; std::map m_gameInfos; + std::map> m_iconCache; std::map m_name_cache; std::shared_ptr m_gameTitleLoadedCallback = nullptr; public: GameTitleLoader(); - void queueTitle(TitleId titleId); - void setOnTitleLoaded(const std::shared_ptr& gameTitleLoadedCallback); - void reloadGameTitles(); - ~GameTitleLoader(); - void titleRefresh(TitleId titleId); - void addGamePath(const fs::path& path); private: void loadGameTitles(); - - std::string GetNameByTitleId(uint64 titleId); - - void registerCallback(); - + std::string getNameByTitleId(TitleId titleId, const std::optional& titleInfo); + std::shared_ptr loadIcon(TitleId titleId, const std::optional& titleInfo); void HandleTitleListCallback(CafeTitleListCallbackEvent* evt); }; diff --git a/src/android/app/src/main/cpp/native-lib.cpp b/src/android/app/src/main/cpp/native-lib.cpp index e6526a65..312d9fef 100644 --- a/src/android/app/src/main/cpp/native-lib.cpp +++ b/src/android/app/src/main/cpp/native-lib.cpp @@ -1,5 +1,4 @@ #include "Cafe/GraphicPack/GraphicPack2.h" -#include "AndroidGameIconLoadedCallback.h" #include "AndroidGameTitleLoadedCallback.h" #include "EmulationState.h" #include "JNIUtils.h" @@ -35,26 +34,17 @@ Java_info_cemu_Cemu_NativeLibrary_startGame([[maybe_unused]] JNIEnv* env, [[mayb extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_Cemu_NativeLibrary_setGameTitleLoadedCallback(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject game_title_loaded_callback) { + if (game_title_loaded_callback == nullptr) + { + s_emulationState.setOnGameTitleLoaded(nullptr); + return; + } jclass gameTitleLoadedCallbackClass = env->GetObjectClass(game_title_loaded_callback); - jmethodID onGameTitleLoadedMID = env->GetMethodID(gameTitleLoadedCallbackClass, "onGameTitleLoaded", "(JLjava/lang/String;)V"); + jmethodID onGameTitleLoadedMID = env->GetMethodID(gameTitleLoadedCallbackClass, "onGameTitleLoaded", "(JLjava/lang/String;[III)V"); env->DeleteLocalRef(gameTitleLoadedCallbackClass); s_emulationState.setOnGameTitleLoaded(std::make_shared(onGameTitleLoadedMID, game_title_loaded_callback)); } -extern "C" [[maybe_unused]] JNIEXPORT void JNICALL -Java_info_cemu_Cemu_NativeLibrary_setGameIconLoadedCallback(JNIEnv* env, [[maybe_unused]] jclass clazz, jobject game_icon_loaded_callback) -{ - jclass gameIconLoadedCallbackClass = env->GetObjectClass(game_icon_loaded_callback); - jmethodID gameIconLoadedMID = env->GetMethodID(gameIconLoadedCallbackClass, "onGameIconLoaded", "(J[III)V"); - s_emulationState.setOnGameIconLoaded(std::make_shared(gameIconLoadedMID, game_icon_loaded_callback)); -} - -extern "C" [[maybe_unused]] JNIEXPORT void JNICALL -Java_info_cemu_Cemu_NativeLibrary_requestGameIcon([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jlong title_id) -{ - s_emulationState.requestGameIcon(static_cast(title_id)); -} - extern "C" [[maybe_unused]] JNIEXPORT void JNICALL Java_info_cemu_Cemu_NativeLibrary_reloadGameTitles([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz) { @@ -97,9 +87,21 @@ Java_info_cemu_Cemu_NativeLibrary_recreateRenderSurface([[maybe_unused]] JNIEnv* } extern "C" [[maybe_unused]] JNIEXPORT void JNICALL -Java_info_cemu_Cemu_NativeLibrary_addGamePath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) +Java_info_cemu_Cemu_NativeLibrary_addGamesPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) { - s_emulationState.addGamePath(JNIUtils::JStringToString(env, uri)); + s_emulationState.addGamesPath(JNIUtils::JStringToString(env, uri)); +} + +extern "C" [[maybe_unused]] JNIEXPORT void JNICALL +Java_info_cemu_Cemu_NativeLibrary_removeGamesPath(JNIEnv* env, [[maybe_unused]] jclass clazz, jstring uri) +{ + s_emulationState.removeGamesPath(JNIUtils::JStringToString(env, uri)); +} + +extern "C" [[maybe_unused]] JNIEXPORT jobject JNICALL +Java_info_cemu_Cemu_NativeLibrary_getGamesPaths(JNIEnv* env, [[maybe_unused]] jclass clazz) +{ + return JNIUtils::createJavaStringArrayList(env, g_config.data().game_paths); } extern "C" [[maybe_unused]] JNIEXPORT void JNICALL diff --git a/src/android/app/src/main/java/info/cemu/Cemu/NativeLibrary.java b/src/android/app/src/main/java/info/cemu/Cemu/NativeLibrary.java index e9727c9b..b00204db 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/NativeLibrary.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/NativeLibrary.java @@ -28,28 +28,23 @@ public class NativeLibrary { public static native void recreateRenderSurface(boolean isMainCanvas); - public interface GameTitleLoadedCallback { - void onGameTitleLoaded(long titleId, String title); + void onGameTitleLoaded(long titleId, String title, int[] colors, int width, int height); } public static native void setGameTitleLoadedCallback(GameTitleLoadedCallback gameTitleLoadedCallback); - public interface GameIconLoadedCallback { - void onGameIconLoaded(long titleId, int[] colors, int width, int height); - } - - public static native void setGameIconLoadedCallback(GameIconLoadedCallback gameIconLoadedCallback); - - public static native void requestGameIcon(long titleId); - public static native void reloadGameTitles(); public static native void initializeActiveSettings(String dataPath, String cachePath); public static native void initializeEmulation(); - public static native void addGamePath(String uri); + public static native void addGamesPath(String uri); + + public static native void removeGamesPath(String uri); + + public static native ArrayList getGamesPaths(); public static native void onNativeKey(String deviceDescriptor, String deviceName, int key, boolean isPressed); diff --git a/src/android/app/src/main/java/info/cemu/Cemu/gameview/Game.java b/src/android/app/src/main/java/info/cemu/Cemu/gameview/Game.java index 4af88106..a907b725 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/gameview/Game.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/gameview/Game.java @@ -7,12 +7,9 @@ public class Game { String title; Bitmap icon; - public Game(Long titleId, String title) { + public Game(Long titleId, String title, Bitmap icon) { this.titleId = titleId; this.title = title; - } - - public void setIconData(Bitmap icon) { this.icon = icon; } diff --git a/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameAdapter.java b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameAdapter.java index 4ef3b471..5e74f951 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameAdapter.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameAdapter.java @@ -1,67 +1,56 @@ package info.cemu.Cemu.gameview; -import android.graphics.Bitmap; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.Filter; -import android.widget.Filterable; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.Locale; import java.util.stream.Collectors; import info.cemu.Cemu.R; -public class GameAdapter extends RecyclerView.Adapter implements Filterable { - private final Map gameInfoMap; - private final List gameTitleIds; - private List filteredGameTitleIds; +public class GameAdapter extends ListAdapter { private final GameTitleClickAction gameTitleClickAction; + private List orignalGameList; + private String filterText; + public static final DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback<>() { + @Override + public boolean areItemsTheSame(@NonNull Game oldItem, @NonNull Game newItem) { + return oldItem.titleId.equals(newItem.titleId); + } - @Override - public Filter getFilter() { - return new Filter() { - @Override - protected FilterResults performFiltering(CharSequence charSequence) { - String gameTitle = charSequence.toString(); - if (gameTitle.isEmpty()) - filteredGameTitleIds = gameTitleIds; - else - filteredGameTitleIds = gameTitleIds.stream() - .filter(titleId -> gameInfoMap.get(titleId).title.toLowerCase().contains(gameTitle.toLowerCase())) - .collect(Collectors.toList()); - FilterResults filterResults = new FilterResults(); - filterResults.values = filteredGameTitleIds; - return filterResults; - } - - @Override - protected void publishResults(CharSequence charSequence, FilterResults filterResults) { - filteredGameTitleIds = (List) filterResults.values; - notifyDataSetChanged(); - } - }; - } - + @Override + public boolean areContentsTheSame(@NonNull Game oldItem, @NonNull Game newItem) { + return oldItem.titleId.equals(newItem.titleId); + } + }; public interface GameTitleClickAction { void action(long titleId); } public GameAdapter(GameTitleClickAction gameTitleClickAction) { - super(); + super(DIFF_CALLBACK); this.gameTitleClickAction = gameTitleClickAction; - gameTitleIds = new ArrayList<>(); - filteredGameTitleIds = new ArrayList<>(); - gameInfoMap = new HashMap<>(); + } + + @Override + public void submitList(@Nullable List list) { + orignalGameList = list; + if (filterText == null || filterText.isBlank() || orignalGameList == null) { + super.submitList(orignalGameList); + return; + } + super.submitList(orignalGameList.stream().filter(g -> g.title.toLowerCase(Locale.US).contains(this.filterText)).collect(Collectors.toList())); } @NonNull @@ -73,7 +62,7 @@ public class GameAdapter extends RecyclerView.Adapter im @Override public void onBindViewHolder(@NonNull GameAdapter.ViewHolder holder, int position) { - Game game = gameInfoMap.get(filteredGameTitleIds.get(position)); + Game game = getItem(position); if (game != null) { holder.icon.setImageBitmap(game.getIcon()); holder.text.setText(game.getTitle()); @@ -84,31 +73,16 @@ public class GameAdapter extends RecyclerView.Adapter im } } - @Override - public int getItemCount() { - return filteredGameTitleIds.size(); - } - - void clear() { - notifyDataSetChanged(); - gameInfoMap.clear(); - gameTitleIds.clear(); - filteredGameTitleIds.clear(); - } - - void addGameInfo(long titleId, String title) { - gameTitleIds.add(titleId); - gameInfoMap.put(titleId, new Game(titleId, title)); - filteredGameTitleIds = gameTitleIds; - notifyDataSetChanged(); - } - - void setGameIcon(long titleId, Bitmap icon) { - Game game = gameInfoMap.get(titleId); - if (game != null) { - game.setIconData(icon); - notifyDataSetChanged(); + public void setFilterText(String filterText) { + if (filterText != null) { + filterText = filterText.toLowerCase(Locale.US); } + this.filterText = filterText; + if (filterText == null || filterText.isBlank() || orignalGameList == null) { + super.submitList(orignalGameList); + return; + } + super.submitList(orignalGameList.stream().filter(g -> g.title.toLowerCase(Locale.US).contains(this.filterText)).collect(Collectors.toList())); } public static class ViewHolder extends RecyclerView.ViewHolder { diff --git a/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameViewModel.java b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameViewModel.java new file mode 100644 index 00000000..dd7bc79b --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GameViewModel.java @@ -0,0 +1,50 @@ +package info.cemu.Cemu.gameview; + +import android.graphics.Bitmap; +import android.util.Log; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import info.cemu.Cemu.NativeLibrary; + +public class GameViewModel extends ViewModel { + private final MutableLiveData> gamesData; + + private final ArrayList games = new ArrayList<>(); + + public LiveData> getGames() { + return gamesData; + } + + public GameViewModel() { + this.gamesData = new MutableLiveData<>(); + NativeLibrary.setGameTitleLoadedCallback((titleId, title, colors, width, height) -> { + Bitmap icon = null; + if (colors != null) + icon = Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888); + Game game = new Game(titleId, title, icon); + synchronized (GameViewModel.this) { + games.add(game); + gamesData.postValue(new ArrayList<>(games)); + } + }); + } + + @Override + protected void onCleared() { + NativeLibrary.setGameTitleLoadedCallback(null); + } + + public void refreshGames() { + games.clear(); + gamesData.setValue(null); + NativeLibrary.reloadGameTitles(); + } +} diff --git a/src/android/app/src/main/java/info/cemu/Cemu/gameview/GamesFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GamesFragment.java index cd660f48..9b746830 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/gameview/GamesFragment.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/gameview/GamesFragment.java @@ -1,7 +1,6 @@ package info.cemu.Cemu.gameview; import android.content.Intent; -import android.graphics.Bitmap; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; @@ -12,88 +11,62 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.RecyclerView; -import info.cemu.Cemu.R; import info.cemu.Cemu.databinding.FragmentGamesBinding; import info.cemu.Cemu.emulation.EmulationActivity; -import info.cemu.Cemu.NativeLibrary; import info.cemu.Cemu.settings.SettingsActivity; -import info.cemu.Cemu.settings.SettingsManager; public class GamesFragment extends Fragment { - private FragmentGamesBinding binding; private GameAdapter gameAdapter; - private SettingsManager settingsManager; + private GameViewModel gameViewModel; @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - settingsManager = new SettingsManager(requireContext()); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - binding = FragmentGamesBinding.inflate(inflater, container, false); - String gamesPath = settingsManager.getGamesPath(); - if (gamesPath != null) - NativeLibrary.addGamePath(gamesPath); - RecyclerView recyclerView = binding.gamesRecyclerView; - recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - binding.settingsButton.setOnClickListener(v -> { - Intent intent = new Intent(requireActivity(), SettingsActivity.class); - startActivity(intent); - }); - View rootView = binding.getRoot(); gameAdapter = new GameAdapter(titleId -> { Intent intent = new Intent(getContext(), EmulationActivity.class); intent.putExtra(EmulationActivity.GAME_TITLE_ID, titleId); startActivity(intent); }); - gameAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { - @Override - public void onChanged() { - if (gameAdapter.getItemCount() == 0) { - if (!binding.searchText.getText().toString().isEmpty()) - binding.gamesListMessage.setText(R.string.no_games_found); - binding.gamesListMessage.setVisibility(View.VISIBLE); + gameViewModel = new ViewModelProvider(this).get(GameViewModel.class); + gameViewModel.getGames().observe(this, gameList -> gameAdapter.submitList(gameList)); + } - } else { - binding.gamesListMessage.setVisibility(View.GONE); - } - } + @Override + public void onResume() { + super.onResume(); + gameViewModel.refreshGames(); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + FragmentGamesBinding binding = FragmentGamesBinding.inflate(inflater, container, false); + RecyclerView recyclerView = binding.gamesRecyclerView; + binding.settingsButton.setOnClickListener(v -> { + Intent intent = new Intent(requireActivity(), SettingsActivity.class); + startActivity(intent); }); + View rootView = binding.getRoot(); + binding.gamesSwipeRefresh.setOnRefreshListener(() -> { - gameAdapter.clear(); - NativeLibrary.reloadGameTitles(); + gameViewModel.refreshGames(); binding.gamesSwipeRefresh.setRefreshing(false); }); recyclerView.setAdapter(gameAdapter); - NativeLibrary.setGameTitleLoadedCallback((titleId, title) -> requireActivity().runOnUiThread(() -> { - gameAdapter.addGameInfo(titleId, title); - NativeLibrary.requestGameIcon(titleId); - })); - NativeLibrary.setGameIconLoadedCallback((titleId, colors, width, height) -> { - Bitmap icon = Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888); - requireActivity().runOnUiThread(() -> gameAdapter.setGameIcon(titleId, icon)); - }); - NativeLibrary.reloadGameTitles(); - binding.searchText.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { - } @Override public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { - gameAdapter.getFilter().filter(charSequence.toString()); + gameAdapter.setFilterText(charSequence.toString()); } @Override public void afterTextChanged(Editable editable) { - } }); diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/OptionsFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/OptionsFragment.java deleted file mode 100644 index a0e1f9b1..00000000 --- a/src/android/app/src/main/java/info/cemu/Cemu/settings/OptionsFragment.java +++ /dev/null @@ -1,69 +0,0 @@ -package info.cemu.Cemu.settings; - -import static android.app.Activity.RESULT_OK; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.Fragment; -import androidx.navigation.fragment.NavHostFragment; - -import java.util.Objects; - -import info.cemu.Cemu.R; -import info.cemu.Cemu.databinding.FragmentOptionsBinding; -import info.cemu.Cemu.guibasecomponents.ButtonRecyclerViewItem; -import info.cemu.Cemu.guibasecomponents.GenericRecyclerViewAdapter; -import info.cemu.Cemu.guibasecomponents.SimpleButtonRecyclerViewItem; - -public class OptionsFragment extends Fragment { - private ActivityResultLauncher folderSelectionLauncher; - private SettingsManager settingsManager; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - settingsManager = new SettingsManager(requireContext()); - } - - @Override - public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - FragmentOptionsBinding binding = FragmentOptionsBinding.inflate(inflater, container, false); - GenericRecyclerViewAdapter genericRecyclerViewAdapter = new GenericRecyclerViewAdapter(); - folderSelectionLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { - if (result.getResultCode() == RESULT_OK) { - Intent data = result.getData(); - if (data != null) { - Uri uri = Objects.requireNonNull(data.getData()); - requireActivity().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - DocumentFile documentFile = DocumentFile.fromTreeUri(requireContext(), uri); - if (documentFile == null) return; - String gamesPath = documentFile.getUri().toString(); - settingsManager.setGamesPath(gamesPath); - } - } - }); - - genericRecyclerViewAdapter.addRecyclerViewItem(new ButtonRecyclerViewItem(getString(R.string.games_folder_label), getString(R.string.games_folder_description), () -> { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - folderSelectionLauncher.launch(intent); - })); - genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.input_settings), () -> NavHostFragment.findNavController(OptionsFragment.this).navigate(R.id.action_optionsFragment_to_inputSettingsFragment))); - genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.graphics_settings), () -> NavHostFragment.findNavController(OptionsFragment.this).navigate(R.id.action_optionsFragment_to_graphicSettingsFragment))); - genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.audio_settings), () -> NavHostFragment.findNavController(OptionsFragment.this).navigate(R.id.action_optionsFragment_to_audioSettingsFragment))); - genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.graphic_packs), () -> NavHostFragment.findNavController(OptionsFragment.this).navigate(R.id.action_optionsFragment_to_graphicPacksRootFragment))); - genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.overlay), () -> NavHostFragment.findNavController(OptionsFragment.this).navigate(R.id.action_optionsFragment_to_overlaySettingsFragment))); - binding.optionsRecyclerView.setAdapter(genericRecyclerViewAdapter); - - return binding.getRoot(); - } -} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java new file mode 100644 index 00000000..57504124 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsFragment.java @@ -0,0 +1,34 @@ +package info.cemu.Cemu.settings; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.navigation.fragment.NavHostFragment; + +import info.cemu.Cemu.R; +import info.cemu.Cemu.databinding.GenericRecyclerViewLayoutBinding; +import info.cemu.Cemu.guibasecomponents.ButtonRecyclerViewItem; +import info.cemu.Cemu.guibasecomponents.GenericRecyclerViewAdapter; +import info.cemu.Cemu.guibasecomponents.SimpleButtonRecyclerViewItem; + +public class SettingsFragment extends Fragment { + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + GenericRecyclerViewLayoutBinding binding = GenericRecyclerViewLayoutBinding.inflate(inflater, container, false); + GenericRecyclerViewAdapter genericRecyclerViewAdapter = new GenericRecyclerViewAdapter(); + + genericRecyclerViewAdapter.addRecyclerViewItem(new ButtonRecyclerViewItem(getString(R.string.add_game_path), getString(R.string.games_folder_description), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_gamePathsFragment))); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.input_settings), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_inputSettingsFragment))); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.graphics_settings), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_graphicSettingsFragment))); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.audio_settings), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_audioSettingsFragment))); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.graphic_packs), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_graphicPacksRootFragment))); + genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.overlay), () -> NavHostFragment.findNavController(SettingsFragment.this).navigate(R.id.action_settingsFragment_to_overlaySettingsFragment))); + binding.recyclerView.setAdapter(genericRecyclerViewAdapter); + + return binding.getRoot(); + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsManager.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsManager.java index b0d31e45..79465f4e 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsManager.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/SettingsManager.java @@ -5,18 +5,9 @@ import android.content.SharedPreferences; public class SettingsManager { public static final String PREFERENCES_NAME = "CEMU_PREFERENCES"; - public static final String GAMES_PATH_KEY = "GAME_PATHS_KEY"; private final SharedPreferences sharedPreferences; public SettingsManager(Context context) { sharedPreferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); } - - public String getGamesPath() { - return sharedPreferences.getString(GAMES_PATH_KEY, null); - } - - public void setGamesPath(String gamesPath) { - sharedPreferences.edit().putString(GAMES_PATH_KEY, gamesPath).apply(); - } } diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathAdapter.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathAdapter.java new file mode 100644 index 00000000..f2df0ad9 --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathAdapter.java @@ -0,0 +1,65 @@ +package info.cemu.Cemu.settings.gamespath; + +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import info.cemu.Cemu.R; + +public class GamePathAdapter extends ListAdapter { + public interface OnRemoveGamePath { + void onRemoveGamePath(String path); + } + + private final static DiffUtil.ItemCallback DIFF_CALLBACK = new DiffUtil.ItemCallback<>() { + @Override + public boolean areItemsTheSame(@NonNull String oldItem, @NonNull String newItem) { + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull String oldItem, @NonNull String newItem) { + return oldItem.equals(newItem); + } + }; + private final OnRemoveGamePath onRemoveGamePath; + + public GamePathAdapter(OnRemoveGamePath onRemoveGamePath) { + super(DIFF_CALLBACK); + this.onRemoveGamePath = onRemoveGamePath; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.layout_game_path, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + String gamePath = getItem(position); + holder.gamePath.setText(gamePath); + holder.gamePath.setSelected(true); + holder.deleteButton.setOnClickListener(v -> onRemoveGamePath.onRemoveGamePath(gamePath)); + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + TextView gamePath; + Button deleteButton; + + public ViewHolder(View itemView) { + super(itemView); + gamePath = itemView.findViewById(R.id.game_path_text); + deleteButton = itemView.findViewById(R.id.remove_game_path_button); + } + } +} diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathsFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathsFragment.java new file mode 100644 index 00000000..d267c28f --- /dev/null +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/gamespath/GamePathsFragment.java @@ -0,0 +1,95 @@ +package info.cemu.Cemu.settings.gamespath; + +import static android.app.Activity.RESULT_OK; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.MenuProvider; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Lifecycle; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import info.cemu.Cemu.NativeLibrary; +import info.cemu.Cemu.R; +import info.cemu.Cemu.databinding.GenericRecyclerViewLayoutBinding; + +public class GamePathsFragment extends Fragment { + private ActivityResultLauncher folderSelectionLauncher; + private GamePathAdapter gamePathAdapter; + private List gamesPaths; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + folderSelectionLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = Objects.requireNonNull(data.getData()); + requireActivity().getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + DocumentFile documentFile = DocumentFile.fromTreeUri(requireContext(), uri); + if (documentFile == null) return; + String gamesPath = documentFile.getUri().toString(); + if (gamesPaths.stream().anyMatch(p -> p.equals(gamesPath))) { + Toast.makeText(requireContext(), R.string.game_path_already_added, Toast.LENGTH_LONG).show(); + return; + } + NativeLibrary.addGamesPath(gamesPath); + gamesPaths = Stream.concat(Stream.of(gamesPath), gamesPaths.stream()).collect(Collectors.toList()); + gamePathAdapter.submitList(gamesPaths); + } + } + }); + gamePathAdapter = new GamePathAdapter(new GamePathAdapter.OnRemoveGamePath() { + @Override + public void onRemoveGamePath(String path) { + NativeLibrary.removeGamesPath(path); + gamesPaths = gamesPaths.stream().filter(p -> !p.equals(path)).collect(Collectors.toList()); + gamePathAdapter.submitList(gamesPaths); + } + }); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + GenericRecyclerViewLayoutBinding binding = GenericRecyclerViewLayoutBinding.inflate(inflater, container, false); + binding.recyclerView.setAdapter(gamePathAdapter); + gamesPaths = NativeLibrary.getGamesPaths(); + gamePathAdapter.submitList(gamesPaths); + requireActivity().addMenuProvider(new MenuProvider() { + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menuInflater.inflate(R.menu.menu_game_paths, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { + if (menuItem.getItemId() == R.id.action_add_game_path) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + folderSelectionLauncher.launch(intent); + return true; + } + return false; + } + }, getViewLifecycleOwner(), Lifecycle.State.RESUMED); + return binding.getRoot(); + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/info/cemu/Cemu/settings/input/InputSettingsFragment.java b/src/android/app/src/main/java/info/cemu/Cemu/settings/input/InputSettingsFragment.java index c29b6c8a..23309db8 100644 --- a/src/android/app/src/main/java/info/cemu/Cemu/settings/input/InputSettingsFragment.java +++ b/src/android/app/src/main/java/info/cemu/Cemu/settings/input/InputSettingsFragment.java @@ -23,7 +23,6 @@ public class InputSettingsFragment extends Fragment { @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - var binding = GenericRecyclerViewLayoutBinding.inflate(inflater, container, false); GenericRecyclerViewAdapter genericRecyclerViewAdapter = new GenericRecyclerViewAdapter(); genericRecyclerViewAdapter.addRecyclerViewItem(new SimpleButtonRecyclerViewItem(getString(R.string.input_overlay_settings), () -> NavHostFragment.findNavController(InputSettingsFragment.this).navigate(R.id.action_inputSettingsFragment_to_inputOverlaySettingsFragment))); diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..40c23537 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_delete.xml b/src/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..d7de5795 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/layout/fragment_games.xml b/src/android/app/src/main/res/layout/fragment_games.xml index 830e68a6..40a5072f 100644 --- a/src/android/app/src/main/res/layout/fragment_games.xml +++ b/src/android/app/src/main/res/layout/fragment_games.xml @@ -1,40 +1,43 @@ + android:padding="8dp" + tools:context=".gameview.GamesFragment"> - + android:orientation="horizontal"> - - - + android:layout_margin="8dp" + app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toBottomOf="@+id/constraintLayout" + app:spanCount="@integer/games_view_span_count" /> + diff --git a/src/android/app/src/main/res/layout/layout_game.xml b/src/android/app/src/main/res/layout/layout_game.xml index 34dfdcef..cc1e5407 100644 --- a/src/android/app/src/main/res/layout/layout_game.xml +++ b/src/android/app/src/main/res/layout/layout_game.xml @@ -3,13 +3,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:clickable="true" - android:orientation="horizontal"> + android:foreground="?android:attr/selectableItemBackground" + android:orientation="horizontal" + android:padding="8dp"> diff --git a/src/android/app/src/main/res/layout/layout_game_path.xml b/src/android/app/src/main/res/layout/layout_game_path.xml new file mode 100644 index 00000000..0006b71d --- /dev/null +++ b/src/android/app/src/main/res/layout/layout_game_path.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/src/android/app/src/main/res/menu/menu_game_paths.xml b/src/android/app/src/main/res/menu/menu_game_paths.xml new file mode 100644 index 00000000..eaffe49e --- /dev/null +++ b/src/android/app/src/main/res/menu/menu_game_paths.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/navigation/nav_settings_graph.xml b/src/android/app/src/main/res/navigation/nav_settings_graph.xml index 00a99252..8ca4a311 100644 --- a/src/android/app/src/main/res/navigation/nav_settings_graph.xml +++ b/src/android/app/src/main/res/navigation/nav_settings_graph.xml @@ -3,11 +3,12 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_settings_graph" - app:startDestination="@id/optionsFragment"> + app:startDestination="@id/settingsFragment"> + android:label="@string/input_settings" + tools:layout="@layout/generic_recycler_view_layout"> @@ -18,7 +19,8 @@ + android:label="@string/graphic_packs" + tools:layout="@layout/generic_recycler_view_layout"> @@ -26,7 +28,8 @@ + android:label="{title}" + tools:layout="@layout/generic_recycler_view_layout"> @@ -36,43 +39,56 @@ app:nullable="true" /> + android:id="@+id/settingsFragment" + android:name="info.cemu.Cemu.settings.SettingsFragment" + android:label="@string/settings" + tools:layout="@layout/generic_recycler_view_layout"> + + android:name="info.cemu.Cemu.settings.input.ControllerInputsFragment" + tools:layout="@layout/generic_recycler_view_layout" /> + android:label="@string/graphics_settings" + tools:layout="@layout/generic_recycler_view_layout" /> + android:label="@string/audio_settings" + tools:layout="@layout/generic_recycler_view_layout" /> + android:label="@string/overlay_settings" + tools:layout="@layout/generic_recycler_view_layout" /> + android:label="@string/input_overlay_settings" + tools:layout="@layout/generic_recycler_view_layout" /> + \ No newline at end of file diff --git a/src/android/app/src/main/res/values-w1240dp/integers.xml b/src/android/app/src/main/res/values-w1240dp/integers.xml new file mode 100644 index 00000000..27a1b013 --- /dev/null +++ b/src/android/app/src/main/res/values-w1240dp/integers.xml @@ -0,0 +1,3 @@ + + 2 + \ No newline at end of file diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml new file mode 100644 index 00000000..53d9c37b --- /dev/null +++ b/src/android/app/src/main/res/values/integers.xml @@ -0,0 +1,3 @@ + + 1 + \ No newline at end of file diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index 11bab72c..fc5545fb 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -65,9 +65,8 @@ Right Axis Extra Nunchuck - Add the games path to scan for games displayed in the game list + Add a game path to scan for games displayed in the game list No games folder selected - Set games folder Options Selected controller: %1s Async shader compile @@ -165,4 +164,8 @@ Replace TV with PAD Installed games Enable motion + Add game path + Remove game path + Game paths + Game path already added \ No newline at end of file