diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..958abe7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "submodules/RetroArch"] + path = submodules/RetroArch + url = https://github.com/libretro/RetroArch.git +[submodule "submodules/ppsspp"] + path = submodules/ppsspp + url = https://github.com/hrydgard/ppsspp.git diff --git a/app-default/build.gradle.kts b/app-default/build.gradle.kts index 2ad5fc6..81519e9 100644 --- a/app-default/build.gradle.kts +++ b/app-default/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.konan.properties.Properties +import java.io.ByteArrayOutputStream import java.io.FileInputStream plugins { @@ -18,9 +19,12 @@ android { applicationId = "moe.tabidachi.emulator" minSdk = 23 targetSdk = 28 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1-${getGitHash()}" + ndk { + abiFilters.add("armeabi-v7a") + } } signingConfigs { val properties = Properties().apply { @@ -50,7 +54,7 @@ android { proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - //signingConfig = signingConfigs.getByName("release") + signingConfig = signingConfigs.getByName("release") } } compileOptions { @@ -63,6 +67,16 @@ android { buildFeatures { compose = true } + applicationVariants.all { + outputs.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val outputFileName = "Emulator-${versionName}-${baseName}.apk" + output.outputFileName = outputFileName + } + } + lint { + baseline = file("lint-baseline.xml") + } } dependencies { @@ -105,4 +119,18 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + testImplementation(kotlin("test")) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} + +fun getGitHash(): String { + return ByteArrayOutputStream().use { + project.exec { + commandLine("git", "rev-parse", "--short", "HEAD") + standardOutput = it + } + it.toByteArray().let(::String).trim() + } } \ No newline at end of file diff --git a/app-default/lint-baseline.xml b/app-default/lint-baseline.xml new file mode 100644 index 0000000..4284a3b --- /dev/null +++ b/app-default/lint-baseline.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/app-default/proguard-rules.pro b/app-default/proguard-rules.pro index 481bb43..33eb1fa 100644 --- a/app-default/proguard-rules.pro +++ b/app-default/proguard-rules.pro @@ -18,4 +18,8 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +# ppsspp +-keep class org.ppsspp.ppsspp.** { *; } +# RetroArch +-keep class com.retroarch.** { *; } \ No newline at end of file diff --git a/app-default/src/main/AndroidManifest.xml b/app-default/src/main/AndroidManifest.xml index 1d2840d..f497ec7 100644 --- a/app-default/src/main/AndroidManifest.xml +++ b/app-default/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + android:theme="@style/Theme.Emulator" + tools:replace="android:banner,android:name"> @@ -28,8 +31,19 @@ + + + + \ No newline at end of file diff --git a/app-default/src/main/ic_launcher-playstore.png b/app-default/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..ba8dbe2 Binary files /dev/null and b/app-default/src/main/ic_launcher-playstore.png differ diff --git a/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt b/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt new file mode 100644 index 0000000..bc8c9dc --- /dev/null +++ b/app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt @@ -0,0 +1,295 @@ +package androidx.compose.material3 + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.tv.material3.MaterialTheme +import kotlin.math.abs + +/** + * Indeterminate Material Design linear progress indicator. + * + * Progress indicators express an unspecified wait time or display the duration of a process. + * + * ![Linear progress indicator + * image](https://firebasestorage.googleapis.com/v0/b/design-spec/o/projects%2Fgoogle-material-3%2Fimages%2Flqdiyyvh-1P-progress-indicator-configurations.png?alt=media) + * + * @sample androidx.compose.material3.samples.IndeterminateLinearProgressIndicatorSample + * + * @param modifier the [Modifier] to be applied to this progress indicator + * @param color color of this progress indicator + * @param trackColor color of the track behind the indicator, visible when the progress has not + * reached the area of the overall indicator yet + * @param strokeCap stroke cap to use for the ends of this progress indicator + * @param gapSize size of the gap between the progress indicator and the track + */ +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.primary, + trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, + strokeCap: StrokeCap = StrokeCap.Round, + gapSize: Dp = 4.dp, +) { + val infiniteTransition = rememberInfiniteTransition() + // Fractional position of the 'head' and 'tail' of the two lines drawn, i.e. if the head is 0.8 + // and the tail is 0.2, there is a line drawn from between 20% along to 80% along the total + // width. + val firstLineHead = + infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = + keyframes { + durationMillis = LinearAnimationDuration + 0f at FirstLineHeadDelay using FirstLineHeadEasing + 1f at FirstLineHeadDuration + FirstLineHeadDelay + } + ) + ) + val firstLineTail = + infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = + keyframes { + durationMillis = LinearAnimationDuration + 0f at FirstLineTailDelay using FirstLineTailEasing + 1f at FirstLineTailDuration + FirstLineTailDelay + } + ) + ) + val secondLineHead = + infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = + keyframes { + durationMillis = LinearAnimationDuration + 0f at SecondLineHeadDelay using SecondLineHeadEasing + 1f at SecondLineHeadDuration + SecondLineHeadDelay + } + ) + ) + val secondLineTail = + infiniteTransition.animateFloat( + 0f, + 1f, + infiniteRepeatable( + animation = + keyframes { + durationMillis = LinearAnimationDuration + 0f at SecondLineTailDelay using SecondLineTailEasing + 1f at SecondLineTailDuration + SecondLineTailDelay + } + ) + ) + Canvas( + modifier + .then(IncreaseSemanticsBounds) + .progressSemantics() + .size(LinearIndicatorWidth, LinearIndicatorHeight) + ) { + val strokeWidth = size.height + val adjustedGapSize = + if (strokeCap == StrokeCap.Butt || size.height > size.width) { + gapSize + } else { + gapSize + strokeWidth.toDp() + } + val gapSizeFraction = adjustedGapSize / size.width.toDp() + + // Track before line 1 + if (firstLineHead.value < 1f - gapSizeFraction) { + val start = if (firstLineHead.value > 0) firstLineHead.value + gapSizeFraction else 0f + drawLinearIndicator(start, 1f, trackColor, strokeWidth, strokeCap) + } + + // Line 1 + if (firstLineHead.value - firstLineTail.value > 0) { + drawLinearIndicator( + firstLineHead.value, + firstLineTail.value, + color, + strokeWidth, + strokeCap, + ) + } + + // Track between line 1 and line 2 + if (firstLineTail.value > gapSizeFraction) { + val start = if (secondLineHead.value > 0) secondLineHead.value + gapSizeFraction else 0f + val end = if (firstLineTail.value < 1f) firstLineTail.value - gapSizeFraction else 1f + drawLinearIndicator(start, end, trackColor, strokeWidth, strokeCap) + } + + // Line 2 + if (secondLineHead.value - secondLineTail.value > 0) { + drawLinearIndicator( + secondLineHead.value, + secondLineTail.value, + color, + strokeWidth, + strokeCap, + ) + } + + // Track after line 2 + if (secondLineTail.value > gapSizeFraction) { + val end = if (secondLineTail.value < 1) secondLineTail.value - gapSizeFraction else 1f + drawLinearIndicator(0f, end, trackColor, strokeWidth, strokeCap) + } + } +} + +private fun DrawScope.drawLinearIndicator( + startFraction: Float, + endFraction: Float, + color: Color, + strokeWidth: Float, + strokeCap: StrokeCap, +) { + val width = size.width + val height = size.height + // Start drawing from the vertical center of the stroke + val yOffset = height / 2 + + val isLtr = layoutDirection == LayoutDirection.Ltr + val barStart = (if (isLtr) startFraction else 1f - endFraction) * width + val barEnd = (if (isLtr) endFraction else 1f - startFraction) * width + + // if there isn't enough space to draw the stroke caps, fall back to StrokeCap.Butt + if (strokeCap == StrokeCap.Butt || height > width) { + // Progress line + drawLine(color, Offset(barStart, yOffset), Offset(barEnd, yOffset), strokeWidth) + } else { + // need to adjust barStart and barEnd for the stroke caps + val strokeCapOffset = strokeWidth / 2 + val coerceRange = strokeCapOffset..(width - strokeCapOffset) + val adjustedBarStart = barStart.coerceIn(coerceRange) + val adjustedBarEnd = barEnd.coerceIn(coerceRange) + + if (abs(endFraction - startFraction) > 0) { + // Progress line + drawLine( + color, + Offset(adjustedBarStart, yOffset), + Offset(adjustedBarEnd, yOffset), + strokeWidth, + strokeCap, + ) + } + } +} + +private val SemanticsBoundsPadding: Dp = 10.dp +private val IncreaseSemanticsBounds: Modifier = + Modifier + .layout { measurable, constraints -> + val paddingPx = SemanticsBoundsPadding.roundToPx() + // We need to add vertical padding to the semantics bounds in order to meet + // screenreader green box minimum size, but we also want to + // preserve a visual appearance and layout size below that minimum + // in order to maintain backwards compatibility. This custom + // layout effectively implements "negative padding". + val newConstraint = constraints.offset(0, paddingPx * 2) + val placeable = measurable.measure(newConstraint) + + // But when actually placing the placeable, create the layout without additional + // space. Place the placeable where it would've been without any extra padding. + val height = placeable.height - paddingPx * 2 + val width = placeable.width + layout(width, height) { placeable.place(0, -paddingPx) } + } + .semantics(mergeDescendants = true) {} + .padding(vertical = SemanticsBoundsPadding) + + +// LinearProgressIndicator Material specs + +// Width is given in the spec but not defined as a token. +/*@VisibleForTesting*/ +internal val LinearIndicatorWidth = 240.dp + +/*@VisibleForTesting*/ +internal val LinearIndicatorHeight = 4.dp + +// CircularProgressIndicator Material specs +// Diameter of the indicator circle +/*@VisibleForTesting*/ +internal val CircularIndicatorDiameter = 48.dp - 4.dp * 2 + +// Indeterminate linear indicator transition specs + +// Total duration for one cycle +private const val LinearAnimationDuration = 1800 + +// Duration of the head and tail animations for both lines +private const val FirstLineHeadDuration = 750 +private const val FirstLineTailDuration = 850 +private const val SecondLineHeadDuration = 567 +private const val SecondLineTailDuration = 533 + +// Delay before the start of the head and tail animations for both lines +private const val FirstLineHeadDelay = 0 +private const val FirstLineTailDelay = 333 +private const val SecondLineHeadDelay = 1000 +private const val SecondLineTailDelay = 1267 + +private val FirstLineHeadEasing = CubicBezierEasing(0.2f, 0f, 0.8f, 1f) +private val FirstLineTailEasing = CubicBezierEasing(0.4f, 0f, 1f, 1f) +private val SecondLineHeadEasing = CubicBezierEasing(0f, 0f, 0.65f, 1f) +private val SecondLineTailEasing = CubicBezierEasing(0.1f, 0f, 0.45f, 1f) + +// Indeterminate circular indicator transition specs + +// The animation comprises of 5 rotations around the circle forming a 5 pointed star. +// After the 5th rotation, we are back at the beginning of the circle. +private const val RotationsPerCycle = 5 + +// Each rotation is 1 and 1/3 seconds, but 1332ms divides more evenly +private const val RotationDuration = 1332 + +// When the rotation is at its beginning (0 or 360 degrees) we want it to be drawn at 12 o clock, +// which means 270 degrees when drawing. +private const val StartAngleOffset = -90f + +// How far the base point moves around the circle +private const val BaseRotationAngle = 286f + +// How far the head and tail should jump forward during one rotation past the base point +private const val JumpRotationAngle = 290f + +// Each rotation we want to offset the start position by this much, so we continue where +// the previous rotation ended. This is the maximum angle covered during one rotation. +private const val RotationAngleOffset = (BaseRotationAngle + JumpRotationAngle) % 360f + +// The head animates for the first half of a rotation, then is static for the second half +// The tail is static for the first half and then animates for the second half +private const val HeadAndTailAnimationDuration = (RotationDuration * 0.5).toInt() +private const val HeadAndTailDelayDuration = HeadAndTailAnimationDuration + +// The easing for the head and tail jump +private val CircularEasing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f) diff --git a/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt b/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt index 6db197b..0e1f1c2 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/MainActivity.kt @@ -6,13 +6,19 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.lifecycle.ViewModelProvider import androidx.tv.material3.Surface import dagger.hilt.android.AndroidEntryPoint import moe.tabidachi.emulator.ui.SharedNavHost +import moe.tabidachi.emulator.ui.common.MediaChangeListener import moe.tabidachi.emulator.ui.theme.EmulatorTheme @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val viewModel by lazy { + ViewModelProvider(this)[MainViewModel::class] + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { @@ -22,8 +28,11 @@ class MainActivity : ComponentActivity() { shape = RectangleShape ) { SharedNavHost() + + } } + MediaChangeListener(onMediaChange = viewModel::onMediaChange) } } } diff --git a/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt new file mode 100644 index 0000000..337ca00 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt @@ -0,0 +1,25 @@ +package moe.tabidachi.emulator + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import moe.tabidachi.emulator.domain.GetSdcardPathsUseCase +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val getSdcardPathsUseCase: GetSdcardPathsUseCase, +) : ViewModel() { + fun onMediaChange(action: String) { + viewModelScope.launch { + when (action) { + Intent.ACTION_MEDIA_MOUNTED -> getSdcardPathsUseCase() + Intent.ACTION_MEDIA_UNMOUNTED -> getSdcardPathsUseCase() + Intent.ACTION_MEDIA_EJECT -> Unit + Intent.ACTION_MEDIA_REMOVED -> Unit + } + } + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt b/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt new file mode 100644 index 0000000..657548b --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt @@ -0,0 +1,109 @@ +package moe.tabidachi.emulator.data.common + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.IOException +import moe.tabidachi.emulator.common.MenuToggle +import moe.tabidachi.emulator.common.ktx.TAG +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.io.PrintWriter + +class MenuToggleItem( + val path: String, + private val scope: CoroutineScope +) { + private val map = mutableMapOf() + private val _input_menu_toggle_gamepad_combo = mutableStateOf(MenuToggle.None) + + val value by _input_menu_toggle_gamepad_combo + + init { + scope.launch { + try { + open(path) + val menuToggle = + MenuToggle.entries.firstOrNull { it.value == getInt(KEY) } ?: MenuToggle.None + _input_menu_toggle_gamepad_combo.value = menuToggle + } catch (ioe: IOException) { + Log.e( + TAG, + "Stream reading the configuration file was suddenly closed for an unknown reason." + ) + } + } + } + + private suspend fun open(configPath: String) = withContext(Dispatchers.IO) { + clear() + FileInputStream(configPath).use { stream -> + append(stream) + } + } + + private fun append(stream: InputStream) { + stream.bufferedReader().use { reader -> + reader.forEachLine { line -> + parseLine(line) + } + } + } + + private fun parseLine(line: String) { + val tokens = line.split("=", limit = 2) + if (tokens.size < 2) return + + val key = tokens[0].trim() + var value = tokens[1].trim() + + value = if (value.startsWith("\"")) { + value.substring(1, value.lastIndexOf('"')) + } else { + value.split(" ")[0] + } + + if (value.isNotEmpty()) { + map[key] = value + } + } + + private fun clear() { + map.clear() + } + + fun update(menu: MenuToggle) { + _input_menu_toggle_gamepad_combo.value = menu + setInt(KEY, menu.value) + scope.launch { + write(path) + } + } + + private fun setInt(key: String, value: Int) { + map[key] = value.toString() + } + + private fun getString(key: String): String? = map[key] + + private fun getInt(key: String): Int? = getString(key)?.toIntOrNull() + + private suspend fun write(path: String) = withContext(Dispatchers.IO) { + val file = File(path) + file.parentFile?.mkdirs() + PrintWriter(file).use { writer -> + map.forEach { (key, value) -> + writer.println("$key = \"$value\"") + } + } + } + + companion object { + private const val KEY = "input_menu_toggle_gamepad_combo" + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt b/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt new file mode 100644 index 0000000..61870f5 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt @@ -0,0 +1,55 @@ +package moe.tabidachi.emulator.data.common + +import kotlin.math.abs + +class StringMatcher( + private val search: String, + private val charSetWeight: Double = 0.2, + private val containsWeight: Double = 0.6, + private val lengthWeight: Double = 0.2 +) { + private val searchChars: Set = search.toSet() + + init { + require(charSetWeight + containsWeight + lengthWeight == 1.0) { "权重之和必须为 1.0" } + } + + fun similarity(target: String): Double { + if (!containsAnyChar(target)) return 0.0 + + val charSetSimilarity = calculateCharSetSimilarity(target) + + val containsSimilarity = if (contains(target)) 1.0 else 0.0 + + val lengthSimilarity = calculateLengthSimilarity(target) + + return charSetSimilarity * charSetWeight + + containsSimilarity * containsWeight + + lengthSimilarity * lengthWeight + } + + private fun containsAnyChar(target: String): Boolean { + return target.any { it in searchChars } + } + + private fun calculateCharSetSimilarity(target: String): Double { + val targetChars = target.toSet() + val missingChars = searchChars.count { it !in targetChars } + + return when (missingChars) { + searchChars.size -> 0.0 + else -> (searchChars.size - missingChars).toDouble() / searchChars.size + } + } + + private fun contains(target: String): Boolean { + return target.replace(" ", "").contains(search.replace(" ", ""), true) + } + + private fun calculateLengthSimilarity(target: String): Double { + val searchLength = search.length.toDouble() + val targetLength = target.length.toDouble() + + return 1 - (abs(targetLength - searchLength) / maxOf(targetLength, searchLength)) + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt index 46bb7f7..577cff1 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/SharedNavHost.kt @@ -10,6 +10,7 @@ import moe.tabidachi.emulator.ui.roms.roms @Composable fun SharedNavHost( + //startDestination: Any = RomsRoute("/storage/12BF-EFB1/psp/emulator.json") startDestination: Any = HomeRoute ) { val navController: NavHostController = rememberNavController() diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt new file mode 100644 index 0000000..083a704 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt @@ -0,0 +1,32 @@ +package moe.tabidachi.emulator.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import moe.tabidachi.emulator.data.ActionIcon + +@Composable +fun ActionIcon( + actionIcon: ActionIcon, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = actionIcon.iconFile, + contentDescription = null, + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.onPrimaryContainer), + modifier = Modifier.size(24.dp) + ) + Text(text = actionIcon.label) + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt index d89b4ba..1ec2d0b 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/BottomBar.kt @@ -16,7 +16,8 @@ import androidx.tv.material3.Text @Composable fun GamepadIndicatorBar( - leadingContent: @Composable (() -> Unit)? = null + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -24,10 +25,11 @@ fun GamepadIndicatorBar( .background(color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) .padding(8.dp) ) { - ProvideTextStyle(MaterialTheme.typography.titleLarge) { + ProvideTextStyle(MaterialTheme.typography.labelLarge) { leadingContent?.invoke() } Spacer(modifier = Modifier.weight(1f)) + trailingContent?.invoke() } } diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt new file mode 100644 index 0000000..fdade2d --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt @@ -0,0 +1,76 @@ +package moe.tabidachi.emulator.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Checkbox +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import kotlinx.coroutines.launch +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.data.Emulator +import moe.tabidachi.emulator.ui.components.TvDialog + +@Composable +fun CoreSelectDialog( + visible: Boolean, + emulator: Emulator, + onDismissRequest: () -> Unit, +) { + if (visible) TvDialog( + onDismissRequest = onDismissRequest + ) { + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(16.dp) + .heightIn(min = 64.dp) + ) { + Text( + text = stringResource(R.string.core_select), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 16.dp) + ) + if (emulator.config.corePaths.isNotEmpty()) { + var currentCore by remember { mutableStateOf(emulator.config.corePaths.first()) } + LazyColumn { + items(emulator.config.corePaths) { + val selected = currentCore == it + ListItem( + selected = selected, + onClick = { + currentCore = it + scope.launch { + emulator.setCore(it) + } + }, headlineContent = { + Text(text = it) + }, scale = ListItemDefaults.scale(focusedScale = 1.0f), + trailingContent = { + Checkbox( + checked = selected, + onCheckedChange = {} + ) + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt new file mode 100644 index 0000000..810ed21 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt @@ -0,0 +1,38 @@ +package moe.tabidachi.emulator.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import androidx.tv.material3.WideButton +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.ui.components.TvAlertDialog + +@Composable +fun ExitDialog( + visible: Boolean, + onConfirm: () -> Unit, + onDismissRequest: () -> Unit, +) { + if (visible) TvAlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.exit_dialog_title)) + }, text = { + Text(text = stringResource(R.string.exit_dialog_content)) + }, confirmButton = { + WideButton( + onClick = onConfirm + ) { + Text(text = stringResource(R.string.dialog_confirm)) + } + }, dismissButton = { + WideButton( + onClick = { + onDismissRequest() + } + ) { + Text(text = stringResource(R.string.dialog_cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt new file mode 100644 index 0000000..0adee26 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt @@ -0,0 +1,105 @@ +package moe.tabidachi.emulator.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Checkbox +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.FilterChip +import androidx.tv.material3.FilterChipDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.common.MenuToggle +import moe.tabidachi.emulator.data.common.MenuToggleItem +import moe.tabidachi.emulator.ui.components.TvDialog + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun MenuToggleDialog( + visible: Boolean, + menuToggleItems: List, + onDismissRequest: () -> Unit, +) { + if (visible) TvDialog( + onDismissRequest = onDismissRequest + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(16.dp) + .heightIn(min = 64.dp) + .fillMaxWidth() + ) { + Text( + text = stringResource(R.string.menu_toggle), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 16.dp) + ) + if (menuToggleItems.isNotEmpty()) { + var menuToggleItem by remember { mutableStateOf(menuToggleItems.first()) } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(menuToggleItems) { + FilterChip( + selected = menuToggleItem == it, + onClick = { + menuToggleItem = it + }, scale = FilterChipDefaults.scale(focusedScale = 1f) + ) { + Text(text = it.path) + } + } + } + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items(MenuToggle.entries) { + val selected = menuToggleItem.value == it + ListItem( + selected = selected, + onClick = { + menuToggleItem.update(it) + }, + headlineContent = { + Text(text = it.text) + }, scale = ListItemDefaults.scale(focusedScale = 1.0f), + trailingContent = { + Checkbox( + checked = selected, + onCheckedChange = {} + ) + } + ) + } + } + } + } + } +} + +@TvPreview +@Composable +fun MenuToggleDialogPreview() { + MenuToggleDialog( + visible = true, + menuToggleItems = emptyList(), + onDismissRequest = {} + ) +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt new file mode 100644 index 0000000..087e080 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt @@ -0,0 +1,285 @@ +package moe.tabidachi.emulator.ui.common + +import android.util.Log +import android.view.KeyEvent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.common.ktx.TAG +import moe.tabidachi.emulator.data.Emulator +import moe.tabidachi.emulator.data.Rom +import moe.tabidachi.emulator.ui.components.TvDialog + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchDialog( + visible: Boolean, + roms: List>, + searching: Boolean, + query: String, + onLaunch: (Emulator, Rom) -> Unit, + onQueryChange: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + if (visible) TvDialog( + onDismissRequest = onDismissRequest, + parent = { + it.align(Alignment.TopCenter) + } + ) { + val listFocusRequester = remember { FocusRequester() } + val clearFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + Column( + //verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + ) + SearchTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .onKeyEvent { + Log.d(TAG, "SearchDialog: $it") + when (it.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + listFocusRequester.requestFocus() + true + } + + KeyEvent.KEYCODE_DPAD_CENTER -> { + keyboardController?.show() + false + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + clearFocusRequester.requestFocus() + true + } + + else -> false + } + } + ) + Surface( + onClick = { + onQueryChange("") + }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + modifier = Modifier.focusRequester(clearFocusRequester), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Text( + text = "Clear", + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(16.dp) + ) { + if (searching) LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec() + ) { + LazyColumn( + modifier = Modifier.focusRequester(listFocusRequester) + ) { + items(roms) { (emulator, rom) -> + ListItem( + selected = false, + onClick = { + onLaunch(emulator, rom) + }, + headlineContent = { + Text(text = rom.name) + }, + scale = ListItemDefaults.scale(focusedScale = 1.0f), + trailingContent = { + Text(text = emulator.title) + }, leadingContent = { + AsyncImage( + model = rom.imageFile, + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + } + ) + } + } + } + } + } +} + +@Composable +fun SearchDialog( + visible: Boolean, + searching: Boolean, + query: String, + onQueryChange: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + if (visible) TvDialog( + onDismissRequest = onDismissRequest, + parent = { + it.align(Alignment.Center) + } + ) { + val clearFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + ) + SearchTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .onKeyEvent { + Log.d(TAG, "SearchDialog: $it") + when (it.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER -> { + keyboardController?.show() + false + } + + KeyEvent.KEYCODE_DPAD_RIGHT -> { + clearFocusRequester.requestFocus() + true + } + + else -> false + } + } + ) + Surface( + onClick = { + onQueryChange("") + }, shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(4.dp)), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1f), + modifier = Modifier.focusRequester(clearFocusRequester), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Text( + text = "Clear", + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.height(16.dp) + ) { + if (searching) LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + } + } +} + +@Composable +fun SearchTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .border( + width = 2.dp, + color = if (isFocused) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.surfaceVariant + ) + .background(color = MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp) + ) { + innerTextField() + } + }, singleLine = true, + interactionSource = interactionSource, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt new file mode 100644 index 0000000..a388728 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt @@ -0,0 +1,62 @@ +package moe.tabidachi.emulator.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.ui.components.TvDialog + +@Composable +fun SettingsDialog( + visible: Boolean, + actions: SettingsActions, + onDismissRequest: () -> Unit +) { + if (visible) TvDialog( + onDismissRequest = onDismissRequest, + parent = { + it.align(Alignment.TopCenter) + } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 16.dp) + ) + ListItem( + selected = false, + onClick = actions.onMenuToggleClick, + headlineContent = { + Text(text = stringResource(R.string.menu_toggle)) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null + ) + }, + scale = ListItemDefaults.scale(focusedScale = 1.0f), + ) + } + } +} + +data class SettingsActions( + val onMenuToggleClick: () -> Unit = {} +) \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt index dcb7568..bd4a19d 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt @@ -10,7 +10,6 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable import kotlinx.serialization.Serializable -import moe.tabidachi.emulator.ui.common.MediaChangeListener import moe.tabidachi.emulator.ui.roms.navigateToRoms fun NavGraphBuilder.home(navController: NavHostController) = composable { @@ -21,11 +20,13 @@ fun NavGraphBuilder.home(navController: NavHostController) = composable(null) } + var settingsDialogVisible by remember { mutableStateOf(false) } + val actionIconMap = state.actionIcons.associateBy { it.keycode } + var menuToggleDialogVisible by remember { mutableStateOf(false) } + var searchDialogVisible by remember { mutableStateOf(false) } + var exitDialogVisible by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + BackHandler { exitDialogVisible = true } Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { + val actionIcon = actionIconMap[it.nativeKeyEvent.keyCode] + if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) actionIcon?.let { + return@onPreviewKeyEvent when (it.type) { + ActionIcon.Type.SETTINGS -> { + settingsDialogVisible = true + true + } + + ActionIcon.Type.SEARCH -> { + searchDialogVisible = true + true + } + + else -> false + } + } + false + } ) { HorizontalPager( state = pagerState @@ -82,7 +133,7 @@ fun HomeScreen( Spacer(modifier = Modifier.weight(1f)) CompositionLocalProvider( LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec( - parentFraction = 0.25f, + parentFraction = 0.2f, ) ) { LazyRow( @@ -91,6 +142,7 @@ fun HomeScreen( contentPadding = PaddingValues(vertical = 16.dp), modifier = Modifier .focusRestorer() + .focusRequester(focusRequester) ) { if (size > 0) items( count = pageCount, @@ -116,7 +168,10 @@ fun HomeScreen( color = Color(0xFFFF9C40) ) ) - ), modifier = Modifier.size(200.dp) + ), + modifier = Modifier + .fillMaxHeight(0.25f) + .aspectRatio(AspectRatio.AR1_1.value) ) { AsyncImage( model = item.icon, @@ -129,12 +184,48 @@ fun HomeScreen( GamepadIndicatorBar( leadingContent = { Text(text = stringResource(R.string.game_size, focusedItem?.romsSize ?: 0)) + }, trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.actionIcons.forEach { + ActionIcon(it) + } + } } ) } + val backgroundColor = Color.Black.copy(alpha = 0.3f) + CompositionLocalProvider( + LocalContentColor provides Color.White + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(color = backgroundColor) + .padding(horizontal = 8.dp) + .fillMaxWidth() + ) { + TimeText(contentPadding = PaddingValues(vertical = 4.dp)) + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .size(24.dp) + .padding(4.dp) + ) { EthernetStatusIcon() } + Box( + modifier = Modifier + .size(24.dp) + .padding(4.dp) + ) { WifiStatusIcon() } + } + } } LaunchedEffect(state.emulatorList) { - if (state.emulatorList.isNotEmpty()) listState.animateScrollToItem(listState.firstVisibleItemIndex) + if (state.emulatorList.isNotEmpty()) { + //listState.animateScrollToItem(listState.firstVisibleItemIndex) + focusRequester.requestFocus() + } } NoSdcardDialog( visible = state.isSdcardEmpty, @@ -154,6 +245,41 @@ fun HomeScreen( onGranted = actions.onPermissionGranted, onDenied = actions.onActivityFinished ) + MenuToggleDialog( + visible = menuToggleDialogVisible, + menuToggleItems = state.menuToggleItems, + onDismissRequest = { + menuToggleDialogVisible = false + }, + ) + SearchDialog( + visible = searchDialogVisible, + roms = state.queryRoms, + searching = state.searching, + query = state.query, + onLaunch = actions.onGameLaunch, + onQueryChange = actions.onQueryChange, + onDismissRequest = { + searchDialogVisible = false + } + ) + SettingsDialog( + visible = settingsDialogVisible, + actions = remember { + SettingsActions( + onMenuToggleClick = { + menuToggleDialogVisible = true + } + ) + }, onDismissRequest = { + settingsDialogVisible = false + } + ) + ExitDialog( + visible = exitDialogVisible, + onConfirm = actions.onExit, + onDismissRequest = { exitDialogVisible = false } + ) } @TvPreview @@ -164,7 +290,7 @@ private fun HomeScreenPreview() { state = HomeViewState( isAssetsExtracting = false, progress = 0.5f, - permissionDialogVisible = true + permissionDialogVisible = false ), actions = HomeActions() ) diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt index 35b9595..c4bb258 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt @@ -1,27 +1,39 @@ package moe.tabidachi.emulator.ui.home -import android.content.Intent import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import moe.tabidachi.emulator.common.GameLauncher import moe.tabidachi.emulator.common.ktx.TAG +import moe.tabidachi.emulator.data.ActionIcon import moe.tabidachi.emulator.data.AssetsState +import moe.tabidachi.emulator.data.Emulator import moe.tabidachi.emulator.data.EmulatorListItem +import moe.tabidachi.emulator.data.EmulatorRepository import moe.tabidachi.emulator.data.PermissionState +import moe.tabidachi.emulator.data.Rom +import moe.tabidachi.emulator.data.common.MenuToggleItem +import moe.tabidachi.emulator.data.common.StringMatcher import moe.tabidachi.emulator.di.AssetsStateFlow import moe.tabidachi.emulator.di.PermissionStateFlow -import moe.tabidachi.emulator.di.SdcardPaths import moe.tabidachi.emulator.domain.CheckPermissionUseCase import moe.tabidachi.emulator.domain.ExtractAssetsUseCase import moe.tabidachi.emulator.domain.GetAssetsFileUseCase @@ -37,76 +49,127 @@ class HomeViewModel @Inject constructor( private val assetsState: MutableStateFlow, @PermissionStateFlow private val permissionState: MutableStateFlow, - @SdcardPaths - private val sdcardPaths: MutableStateFlow>, private val checkPermissionUseCase: CheckPermissionUseCase, private val getSdcardPathsUseCase: GetSdcardPathsUseCase, getAssetsFileUseCase: GetAssetsFileUseCase, private val extractAssetsUseCase: ExtractAssetsUseCase, - private val getEmulatorListUseCase: GetEmulatorListUseCase + private val getEmulatorListUseCase: GetEmulatorListUseCase, + private val emulatorRepository: EmulatorRepository, + private val gameLauncher: GameLauncher ) : ViewModel() { private val _state = MutableStateFlow(HomeViewState()) val state = _state.asStateFlow() val actions = HomeActions( - onMediaChange = ::onMediaChange, onExtractDialogVisibleChange = ::onExtractDialogVisibleChange, onPermissionDialogVisibleChange = ::onPermissionDialogVisibleChange, - onPermissionGranted = ::onPermissionGranted - ) - - init { - permissionState.onEach { - when (it) { - PermissionState.None -> checkPermissionUseCase() - PermissionState.Granted -> getSdcardPathsUseCase() - PermissionState.Denied -> _state.update { it.copy(permissionDialogVisible = true) } - } - }.launchIn(viewModelScope) - assetsState.onEach { - if (it == AssetsState.None) getAssetsStateUseCase() - }.launchIn(viewModelScope) - combine( - assetsState, - getAssetsFileUseCase(), - ) { assetsState, file -> - assetsState to file - }.onEach { (assetsState, file) -> - Log.d(TAG, "$assetsState, getAssetsFileUseCase($file)") - if (assetsState == AssetsState.NotExtracted) { - try { - _state.update { it.copy(isAssetsExtracting = true) } - extractAssetsUseCase( - file, - onProgress = { progress -> - _state.update { it.copy(progress = progress) } - }, - ) - } catch (e: Throwable) { - Log.d(TAG, "解压失败", e) - } finally { - _state.update { it.copy(isAssetsExtracting = false) } - } - } - }.launchIn(viewModelScope) - viewModelScope.launch(Dispatchers.IO) { - getEmulatorListUseCase().collect { emulatorList: List -> - _state.update { it.copy(emulatorList = emulatorList) } + onPermissionGranted = ::onPermissionGranted, + onQueryChange = { query -> + _state.update { it.copy(query = query) } + }, onGameLaunch = { emulator, rom -> + viewModelScope.launch { + gameLauncher.launch(emulator, rom) } } - combine(permissionState, sdcardPaths) { permissionState, sdcardPaths -> - permissionState to sdcardPaths - }.filter { it.first == PermissionState.Granted }.map { it.second.isEmpty() } - .onEach { isSdcardEmpty -> - _state.update { it.copy(isSdcardEmpty = isSdcardEmpty) } - }.launchIn(viewModelScope) - } + ) + private val menuToggleItems = emulatorRepository.storageList.map { + it.map { + MenuToggleItem(it.retroarchConfigFile.path, viewModelScope) + } + }.onEach { menuToggleItems -> + _state.update { it.copy(menuToggleItems = menuToggleItems) } + }.flowOn(Dispatchers.Default).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) + private val actionIcons = emulatorRepository.storageList.mapNotNull { + it.firstOrNull()?.actionIcons?.takeIf { it.isNotEmpty() } + }.onEach { actionIcons -> + _state.update { it.copy(actionIcons = actionIcons) } + }.flowOn(Dispatchers.Default).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) - private fun onMediaChange(action: String) { - when (action) { - Intent.ACTION_MEDIA_MOUNTED -> getSdcardPathsUseCase() - Intent.ACTION_MEDIA_UNMOUNTED -> getSdcardPathsUseCase() - Intent.ACTION_MEDIA_EJECT -> Unit - Intent.ACTION_MEDIA_REMOVED -> Unit + @OptIn(FlowPreview::class) + private val roms = _state + .map { it.query } + .distinctUntilChanged() + .debounce(2000) + .combine(emulatorRepository.emulatorList) { query, emulatorList -> + _state.update { it.copy(searching = true) } + val matcher = StringMatcher(query) + when (query.isBlank()) { + true -> emulatorList.flatMap { emulator -> + emulator.roms.map { emulator to it } + } + + else -> emulatorList.flatMap { emulator -> + emulator.roms.map { + matcher.similarity(it.name) to (emulator to it) + }.filter { it.first > 0.3 } + }.sortedByDescending { + it.first + }.map { it.second } + } + }.onEach { queryRoms -> + _state.update { it.copy(queryRoms = queryRoms, searching = false) } + }.flowOn(Dispatchers.Default).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) + + init { + viewModelScope.launch(Dispatchers.Default) { + permissionState.onEach { + when (it) { + PermissionState.None -> checkPermissionUseCase() + PermissionState.Granted -> getSdcardPathsUseCase() + PermissionState.Denied -> _state.update { it.copy(permissionDialogVisible = true) } + } + }.launchIn(this) + assetsState.onEach { + if (it == AssetsState.None) getAssetsStateUseCase() + }.launchIn(this) + combine( + assetsState, + getAssetsFileUseCase(), + ) { assetsState, file -> + assetsState to file + }.onEach { (assetsState, file) -> + Log.d(TAG, "$assetsState, getAssetsFileUseCase($file)") + if (assetsState == AssetsState.NotExtracted) { + try { + _state.update { it.copy(isAssetsExtracting = true) } + extractAssetsUseCase( + file, + onProgress = { progress -> + _state.update { it.copy(progress = progress) } + }, + ) + } catch (e: Throwable) { + Log.d(TAG, "解压失败", e) + } finally { + _state.update { it.copy(isAssetsExtracting = false) } + } + } + }.launchIn(this) + launch(Dispatchers.IO) { + getEmulatorListUseCase().collect { emulatorList: List -> + _state.update { it.copy(emulatorList = emulatorList) } + } + } + combine(permissionState, emulatorRepository.storageList) { permissionState, storages -> + permissionState to storages + }.filter { it.first == PermissionState.Granted }.map { it.second.isEmpty() } + .onEach { isSdcardEmpty -> + _state.update { it.copy(isSdcardEmpty = isSdcardEmpty) } + }.launchIn(this) + menuToggleItems.launchIn(this) + actionIcons.launchIn(this) + roms.launchIn(this) } } @@ -130,14 +193,21 @@ data class HomeViewState( val progress: Float = 0.0f, val permissionDialogVisible: Boolean = false, val emulatorList: List = emptyList(), - val isSdcardEmpty: Boolean = false + val isSdcardEmpty: Boolean = false, + val menuToggleItems: List = emptyList(), + val actionIcons: List = emptyList(), + val query: String = "", + val queryRoms: List> = emptyList(), + val searching: Boolean = false ) data class HomeActions( - val onMediaChange: (String) -> Unit = {}, val onExtractDialogVisibleChange: (Boolean) -> Unit = {}, val onPermissionDialogVisibleChange: (Boolean) -> Unit = {}, val onPermissionGranted: () -> Unit = {}, val onActivityFinished: () -> Unit = {}, val navigateToRoms: (id: String) -> Unit = {}, + val onQueryChange: (String) -> Unit = {}, + val onGameLaunch: (Emulator, Rom) -> Unit = { _, _ -> }, + val onExit: () -> Unit = {} ) diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt index 7a98a70..2ceffc5 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt @@ -12,14 +12,12 @@ import kotlinx.serialization.Serializable fun NavGraphBuilder.roms(navController: NavHostController) = composable { val viewModel: RomsViewModel = hiltViewModel() val state by viewModel.state.collectAsState() - val actions = remember(viewModel.actions) { - viewModel.actions - } + val actions = viewModel.actions RomsScreen(state = state, actions = actions) } @Serializable -data class RomsRoute(val id: String) +data class RomsRoute(val emulatorId: String) fun NavHostController.navigateToRoms(id: String) { navigate(RomsRoute(id)) diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt index 2278235..1078c08 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt @@ -1,15 +1,20 @@ package moe.tabidachi.emulator.ui.roms +import android.view.KeyEvent import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -18,19 +23,29 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import moe.tabidachi.emulator.common.ktx.digits +import moe.tabidachi.emulator.data.ActionIcon import moe.tabidachi.emulator.data.RomListItem +import moe.tabidachi.emulator.ui.common.ActionIcon +import moe.tabidachi.emulator.ui.common.CoreSelectDialog +import moe.tabidachi.emulator.ui.common.GamepadIndicatorBar import moe.tabidachi.emulator.ui.common.PreviewSurface +import moe.tabidachi.emulator.ui.common.SearchDialog import moe.tabidachi.emulator.ui.common.TvPreview import moe.tabidachi.emulator.ui.common.rememberPivotBringIntoViewSpec @@ -41,49 +56,146 @@ fun RomsScreen( actions: RomsActions ) { var focusedItem by remember { mutableStateOf(null) } - + var settingsDialogVisible by remember { mutableStateOf(false) } + var searchDialogVisible by remember { mutableStateOf(false) } + val actionIconMap = state.actionIcons.associateBy { it.keycode } + val focusRequester = remember { FocusRequester() } Box( - modifier = Modifier.fillMaxSize() - ) { - Row { - CompositionLocalProvider( - LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec() - ) { - LazyColumn( - contentPadding = PaddingValues(16.dp), - modifier = Modifier.weight(1f) - ) { - itemsIndexed(state.roms) { index, rom -> - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(isFocused) { - if (isFocused) focusedItem = rom + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { + val actionIcon = actionIconMap[it.nativeKeyEvent.keyCode] + if (it.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) actionIcon?.let { + return@onPreviewKeyEvent when (it.type) { + ActionIcon.Type.SETTINGS -> { + settingsDialogVisible = true + true } - ListItem( - selected = false, - headlineContent = { - val text = buildAnnotatedString { - withStyle(MaterialTheme.typography.labelSmall.toSpanStyle()) { - append("%0${state.roms.size.digits}d".format(index + 1)) - } - append(" ") - append(rom.name) - } - Text(text = text) - }, onClick = { - }, interactionSource = interactionSource - ) + ActionIcon.Type.SEARCH -> { + searchDialogVisible = true + true + } + + ActionIcon.Type.FAVORITE -> { + true + } + + else -> false } } + false } - AsyncImage( - model = focusedItem?.imagePath, - contentDescription = null, - modifier = Modifier.weight(1f) + ) { + AsyncImage( + model = state.background, + contentDescription = null + ) + Column { + Row( + modifier = Modifier + .background(color = Color.Black.copy(alpha = 0.3f)) + .weight(1f) + ) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec() + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(16.dp) + .background(color = Color.Black.copy(alpha = 0.1f)) + ) { + when (state.roms.isEmpty()) { + true -> Text( + text = "Empty", + modifier = Modifier.align(Alignment.Center).focusTarget() + ) + + else -> LazyColumn( + contentPadding = PaddingValues(16.dp), + modifier = Modifier.focusRequester(focusRequester) + ) { + itemsIndexed(state.roms) { index, rom -> + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(isFocused) { + if (isFocused) focusedItem = rom + } + ListItem( + selected = false, + headlineContent = { + val text = buildAnnotatedString { + withStyle(MaterialTheme.typography.labelSmall.toSpanStyle()) { + append( + "%0${state.roms.size.digits}d".format( + index + 1 + ) + ) + } + append(" ") + append(rom.name) + } + Text(text = text) + }, onClick = { + actions.onRomClick(rom.id) + }, interactionSource = interactionSource, + scale = ListItemDefaults.scale(focusedScale = 1f) + ) + } + } + } + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(16.dp) + ) { + AsyncImage( + model = focusedItem?.imagePath, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } + } + GamepadIndicatorBar( + leadingContent = { + + }, trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.actionIcons.forEach { + ActionIcon(it) + } + } + } ) } } + LaunchedEffect(state.roms) { + if (state.roms.isNotEmpty()) focusRequester.requestFocus() + } + state.emulator?.let { + CoreSelectDialog( + visible = settingsDialogVisible, + emulator = it, + onDismissRequest = { + settingsDialogVisible = false + } + ) + } + SearchDialog( + visible = searchDialogVisible, + searching = state.searching, + query = state.query, + onQueryChange = actions.onQueryChange, + onDismissRequest = { searchDialogVisible = false } + ) } @TvPreview diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt index 703f91b..4cfba7f 100644 --- a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt @@ -1,49 +1,129 @@ package moe.tabidachi.emulator.ui.roms +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import moe.tabidachi.emulator.data.EmulatorDataSource +import kotlinx.coroutines.launch +import moe.tabidachi.emulator.common.GameLauncher +import moe.tabidachi.emulator.common.ktx.TAG +import moe.tabidachi.emulator.data.ActionIcon +import moe.tabidachi.emulator.data.Emulator +import moe.tabidachi.emulator.data.EmulatorRepository import moe.tabidachi.emulator.data.RomListItem +import moe.tabidachi.emulator.data.common.StringMatcher +import moe.tabidachi.emulator.di.SdcardPaths +import moe.tabidachi.emulator.domain.GetSdcardPathsUseCase +import java.io.File import javax.inject.Inject @HiltViewModel class RomsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - getRomsUseCase: GetRomsUseCase + emulatorRepository: EmulatorRepository, + @SdcardPaths + private val sdcardPaths: MutableStateFlow>, + private val getSdcardPathsUseCase: GetSdcardPathsUseCase, + private val gameLauncher: GameLauncher ) : ViewModel() { - private val romId = savedStateHandle.toRoute().id private val _state = MutableStateFlow(RomsViewState()) val state = _state.asStateFlow() - val actions = RomsActions() + val actions = RomsActions( + onRomClick = { romId -> + viewModelScope.launch { + roms.value.firstOrNull { it.id == romId }?.let { rom -> + emulator.firstOrNull()?.let { emulator -> + gameLauncher.launch(emulator, rom) + } + } + } + }, onQueryChange = { query -> + _state.update { it.copy(query = query) } + } + ) + private val emulatorId = savedStateHandle.toRoute().emulatorId + private val emulator = emulatorRepository.emulatorList.mapNotNull { + it.firstOrNull { it.id == emulatorId } + }.onEach { emulator -> + _state.update { + it.copy( + background = emulator.backgroundFile, + emulator = emulator, + actionIcons = emulator.actionIcons + ) + } + }.flowOn(Dispatchers.Default).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = null + ) + + @OptIn(FlowPreview::class) + private val roms = _state + .map { it.query } + .distinctUntilChanged() + .debounce(2000) + .combine(emulator) { query, emulator -> + emulator?.let { emulator -> + _state.update { it.copy(searching = true) } + val matcher = StringMatcher(query) + when (query.isBlank()) { + true -> emulator.roms + + else -> emulator.roms.map { + matcher.similarity(it.name) to it + }.filter { it.first > 0.3 }.sortedByDescending { + it.first + }.map { it.second } + } + } + }.mapNotNull { it }.onEach { roms -> + val items = roms.map(::RomListItem) + _state.update { it.copy(roms = items, searching = false) } + }.flowOn(Dispatchers.Default).stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) init { - getRomsUseCase(romId).onEach { roms -> - _state.update { it.copy(roms = roms) } - }.launchIn(viewModelScope) + Log.d(TAG, "emulatorId: $emulatorId") + if (sdcardPaths.value.isEmpty()) viewModelScope.launch { + getSdcardPathsUseCase() + } + emulator.launchIn(viewModelScope) + roms.launchIn(viewModelScope) } } data class RomsViewState( val roms: List = emptyList(), + val background: File? = null, + val emulator: Emulator? = null, + val actionIcons: List = emptyList(), + val searching: Boolean = false, + val query: String = "" ) data class RomsActions( - val onClick: () -> Unit = {} + val onRomClick: (romId: String) -> Unit = {}, + val onQueryChange: (String) -> Unit = {} ) - -class GetRomsUseCase @Inject constructor( - private val emulatorDataSource: EmulatorDataSource -) { - operator fun invoke(romId: String): Flow> { - return emulatorDataSource.getRomListByEmulatorId(romId) - } -} \ No newline at end of file diff --git a/app-default/src/main/res/drawable/ic_launcher_background.xml b/app-default/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a29a2c2 --- /dev/null +++ b/app-default/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app-default/src/main/res/drawable/ic_launcher_foreground.xml b/app-default/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..b619620 --- /dev/null +++ b/app-default/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78..d48637b 100644 Binary files a/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5697025 Binary files /dev/null and b/app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp index 4f0f1d6..723cfc2 100644 Binary files a/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..aba8a17 Binary files /dev/null and b/app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp index 948a307..6a14105 100644 Binary files a/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a55ce78 Binary files /dev/null and b/app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 28d4b77..ca73e4c 100644 Binary files a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..880629a Binary files /dev/null and b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d642..b4d54a3 100644 Binary files a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..64b5cf1 Binary files /dev/null and b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app-default/src/main/res/xml/file_paths.xml b/app-default/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..7b281d1 --- /dev/null +++ b/app-default/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c1f2f53..a9521b9 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -63,6 +63,9 @@ dependencies { api(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) + api(project(":libretroarch")) + api(project(":libppsspp")) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt b/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt new file mode 100644 index 0000000..9f1ac88 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt @@ -0,0 +1,7 @@ +package moe.tabidachi.emulator.common + +data class AspectRatio(val value: Float) { + companion object { + val AR1_1 = AspectRatio(1f / 1f) + } +} diff --git a/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt index 131925e..b88dac5 100644 --- a/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt +++ b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt @@ -1,7 +1,90 @@ package moe.tabidachi.emulator.common -class GameLauncher( +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Environment +import android.provider.Settings +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.retroarch.browser.retroactivity.RetroActivityFuture +import dagger.hilt.android.qualifiers.ApplicationContext +import moe.tabidachi.emulator.R +import moe.tabidachi.emulator.common.ktx.TAG +import moe.tabidachi.emulator.common.ktx.toast +import moe.tabidachi.emulator.common.ktx.unzip +import moe.tabidachi.emulator.data.Emulator +import moe.tabidachi.emulator.data.Rom +import org.ppsspp.ppsspp.NativeActivity +import org.ppsspp.ppsspp.PpssppActivity +import java.io.File +import javax.inject.Inject +class GameLauncher @Inject constructor( + @ApplicationContext + val context: Context, ) { + private fun ppsspp(emulator: Emulator, file: File) { + val uriForFile = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + Log.d(TAG, "ppsspp: $uriForFile") + val intent = Intent().apply { + component = ComponentName(context, PpssppActivity::class.java) + setDataAndType(uriForFile, "application/octet-stream") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + addCategory(Intent.CATEGORY_DEFAULT) + putExtra(NativeActivity.MEMSTICK_DIR_EXTRA_KEY, emulator.storage.path) + } + context.startActivity(intent) + } + suspend fun launch(emulator: Emulator, rom: Rom) { + when (emulator.type) { + Emulator.TYPE_PPSSPP -> { + ppsspp(emulator, rom.path) + } + + //Emulator.TYPE_N64 -> {} + + else -> { + val coreFile = emulator.coreFiles.firstOrNull() ?: return context.toast(R.string.no_core) + val extractToPath = File(ContextCompat.getDataDir(context), "cores") + val coreName = + if (coreFile.extension == "zip") coreFile.nameWithoutExtension else coreFile.name + val internalCorePath = File(extractToPath, coreName) + if (coreFile.exists() && !internalCorePath.exists()) { + extractToPath.mkdirs() + if (coreFile.extension == "zip") { + coreFile.unzip(extractToPath = extractToPath) + } else { + coreFile.copyTo(internalCorePath) + } + } + val intent = Intent(context, RetroActivityFuture::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("ROM", rom.path.path) + putExtra("LIBRETRO", internalCorePath.path) + putExtra("CONFIGFILE", emulator.storage.retroarchConfigFile.path) + putExtra( + "IME", Settings.Secure.getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD + ) + ) + putExtra("DATADIR", context.applicationInfo.dataDir) + putExtra("APK", context.applicationInfo.sourceDir) + //putExtra("SDCARD", Environment.getExternalStorageDirectory().absolutePath) + putExtra("SDCARD", emulator.storage.path) + val external = + Environment.getExternalStorageDirectory().absolutePath + "/Android/data/" + context.packageName + "/files" + putExtra("EXTERNAL", external) + } + context.startActivity(intent) + } + } + } } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/Json.kt b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt index 8183eba..a42693d 100644 --- a/core/src/main/java/moe/tabidachi/emulator/common/Json.kt +++ b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt @@ -7,7 +7,7 @@ fun SharedJson() = Json { isLenient = true allowSpecialFloatingPointValues = true allowStructuredMapKeys = true - prettyPrint = false + prettyPrint = true useArrayPolymorphism = false ignoreUnknownKeys = true } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt b/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt new file mode 100644 index 0000000..66fd4ae --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt @@ -0,0 +1,31 @@ +package moe.tabidachi.emulator.common + +/** + * # 0: None + * # 1: Down + Y + L1 + R1 + * # 2: L3 + R3 + * # 3: L1 + R1 + Start + Select + * # 4: Start + Select + * # 5: L3 + R1 + * # 6: L1 + R1 + * # 7: Hold Start (2 seconds) + * # 8: Hold Select (2 seconds) + * # 9: Down + Select + * # 10: L2 + R2 + */ +enum class MenuToggle( + val value: Int, + val text: String +) { + None(0, "None"), + DownYL1R1(1, "Down + Y + L1 + R1"), + L3R3(2, "L3 + R3"), + L1R1StartSelect(3, "L1 + R1 + Start + Select"), + StartSelect(4, "Start + Select"), + L3R1(5, "L3 + R1"), + L1R1(6, "L1 + R1"), + HoldStart(7, "Hold Start (2 seconds)"), + HoldSelect(8, "Hold Select (2 seconds)"), + DownSelect(9, "Down + Select"), + L2R2(10, "L2 + R2"), +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt new file mode 100644 index 0000000..291a152 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt @@ -0,0 +1,13 @@ +package moe.tabidachi.emulator.common.ktx + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes + +fun Context.toast(text: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, text, duration).show() +} + +fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, text, duration).show() +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt index 63d7ea0..cd03b65 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt @@ -1,30 +1,85 @@ package moe.tabidachi.emulator.data +import androidx.annotation.IntDef +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToStream +import java.io.File -/** - * @param extensions 游戏扩展名 - * @param iconPath 模拟器图标的相对路径 - */ -@Serializable -data class EmulatorConfig( - @SerialName("title") - val title: String, - @SerialName("extensions") - val extensions: List = emptyList(), - @SerialName("icon_path") - val iconPath: String? = null, - @SerialName("background_path") - val backgroundPath: String? = null, - @SerialName("roms_paths") - val romsPaths: List = emptyList(), - @SerialName("images_paths") - val imagesPaths: List = emptyList(), - @SerialName("roms_scan_mode") - val romsScanMode: Int = 0, - @SerialName("roms_size") - val romsSize: Int = 0, - @SerialName("roms") - val roms: List = emptyList() -) +class Emulator( + val id: String, + val path: String, + val storage: Storage, + var config: Config, + val json: Json, +) { + internal val root = storage.path + val iconFile: File = File(root, config.iconPath ?: "") + val title: String = config.title + val backgroundFile: File = File(root, config.backgroundPath ?: "") + val romsSize: Int = if (config.romsSize <= 0) config.roms.size else config.roms.size + val roms = config.roms.map { Rom(this, it) } + val coreFiles get() = config.corePaths.map { File(root, it) } + @EmulatorType + val type = config.type + val actionIcons: List = config.actionIcons.map { ActionIcon(root, it) } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun setCore(core: String) { + val cores = config.corePaths.toMutableList() + if (cores.contains(core)) { + cores.remove(core) + cores.add(0, core) + val config = config.copy(corePaths = cores).also { config = it } + withContext(Dispatchers.IO) { + json.encodeToStream(config, File(path).outputStream()) + } + } + } + + /** + * @param extensions 游戏扩展名 + * @param iconPath 模拟器图标的相对路径 + */ + @Serializable + data class Config( + @SerialName("title") + val title: String, + @SerialName("extensions") + val extensions: List = emptyList(), + @SerialName("icon_path") + val iconPath: String? = null, + @SerialName("background_path") + val backgroundPath: String? = null, + @SerialName("roms_paths") + val romsPaths: List = emptyList(), + @SerialName("images_paths") + val imagesPaths: List = emptyList(), + @SerialName("roms_scan_mode") + val romsScanMode: Int = 0, + @SerialName("core_paths") + val corePaths: List = emptyList(), + @SerialName("emulator_type") + @EmulatorType + val type: Int, + @SerialName("action_icons") + val actionIcons: List = emptyList(), + @SerialName("roms_size") + val romsSize: Int = 0, + @SerialName("roms") + val roms: List = emptyList(), + ) + + @IntDef(value = [TYPE_RETROARCH, TYPE_PPSSPP, TYPE_N64]) + annotation class EmulatorType + + companion object { + const val TYPE_RETROARCH = 0 + const val TYPE_PPSSPP = 1 + const val TYPE_N64 = 2 + } +} diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt index 447ad25..edc6ee9 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt @@ -3,9 +3,6 @@ package moe.tabidachi.emulator.data import android.util.Log import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.serialization.json.Json @@ -15,10 +12,11 @@ import java.io.File import javax.inject.Inject interface EmulatorDataSource : DataSource { - val storageConfigList: Flow>> - fun getAssetsFile(): Flow - fun getEmulatorList(): Flow> - fun getRomListByEmulatorId(id: String): Flow> + val storageList: Flow> + val emulatorList: Flow> + val assetsFile: Flow + //fun getEmulatorListItem(): Flow> + //fun getRomListByEmulatorId(id: Any): Flow> } class SdcardEmulatorDataSource @Inject constructor( @@ -26,76 +24,44 @@ class SdcardEmulatorDataSource @Inject constructor( private val sdcardPaths: MutableStateFlow>, private val json: Json ) : EmulatorDataSource { - override val storageConfigList: Flow>> = - sdcardPaths/*.distinctUntilChanged { old, new -> - old.containsAll(new) - }*/.map { paths: Set -> + override val storageList: Flow> = + sdcardPaths.map { paths: Set -> paths.map { root: String -> root to File(root, "config.json") }.mapNotNull { (root, file) -> runCatching { - root to json.decodeFromString(file.readText()) + Storage(root, json.decodeFromString(file.readText())) }.onFailure { Log.e(TAG, it.message, it) }.getOrNull() } } - override fun getAssetsFile(): Flow { - return storageConfigList.mapNotNull { - it.map { (root, storageConfig) -> - File(root, storageConfig.assetsPath) - }.firstOrNull { + override val emulatorList: Flow> = storageList.map { configs -> + configs.map { storage -> + storage.emulatorConfigFiles.filter { it.exists() + }.mapNotNull { file -> + runCatching { + Emulator( + id = file.toString(), + path = file.path, + storage = storage, + config = json.decodeFromString(file.readText()), + json = json, + ) + }.onFailure { + Log.e(TAG, "$file 反序列化失败", it) + }.getOrNull() } - } + }.flatten() } - private fun getEmulatorConfigList(): Flow>> { - return storageConfigList.map { configs -> - configs.map { (root, config) -> - config.emulatorConfigPaths.map { path: String -> - File(root, path) - }.filter { - val exists = it.exists() - if (!exists) Log.d(TAG, "$it 文件不存在") - exists - }.mapNotNull { file -> - runCatching { - root to json.decodeFromString(file.readText()) - }.onFailure { - Log.e(TAG, "$file 反序列化失败", it) - }.getOrNull() - } - }.flatten() - } - } - - override fun getEmulatorList(): Flow> { - return getEmulatorConfigList().map { - it.map { (root, config) -> - EmulatorListItem( - id = root, - icon = File(root, config.iconPath ?: ""), - title = config.title, - background = File(root, config.backgroundPath ?: ""), - romsSize = config.roms.size, - ) - } - } - } - - override fun getRomListByEmulatorId(id: String): Flow> { - return getEmulatorConfigList().map { - it.first { it.first == id } - }.map { (root, config) -> - config.roms.map { - RomListItem( - name = it.name ?: "", - path = File(root, it.path), - imagePath = File(root, it.imagePath ?: "") - ) - } + override val assetsFile: Flow = storageList.mapNotNull { + it.map { storage -> + storage.assetsFile + }.firstOrNull { + it.exists() } } } diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt index 300c823..9f94c32 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt @@ -4,8 +4,18 @@ import java.io.File data class EmulatorListItem( val id: String, - val icon: File, + val icon: File?, val title: String, - val background: File, + val background: File?, val romsSize: Int, -) \ No newline at end of file +) { + companion object { + val Empty = EmulatorListItem( + id = "", + icon = null, + title = "", + background = null, + romsSize = 0 + ) + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt index f08aa98..cdd918b 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt @@ -1,22 +1,41 @@ package moe.tabidachi.emulator.data +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn import java.io.File import javax.inject.Inject interface EmulatorRepository : Repository { - fun getAssetsFile(): Flow - fun getEmulatorList(): Flow> + val storageList: Flow> + val assetsFile: Flow + val emulatorList: Flow> } class DefaultEmulatorRepository @Inject constructor( - private val emulatorDataSource: EmulatorDataSource + private val emulatorDataSource: EmulatorDataSource, + private val scope: CoroutineScope ) : EmulatorRepository { - override fun getAssetsFile(): Flow { - return emulatorDataSource.getAssetsFile() - } + override val storageList: Flow> = emulatorDataSource.storageList + .stateIn( + scope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) - override fun getEmulatorList(): Flow> { - return emulatorDataSource.getEmulatorList() - } + override val assetsFile: Flow = emulatorDataSource.assetsFile + .shareIn( + scope, + started = SharingStarted.WhileSubscribed(5000), + replay = 1 + ) + + override val emulatorList: Flow> = emulatorDataSource.emulatorList + .stateIn( + scope, + started = SharingStarted.Lazily, + initialValue = emptyList() + ) } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt index f151097..946daae 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt @@ -2,13 +2,24 @@ package moe.tabidachi.emulator.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.io.File -@Serializable -data class Rom( - @SerialName("name") - val name: String? = null, - @SerialName("path") - val path: String, - @SerialName("image_path") - val imagePath: String? = null -) +class Rom( + emulator: Emulator, + config: Config +) { + val name: String = config.name ?: "" + val id: String = emulator.id + name + val path = File(emulator.root, config.path) + val imageFile = File(emulator.root, config.imagePath ?: "") + + @Serializable + data class Config( + @SerialName("name") + val name: String? = null, + @SerialName("path") + val path: String, + @SerialName("image_path") + val imagePath: String? = null, + ) +} diff --git a/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt index eae6fed..342b84b 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt @@ -3,7 +3,10 @@ package moe.tabidachi.emulator.data import java.io.File data class RomListItem( + val id: String, val name: String, val path: File, val imagePath: File -) +) { + constructor(rom: Rom) : this(rom.id, rom.name, rom.path, rom.imageFile) +} diff --git a/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt index 6c726d6..c149c4f 100644 --- a/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt +++ b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt @@ -2,17 +2,62 @@ package moe.tabidachi.emulator.data import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import java.io.File -@Serializable -data class StorageConfig( - @SerialName("emulator_config_paths") - val emulatorConfigPaths: List, - @SerialName("assets_path") - val assetsPath: String, - @SerialName("retroarch_config_path") - val retroarchConfigPath: String, - @SerialName("bios_path") - val biosPath: String, - @SerialName("cores_path") - val coresPath: String -) \ No newline at end of file +class Storage( + val path: String, + private val config: Config +) { + val assetsFile: File = File(path, config.assetsPath) + val emulatorConfigFiles: List = config.emulatorConfigPaths.map { File(path, it) } + val retroarchConfigFile: File = File(path, config.retroarchConfigFile) + val actionIcons: List = config.actionIcons.map { ActionIcon(path, it) } + + @Serializable + data class Config( + @SerialName("emulator_config_paths") + val emulatorConfigPaths: List, + @SerialName("assets_path") + val assetsPath: String, + @SerialName("retroarch_config_path") + val retroarchConfigPath: String, + @SerialName("retroarch_config_file") + val retroarchConfigFile: String, + @SerialName("bios_path") + val biosPath: String, + @SerialName("cores_path") + val coresPath: String, + @SerialName("action_icons") + val actionIcons: List = emptyList() + ) +} + +class ActionIcon( + root: String, + config: Config +) { + val keycode = config.keycode + val label = config.label + val iconFile = File(root, config.iconPath) + val type = config.type + + @Serializable + data class Config( + @SerialName("keycode") + val keycode: Int, + @SerialName("label") + val label: String, + @SerialName("icon_path") + val iconPath: String, + @SerialName("type") + val type: Type + ) + + enum class Type { + SETTINGS, + SEARCH, + FAVORITE, + OK, + BACK + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt index eccd079..997ef52 100644 --- a/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt +++ b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt @@ -6,6 +6,9 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import moe.tabidachi.emulator.data.DefaultEmulatorRepository import moe.tabidachi.emulator.data.DefaultPreferencesDataSource import moe.tabidachi.emulator.data.EmulatorDataSource @@ -23,6 +26,6 @@ object RepositoryModule { context: Context, emulatorDataSource: EmulatorDataSource ): EmulatorRepository { - return DefaultEmulatorRepository(emulatorDataSource) + return DefaultEmulatorRepository(emulatorDataSource, CoroutineScope(Dispatchers.Default + SupervisorJob())) } } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt index 4fbb59a..452bdc8 100644 --- a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt @@ -9,6 +9,6 @@ class GetAssetsFileUseCase @Inject constructor( private val emulatorRepository: EmulatorRepository ) { operator fun invoke(): Flow { - return emulatorRepository.getAssetsFile() + return emulatorRepository.assetsFile } } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt index ba930b4..847c18b 100644 --- a/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt @@ -1,6 +1,7 @@ package moe.tabidachi.emulator.domain import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import moe.tabidachi.emulator.data.EmulatorListItem import moe.tabidachi.emulator.data.EmulatorRepository import javax.inject.Inject @@ -8,7 +9,15 @@ import javax.inject.Inject class GetEmulatorListUseCase @Inject constructor( private val emulatorRepository: EmulatorRepository ) { - operator fun invoke(): Flow> { - return emulatorRepository.getEmulatorList() + operator fun invoke(): Flow> = emulatorRepository.emulatorList.map { + it.map { emulator -> + EmulatorListItem( + id = emulator.id, + icon = emulator.iconFile, + title = emulator.title, + background = emulator.backgroundFile, + romsSize = emulator.romsSize, + ) + } } } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt new file mode 100644 index 0000000..1fc270f --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt @@ -0,0 +1,23 @@ +package moe.tabidachi.emulator.domain + +import kotlinx.coroutines.flow.map +import moe.tabidachi.emulator.data.EmulatorRepository +import moe.tabidachi.emulator.data.RomListItem +import javax.inject.Inject + +class GetRomListUseCase @Inject constructor( + private val emulatorRepository: EmulatorRepository +) { + operator fun invoke(emulatorId: String) = emulatorRepository.emulatorList.map { + it.first { it.id == emulatorId } + }.map { emulator -> + emulator.roms.map { + RomListItem( + id = it.id, + name = it.name, + path = it.path, + imagePath = it.imageFile + ) + } + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt index ab99cb3..d3b1a03 100644 --- a/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt @@ -6,6 +6,7 @@ import android.os.storage.StorageVolume import android.util.Log import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import moe.tabidachi.emulator.common.ktx.TAG import moe.tabidachi.emulator.di.SdcardPaths @@ -17,9 +18,9 @@ class GetSdcardPathsUseCase @Inject constructor( @SdcardPaths private val sdcardPaths: MutableStateFlow> ) { - operator fun invoke(): List { - return kotlin.runCatching { - val storageManager = context.getSystemService() ?: return emptyList() + suspend operator fun invoke(): List = coroutineScope { + kotlin.runCatching { + val storageManager = context.getSystemService() ?: return@coroutineScope emptyList() val clazz = Class.forName("android.os.storage.StorageVolume") val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList") val getPathMethod = clazz.getMethod("getPath") diff --git a/core/src/main/res/values-zh-rCN/strings.xml b/core/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..3107c14 --- /dev/null +++ b/core/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,4 @@ + + + 没有核心库 + \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml new file mode 100644 index 0000000..79232c6 --- /dev/null +++ b/core/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + No Core + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd5fd87..a695ced 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,24 +1,24 @@ [versions] -agp = "8.8.0" +agp = "8.9.0" kotlin = "2.1.10" coreKtx = "1.15.0" appcompat = "1.7.0" -composeBom = "2025.01.01" +composeBom = "2025.03.00" tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.8.7" -activityCompose = "1.10.0" +activityCompose = "1.10.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" material = "1.12.0" -ktor = "3.0.3" +ktor = "3.1.1" hilt = "2.55" -ksp = "2.1.10-1.0.29" +ksp = "2.1.10-1.0.31" hilt-navigation-compose = "1.2.0" -compose-navigation = "2.8.6" +compose-navigation = "2.8.9" kotlin-serialization = "1.8.0" -datastore = "1.1.2" -coil3 = "3.0.4" +datastore = "1.1.3" +coil3 = "3.1.0" work = "2.10.0" androidx-room = "2.6.1" [libraries] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e18bc25..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6..f3b75f3 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/libppsspp/.gitignore b/libppsspp/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/libppsspp/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libppsspp/build.gradle.kts b/libppsspp/build.gradle.kts new file mode 100644 index 0000000..e3c41d9 --- /dev/null +++ b/libppsspp/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +val projectPath = File(project.rootDir, "submodules/ppsspp") + +android { + namespace = "org.ppsspp.ppsspp" + compileSdk = 35 + ndkVersion = "21.4.7075529" + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + externalNativeBuild { + ndkBuild { + arguments( + "-DANDROID=true", + "-DANDROID_PLATFORM=android-16", + "-DANDROID_TOOLCHAIN=clang", + "-DANDROID_CPP_FEATURES=", + "-DANDROID_STL=c++_static", + "-j${Runtime.getRuntime().availableProcessors()}", + ) + } + } + + buildConfigField("String", "FLAVOR", "\"normal\"") + } + sourceSets { + getByName("main") { + //manifest.srcFile(File(projectPath, "android/AndroidManifest.xml")) + java.srcDirs( + File(projectPath, "android/src"), + ) + res.srcDirs( + File(projectPath, "android/res"), + File(projectPath, "android/normal/res"), + ) + assets.srcDirs( + File(projectPath, "assets"), + ) + } + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + buildConfig = true + } + externalNativeBuild { + cmake { + path(File(projectPath, "CMakeLists.txt")) + version = "3.22.1" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + lint { + baseline = file("lint-baseline.xml") + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(files(File(projectPath, "android/libs/com.bda.controller.jar"))) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/libppsspp/consumer-rules.pro b/libppsspp/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/libppsspp/lint-baseline.xml b/libppsspp/lint-baseline.xml new file mode 100644 index 0000000..97a1e12 --- /dev/null +++ b/libppsspp/lint-baseline.xml @@ -0,0 +1,1536 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libppsspp/proguard-rules.pro b/libppsspp/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/libppsspp/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/libppsspp/src/main/AndroidManifest.xml b/libppsspp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fbdb235 --- /dev/null +++ b/libppsspp/src/main/AndroidManifest.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libretroarch/.gitignore b/libretroarch/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/libretroarch/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libretroarch/build.gradle.kts b/libretroarch/build.gradle.kts new file mode 100644 index 0000000..3fbcb86 --- /dev/null +++ b/libretroarch/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.retroarch" + compileSdk = 35 + ndkVersion = "22.0.7026061" + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + externalNativeBuild { + ndkBuild { + arguments += "-j${Runtime.getRuntime().availableProcessors()}" + } + } + + buildConfigField("boolean", "PLAY_STORE_BUILD", "false") + resValue("string", "app_name", "RetroArch") + } + val path = File(project.rootDir, "submodules/RetroArch") + sourceSets { + getByName("main") { + //manifest.srcFile(File(path, "pkg/android/phoenix/AndroidManifest.xml")) + java.srcDirs( + File(path, "pkg/android/phoenix/src"), + File(path, "pkg/android/phoenix-common/src"), + File(path, "pkg/android/play-core-stub"), + ) + res.srcDirs( + File(path, "pkg/android/phoenix/res"), + File(path, "pkg/android/phoenix-common/res"), + ) + } + } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + buildConfig = true + } + externalNativeBuild { + ndkBuild { + path(File(path, "pkg/android/phoenix-common/jni/Android.mk")) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/libretroarch/consumer-rules.pro b/libretroarch/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/libretroarch/proguard-rules.pro b/libretroarch/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/libretroarch/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/libretroarch/src/main/AndroidManifest.xml b/libretroarch/src/main/AndroidManifest.xml new file mode 100644 index 0000000..23bdd9c --- /dev/null +++ b/libretroarch/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index d9a6380..61e6722 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,3 +23,5 @@ rootProject.name = "Emulator" include(":app-default") include(":core") include(":ui") +include(":libretroarch") +include(":libppsspp") diff --git a/submodules/RetroArch b/submodules/RetroArch new file mode 160000 index 0000000..a3f1abf --- /dev/null +++ b/submodules/RetroArch @@ -0,0 +1 @@ +Subproject commit a3f1abfad7afea0160364562b8c1934b45752ce5 diff --git a/submodules/ppsspp b/submodules/ppsspp new file mode 160000 index 0000000..6423cc9 --- /dev/null +++ b/submodules/ppsspp @@ -0,0 +1 @@ +Subproject commit 6423cc9ea50bb5b797ba118f9637902bb272d9bf diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt index ed77fd3..5daeb18 100644 --- a/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt @@ -25,6 +25,9 @@ import moe.tabidachi.emulator.ui.common.TvPreview @Composable fun TvDialog( onDismissRequest: () -> Unit, + parent: BoxScope.(Modifier) -> Modifier = { + it.align(Alignment.BottomCenter) + }, content: @Composable BoxScope.() -> Unit ) { Dialog( @@ -36,9 +39,7 @@ fun TvDialog( ) { Surface( shape = TvDialogShape, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), + modifier = parent(Modifier).padding(16.dp), content = content ) } diff --git a/ui/src/main/res/values-zh-rCN/strings.xml b/ui/src/main/res/values-zh-rCN/strings.xml index 01c892b..dd1e174 100644 --- a/ui/src/main/res/values-zh-rCN/strings.xml +++ b/ui/src/main/res/values-zh-rCN/strings.xml @@ -10,4 +10,10 @@ 确定 取消 %s GAMES + 菜单切换 + 核心选择 + 搜索 + 退出 + 您确定要退出吗? + 设置 \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5ec279a..2a087df 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -10,4 +10,10 @@ Confirm Cancel %s GAMES + Menu Toggle + Core Select + Search + Exit + Are you sure to exit? + Settings \ No newline at end of file