Refactored game list code & added option to remove & add multiple game paths

This commit is contained in:
SSimco 2024-09-03 19:34:15 +03:00
parent 1485d0e315
commit 0037544a00
30 changed files with 565 additions and 483 deletions

View File

@ -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<const jint*>(colors));
env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameIconLoadedMID, static_cast<jlong>(titleId), jIconData, width, height);
env->DeleteLocalRef(jIconData);
}
};

View File

@ -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<Image>& icon) override
{
JNIUtils::ScopedJNIENV env;
jstring name = env->NewStringUTF(game.name.c_str());
jlong titleId = static_cast<const jlong>(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<const jint*>(icon->intColors()));
}
env->CallVoidMethod(*m_gameTitleLoadedCallbackObj, m_onGameTitleLoadedMID, static_cast<jlong>(titleId), name, jIconData, width, height);
if (jIconData != nullptr)
env->DeleteLocalRef(jIconData);
env->DeleteLocalRef(name);
}
};

View File

@ -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

View File

@ -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<int64_t, GraphicPackPtr> 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<class GameIconLoadedCallback>& 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()

View File

@ -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<GameIconLoadedCallback>& onIconLoaded)
{
{
std::lock_guard lock(m_threadMutex);
m_onIconLoaded = onIconLoaded;
}
m_condVar.notify_one();
};

View File

@ -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<TitleId> m_iconsToLoad;
std::map<TitleId, Image> m_iconCache;
std::shared_ptr<GameIconLoadedCallback> m_onIconLoaded = nullptr;
public:
GameIconLoader();
~GameIconLoader();
const Image& getGameIcon(TitleId titleId);
void setOnIconLoaded(const std::shared_ptr<GameIconLoadedCallback>& onIconLoaded);
void requestIcon(TitleId titleId);
private:
void loadGameIcons();
};

View File

@ -1,5 +1,13 @@
#include "GameTitleLoader.h"
std::optional<TitleInfo> 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<GameTitleLoader*>(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> 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<Image> 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>& 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<Image> GameTitleLoader::loadIcon(TitleId titleId, const std::optional<TitleInfo>& titleInfo)
{
m_callbackIdTitleList = CafeTitleList::RegisterCallback(
[](CafeTitleListCallbackEvent* evt, void* ctx) {
static_cast<GameTitleLoader*>(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<Image>(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();
}

View File

@ -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<Image>& 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<TitleId> m_titlesToLoad;
std::optional<uint64> m_callbackIdTitleList;
std::map<TitleId, Game> m_gameInfos;
std::map<TitleId, std::shared_ptr<Image>> m_iconCache;
std::map<TitleId, std::string> m_name_cache;
std::shared_ptr<GameTitleLoadedCallback> m_gameTitleLoadedCallback = nullptr;
public:
GameTitleLoader();
void queueTitle(TitleId titleId);
void setOnTitleLoaded(const std::shared_ptr<GameTitleLoadedCallback>& 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>& titleInfo);
std::shared_ptr<Image> loadIcon(TitleId titleId, const std::optional<TitleInfo>& titleInfo);
void HandleTitleListCallback(CafeTitleListCallbackEvent* evt);
};

View File

@ -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<AndroidGameTitleLoadedCallback>(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<AndroidGameIconLoadedCallback>(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<TitleId>(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

View File

@ -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<String> getGamesPaths();
public static native void onNativeKey(String deviceDescriptor, String deviceName, int key, boolean isPressed);

View File

@ -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;
}

View File

@ -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<GameAdapter.ViewHolder> implements Filterable {
private final Map<Long, Game> gameInfoMap;
private final List<Long> gameTitleIds;
private List<Long> filteredGameTitleIds;
public class GameAdapter extends ListAdapter<Game, GameAdapter.ViewHolder> {
private final GameTitleClickAction gameTitleClickAction;
private List<Game> orignalGameList;
private String filterText;
public static final DiffUtil.ItemCallback<Game> 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<Long>) 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<Game> 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<GameAdapter.ViewHolder> 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<GameAdapter.ViewHolder> 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 {

View File

@ -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<List<Game>> gamesData;
private final ArrayList<Game> games = new ArrayList<>();
public LiveData<List<Game>> 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();
}
}

View File

@ -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) {
}
});

View File

@ -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<Intent> 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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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<String, GamePathAdapter.ViewHolder> {
public interface OnRemoveGamePath {
void onRemoveGamePath(String path);
}
private final static DiffUtil.ItemCallback<String> 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);
}
}
}

View File

@ -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<Intent> folderSelectionLauncher;
private GamePathAdapter gamePathAdapter;
private List<String> 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();
}
}

View File

@ -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)));

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" />
</vector>

View File

@ -1,40 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/games_swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
android:padding="8dp"
tools:context=".gameview.GamesFragment">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
android:orientation="horizontal">
<com.google.android.material.card.MaterialCardView
android:id="@+id/materialCardView"
style="?attr/materialCardViewFilledStyle"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_alignParentStart="true"
android:layout_margin="8dp"
android:layout_marginEnd="328dp"
android:layout_toStartOf="@+id/settings_button"
app:cardCornerRadius="30dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/settings_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<ImageView
android:id="@+id/game_search_icon"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="center"
@ -44,49 +47,42 @@
<EditText
android:id="@+id/search_text"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@android:color/transparent"
android:hint="@string/search_games"
android:importantForAutofill="no"
android:inputType="text"
android:maxWidth="300dp"
android:maxLines="1" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/settings_button"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="8dp"
style="?attr/materialIconButtonFilledStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_gravity="end"
android:contentDescription="@string/settings"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:gravity="center_vertical"
app:icon="@drawable/ic_settings"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/games_list_message"
style="@style/TextAppearance.Material3.TitleLarge"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:gravity="center"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/games_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintTop_toBottomOf="@+id/constraintLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>
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" />
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -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">
<ImageView
android:id="@+id/game_icon"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="8dp"
android:contentDescription="@string/game_icon"
android:scaleType="fitXY" />

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/game_path_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_weight="1"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:padding="12dp"
android:paddingVertical="12dp"
android:singleLine="true"
android:text="Game path"
tools:ignore="HardcodedText" />
<com.google.android.material.button.MaterialButton
android:id="@+id/remove_game_path_button"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:contentDescription="@string/remove_game_path"
android:tooltipText="@string/remove_game_path"
app:icon="@drawable/ic_delete"
app:iconSize="32dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_add_game_path"
android:icon="@drawable/ic_add"
android:title="@string/add_game_path"
app:showAsAction="collapseActionView|ifRoom" />
</menu>

View File

@ -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">
<fragment
android:id="@+id/inputSettingsFragment"
android:name="info.cemu.Cemu.settings.input.InputSettingsFragment"
android:label="@string/input_settings">
android:label="@string/input_settings"
tools:layout="@layout/generic_recycler_view_layout">
<action
android:id="@+id/action_inputSettingsFragment_to_controllerInputsFragment"
app:destination="@id/controllerInputsFragment" />
@ -18,7 +19,8 @@
<fragment
android:id="@+id/graphicPacksRootFragment"
android:name="info.cemu.Cemu.settings.graphicpacks.GraphicPacksRootFragment"
android:label="@string/graphic_packs">
android:label="@string/graphic_packs"
tools:layout="@layout/generic_recycler_view_layout">
<action
android:id="@+id/action_graphicPacksRootFragment_to_graphicPacksFragment"
app:destination="@id/graphicPacksFragment" />
@ -26,7 +28,8 @@
<fragment
android:id="@+id/graphicPacksFragment"
android:name="info.cemu.Cemu.settings.graphicpacks.GraphicPacksFragment"
android:label="{title}">
android:label="{title}"
tools:layout="@layout/generic_recycler_view_layout">
<action
android:id="@+id/action_graphicPacksFragment_self"
app:destination="@id/graphicPacksFragment" />
@ -36,43 +39,56 @@
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/optionsFragment"
android:name="info.cemu.Cemu.settings.OptionsFragment"
android:label="@string/options"
tools:layout="@layout/fragment_options">
android:id="@+id/settingsFragment"
android:name="info.cemu.Cemu.settings.SettingsFragment"
android:label="@string/settings"
tools:layout="@layout/generic_recycler_view_layout">
<action
android:id="@+id/action_optionsFragment_to_inputSettingsFragment"
android:id="@+id/action_settingsFragment_to_inputSettingsFragment"
app:destination="@id/inputSettingsFragment" />
<action
android:id="@+id/action_optionsFragment_to_graphicSettingsFragment"
android:id="@+id/action_settingsFragment_to_graphicSettingsFragment"
app:destination="@id/graphicsSettingsFragment" />
<action
android:id="@+id/action_optionsFragment_to_audioSettingsFragment"
android:id="@+id/action_settingsFragment_to_audioSettingsFragment"
app:destination="@id/audioSettingsFragment" />
<action
android:id="@+id/action_optionsFragment_to_graphicPacksRootFragment"
android:id="@+id/action_settingsFragment_to_graphicPacksRootFragment"
app:destination="@id/graphicPacksRootFragment" />
<action
android:id="@+id/action_optionsFragment_to_overlaySettingsFragment"
android:id="@+id/action_settingsFragment_to_overlaySettingsFragment"
app:destination="@id/overlaySettingsFragment" />
<action
android:id="@+id/action_settingsFragment_to_gamePathsFragment"
app:destination="@id/gamePathsFragment" />
</fragment>
<fragment
android:id="@+id/controllerInputsFragment"
android:name="info.cemu.Cemu.settings.input.ControllerInputsFragment" />
android:name="info.cemu.Cemu.settings.input.ControllerInputsFragment"
tools:layout="@layout/generic_recycler_view_layout" />
<fragment
android:id="@+id/graphicsSettingsFragment"
android:name="info.cemu.Cemu.settings.graphics.GraphicsSettingsFragment"
android:label="@string/graphics_settings" />
android:label="@string/graphics_settings"
tools:layout="@layout/generic_recycler_view_layout" />
<fragment
android:id="@+id/audioSettingsFragment"
android:name="info.cemu.Cemu.settings.audio.AudioSettingsFragment"
android:label="@string/audio_settings" />
android:label="@string/audio_settings"
tools:layout="@layout/generic_recycler_view_layout" />
<fragment
android:id="@+id/overlaySettingsFragment"
android:name="info.cemu.Cemu.settings.overlay.OverlaySettingsFragment"
android:label="@string/overlay_settings" />
android:label="@string/overlay_settings"
tools:layout="@layout/generic_recycler_view_layout" />
<fragment
android:id="@+id/inputOverlaySettingsFragment"
android:name="info.cemu.Cemu.inputoverlay.InputOverlaySettingsFragment"
android:label="@string/input_overlay_settings" />
android:label="@string/input_overlay_settings"
tools:layout="@layout/generic_recycler_view_layout" />
<fragment
android:id="@+id/gamePathsFragment"
android:name="info.cemu.Cemu.settings.gamespath.GamePathsFragment"
android:label="@string/game_paths_settings"
tools:layout="@layout/generic_recycler_view_layout" />
</navigation>

View File

@ -0,0 +1,3 @@
<resources>
<integer name="games_view_span_count">2</integer>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<integer name="games_view_span_count">1</integer>
</resources>

View File

@ -65,9 +65,8 @@
<string name="right_axis">Right Axis</string>
<string name="extra">Extra</string>
<string name="nunchuck">Nunchuck</string>
<string name="games_folder_description">Add the games path to scan for games displayed in the game list</string>
<string name="games_folder_description">Add a game path to scan for games displayed in the game list</string>
<string name="games_folder_empty">No games folder selected</string>
<string name="games_folder_label">Set games folder</string>
<string name="options">Options</string>
<string name="emulated_controller_selection_description">Selected controller: %1s</string>
<string name="async_shader_compile">Async shader compile</string>
@ -165,4 +164,8 @@
<string name="replace_tv_with_pad">Replace TV with PAD</string>
<string name="installed_games_title">Installed games</string>
<string name="enable_motion">Enable motion</string>
<string name="add_game_path">Add game path</string>
<string name="remove_game_path">Remove game path</string>
<string name="game_paths_settings">Game paths</string>
<string name="game_path_already_added">Game path already added</string>
</resources>