Update graphic packs

This commit is contained in:
SSimco
2025-08-26 22:25:24 +03:00
parent 91ce31ca9f
commit 8d717160e9
17 changed files with 456 additions and 477 deletions
@@ -1,5 +1,6 @@
package info.cemu.cemu.common.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
@@ -53,6 +54,9 @@ fun FilledSearchToolbar(
actions: @Composable RowScope.() -> Unit = {},
) {
var searchBarActive by remember { mutableStateOf(false) }
BackHandler(enabled = searchBarActive) { searchBarActive = false }
TopAppBar(
actions = actions,
title = {
@@ -4,7 +4,7 @@
package info.cemu.cemu.gamelist
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@@ -92,6 +92,7 @@ fun GamesListScreen(
gameListViewModel.setFilterText("")
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
@@ -196,7 +197,7 @@ private fun GameList(
}
@Composable
fun ShaderCachesConfirmationDialog(
private fun ShaderCachesConfirmationDialog(
gameName: String,
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
@@ -227,7 +228,7 @@ fun ShaderCachesConfirmationDialog(
}
@Composable
fun GameListItem(
private fun GameListItem(
onStartGame: (Game) -> Unit,
onIsFavoriteChanged: (Boolean) -> Unit,
onEditGameProfile: () -> Unit,
@@ -290,7 +291,7 @@ fun GameListItem(
}
@Composable
fun GameContextMenu(
private fun GameContextMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
onIsFavoriteChanged: (Boolean) -> Unit,
@@ -354,4 +355,3 @@ fun GameContextMenu(
)
}
}
@@ -1,30 +0,0 @@
package info.cemu.cemu.graphicpacks
class GraphicPackDataNode(
val id: Long,
name: String,
val path: String,
enabled: Boolean,
val parent: GraphicPackSectionNode?,
) :
GraphicPackNode(name) {
constructor(
id: Long,
name: String,
path: String,
enabled: Boolean,
titleIdInstalled: Boolean,
parentNode: GraphicPackSectionNode,
) : this(id, name, path, enabled, parentNode) {
this.titleIdInstalled = titleIdInstalled
}
var enabled: Boolean = enabled
set(value) {
if (field == value) {
return
}
field = value
parent?.updateEnabledCount(value)
}
}
@@ -1,76 +0,0 @@
package info.cemu.cemu.graphicpacks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import info.cemu.cemu.nativeinterface.NativeGraphicPacks
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
data class Preset(
val index: Int,
val category: String?,
val activePreset: String,
val presets: List<String>,
)
class GraphicPackDataViewModel(private val graphicPackNode: GraphicPackDataNode) : ViewModel() {
private val nativeGraphicPack = NativeGraphicPacks.getGraphicPack(graphicPackNode.id)
val description = nativeGraphicPack?.description ?: ""
private var nativePresets: List<NativeGraphicPacks.GraphicPackPreset> = emptyList()
private val _presets = MutableStateFlow<List<Preset>>(emptyList())
val presets = _presets.asStateFlow()
val name = graphicPackNode.name
private var _enabled = MutableStateFlow(graphicPackNode.enabled)
val enabled = _enabled.asStateFlow()
fun setEnabled(enabled: Boolean) {
nativeGraphicPack?.setActive(enabled)
_enabled.value = enabled
graphicPackNode.enabled = enabled
}
init {
refreshPresets()
}
private fun refreshPresets() {
nativeGraphicPack?.reloadPresets()
if (nativePresets == nativeGraphicPack?.presets) {
return
}
nativePresets = nativeGraphicPack?.presets ?: emptyList()
nativePresets.mapIndexed { index, preset ->
Preset(
index = index,
category = preset.category,
activePreset = preset.activePreset,
presets = preset.presets,
)
}.let {
_presets.value = it
}
}
fun setActivePreset(index: Int, activePreset: String) {
_presets.value = _presets.value.toMutableList().apply {
set(index, get(index).copy(activePreset = activePreset))
}
nativePresets[index].activePreset = activePreset
refreshPresets()
}
companion object {
val GRAPHIC_PACK_KEY = object : CreationExtras.Key<GraphicPackDataNode> {}
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
GraphicPackDataViewModel(
this[GRAPHIC_PACK_KEY] as GraphicPackDataNode
)
}
}
}
}
@@ -1,8 +0,0 @@
package info.cemu.cemu.graphicpacks
abstract class GraphicPackNode(
val name: String?
) {
var titleIdInstalled: Boolean = false
protected set
}
@@ -3,21 +3,27 @@ package info.cemu.cemu.graphicpacks
import info.cemu.cemu.nativeinterface.NativeGraphicPacks
import kotlin.math.max
sealed class GraphicPackNode(
val name: String?,
val parent: GraphicPackSectionNode?
) {
var titleIdInstalled: Boolean = false
protected set
fun isRoot() = parent == null
}
class GraphicPackSectionNode : GraphicPackNode {
constructor() : super(null) {
this.parent = null
}
constructor() : super(null, null)
constructor(
name: String,
titleIdInstalled: Boolean,
parent: GraphicPackSectionNode?
) : super(name) {
parent: GraphicPackSectionNode
) : super(name, parent) {
this.titleIdInstalled = titleIdInstalled
this.parent = parent
}
val parent: GraphicPackSectionNode?
var enabledGraphicPacksCount: Int = 0
private set
var children: ArrayList<GraphicPackNode> = ArrayList()
@@ -79,4 +85,32 @@ class GraphicPackSectionNode : GraphicPackNode {
enabledGraphicPacksCount = max(0, enabledGraphicPacksCount + if (enabled) 1 else -1)
parent?.updateEnabledCount(enabled)
}
}
}
class GraphicPackDataNode(
val id: Long,
name: String,
val path: String,
enabled: Boolean,
parent: GraphicPackSectionNode,
) : GraphicPackNode(name, parent) {
constructor(
id: Long,
name: String,
path: String,
enabled: Boolean,
titleIdInstalled: Boolean,
parentNode: GraphicPackSectionNode,
) : this(id, name, path, enabled, parentNode) {
this.titleIdInstalled = titleIdInstalled
}
var enabled: Boolean = enabled
set(value) {
if (field == value) {
return
}
field = value
parent?.updateEnabledCount(value)
}
}
@@ -1,5 +0,0 @@
package info.cemu.cemu.graphicpacks
import androidx.lifecycle.ViewModel
class GraphicPackViewModel(var graphicPackNode: GraphicPackNode? = null) : ViewModel()
@@ -124,7 +124,7 @@ class GraphicPacksDownloader {
graphicPacksTempDir.deleteRecursively()
unzip(
response.body.byteStream(),
graphicPacksTempDir.path.toString()
graphicPacksTempDir.path
)
graphicPacksTempDir.resolve("version.txt").writeText(version)
val downloadedGraphicPacksDir =
@@ -1,149 +0,0 @@
package info.cemu.cemu.graphicpacks
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import info.cemu.cemu.nativeinterface.NativeGameTitles
import info.cemu.cemu.nativeinterface.NativeGraphicPacks
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
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.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.regex.Pattern
class GraphicPacksListViewModel : ViewModel() {
private val installedTitleIds = NativeGameTitles.getInstalledGamesTitleIds()
private var rootNode = GraphicPackSectionNode()
private val _installedOnly = MutableStateFlow(installedTitleIds.size > 1)
val installedOnly = _installedOnly.asStateFlow()
fun setInstalledOnly(installedOnly: Boolean) {
_installedOnly.value = installedOnly
}
private val _downloadStatus = MutableStateFlow<GraphicPacksDownloadStatus?>(null)
private suspend fun updateDownloadStatus(status: GraphicPacksDownloadStatus?) {
_downloadStatus.first { it == null }
_downloadStatus.value = status
}
val downloadStatus = _downloadStatus.asStateFlow()
private var downloadJob: Job? = null
fun downloadNewUpdate(context: Context) {
if (_downloadStatus.value != null) return
downloadJob = viewModelScope.launch {
try {
GraphicPacksDownloader.download(context) { updateDownloadStatus(it) }
refreshGraphicPacks()
} catch (_: Exception) {
updateDownloadStatus(GraphicPacksDownloadStatus.ERROR)
}
}
}
fun downloadStatusRead() {
_downloadStatus.value = null
}
fun cancelDownload() {
val oldDownloadJob = downloadJob ?: return
downloadJob = null
viewModelScope.launch {
oldDownloadJob.cancelAndJoin()
updateDownloadStatus(GraphicPacksDownloadStatus.CANCELED)
}
}
private val _filterText = MutableStateFlow("")
val filterText: StateFlow<String> = _filterText
fun setFilterText(filterText: String) {
_filterText.value = filterText
}
private val filterPattern = filterText.map { filterText ->
if (filterText.isBlank()) {
return@map null
}
return@map buildString {
filterText.trim()
.split(" ".toRegex())
.forEach { append("(?=.*" + Pattern.quote(it) + ")") }
append(".*")
}.toPattern(Pattern.CASE_INSENSITIVE)
}
private val _graphicPackDataNodes = MutableStateFlow<List<GraphicPackDataNode>>(emptyList())
val graphicPackDataNodes: StateFlow<List<GraphicPackDataNode>> =
combine(
_graphicPackDataNodes,
installedOnly,
filterPattern
) { graphicPackNodes, installedOnly, pattern ->
if (!installedOnly && pattern == null) {
return@combine graphicPackNodes
}
if (pattern != null) {
return@combine graphicPackNodes.filter {
pattern.matcher(it.path).matches() && (it.titleIdInstalled || !installedOnly)
}
}
return@combine graphicPackNodes.filter { it.titleIdInstalled }
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
private val _graphicPackNodes = MutableStateFlow<List<GraphicPackNode>>(emptyList())
val graphicPackNodes: StateFlow<List<GraphicPackNode>> =
installedOnly.combine(_graphicPackNodes) { installedOnly, graphicPackNodes ->
if (installedOnly) {
return@combine graphicPackNodes.filter { it.titleIdInstalled }
}
return@combine graphicPackNodes
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
init {
refreshGraphicPacks()
}
private fun MutableList<GraphicPackDataNode>.fillWithDataNodes(graphicPackSectionNode: GraphicPackSectionNode): MutableList<GraphicPackDataNode> {
for (node in graphicPackSectionNode.children) {
when (node) {
is GraphicPackSectionNode -> fillWithDataNodes(node)
is GraphicPackDataNode -> add(node)
}
}
return this
}
private fun refreshGraphicPacks() {
rootNode = GraphicPackSectionNode().apply {
NativeGraphicPacks.getGraphicPackBasicInfos().forEach {
val hasTitleInstalled = it.titleIds.any { titleId -> titleId in installedTitleIds }
addGraphicPackDataByTokens(it, hasTitleInstalled)
}
sort()
}
_graphicPackDataNodes.value =
mutableListOf<GraphicPackDataNode>().fillWithDataNodes(rootNode)
_graphicPackNodes.value = rootNode.children.toList()
}
companion object {
private val GraphicPacksDownloader = GraphicPacksDownloader()
}
}
@@ -1,93 +1,17 @@
package info.cemu.cemu.graphicpacks
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.MutableCreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import kotlinx.serialization.Serializable
@Serializable
object GraphicPacksRoute
private object GraphicPackRoutes {
@Serializable
object GraphicPacksRootSectionRoute
@Serializable
object GraphicPackSectionScreenRoute
@Serializable
object GraphicPackDataScreenRoute
}
private inline fun <reified T : Any, reified U : GraphicPackNode> NavGraphBuilder.composableNestedGraphicPacks(
navController: NavController,
noinline content: @Composable (AnimatedContentScope.(NavBackStackEntry, U) -> Unit),
) {
composable<T> { backStackEntry ->
val previousBackStackEntry =
navController.previousBackStackEntry ?: return@composable
val graphicPackNode =
viewModel<GraphicPackViewModel>(previousBackStackEntry).graphicPackNode
if (graphicPackNode == null || graphicPackNode !is U) {
return@composable
}
content(backStackEntry, graphicPackNode)
}
}
private fun graphicPacksNavigate(navController: NavController, graphicPackNode: GraphicPackNode) {
when (graphicPackNode) {
is GraphicPackSectionNode -> navController.navigate(GraphicPackRoutes.GraphicPackSectionScreenRoute)
is GraphicPackDataNode -> navController.navigate(GraphicPackRoutes.GraphicPackDataScreenRoute)
}
}
fun NavGraphBuilder.graphicPacksNavigation(navController: NavHostController) {
navigation<GraphicPacksRoute>(startDestination = GraphicPackRoutes.GraphicPacksRootSectionRoute) {
composable<GraphicPackRoutes.GraphicPacksRootSectionRoute> { backStackEntry ->
val graphicPackViewModel: GraphicPackViewModel = viewModel(backStackEntry)
GraphicPacksRootSectionScreen(
navigateBack = { navController.popBackStack() },
graphicPackNodeNavigate = {
graphicPackViewModel.graphicPackNode = it
graphicPacksNavigate(navController, it)
}
)
}
composableNestedGraphicPacks<GraphicPackRoutes.GraphicPackSectionScreenRoute, GraphicPackSectionNode>(
navController
) { backStackEntry, graphicPackNode ->
val graphicPacksViewModel: GraphicPackViewModel = viewModel(backStackEntry)
GraphicPacksSectionScreen(
navigateBack = { navController.popBackStack() },
graphicPackNodeNavigate = {
graphicPacksViewModel.graphicPackNode = it
graphicPacksNavigate(navController, it)
},
graphicPackSectionNode = graphicPackNode,
)
}
composableNestedGraphicPacks<GraphicPackRoutes.GraphicPackDataScreenRoute, GraphicPackDataNode>(
navController
) { backStackEntry, graphicPackNode ->
val graphicPackDataViewModel: GraphicPackDataViewModel = viewModel(
viewModelStoreOwner = backStackEntry,
factory = GraphicPackDataViewModel.Factory,
extras = MutableCreationExtras().apply {
set(GraphicPackDataViewModel.GRAPHIC_PACK_KEY, graphicPackNode)
}
)
GraphicPackDataScreen(
navigateBack = { navController.popBackStack() },
graphicPackDataViewModel = graphicPackDataViewModel,
)
}
composable<GraphicPacksRoute> {
GraphicPacksScreen(
navigateBack = { navController.popBackStack() }
)
}
}
}
@@ -2,6 +2,7 @@
package info.cemu.cemu.graphicpacks
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -62,28 +63,29 @@ import info.cemu.cemu.common.ui.components.SingleSelection
import info.cemu.cemu.common.ui.localization.tr
import kotlinx.coroutines.launch
@Composable
fun GraphicPacksRootSectionScreen(
fun GraphicPacksScreen(
navigateBack: () -> Unit,
graphicPackNodeNavigate: (GraphicPackNode) -> Unit,
graphicPacksListViewModel: GraphicPacksListViewModel = viewModel(),
graphicPacksViewModel: GraphicPacksViewModel = viewModel(),
) {
val graphicPackNodes by graphicPacksListViewModel.graphicPackNodes.collectAsState()
val graphicPackDataNodes by graphicPacksListViewModel.graphicPackDataNodes.collectAsState()
val query by graphicPacksListViewModel.filterText.collectAsState()
val installedOnly by graphicPacksListViewModel.installedOnly.collectAsState()
val graphicPackDataNodes by graphicPacksViewModel.graphicPackDataNodes.collectAsState()
val query by graphicPacksViewModel.filterText.collectAsState()
val installedOnly by graphicPacksViewModel.installedOnly.collectAsState()
var showGraphicPackSearch by rememberSaveable { mutableStateOf(false) }
val downloadStatus by graphicPacksListViewModel.downloadStatus.collectAsState()
val downloadStatus by graphicPacksViewModel.downloadStatus.collectAsState()
val snackbarScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var downloadDialogText by rememberSaveable { mutableStateOf<String?>(null) }
val context = LocalContext.current
val currentNodeState = graphicPacksViewModel.currentNode.collectAsState()
val currentNode = currentNodeState.value
val graphicPackDataState = graphicPacksViewModel.currentDataGraphicPack.collectAsState()
val graphicPackData = graphicPackDataState.value
downloadStatus?.let { status ->
downloadDialogText = downloadStatusToDialogTextString(status)
LaunchedEffect(status) {
graphicPacksListViewModel.downloadStatusRead()
graphicPacksViewModel.downloadStatusRead()
val downloadNotificationText =
downloadStatusToNotificationString(status) ?: return@LaunchedEffect
@@ -94,26 +96,39 @@ fun GraphicPacksRootSectionScreen(
}
}
}
fun onNavigateBack() {
fun handleBack() {
if (showGraphicPackSearch) {
showGraphicPackSearch = false
} else {
navigateBack()
return
}
if (!currentNodeState.value.isRoot()) {
graphicPacksViewModel.navigateBack()
return
}
navigateBack()
}
BackHandler(enabled = showGraphicPackSearch || !currentNodeState.value.isRoot()) {
handleBack()
}
ScreenContentLazy(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
actions = {
GraphicPacksRootSectionActions(
showMainActions = !showGraphicPackSearch,
onSearchClicked = {
showGraphicPackSearch = true
},
onDownloadClicked = { graphicPacksListViewModel.downloadNewUpdate(context) },
installedOnlyChecked = installedOnly,
installedOnlyValueChange = graphicPacksListViewModel::setInstalledOnly,
)
if (currentNode.isRoot()) {
GraphicPacksRootSectionActions(
showMainActions = !showGraphicPackSearch,
onSearchClicked = {
showGraphicPackSearch = true
},
onDownloadClicked = { graphicPacksViewModel.downloadNewUpdate(context) },
installedOnlyChecked = installedOnly,
installedOnlyValueChange = graphicPacksViewModel::setInstalledOnly,
)
}
},
appBarTitle = {
Box(
@@ -121,35 +136,47 @@ fun GraphicPacksRootSectionScreen(
.height(IntrinsicSize.Min)
.padding(8.dp),
) {
if (showGraphicPackSearch) {
if (showGraphicPackSearch && currentNode.isRoot()) {
SearchToolbarInput(
value = query,
onValueChange = graphicPacksListViewModel::setFilterText,
onValueChange = graphicPacksViewModel::setFilterText,
hint = tr("Search graphic packs"),
)
} else {
DefaultAppBarTitle(tr("Graphic packs"))
DefaultAppBarTitle(currentNode.name ?: tr("Graphic packs"))
}
}
},
navigateBack = ::onNavigateBack,
navigateBack = ::handleBack,
) {
if (showGraphicPackSearch) {
if (showGraphicPackSearch && currentNode.isRoot()) {
graphicPackDataSearchItems(
nodes = graphicPackDataNodes,
onClick = graphicPackNodeNavigate,
onClick = { graphicPacksViewModel.navigateTo(it) },
)
} else {
return@ScreenContentLazy
}
if (currentNode is GraphicPackSectionNode) {
graphicPackSectionItems(
nodes = graphicPackNodes,
onClick = graphicPackNodeNavigate,
installedOnly = installedOnly,
nodes = currentNode.children,
onClick = { graphicPacksViewModel.navigateTo(it) },
)
}
if (graphicPackData != null) {
graphicPackDataNodeItem(
graphicPacksViewModel = graphicPacksViewModel,
graphicPackData = graphicPackData
)
}
}
if (downloadDialogText != null) {
GraphicPacksDownloadDialog(
onCancelRequest = {
graphicPacksListViewModel.cancelDownload()
graphicPacksViewModel.cancelDownload()
},
text = downloadDialogText!!,
)
@@ -173,7 +200,7 @@ private fun downloadStatusToNotificationString(downloadStatus: GraphicPacksDownl
}
@Composable
fun GraphicPacksDownloadDialog(
private fun GraphicPacksDownloadDialog(
onCancelRequest: () -> Unit,
text: String,
) {
@@ -206,7 +233,7 @@ fun GraphicPacksDownloadDialog(
}
@Composable
fun GraphicPacksRootSectionActions(
private fun GraphicPacksRootSectionActions(
showMainActions: Boolean,
onSearchClicked: () -> Unit,
onDownloadClicked: () -> Unit,
@@ -261,7 +288,7 @@ fun GraphicPacksRootSectionActions(
}
}
fun LazyListScope.graphicPackDataSearchItems(
private fun LazyListScope.graphicPackDataSearchItems(
nodes: List<GraphicPackDataNode>,
onClick: (GraphicPackDataNode) -> Unit,
) {
@@ -291,80 +318,52 @@ fun LazyListScope.graphicPackDataSearchItems(
}
}
@Composable
fun GraphicPacksSectionScreen(
navigateBack: () -> Unit,
graphicPackNodeNavigate: (GraphicPackNode) -> Unit,
graphicPackSectionNode: GraphicPackSectionNode,
private fun LazyListScope.graphicPackDataNodeItem(
graphicPacksViewModel: GraphicPacksViewModel,
graphicPackData: GraphicPackData
) {
val appBarText = graphicPackSectionNode.name ?: tr("Graphic packs")
ScreenContentLazy(
appBarText = appBarText,
navigateBack = navigateBack,
) {
graphicPackSectionItems(
nodes = graphicPackSectionNode.children,
onClick = graphicPackNodeNavigate,
item {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = tr("Enabled"))
Switch(
modifier = Modifier.padding(horizontal = 8.dp),
checked = graphicPackData.active,
onCheckedChange = graphicPacksViewModel::setCurrentGraphicPackActive,
)
}
}
item {
Text(
modifier = Modifier.padding(8.dp),
text = graphicPackData.description
)
}
items(items = graphicPackData.presets) {
SingleSelection(
modifier = Modifier.animateItem(),
label = it.category ?: tr("Active preset"),
choices = it.choices,
choice = it.activeChoice,
onChoiceChanged = { activePreset ->
graphicPacksViewModel.setCurrentGraphicPackActivePreset(
it.index,
activePreset
)
}
)
}
}
@Composable
fun GraphicPackDataScreen(
navigateBack: () -> Unit,
graphicPackDataViewModel: GraphicPackDataViewModel,
) {
val appBarText = graphicPackDataViewModel.name ?: tr("Graphic packs")
val enabled by graphicPackDataViewModel.enabled.collectAsState()
val presets by graphicPackDataViewModel.presets.collectAsState()
ScreenContentLazy(
appBarText = appBarText,
navigateBack = navigateBack,
) {
item {
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = tr("Enabled"))
Switch(
modifier = Modifier.padding(horizontal = 8.dp),
checked = enabled,
onCheckedChange = graphicPackDataViewModel::setEnabled,
)
}
}
item {
Text(
modifier = Modifier.padding(8.dp),
text = graphicPackDataViewModel.description
)
}
items(items = presets) {
SingleSelection(
modifier = Modifier.animateItem(),
label = it.category ?: tr("Active preset"),
choices = it.presets,
choice = it.activePreset,
onChoiceChanged = { activePreset ->
graphicPackDataViewModel.setActivePreset(
it.index,
activePreset
)
}
)
}
}
}
private fun LazyListScope.graphicPackSectionItems(
nodes: List<GraphicPackNode>,
installedOnly: Boolean,
onClick: (GraphicPackNode) -> Unit,
) {
items(
items = nodes,
items = if (installedOnly) nodes.filter { it.titleIdInstalled } else nodes,
) {
GraphicPackListItem(
label = it.name,
@@ -380,7 +379,7 @@ private fun LazyListScope.graphicPackSectionItems(
}
@Composable
fun GraphicPackDataListItemIcon(isEnabled: Boolean) {
private fun GraphicPackDataListItemIcon(isEnabled: Boolean) {
GraphicPackListItemIcon(
painter = painterResource(R.drawable.ic_package_2),
showExtraInfo = isEnabled,
@@ -397,7 +396,7 @@ fun GraphicPackDataListItemIcon(isEnabled: Boolean) {
}
@Composable
fun GraphicPackSectionListItemIcon(numberOfEnabledPacks: Int) {
private fun GraphicPackSectionListItemIcon(numberOfEnabledPacks: Int) {
GraphicPackListItemIcon(
painter = painterResource(R.drawable.ic_lists),
showExtraInfo = numberOfEnabledPacks > 0,
@@ -414,7 +413,7 @@ fun GraphicPackSectionListItemIcon(numberOfEnabledPacks: Int) {
}
@Composable
fun GraphicPackListItemIcon(
private fun GraphicPackListItemIcon(
painter: Painter,
showExtraInfo: Boolean,
extraInfoContent: @Composable () -> Unit,
@@ -434,7 +433,7 @@ fun GraphicPackListItemIcon(
}
@Composable
fun GraphicPackListItem(
private fun GraphicPackListItem(
label: String?,
onClick: () -> Unit,
modifier: Modifier,
@@ -0,0 +1,274 @@
package info.cemu.cemu.graphicpacks
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import info.cemu.cemu.nativeinterface.NativeGameTitles
import info.cemu.cemu.nativeinterface.NativeGraphicPacks
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
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.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.regex.Pattern
data class Preset(
val index: Int,
val category: String?,
val activeChoice: String,
val choices: List<String>,
) {
companion object {
fun fromNativeGraphicPack(graphicPack: NativeGraphicPacks.GraphicPack): List<Preset> {
return graphicPack.presets.mapIndexed { index, preset ->
Preset(
index = index,
category = preset.category,
activeChoice = preset.activePreset,
choices = preset.presets,
)
}
}
}
}
data class GraphicPackData(
val description: String,
val active: Boolean,
val presets: List<Preset>,
)
private fun MutableList<GraphicPackDataNode>.fillWithDataNodes(graphicPackSectionNode: GraphicPackSectionNode): MutableList<GraphicPackDataNode> {
for (node in graphicPackSectionNode.children) {
when (node) {
is GraphicPackSectionNode -> fillWithDataNodes(node)
is GraphicPackDataNode -> add(node)
}
}
return this
}
class GraphicPacksViewModel : ViewModel() {
private var rootNode = GraphicPackSectionNode()
val installedTitleIds = NativeGameTitles.getInstalledGamesTitleIds()
private val _installedOnly = MutableStateFlow(installedTitleIds.size > 1)
val installedOnly = _installedOnly.asStateFlow()
fun setInstalledOnly(installedOnly: Boolean) {
_installedOnly.value = installedOnly
}
private val path = MutableStateFlow(listOf<GraphicPackNode>(rootNode))
val currentNode = path.map { it.last() }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
rootNode
)
private var currentNativeGraphicPack: NativeGraphicPacks.GraphicPack? = null
private val _currentDataGraphicPack = MutableStateFlow<GraphicPackData?>(null)
val currentDataGraphicPack = _currentDataGraphicPack.asStateFlow()
private fun setCurrentDataGraphicPack(graphicPackDataNode: GraphicPackDataNode) {
val nativeGraphicPack = NativeGraphicPacks.getGraphicPack(graphicPackDataNode.id) ?: return
currentNativeGraphicPack = nativeGraphicPack
_currentDataGraphicPack.value = GraphicPackData(
description = nativeGraphicPack.description,
active = nativeGraphicPack.isActive(),
presets = Preset.fromNativeGraphicPack(nativeGraphicPack)
)
}
private fun clearCurrentDataGraphicPack() {
currentNativeGraphicPack = null
_currentDataGraphicPack.value = null
}
fun setCurrentGraphicPackActive(active: Boolean) {
val dataNode = currentNode.value
if (dataNode !is GraphicPackDataNode) {
return
}
dataNode.enabled = active
currentNativeGraphicPack?.setActive(active)
_currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy(active = active)
}
private fun refreshCurrentGraphicPackPresets() {
val nativeGraphicPack = currentNativeGraphicPack ?: return
val oldPresets = nativeGraphicPack.presets
nativeGraphicPack.reloadPresets()
if (oldPresets == nativeGraphicPack.presets) {
return
}
_currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy(
presets = Preset.fromNativeGraphicPack(nativeGraphicPack)
)
}
fun setCurrentGraphicPackActivePreset(index: Int, activePreset: String) {
val nativeGraphicPack = currentNativeGraphicPack ?: return
var presets = _currentDataGraphicPack.value?.presets ?: return
presets = presets.toMutableList().apply {
set(index, get(index).copy(activeChoice = activePreset))
}
_currentDataGraphicPack.value = _currentDataGraphicPack.value?.copy(presets = presets)
nativeGraphicPack.presets[index].activePreset = activePreset
refreshCurrentGraphicPackPresets()
}
fun navigateBack() {
val currentPath = path.value
val lastNode = currentPath.last()
if (currentPath.size > 1) {
path.value = currentPath.dropLast(1)
}
if (lastNode is GraphicPackDataNode) {
clearCurrentDataGraphicPack()
}
}
fun navigateTo(node: GraphicPackNode) {
val currentPath = path.value
if (node is GraphicPackSectionNode) {
path.value += node
return
}
if (node !is GraphicPackDataNode) {
return
}
setCurrentDataGraphicPack(node)
if (currentPath.last() === node.parent) {
path.value += node
return
}
val newPath = mutableListOf<GraphicPackNode>(node)
var currentNode = node.parent
while (currentNode != null) {
newPath.add(currentNode)
currentNode = currentNode.parent
}
path.value = newPath.reversed()
}
private val _downloadStatus = MutableStateFlow<GraphicPacksDownloadStatus?>(null)
private suspend fun updateDownloadStatus(status: GraphicPacksDownloadStatus?) {
_downloadStatus.first { it == null }
_downloadStatus.value = status
}
val downloadStatus = _downloadStatus.asStateFlow()
private var downloadJob: Job? = null
fun downloadNewUpdate(context: Context) {
if (_downloadStatus.value != null) return
downloadJob = viewModelScope.launch {
try {
GraphicPacksDownloader.download(context) { updateDownloadStatus(it) }
refreshGraphicPacks()
} catch (_: Exception) {
updateDownloadStatus(GraphicPacksDownloadStatus.ERROR)
}
}
}
fun downloadStatusRead() {
_downloadStatus.value = null
}
fun cancelDownload() {
val oldDownloadJob = downloadJob ?: return
downloadJob = null
viewModelScope.launch {
oldDownloadJob.cancelAndJoin()
updateDownloadStatus(GraphicPacksDownloadStatus.CANCELED)
}
}
private val _filterText = MutableStateFlow("")
val filterText: StateFlow<String> = _filterText
fun setFilterText(filterText: String) {
_filterText.value = filterText
}
private val filterPattern = filterText.map { filterText ->
if (filterText.isBlank()) {
return@map null
}
return@map buildString {
filterText.trim().split(" ".toRegex())
.forEach { append("(?=.*" + Pattern.quote(it) + ")") }
append(".*")
}.toPattern(Pattern.CASE_INSENSITIVE)
}
private val _graphicPackDataNodes = MutableStateFlow<List<GraphicPackDataNode>>(emptyList())
val graphicPackDataNodes: StateFlow<List<GraphicPackDataNode>> = combine(
_graphicPackDataNodes, installedOnly, filterPattern
) { graphicPackNodes, installedOnly, pattern ->
if (!installedOnly && pattern == null) {
return@combine graphicPackNodes
}
if (pattern != null) {
return@combine graphicPackNodes.filter {
pattern.matcher(it.path).matches() && (it.titleIdInstalled || !installedOnly)
}
}
return@combine graphicPackNodes.filter { it.titleIdInstalled }
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
private fun refreshGraphicPacks() {
rootNode = GraphicPackSectionNode().apply {
NativeGraphicPacks.getGraphicPackBasicInfos().forEach {
val hasTitleInstalled = it.titleIds.any { titleId -> titleId in installedTitleIds }
addGraphicPackDataByTokens(it, hasTitleInstalled)
}
sort()
}
path.value = listOf(rootNode)
_graphicPackDataNodes.value =
mutableListOf<GraphicPackDataNode>().fillWithDataNodes(rootNode)
}
init {
refreshGraphicPacks()
}
companion object {
private val GraphicPacksDownloader = GraphicPacksDownloader()
}
}
@@ -5,7 +5,7 @@ import androidx.annotation.Keep
object NativeAccount {
const val MAX_ACCOUNT_COUNT = 12
const val MIN_ACCOUNT_COUNT = 1
const val MIN_PERSISTENT_ID: UInt = 2147483649u
const val MIN_PERSISTENT_ID: UInt = 0x80000001u
const val DEFAULT_MII_NAME = "default"
object AccountGender {
@@ -54,16 +54,22 @@ object NativeAccount {
@Keep
sealed interface OnlineValidationError
@Keep
class MissingOTP : OnlineValidationError
@Keep
class CorruptedOTP : OnlineValidationError
@Keep
class MissingSEEPROM : OnlineValidationError
@Keep
class CorruptedSEEPROM : OnlineValidationError
@Keep
data class MissingFile(val file: String) : OnlineValidationError
@Keep
data class AccountError(val accountError: Int) : OnlineValidationError
@@ -201,13 +201,13 @@ class TitleListViewModel : ViewModel() {
val titleInstallProgress = installUseCase.progress
fun installQueuedTitle(context: Context, callback: (InstallResult) -> Unit) {
val queued = _queuedTitleToInstall.value ?: return
val (titleUri, titleExistsStatus) = _queuedTitleToInstall.value ?: return
_queuedTitleToInstall.value = null
installUseCase.install(
context = context,
titleUri = queued.first,
targetLocation = queued.second.targetLocation,
titleUri = titleUri,
targetLocation = titleExistsStatus.targetLocation,
callback = callback,
)
}
@@ -246,6 +246,7 @@ class TitleListViewModel : ViewModel() {
uri: Uri,
onResult: (CompressResult) -> Unit
) {
_queuedTitleToCompress.value = null
compressUseCase.compress(context, uri, onResult)
}
@@ -51,6 +51,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -107,6 +108,7 @@ fun TitleManagerScreen(
when (it) {
InstallResult.ERROR -> showNotificationMessage(tr("Error installing"))
InstallResult.FINISHED -> showNotificationMessage(tr("Finished installing"))
InstallResult.NOT_ENOUGH_SPACE -> showNotificationMessage(tr("Not enough space"))
}
},
)
@@ -240,7 +242,7 @@ fun TitleManagerScreen(
@Composable
private fun TitleCompressProgressDialog(bytesWritten: Long?, onCancel: () -> Unit) {
var showCancelConfirmDialog by remember { mutableStateOf(false) }
var showCancelConfirmDialog by rememberSaveable { mutableStateOf(false) }
AlertDialog(
title = { Text(tr("Compressing title")) },
@@ -345,7 +347,7 @@ private fun TitleInstallProgressDialog(
progress: Pair<Long, Long>?,
onCancel: () -> Unit,
) {
var showCancelConfirmDialog by remember { mutableStateOf(false) }
var showCancelConfirmDialog by rememberSaveable { mutableStateOf(false) }
AlertDialog(
title = { Text(tr("Installing title")) },
@@ -504,7 +506,7 @@ private fun <T : Enum<T>> FilterRow(
valueToLabel: @Composable (T) -> String,
onToggle: (T) -> Unit
) {
var showOptions by remember { mutableStateOf(false) }
var showOptions by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.padding(8.dp)
@@ -561,7 +563,7 @@ private fun TitleEntryListItem(
onDeleteRequest: () -> Unit,
onCompressRequested: () -> Unit,
) {
var showDeleteConfirmationDialog by remember { mutableStateOf(false) }
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
Card(
colors = CardDefaults.cardColors(
@@ -572,7 +574,7 @@ private fun TitleEntryListItem(
.animateContentSize()
.padding(8.dp),
) {
var showTitleInfo by remember { mutableStateOf(false) }
var showTitleInfo by rememberSaveable { mutableStateOf(false) }
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
@@ -622,7 +624,7 @@ private fun TitleDropDownMenu(
onDeleteClicked: () -> Unit,
onCompressClicked: () -> Unit,
) {
var expandMenu by remember { mutableStateOf(false) }
var expandMenu by rememberSaveable { mutableStateOf(false) }
@Composable
fun DropdownMenuItem(text: String, onClick: () -> Unit) {
@@ -29,6 +29,8 @@ class CompressTitleUseCase(private val scope: CoroutineScope) {
scope.launch(Dispatchers.IO) {
progressJob?.cancelAndJoin()
NativeGameTitles.cancelTitleCompression()
_inProgress.value = false
_progress.value = null
}
}
@@ -29,6 +29,7 @@ import kotlin.random.nextUInt
enum class InstallResult{
ERROR,
FINISHED,
NOT_ENOUGH_SPACE,
}
private sealed class DirEntry {
@@ -87,7 +88,7 @@ class InstallTitleUseCase(
)
if (totalSize > mlcPath.toFile().freeSpace) {
callback(InstallResult.ERROR)
callback(InstallResult.NOT_ENOUGH_SPACE)
return@launch
}