Rewrite with compose

This commit is contained in:
SSimco
2025-03-10 17:15:53 +02:00
parent 5631c5a0ec
commit c587f73810
157 changed files with 5229 additions and 5182 deletions
+18 -7
View File
@@ -3,6 +3,8 @@ import java.io.IOException
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
fun String.runCommand(workingDir: File = File(".")): String? {
@@ -61,7 +63,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -118,6 +120,7 @@ android {
buildConfig = true
dataBinding = true
viewBinding = true
compose = true
}
kotlinOptions {
jvmTarget = "17"
@@ -125,18 +128,26 @@ android {
}
dependencies {
implementation(libs.androidx.datastore.rxjava3)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.okhttp)
implementation(libs.okhttp.coroutines)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.navigation.fragment)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation.ui)
implementation(libs.androidx.legacy.support.v4)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.core.ktx)
testImplementation(libs.junit)
+1 -21
View File
@@ -1,21 +1 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontobfuscate
+23 -15
View File
@@ -3,7 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature
@@ -12,7 +11,7 @@
android:version="0x401000" />
<application
android:name="info.cemu.cemu.CemuApplication"
android:name=".CemuApplication"
android:allowBackup="true"
android:appCategory="game"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -23,6 +22,27 @@
android:supportsRtl="true"
android:theme="@style/Theme.Cemu"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".about.AboutCemuActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:name=".graphicpacks.GraphicPacksActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:name=".settings.SettingsActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:name=".emulation.EmulationActivity"
android:configChanges="orientation|screenSize"
@@ -33,6 +53,7 @@
tools:ignore="DiscouragedApi">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
@@ -51,19 +72,6 @@
-->
</intent-filter>
</activity>
<activity
android:name=".settings.SettingsActivity"
android:exported="false"
android:parentActivityName=".MainActivity" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name=".features.DocumentsProvider"
@@ -27,7 +27,7 @@ class AndroidFilesystemCallbacks : public FilesystemAndroid::FilesystemCallbacks
AndroidFilesystemCallbacks()
{
JNIUtils::ScopedJNIENV env;
m_fileUtilClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/FileCallbacksKt");
m_fileUtilClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeFilesKt");
m_openContentUriMid = env->GetStaticMethodID(*m_fileUtilClass, "openContentUri", "(Ljava/lang/String;)I");
m_listFilesMid = env->GetStaticMethodID(*m_fileUtilClass, "listFiles", "(Ljava/lang/String;)[Ljava/lang/String;");
m_isDirectoryMid = env->GetStaticMethodID(*m_fileUtilClass, "isDirectory", "(Ljava/lang/String;)Z");
@@ -3,7 +3,7 @@
#include "GameTitleLoader.h"
#include "JNIUtils.h"
#include <android/bitmap.h>
// TODO: Refactor this:
class AndroidGameTitleLoadedCallback : public GameTitleLoadedCallback
{
jmethodID m_onGameTitleLoadedMID;
@@ -6,6 +6,7 @@ add_library(CemuAndroid SHARED
GameTitleLoader.cpp
Image.cpp
JNIUtils.cpp
NativeActiveSettings.cpp
NativeEmulation.cpp
NativeGameTitles.cpp
NativeGraphicPacks.cpp
@@ -0,0 +1,8 @@
#include "JNIUtils.h"
#include "config/ActiveSettings.h"
extern "C" [[maybe_unused]] JNIEXPORT jstring JNICALL
Java_info_cemu_cemu_nativeinterface_NativeActiveSettings_getMLCPath(JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return env->NewStringUTF(ActiveSettings::GetMlcPath().c_str());
}
@@ -1,3 +1,4 @@
#include <Cafe/TitleList/SaveList.h>
#include "JNIUtils.h"
#include "Cafe/GameProfile/GameProfile.h"
#include "GameTitleLoader.h"
@@ -142,3 +143,185 @@ Java_info_cemu_cemu_nativeinterface_NativeGameTitles_getInstalledGamesTitleIds(J
{
return JNIUtils::createJavaLongArrayList(env, CafeTitleList::GetAllTitleIds());
}
class SaveListCallback
{
private:
uint64 m_callbackIdSaveList;
JNIUtils::Scopedjobject m_saveListCallbackObj;
jmethodID m_onSaveDiscoveredMID;
JNIUtils::Scopedjclass m_saveDataClass;
jmethodID m_saveDataConstructorMID;
void HandleSaveListCallback(CafeSaveListCallbackEvent* evt)
{
if (evt->eventType != CafeSaveListCallbackEvent::TYPE::SAVE_DISCOVERED)
return;
ParsedMetaXml* metaInfo = evt->saveInfo->GetMetaInfo();
if (!metaInfo)
return;
auto& saveInfo = *evt->saveInfo;
auto locationUID = std::hash<uint64>()(metaInfo->GetTitleId());
std::string name = metaInfo->GetLongName(GetConfig().console_language.GetValue());
const auto nl = name.find(L'\n');
if (nl != std::string::npos)
name.replace(nl, 1, " - ");
JNIUtils::ScopedJNIENV env;
jstring nameJava = env->NewStringUTF(name.c_str());
jobject pathJava = env->NewStringUTF(saveInfo.GetPath().c_str());
jobject saveData = env->NewObject(
*m_saveDataClass,
m_saveDataConstructorMID,
nameJava,
pathJava,
metaInfo->GetTitleId(),
locationUID,
metaInfo->GetTitleVersion(),
metaInfo->GetRegion());
env->CallVoidMethod(*m_saveListCallbackObj, m_onSaveDiscoveredMID, saveData);
env->DeleteLocalRef(saveData);
env->DeleteLocalRef(nameJava);
env->DeleteLocalRef(pathJava);
}
public:
SaveListCallback(jobject saveListCallbackObject)
{
JNIUtils::ScopedJNIENV env;
m_saveListCallbackObj = JNIUtils::Scopedjobject(saveListCallbackObject);
JNIUtils::Scopedjclass saveCallbacksClass{"info/cemu/cemu/nativeinterface/NativeGameTitles$SaveListCallback"};
m_onSaveDiscoveredMID = env->GetMethodID(*saveCallbacksClass, "onSaveDiscovered", "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$SaveData;)V");
m_saveDataClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeGameTitles$SaveData");
m_saveDataConstructorMID = env->GetMethodID(*m_saveDataClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;JJSI)V");
m_callbackIdSaveList = CafeSaveList::RegisterCallback(
[](CafeSaveListCallbackEvent* evt, void* ctx) {
static_cast<SaveListCallback*>(ctx)->HandleSaveListCallback(evt);
},
this);
}
~SaveListCallback()
{
CafeSaveList::UnregisterCallback(m_callbackIdSaveList);
}
};
class TitleListCallbacks
{
private:
JNIUtils::Scopedjobject m_titleListCallbacksObj;
jmethodID m_onTitleDiscoveredMID;
jmethodID m_onTitleRemovedMID;
jmethodID m_titleDataConstructorMID;
JNIUtils::Scopedjclass m_titleDataClass;
uint64 m_callbackIdTitleList;
void OnTitleDiscovered(TitleInfo& titleInfo)
{
if (titleInfo.IsCached())
return; // the title list only displays non-cached entries
if (titleInfo.IsSystemDataTitle())
return; // don't show system data titles for now
JNIUtils::ScopedJNIENV env;
ParsedMetaXml* metaInfo = titleInfo.GetMetaInfo();
std::string name = metaInfo->GetLongName(GetConfig().console_language.GetValue());
const auto nl = name.find(L'\n');
if (nl != std::string::npos)
name.replace(nl, 1, " - ");
jobject nameJava = env->NewStringUTF(name.c_str());
jobject pathJava = env->NewStringUTF(titleInfo.GetPath().c_str());
jobject titleData = env->NewObject(
*m_titleDataClass,
m_titleDataConstructorMID,
nameJava,
pathJava,
titleInfo.GetAppTitleId(),
titleInfo.GetUID(),
titleInfo.GetAppTitleVersion(),
metaInfo->GetRegion(),
titleInfo.GetTitleType(),
titleInfo.GetFormat());
env->CallVoidMethod(*m_titleListCallbacksObj, m_onTitleDiscoveredMID, titleData);
env->DeleteLocalRef(titleData);
env->DeleteLocalRef(nameJava);
env->DeleteLocalRef(pathJava);
}
void OnTitleRemoved(TitleInfo& titleInfo)
{
JNIUtils::ScopedJNIENV()->CallVoidMethod(*m_titleListCallbacksObj, m_onTitleRemovedMID, titleInfo.GetUID());
}
void HandleTitleListCallback(CafeTitleListCallbackEvent* evt)
{
if (evt->eventType != CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED &&
evt->eventType != CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED)
return;
if (evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_DISCOVERED)
{
OnTitleDiscovered(*evt->titleInfo);
}
else if (evt->eventType == CafeTitleListCallbackEvent::TYPE::TITLE_REMOVED)
{
OnTitleRemoved(*evt->titleInfo);
}
}
public:
TitleListCallbacks(jobject titleListCallbacks)
{
JNIUtils::ScopedJNIENV env;
m_titleListCallbacksObj = JNIUtils::Scopedjobject(titleListCallbacks);
jclass titleCallbacksClass = env->FindClass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleListCallbacks");
m_onTitleDiscoveredMID = env->GetMethodID(titleCallbacksClass, "onTitleDiscovered", "(Linfo/cemu/cemu/nativeinterface/NativeGameTitles$TitleData;)V");
m_onTitleRemovedMID = env->GetMethodID(titleCallbacksClass, "onTitleRemoved", "(J)V");
env->DeleteLocalRef(titleCallbacksClass);
m_titleDataClass = JNIUtils::Scopedjclass("info/cemu/cemu/nativeinterface/NativeGameTitles$TitleData");
m_titleDataConstructorMID = env->GetMethodID(*m_titleDataClass, "<init>", "(Ljava/lang/String;Ljava/lang/String;JJSIII)V");
m_callbackIdTitleList = CafeTitleList::RegisterCallback(
[](CafeTitleListCallbackEvent* evt, void* ctx) {
static_cast<TitleListCallbacks*>(ctx)->HandleTitleListCallback(evt);
},
this);
}
~TitleListCallbacks()
{
CafeTitleList::UnregisterCallback(m_callbackIdTitleList);
}
};
extern "C" [[maybe_unused]] JNIEXPORT void JNICALL
Java_info_cemu_cemu_nativeinterface_NativeGameTitles_refreshCafeTitleList([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
CafeTitleList::Refresh();
}
std::unique_ptr<TitleListCallbacks> s_titleListCallbacks;
extern "C" [[maybe_unused]] JNIEXPORT void JNICALL
Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setTitleListCallbacks([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jobject title_list_callbacks)
{
if (title_list_callbacks == nullptr)
s_titleListCallbacks = nullptr;
else
s_titleListCallbacks = std::make_unique<TitleListCallbacks>(title_list_callbacks);
}
std::unique_ptr<SaveListCallback> s_saveListCallback;
extern "C" [[maybe_unused]] JNIEXPORT void JNICALL
Java_info_cemu_cemu_nativeinterface_NativeGameTitles_setSaveListCallback([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jobject save_list_callback)
{
if (save_list_callback == nullptr)
s_saveListCallback = nullptr;
else
s_saveListCallback = std::make_unique<SaveListCallback>(save_list_callback);
}
@@ -1,4 +1,5 @@
#include "JNIUtils.h"
#include "audio/IAudioAPI.h"
#include "config/CemuConfig.h"
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
@@ -223,13 +224,13 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_setAsyncShaderCompile([[maybe
}
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_getVSyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
Java_info_cemu_cemu_nativeinterface_NativeSettings_getVsyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return g_config.data().vsync;
}
extern "C" [[maybe_unused]] JNIEXPORT void JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_setVSyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint vsync_mode)
Java_info_cemu_cemu_nativeinterface_NativeSettings_setVsyncMode([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint vsync_mode)
{
g_config.data().vsync = vsync_mode;
g_config.Save();
@@ -248,7 +249,7 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_setUpscalingFilter([[maybe_un
g_config.Save();
}
extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_getUpscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return g_config.data().upscale_filter;
@@ -261,7 +262,7 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_setDownscalingFilter([[maybe_
g_config.Save();
}
extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_getDownscalingFilter([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return g_config.data().downscale_filter;
@@ -274,7 +275,7 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_setFullscreenScaling([[maybe_
g_config.Save();
}
extern "C" [[maybe_unused]] JNIEXPORT jboolean JNICALL
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_getFullscreenScaling([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return g_config.data().fullscreen_scaling;
@@ -338,13 +339,14 @@ Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioDeviceVolume([[maybe_
extern "C" [[maybe_unused]] JNIEXPORT jint JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_getAudioLatency([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz)
{
return g_config.data().audio_delay;
return g_config.data().audio_delay * 12;
}
extern "C" [[maybe_unused]] JNIEXPORT void JNICALL
Java_info_cemu_cemu_nativeinterface_NativeSettings_setAudioLatency([[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint latency)
{
g_config.data().audio_delay = latency;
g_config.data().audio_delay = latency / 12;
IAudioAPI::SetAudioDelay(g_config.data().audio_delay);
g_config.Save();
}
@@ -13,7 +13,7 @@ import java.io.StringWriter
class CemuApplication : Application() {
init {
application = this
Application = this
}
val internalFolder: File
@@ -27,8 +27,8 @@ class CemuApplication : Application() {
override fun onCreate() {
super.onCreate()
if (defaultUncaughtExceptionHandler == null) {
defaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
if (DefaultUncaughtExceptionHandler == null) {
DefaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
}
Thread.setDefaultUncaughtExceptionHandler { thread: Thread, exception: Throwable ->
val stringWriter = StringWriter()
@@ -36,7 +36,7 @@ class CemuApplication : Application() {
exception.printStackTrace(printWriter)
val stacktrace = stringWriter.toString()
crashLog(stacktrace)
defaultUncaughtExceptionHandler!!.uncaughtException(
DefaultUncaughtExceptionHandler!!.uncaughtException(
thread,
exception
)
@@ -54,10 +54,10 @@ class CemuApplication : Application() {
System.loadLibrary("CemuAndroid")
}
private var defaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null
private var DefaultUncaughtExceptionHandler: Thread.UncaughtExceptionHandler? = null
@JvmStatic
lateinit var application: CemuApplication
lateinit var Application: CemuApplication
private set
}
}
@@ -1,13 +1,185 @@
package info.cemu.cemu
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import info.cemu.cemu.databinding.ActivityMainBinding
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import info.cemu.cemu.about.AboutCemuActivity
import info.cemu.cemu.emulation.EmulationActivity
import info.cemu.cemu.features.DocumentsProvider
import info.cemu.cemu.gamelist.GameDetailsScreen
import info.cemu.cemu.gamelist.GameProfileEditScreen
import info.cemu.cemu.gamelist.GameViewModel
import info.cemu.cemu.gamelist.GamesListScreen
import info.cemu.cemu.gamelist.GamesListScreenActions
import info.cemu.cemu.graphicpacks.GraphicPacksActivity
import info.cemu.cemu.guicore.ActivityContent
import info.cemu.cemu.nativeinterface.NativeGameTitles.Game
import info.cemu.cemu.settings.SettingsActivity
import kotlinx.serialization.Serializable
class MainActivity : AppCompatActivity() {
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setContent {
ActivityContent {
MainNav()
}
}
}
}
}
private fun startGame(context: Context, game: Game) {
context.startActivity(
Intent(
context,
EmulationActivity::class.java
).apply {
putExtra(EmulationActivity.EXTRA_LAUNCH_PATH, game.path)
}
)
}
sealed class MainRoutes {
@Serializable
object GamesRoute
@Serializable
object GameDetailsRoute
@Serializable
object GameProfileEditRoute
}
@Composable
fun MainNav() {
val navController = rememberNavController()
val selectedGameViewModel: GameViewModel = viewModel()
fun navigateBack() {
navController.popBackStack()
}
NavHost(navController = navController, startDestination = MainRoutes.GamesRoute) {
composable<MainRoutes.GamesRoute> {
val context = LocalContext.current
GamesListScreen(
selectedGameViewModel = selectedGameViewModel,
gameListActions = GamesListScreenActions(
goToGameEditProfile = { navController.navigate(MainRoutes.GameProfileEditRoute) },
startGame = { startGame(context, it) },
goToGameDetails = { navController.navigate(MainRoutes.GameDetailsRoute) },
),
toolbarActions = {
GameListToolBarActions()
}
)
}
composable<MainRoutes.GameDetailsRoute> {
GameDetailsScreen(
selectedGameViewModel = selectedGameViewModel,
navigateBack = ::navigateBack,
)
}
composable<MainRoutes.GameProfileEditRoute> {
GameProfileEditScreen(
selectedGameViewModel = selectedGameViewModel,
navigateBack = ::navigateBack,
)
}
}
}
@Composable
fun GameListToolBarActions() {
var expandMenu by remember { mutableStateOf(false) }
val context = LocalContext.current
IconButton(
modifier = Modifier.padding(end = 8.dp),
onClick = { expandMenu = true },
) {
IconButton(
onClick = { expandMenu = true }
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = "More options"
)
}
}
DropdownMenu(
expanded = expandMenu,
onDismissRequest = { expandMenu = false }
) {
DropdownMenuItem(
onClick = { context.goToActivity(SettingsActivity::class.java) },
text = { Text(stringResource(R.string.settings)) },
)
DropdownMenuItem(
onClick = { context.goToActivity(GraphicPacksActivity::class.java) },
text = { Text(stringResource(R.string.graphic_packs)) },
)
DropdownMenuItem(
onClick = { openCemuFolder(context) },
text = { Text(stringResource(R.string.open_cemu_folder)) },
)
DropdownMenuItem(
onClick = { context.goToActivity(AboutCemuActivity::class.java) },
text = { Text(stringResource(R.string.about_cemu)) },
)
}
}
private fun <T : ComponentActivity> Context.goToActivity(activityClass: Class<T>) {
startActivity(
Intent(
this,
activityClass,
)
)
}
fun openCemuFolder(context: Context) {
try {
Intent(Intent.ACTION_VIEW).apply {
addCategory(Intent.CATEGORY_DEFAULT)
data = DocumentsContract.buildRootUri(
DocumentsProvider.AUTHORITY,
DocumentsProvider.ROOT_ID
)
addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
context.startActivity(this@apply)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
Toast.makeText(context, R.string.failed_to_open_cemu_folder, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,18 @@
package info.cemu.cemu.about
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import info.cemu.cemu.guicore.ActivityContent
class AboutCemuActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ActivityContent {
AboutCemuScreen(::onNavigateUp)
}
}
}
}
@@ -0,0 +1,151 @@
package info.cemu.cemu.about
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import info.cemu.cemu.BuildConfig
import info.cemu.cemu.R
import info.cemu.cemu.guicore.ScreenContent
@Composable
fun AboutCemuScreen(navigateBack: () -> Unit) {
ScreenContent(
appBarText = stringResource(R.string.about_cemu),
navigateBack = navigateBack,
contentModifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentVerticalArrangement = Arrangement.spacedBy(16.dp),
) {
AboutSection {
Text(
text = stringResource(R.string.cemu),
fontSize = 32.sp,
)
Text(
text = stringResource(R.string.cemu_version, BuildConfig.VERSION_NAME),
fontSize = 18.sp,
)
Text(
text = stringResource(
R.string.original_authors,
stringResource(R.string.cemu_original_authors)
),
fontSize = 18.sp,
)
CemuWebsite()
}
AboutSection {
Text(
text = stringResource(R.string.cemu_description),
fontSize = 18.sp
)
}
AboutSection {
Text(
text = stringResource(R.string.used_libraries),
fontSize = 18.sp,
)
UsedLibraries.forEach {
Library(it)
}
Text(stringResource(R.string.ih264_library_description))
}
}
}
@Composable
fun CemuWebsite() {
Text(
buildAnnotatedString {
append(stringResource(R.string.website))
append(" ")
withLink(
LinkAnnotation.Url(
CEMU_WEBSITE,
TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textDecoration = TextDecoration.Underline,
),
)
)
) {
append(CEMU_WEBSITE)
}
}
)
}
@Composable
fun AboutSection(content: @Composable ColumnScope.() -> Unit) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier = Modifier.fillMaxWidth(),
) {
SelectionContainer {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp),
content = content,
)
}
}
}
@Composable
fun Library(nameLinkPair: Pair<String, String>) {
val (name, link) = nameLinkPair
Text(
buildAnnotatedString {
append(name)
append(" (")
withLink(
LinkAnnotation.Url(
link,
TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textDecoration = TextDecoration.Underline,
),
)
)
) {
append(link)
}
append(")")
}
)
}
private val UsedLibraries: List<Pair<String, String>> = listOf(
"zlib" to "https://www.zlib.net",
"OpenSSL" to "https://www.openssl.org",
"libcurl" to "https://curl.haxx.se/libcurl",
"imgui" to "https://github.com/ocornut/imgui",
"fontawesome" to "https://github.com/FortAwesome/Font-Awesome",
"boost" to "https://www.boost.org",
"libusb" to "https://libusb.info",
)
private const val CEMU_WEBSITE = "https://cemu.info"
@@ -7,6 +7,7 @@ import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.annotation.Keep
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
@@ -17,7 +18,6 @@ import info.cemu.cemu.BuildConfig
import info.cemu.cemu.R
import info.cemu.cemu.databinding.ActivityEmulationBinding
import info.cemu.cemu.emulation.EmulationFragment.OnEmulationErrorCallback
import info.cemu.cemu.emulation.EmulationTextInputEditText.OnTextChangedListener
import info.cemu.cemu.input.InputManager
import info.cemu.cemu.nativeinterface.NativeSwkbd.setCurrentInputText
import java.util.Objects
@@ -89,8 +89,8 @@ class EmulationActivity : AppCompatActivity() {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.exit_confirmation_title)
.setMessage(R.string.exit_confirm_message)
.setPositiveButton(R.string.yes) { _: DialogInterface?, _: Int -> quit() }
.setNegativeButton(R.string.no) { dialog: DialogInterface, _: Int -> dialog.cancel() }
.setPositiveButton(R.string.yes) { _, _ -> quit() }
.setNegativeButton(R.string.no) { _, _ -> }
.show()
}
@@ -102,10 +102,8 @@ class EmulationActivity : AppCompatActivity() {
val builder = MaterialAlertDialogBuilder(this)
builder.setTitle(R.string.error)
.setMessage(emulationError)
.setNeutralButton(
R.string.quit
) { dialog: DialogInterface, which: Int -> dialog.dismiss() }
.setOnDismissListener { dialog: DialogInterface? -> quit() }
.setNeutralButton(R.string.quit) { _, _ -> }
.setOnDismissListener { _ -> quit() }
.show()
}
@@ -129,7 +127,7 @@ class EmulationActivity : AppCompatActivity() {
/**
* This method is called by swkbd using JNI.
*/
@Suppress("unused")
@Keep
@JvmStatic
fun showEmulationTextInput(initialText: String?, maxLength: Int) {
if (emulationActivityInstance == null || emulationActivityInstance!!.emulationTextInputDialog != null) {
@@ -171,7 +169,7 @@ class EmulationActivity : AppCompatActivity() {
/**
* This method is called by swkbd using JNI.
*/
@Suppress("unused")
@Keep
@JvmStatic
fun hideEmulationTextInput() {
if (emulationActivityInstance?.emulationTextInputDialog == null) {
@@ -22,7 +22,6 @@ import info.cemu.cemu.R
import info.cemu.cemu.databinding.FragmentEmulationBinding
import info.cemu.cemu.input.SensorManager
import info.cemu.cemu.inputoverlay.InputOverlaySettingsProvider
import info.cemu.cemu.inputoverlay.InputOverlaySettingsProvider.OverlaySettings
import info.cemu.cemu.inputoverlay.InputOverlaySurfaceView
import info.cemu.cemu.nativeinterface.NativeEmulation
import info.cemu.cemu.nativeinterface.NativeEmulation.clearSurface
@@ -81,7 +80,7 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
surfaceHolder: SurfaceHolder,
format: Int,
width: Int,
height: Int
height: Int,
) {
try {
setSurfaceSize(width, height, isMainCanvas)
@@ -115,7 +114,6 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
private var sensorManager: SensorManager? = null
private var onEmulationErrorCallback: OnEmulationErrorCallback? = null
private var hasEmulationError = false
private var overlaySettings: OverlaySettings? = null
fun setOnEmulationErrorCallback(onEmulationErrorCallback: OnEmulationErrorCallback) {
this.onEmulationErrorCallback = onEmulationErrorCallback
@@ -128,7 +126,6 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
sensorManager = SensorManager(requireContext())
}
sensorManager!!.setIsLandscape(resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
overlaySettings = inputOverlaySettingsProvider.overlaySettings
}
override fun onConfigurationChanged(newConfig: Configuration) {
@@ -248,7 +245,7 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
savedInstanceState: Bundle?,
): View {
binding = FragmentEmulationBinding.inflate(inflater, container, false)
inputOverlaySurfaceView = binding!!.inputOverlay
@@ -282,12 +279,12 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
settingsMenu!!.setOnMenuItemClickListener(this@EmulationFragment)
binding!!.emulationSettingsButton.setOnClickListener { v: View? -> settingsMenu!!.show() }
val menu = settingsMenu!!.menu
menu.findItem(R.id.show_input_overlay).setChecked(overlaySettings!!.isOverlayEnabled)
if (!overlaySettings!!.isOverlayEnabled) {
menu.findItem(R.id.reset_inputs).setEnabled(false)
menu.findItem(R.id.edit_inputs).setEnabled(false)
inputOverlaySurfaceView!!.visibility = View.GONE
}
// menu.findItem(R.id.show_input_overlay).setChecked(overlaySettings!!.isOverlayEnabled)
// if (!overlaySettings!!.isOverlayEnabled) {
// menu.findItem(R.id.reset_inputs).setEnabled(false)
// menu.findItem(R.id.edit_inputs).setEnabled(false)
// inputOverlaySurfaceView!!.visibility = View.GONE
// }
val mainCanvas = binding!!.mainCanvas
try {
val testSurfaceTexture = SurfaceTexture(0)
@@ -315,7 +312,7 @@ class EmulationFragment(private val launchPath: String) : Fragment(),
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
height: Int,
) {
if (hasEmulationError) {
return
@@ -9,14 +9,13 @@ import android.util.AttributeSet
import android.view.inputmethod.EditorInfo
import com.google.android.material.R
import com.google.android.material.textfield.TextInputEditText
import info.cemu.cemu.nativeinterface.NativeSwkbd.onFinishedInputEdit
import info.cemu.cemu.nativeinterface.NativeSwkbd.onTextChanged
import info.cemu.cemu.nativeinterface.NativeSwkbd
import java.util.regex.Pattern
class EmulationTextInputEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle
defStyleAttr: Int = R.attr.editTextStyle,
) :
TextInputEditText(context, attrs, defStyleAttr) {
fun appendFilter(inputFilter: InputFilter) {
@@ -60,7 +59,7 @@ class EmulationTextInputEditText @JvmOverloads constructor(
if (onTextChangedListener != null) {
onTextChangedListener!!.onTextChanged(text)
}
onTextChanged(text.toString())
NativeSwkbd.onTextChanged(text.toString())
}
override fun afterTextChanged(s: Editable) {
@@ -77,7 +76,7 @@ class EmulationTextInputEditText @JvmOverloads constructor(
}
fun onFinishedEdit() {
onFinishedInputEdit()
NativeSwkbd.onFinishedInputEdit()
}
fun setOnTextChangedListener(onTextChangedListener: OnTextChangedListener) {
@@ -22,45 +22,29 @@ import java.io.IOException
import java.util.Objects
class DocumentsProvider : DocumentsProvider() {
private var baseDirectory: File? = null
private val applicationName =
CemuApplication.application.applicationInfo.loadLabel(CemuApplication.application.packageManager)
.toString()
private val DEFAULT_ROOT_PROJECTION = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_MIME_TYPES,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
)
private val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
)
init {
try {
baseDirectory = CemuApplication.application.internalFolder.canonicalFile
} catch (e: IOException) {
throw RuntimeException(e)
private var _baseDirectory: File? = null
private val baseDirectory: File
get() {
if (_baseDirectory == null) {
try {
_baseDirectory = CemuApplication.Application.internalFolder.canonicalFile
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return _baseDirectory!!
}
}
private val applicationName =
CemuApplication.Application.applicationInfo.loadLabel(CemuApplication.Application.packageManager)
.toString()
override fun onCreate(): Boolean {
return true
}
override fun queryRoots(projection: Array<String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
val cursor = MatrixCursor(projection ?: DefaultRootProjection)
cursor.newRow().add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
.add(DocumentsContract.Root.COLUMN_SUMMARY, null)
.add(
@@ -68,16 +52,16 @@ class DocumentsProvider : DocumentsProvider() {
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
)
.add(DocumentsContract.Root.COLUMN_TITLE, applicationName)
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory!!))
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory))
.add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*")
.add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory!!.freeSpace)
.add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace)
.add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher)
return cursor
}
@Throws(FileNotFoundException::class)
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
val cursor = MatrixCursor(projection ?: DefaultDocumentProjection)
includeFile(cursor, documentId, null)
return cursor
}
@@ -139,11 +123,11 @@ class DocumentsProvider : DocumentsProvider() {
continue
}
if (!file.delete()) {
throw FileNotFoundException("Couldn't delete file " + file.path)
throw FileNotFoundException("Couldn't delete file ${file.path}")
}
}
if (!dirFile.delete()) {
throw FileNotFoundException("Couldn't delete file " + dirFile.path)
throw FileNotFoundException("Couldn't delete file ${dirFile.path}")
}
}
@@ -177,10 +161,10 @@ class DocumentsProvider : DocumentsProvider() {
try {
if (!sourceFile.renameTo(destFile)) {
throw FileNotFoundException("Couldn't rename document from '" + sourceFile.name + "' to '" + destFile.name + "'")
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'")
}
} catch (exception: Exception) {
throw FileNotFoundException("Couldn't rename document from '" + sourceFile.name + "' to '" + destFile.name + "':" + exception.message)
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to 'destFile.name': ${exception.message}")
}
return getDocumentId(destFile)
@@ -206,7 +190,7 @@ class DocumentsProvider : DocumentsProvider() {
}
}
} catch (exception: IOException) {
throw FileNotFoundException("Couldn't copy document '" + sourceDocumentId + "': " + exception.message)
throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${exception.message}")
}
return getDocumentId(newFile)
}
@@ -233,7 +217,7 @@ class DocumentsProvider : DocumentsProvider() {
projection: Array<String>?,
sortOrder: String
): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
val cursor = MatrixCursor(projection ?: DefaultDocumentProjection)
val parent = getFile(parentDocumentId)
val files = parent.listFiles() ?: return cursor
for (file in files) {
@@ -306,18 +290,19 @@ class DocumentsProvider : DocumentsProvider() {
}
flags = (flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE)
val curorRowBuilder =
cursor.newRow().add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
.add(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
if (localFile == baseDirectory) applicationName else localFile.name
)
.add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
.add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
.add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
.add(DocumentsContract.Document.COLUMN_FLAGS, flags)
if (localFile == baseDirectory) {
curorRowBuilder.add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher)
cursor.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
add(
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
if (localFile == baseDirectory) applicationName else localFile.name
)
add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
if (localFile == baseDirectory) {
add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher)
}
}
}
@@ -344,7 +329,7 @@ class DocumentsProvider : DocumentsProvider() {
private fun getFile(documentId: String?): File {
Objects.requireNonNull(documentId)
if (documentId!!.startsWith(ROOT_ID)) {
val file = resolve(baseDirectory!!, documentId.substring(ROOT_ID.length + 1))
val file = resolve(baseDirectory, documentId.substring(ROOT_ID.length + 1))
if (!file.exists()) {
throw FileNotFoundException(file.absolutePath + " " + documentId + " not found")
}
@@ -355,11 +340,29 @@ class DocumentsProvider : DocumentsProvider() {
}
private fun getDocumentId(file: File): String {
return ROOT_ID + "/" + baseDirectory!!.toPath().relativize(file.toPath()).toString()
return ROOT_ID + "/" + baseDirectory.toPath().relativize(file.toPath()).toString()
}
companion object {
const val ROOT_ID: String = "root"
const val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".provider"
private val DefaultRootProjection = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_MIME_TYPES,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
)
private val DefaultDocumentProjection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE
)
}
}
@@ -0,0 +1,115 @@
package info.cemu.cemu.gamelist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import info.cemu.cemu.R
import info.cemu.cemu.guicore.ScreenContent
import info.cemu.cemu.guicore.enumtostringmapper.native.regionToStringId
import info.cemu.cemu.nativeinterface.NativeGameTitles.Game
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun GameDetailsScreen(selectedGameViewModel: GameViewModel, navigateBack: () -> Unit) {
val game by selectedGameViewModel.game.collectAsState()
if (game == null) return
ScreenContent(
appBarText = stringResource(R.string.about_title),
contentModifier = Modifier.padding(16.dp),
contentVerticalArrangement = Arrangement.spacedBy(16.dp),
navigateBack = navigateBack,
) {
GameDetails(game!!)
}
}
@Composable
fun GameDetails(game: Game) {
GameIcon(
game = game,
modifier = Modifier.size(128.dp),
)
TitleDetailsEntry(entryName = stringResource(R.string.title_name), entryData = game.name)
TitleDetailsEntry(entryName = stringResource(R.string.title_id), entryData = game.titleId)
TitleDetailsEntry(entryName = stringResource(R.string.version), entryData = game.version)
TitleDetailsEntry(entryName = stringResource(R.string.dlc), entryData = game.dlc)
TitleDetailsEntry(
entryName = stringResource(R.string.title_time_played),
entryData = getTimePlayed(game)
)
TitleDetailsEntry(
entryName = stringResource(R.string.title_last_played),
entryData = getLastPlayedDate(game)
)
TitleDetailsEntry(
entryName = stringResource(R.string.title_region),
entryData = stringResource(regionToStringId(game.region))
)
TitleDetailsEntry(
entryName = stringResource(R.string.title_path),
entryData = game.path
)
}
@ReadOnlyComposable
@Composable
fun getTimePlayed(game: Game): String {
if (game.minutesPlayed == 0) {
return stringResource(R.string.never_played)
}
if (game.minutesPlayed < 60) {
return stringResource(R.string.minutes_played, game.minutesPlayed)
}
return stringResource(
R.string.hours_minutes_played,
game.minutesPlayed / 60,
game.minutesPlayed % 60
)
}
private val DateFormatter = DateTimeFormatter.ofLocalizedDate(
FormatStyle.SHORT
)
@ReadOnlyComposable
@Composable
private fun getLastPlayedDate(game: Game): String {
if (game.lastPlayedYear.toInt() == 0) {
return stringResource(R.string.never_played)
}
val lastPlayedDate = LocalDate.of(
game.lastPlayedYear.toInt(),
game.lastPlayedMonth.toInt(),
game.lastPlayedDay.toInt()
)
return DateFormatter.format(lastPlayedDate)
}
@Composable
fun <T> TitleDetailsEntry(entryName: String, entryData: T?) {
Column {
Text(
text = entryName,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
)
Text(
text = entryData?.toString() ?: "",
fontSize = 16.sp,
)
}
}
@@ -0,0 +1,43 @@
package info.cemu.cemu.gamelist
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import info.cemu.cemu.R
import info.cemu.cemu.nativeinterface.NativeGameTitles
@Composable
fun GameIcon(
game: NativeGameTitles.Game,
modifier: Modifier
) {
if (game.icon != null) {
Image(
modifier = modifier
.border(
width = Dp.Hairline,
color = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(8.dp)
)
.clip(RoundedCornerShape(8.dp)),
bitmap = game.icon!!,
contentDescription = stringResource(R.string.game_icon),
)
} else {
Icon(
modifier = modifier,
painter = painterResource(R.drawable.ic_question_mark),
contentDescription = stringResource(R.string.game_icon_empty),
)
}
}
@@ -0,0 +1,90 @@
package info.cemu.cemu.gamelist
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import info.cemu.cemu.nativeinterface.NativeGameTitles
import info.cemu.cemu.nativeinterface.NativeGameTitles.Game
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class GameListViewModel : ViewModel() {
private val _filterText = MutableStateFlow("")
private val filterText = _filterText.asStateFlow()
fun setFilterText(filterText: String) {
_filterText.value = filterText
}
private val _games = MutableStateFlow<Set<Game>>(emptySet())
val games: StateFlow<List<Game>> = filterText.combine(_games) { filter, games ->
if (filter.isBlank()) {
games
} else {
games.filter { it.name?.contains(filter, true) ?: false }
}
}.map {
it.sorted()
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
fun getGames(): LiveData<List<Game>> = MutableLiveData(emptyList())
init {
NativeGameTitles.setGameTitleLoadedCallback(NativeGameTitles.GameTitleLoadedCallback { game: Game? ->
if (game == null || !isGameValid(game)) {
return@GameTitleLoadedCallback
}
_games.value += game
})
refreshGames()
}
private val _gameToRemoveShaders = MutableStateFlow<Game?>(null)
val gameToRemoveShaders = _gameToRemoveShaders.asStateFlow()
fun removeShadersForSelectedGame() {
if (_gameToRemoveShaders.value == null) return
NativeGameTitles.removeShaderCacheFilesForTitle(_gameToRemoveShaders.value!!.titleId)
_gameToRemoveShaders.value = null
}
fun setGameForShadersRemoval(game: Game) {
_gameToRemoveShaders.value = game
}
fun clearSelectedGameForShaderRemoval() {
_gameToRemoveShaders.value = null
}
private fun isGameValid(game: Game): Boolean {
return !game.path.isNullOrEmpty() && !game.name.isNullOrEmpty()
}
fun setGameTitleFavorite(game: Game, isFavorite: Boolean) {
if (!_games.value.contains(game)) {
return
}
NativeGameTitles.setGameTitleFavorite(game.titleId, isFavorite)
_games.value = _games.value.toMutableSet().apply {
remove(game)
add(game.copy(isFavorite = isFavorite))
}
}
override fun onCleared() {
NativeGameTitles.setGameTitleLoadedCallback(null)
}
fun refreshGames() {
_games.value = emptySet()
NativeGameTitles.reloadGameTitles()
}
}

Some files were not shown because too many files have changed in this diff Show More