From cbb57ec10903aa6be91a76e219ca0b1a67c90e4a Mon Sep 17 00:00:00 2001 From: tabidachinokaze Date: Sun, 16 Mar 2025 22:53:08 +0800 Subject: [PATCH] v1.1 --- .gitmodules | 6 + app-default/build.gradle.kts | 34 +- app-default/lint-baseline.xml | 26 + app-default/proguard-rules.pro | 6 +- app-default/src/main/AndroidManifest.xml | 16 +- .../src/main/ic_launcher-playstore.png | Bin 0 -> 9214 bytes .../compose/material3/ProgressIndicator.kt | 295 ++++ .../moe/tabidachi/emulator/MainActivity.kt | 9 + .../moe/tabidachi/emulator/MainViewModel.kt | 25 + .../emulator/data/common/MenuToggleItem.kt | 109 ++ .../emulator/data/common/StringMatcher.kt | 55 + .../tabidachi/emulator/ui/SharedNavHost.kt | 1 + .../emulator/ui/common/ActionIcon.kt | 32 + .../tabidachi/emulator/ui/common/BottomBar.kt | 6 +- .../emulator/ui/common/CoreSelectDialog.kt | 76 + .../emulator/ui/common/ExitDialog.kt | 38 + .../emulator/ui/common/MenuToggleDialog.kt | 105 ++ .../emulator/ui/common/SearchDialog.kt | 285 +++ .../emulator/ui/common/SettingsDialog.kt | 62 + .../tabidachi/emulator/ui/home/HomeRoute.kt | 7 +- .../tabidachi/emulator/ui/home/HomeScreen.kt | 136 +- .../emulator/ui/home/HomeViewModel.kt | 192 ++- .../tabidachi/emulator/ui/roms/RomsRoute.kt | 6 +- .../tabidachi/emulator/ui/roms/RomsScreen.kt | 184 +- .../emulator/ui/roms/RomsViewModel.kt | 114 +- .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 16 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 752 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2148 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 628 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1308 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 1012 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3006 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 1566 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 4780 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 2140 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 6604 bytes app-default/src/main/res/xml/file_paths.xml | 6 + core/build.gradle.kts | 3 + .../tabidachi/emulator/common/AspectRatio.kt | 7 + .../tabidachi/emulator/common/GameLauncher.kt | 85 +- .../moe/tabidachi/emulator/common/Json.kt | 2 +- .../tabidachi/emulator/common/MenuToggle.kt | 31 + .../tabidachi/emulator/common/ktx/Context.kt | 13 + .../tabidachi/emulator/data/EmulatorConfig.kt | 105 +- .../emulator/data/EmulatorDataSource.kt | 92 +- .../emulator/data/EmulatorListItem.kt | 16 +- .../emulator/data/EmulatorRepository.kt | 37 +- .../java/moe/tabidachi/emulator/data/Rom.kt | 29 +- .../tabidachi/emulator/data/RomListItem.kt | 5 +- .../tabidachi/emulator/data/StorageConfig.kt | 71 +- .../tabidachi/emulator/di/RepositoryModule.kt | 5 +- .../emulator/domain/GetAssetsFileUseCase.kt | 2 +- .../emulator/domain/GetEmulatorListUseCase.kt | 13 +- .../emulator/domain/GetRomListUseCase.kt | 23 + .../emulator/domain/GetSdcardPathsUseCase.kt | 7 +- core/src/main/res/values-zh-rCN/strings.xml | 4 + core/src/main/res/values/strings.xml | 4 + gradle/libs.versions.toml | 16 +- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 3 +- libppsspp/.gitignore | 1 + libppsspp/build.gradle.kts | 88 + libppsspp/consumer-rules.pro | 0 libppsspp/lint-baseline.xml | 1536 +++++++++++++++++ libppsspp/proguard-rules.pro | 21 + libppsspp/src/main/AndroidManifest.xml | 120 ++ libretroarch/.gitignore | 1 + libretroarch/build.gradle.kts | 75 + libretroarch/consumer-rules.pro | 0 libretroarch/proguard-rules.pro | 21 + libretroarch/src/main/AndroidManifest.xml | 55 + settings.gradle.kts | 2 + submodules/RetroArch | 1 + submodules/ppsspp | 1 + .../emulator/ui/components/TvDialog.kt | 7 +- ui/src/main/res/values-zh-rCN/strings.xml | 6 + ui/src/main/res/values/strings.xml | 6 + 80 files changed, 4106 insertions(+), 279 deletions(-) create mode 100644 .gitmodules create mode 100644 app-default/lint-baseline.xml create mode 100644 app-default/src/main/ic_launcher-playstore.png create mode 100644 app-default/src/main/java/androidx/compose/material3/ProgressIndicator.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/MainViewModel.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/data/common/MenuToggleItem.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/data/common/StringMatcher.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/ActionIcon.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/CoreSelectDialog.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/ExitDialog.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/MenuToggleDialog.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/SearchDialog.kt create mode 100644 app-default/src/main/java/moe/tabidachi/emulator/ui/common/SettingsDialog.kt create mode 100644 app-default/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app-default/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app-default/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app-default/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app-default/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app-default/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app-default/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app-default/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app-default/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app-default/src/main/res/xml/file_paths.xml create mode 100644 core/src/main/java/moe/tabidachi/emulator/common/AspectRatio.kt create mode 100644 core/src/main/java/moe/tabidachi/emulator/common/MenuToggle.kt create mode 100644 core/src/main/java/moe/tabidachi/emulator/common/ktx/Context.kt create mode 100644 core/src/main/java/moe/tabidachi/emulator/domain/GetRomListUseCase.kt create mode 100644 core/src/main/res/values-zh-rCN/strings.xml create mode 100644 core/src/main/res/values/strings.xml create mode 100644 libppsspp/.gitignore create mode 100644 libppsspp/build.gradle.kts create mode 100644 libppsspp/consumer-rules.pro create mode 100644 libppsspp/lint-baseline.xml create mode 100644 libppsspp/proguard-rules.pro create mode 100644 libppsspp/src/main/AndroidManifest.xml create mode 100644 libretroarch/.gitignore create mode 100644 libretroarch/build.gradle.kts create mode 100644 libretroarch/consumer-rules.pro create mode 100644 libretroarch/proguard-rules.pro create mode 100644 libretroarch/src/main/AndroidManifest.xml create mode 160000 submodules/RetroArch create mode 160000 submodules/ppsspp 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 0000000000000000000000000000000000000000..ba8dbe2f1ef607718d93d37e17b12685712e6951 GIT binary patch literal 9214 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 V^p-Dx|JMT;oxOY}@AS<-{|9kic(DKg literal 0 HcmV?d00001 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 c209e78ecd372343283f4157dcfd918ec5165bb3..d48637b09233cab038fa7f1c5b8836ed1e44c861 100644 GIT binary patch literal 752 zcmVkrBuPzH^W!znV#X^O;i!!y zNpkebpUHCD|FnZhk`$?``<_|G`?t3MJCbcxRWIC~7+ew{MA9K8e*L)|0ZD=f>IQ)P zfhq(7fjTrH5C~MD4Gjp?cwX?I{$AHOK=n93i(2D#-t^IcK$>~MS7?xeMLv_#qX}UL zqF#qO7AWuh@YWIXwkq#$_Fh(%4f1}R7Vqow8UMxk4c^991;3SAgoFT9M5q*?5?WPS zwW^dj&NFlvheXAyQlyAhND*3rgb7q}zdy%$u>wUx;i{#4d`9&6 z+$x`H6}+^{d~E|9apK3Afa-hzdNwTOX zvLU$V%^?Ikf>)Vi=BB$uy5Q&Sb%VLed9^@_k iSH&kFBs2^?TlxF4Ce1S{G6Fqidz$>Ghal}QDh2@6q;sYK literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG 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 0000000000000000000000000000000000000000..5697025d1116e66a2737f8a2ccefe4b1a783dc87 GIT binary patch literal 2148 zcmV-q2%Gm(Nk&Fo2mk>UgdF#+BcdA8%~ zGJNX*NBjRn&MDU34NKceYumPM+nAhpcmDtX%n8B+*sAh8zp!;mR$Il%bSZNHvkd^S z%>RGIu5H_$9RvVixPn@|ZDY1=d*}W$aN9_cJA2Id2inrMr;(ihWO&>l>F?P}PILbk z*`T)N8~DP>GJ_rFKjbiUt+pJ7US_g3#hfUd^v=veY}-hZr2J#NAoeS{s(ShWIkxS# zt+K0310VtX_dcA%-b*)N2$Ce5wjRE1+qP}nwr$(kf^FN@7Hs?R>-=%xHj*M$H4W|_ z3_bx_Nq`LmxFLps92p4k#6L|53(L9;xxm*R1K#lm{cYdIqw*%&n zHGtbcJGbd)%U&tZWOZ|Smy@S#zOYH|mA{|=@0r-F_e#01eGP#4rzu^c4nVIKKbND~ zoMQ8lO&H6qJw5r#H6YZ^Y7pRn-Zu={Y-96HL|7wMf012(O~dAYpH1^{j8hT$>e@Yk zFWuwf*0P8^9Gmf`i4Tj&z9>&YsOASFu*1VzMrzYQzDXN_0P8to^2H z2C31jwBJ9P>mqYMyBtubjydTg62%##2*>`ono@fec0=k?SOeE|N+mUB`v>Y)xctQq zjTD6*ieNKY7Xx)II_t7c=On;f;Qb_(IAPumsg|AiAMs(+^%99BE zu;+>>tBPMfDjC(Pdk^{d62Ls$b@CpSjCX(*HqnMgE1#;Y1t!Xc$-nc*h+@C z)+0uhG3QGF0WA(LHt%i5ZiVa*UCwo`~0HR^GhB8UK#FfB`KF9AjO3Za|)doW;F zfkh9o5fejq4rdV13`$ItYi%8x59mf9{V+Ln)5FYhx6FJXR%Q*VN7-OWE(vfZ*_{s4 zhNlMLG$j2P5upIWA zbGBnJ1PP8d_6g`h2q0kK^0BZ+sUUqN0Q1EqVljIF0O8pPtr1gkO2A5h=?}_PD&JP8 zn9*E{mPcP10R+RSrzM_5;5|A$Bv^lr1qE5Yv(Iuj9G4i)#jgNhau`X`g5$VSbD{vn zl<8q=c~#9(Kru{2NOqSWAlXBX6-B0FkPyvw02#$CmFqJ`a=297G6+6SeW@+{jUJhb zF(@mistxcm+cs0rwF zMoBt!QDAVY01ji^htU->{Rs79uyIA`%kJ!TZ`VLcK+~G(ERqKIQx`_3o*XX!+4;_h z(XhJRZ#F}JL@xFwZjyJlivDF6b%4$M)yBS8%o3dU5KHRB!@}I<`n>`(0(N6e+mg0Z z#NKy!-);;MbCi9FfMW&#H33s+Mw-p&)BOzv7)vNXKlSwgi-{q5dcmt#0U!|2&U05} z^<)##D@3HjYIW@)pbtalIXVu{hV*x>5gonEao4yUFd*?u_w*MMZR@7|meiN>$~-3y z6}H*DlR5l!J+dEoHQC})fU``one>+^aKJbv6|=pq?@r#Ab0>Hm=Un9 zZu9+8rg?cC!^&=<$1h`)1Pla1-HFNE_HRXd#@vy=toy*@OXq6xUtar3Z4efptL1S5 z8rVquFkJ6+t1wAm{g(%B84by_`s>68FsHNpmF;h`~W1(K;m<#q;G)JdjrYBM>Lh?ethO3Zy!kc@S0- zJKa|Z^nSO+Br6b;oj{mCB{@f}YJC*_AY)WqNzUFtp!)CD0AvvYCIUeMxdk5fo%ES^ z3Ssq^x|=47ywmP6yTHTY{>iHW89DoeK#V{+Ia_3>=cbO(m%=8B&DiEgoA|R7Izlfd zXNx9K-uKDKV9uDiv4cQuyt}Zewgl7~?IG!f3d-bLDQKH+O8j8XR5p zyfE2uc7W)d8w4T*(gX^} z=0I}}bbnmnYPWo^j*p!BaSn7II`-jD7dPa9&*Nr}fbKYPa1o8~`sCui-+$kCadQ3q af5+eUIbIcd0vZC^1AqN>;PT7|WFr6(sU<)F literal 0 HcmV?d00001 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 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..723cfc2d14dfb9c89bf1fc0c6daacc756e906605 100644 GIT binary patch literal 628 zcmV-)0*n1pNk&F&0ssJ4MM6+kP&iCq0ssInFTe{BHw2-PBu7$YkGXezpYgN!TN;kq zMiL~ado=ukEW7*PIEW-kk*Wrs`Cr2OSGeFXl4Mosdmnd)WQmG&GzE9K01yxm5Ky2% zK!FOT*R_ZLIeP{1yhD3Idp*X)gsl30Am49Qoqc-0QT@Nsn10`w&Dw@LBxLxoYwSNe z9+U?G73O3Aml#4p6(TeR0R?F9QM9+A=<`0TX;X=$RoQzqr76s`Ql*3nYuXf6W$i&! zAy{*8&It;TfQBl3+=r#yb1|hg1qITchJu-c0;=$H-_AKp5F%7SD5ww#C_<$Q0s8&^ z8%TeDO?+2*B7g9I?OH!p_}%Y+_;61Jv}@bTNF8HsTVrc&&f2zz>)oz3KYXW?O|tyh zw~6RKBe!iFQM2{bcmKfsloSc#7@AxH%&?D50(dDaCyYq|a>VrZTvJV)2ydK}>d8W- zN12cUUh6CsCgQ+SU(6|z5?o=WMFiqRFywOyp@J$(@(^gva{pl*t?f8TfX}~r7{W_j4hGb^?J5Z_IpOoNbPu?n+PXe7FZ;5Iv(tI+WLYM zQK5@oi*B%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!T^csNk&E#1pok7MM6+kP&iBo1pojqFTe{B_XjtUB+E+n%--W;5B@uZj0W2x zF2HRgNs`2LR@9ycd&bt3UPq`~P;YO0=$jRBG&sA{d z1e}2m7ooxw1T@as-ThAji2tF5fPjDkH3S3%1XK_Z5UdUg^Nic`n2R-?ZFA0P4JD2t zGVXGBJ-SREQ?@BP>s6(PD2i+bX?<_}v)!1xEnVg_IVCKn?wEvxH7(XvN(XgN$|157 zH7#_bW(=3=LSil&sDol@k?0U?gMffQkdUy$2&(EE43O|nSxO;f*{f=_Rmq<)*%M^Y z!=meP_faaRk?A1p+^ej&c!sQKuIXHbFycdMrayR2qmC1C!eoRD3pYz zNU?Jgm^p;dYo4gKW#%vidd}I-i4Z`6G(vk$KA#`ZnUf?m1xgA@0fa&VMFfx#KoW}o z|C!O8tR9?g+qSB!pI+UweOLEt+qP}vkgeTbZQHipolGYC7i2cSClh=(6VZPLZrjF@ zlJ35@bZ;h}31OwgrHUJh=t?FX#nsACR`PU+!vy5)8Ap=GdEWbk zkbuL$)V;~Iu5>fzz_C6yizs5}9X3~B*j}C-HQ7i?= zkVh$^OW5USJ^JR`g4RBECcF6hh*BBae>u}!^z;vZ3;X>u7Xgsi@kGj)bOe}fPWx3A z`170JMdHUY0O;Uoax-)dyX18ClwLaT$?xsv{-uY#nR1 zd2#C4#w{$R*sJWblk-_2cJ3XUL~Jw~I%SsAI;*B!VvKWt#ka2C#h_+&J_651s}MN$>Z8qW{IziTMpj=T4bcw*VukoOWP!hDw_A) zXv+U1{F1MwuMsD`ngi4uiLt3}CjjSOTE@Asx$W1b;`&%BGzLP26_M4C^q6 zqlvZJt8__JbmuHSfa8i?GHkS0Fxg8 literal 0 HcmV?d00001 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 948a3070fe34c611c42c0d3ad3013a0dce358be0..6a14105c764b48dace933f097759edd0874a1115 100644 GIT binary patch literal 1012 zcmVKMk7`%9+iP3Yj~^$x-FY<-Pp`jq0Yy@Trcx?JvkXC%lXWLR!c@={Q>j1! z5~9S05ki!hrJz8H6zKA}IxZWfip0(JOA#c5!!n(%N7FFHR1WJj6;zsH8fF;+R1qLl zKv4H9DTv58C^i9)oG7_?ad z6ZBa}hM^!;T-_V&G-jAOmJAhz1DCjinaVhAyhwa!>2#)>{t76T5gSE$!wPE)kME^YPF-Mta z2_7vKSu7%Awo&u>%?`5Q^QYydrT_%ULIA*)uUY;?6O7;E^tl&#`t z$ei1&xN3H)VxGIQhj{YRRc0@qAl5U(@!tS^6qzm*-`PsIPY}uCd2}Z1xt0tQg^J6& z(?P=)TLzC9A_lwey|o1x=$h?cI(WqJp#lVb@QMS?8_^kici6qmn+Q=~xu-)fBONqo z)oI_hRg2~?P`TFBg|L0Eqjdv=g2u)@9NiA?+Od;!9S?PLXpN0ZfT>BFj-9)7K;yZf?+!dlhxVucZJ1I`2RL literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? 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 0000000000000000000000000000000000000000..a55ce78da5bcd1c64240ce88a56e80ffd8abf42c GIT binary patch literal 3006 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 Av;Y7A literal 0 HcmV?d00001 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 28d4b77f9f036a47549d47db79c16788749dca10..ca73e4c025cdeaa6a72ba3e14970c3a2731abe0d 100644 GIT binary patch literal 1566 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 Gr*l=<&>iCd literal 0 HcmV?d00001 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 aa7d6427e6fa1074b79ccd52ef67ac15c5637e85..b4d54a3e6ed356be178c36014c259d09999cd736 100644 GIT binary patch literal 2140 zcmV-i2&4B>Nk&Fg2mklm&dnmep^9k{mauyOrxcZUqz$2FNP4&1g4BmV!rSI6mY1KdWE9Lc&; znO)otpRSsF&b1fcfroS3wyEc{ZGW+C+eV$vtZe5GsJE-6vu&%g>krYM0J*ld9h-9$ zv2EM7ZQHi>YwVfho&S6P|NYkUK5Xt*%#O+nBvD+sJiS|0jSKY!e^?Wca7RA2st! zrz1v7$Cl_y9SK@8Gn;J1oZoVu{hO|t|K2~$%%ze8NWz@#LzFr0Hb)z?j_ysi!NlLQ zj?5W9Zersz8+I*Y79r-C%_z)7OD2Xz{&y*}BjuRW>=y66$^NxM6aBSXE$@Ar-OB6; z3G*)RM@{_it?|)O@6B1g%0fm(@ANko5h`1crej3wE)}IxYFb5jzlf08@@WFmu0$<~ zD2s`f{V1VxG#w$HwFOXBX_Ol8J=Xh2@$slnCJ=7V)KxS9ic8}@6Qku3+WI~09CS4a zm1SwXZ7J`DyN&^t2!+~(oq7{LT8fDfb-fJU;^dZ5fM43OG|k7vCQI>2rYUTWTn0aM z3X1?*JT-9Ko9NF*Hu!krXSiqFb@0JZdHOQIh^GeGORza%6TC1<0L-X0#9o5ElBP)P zxjdQ@YA?Ya$(O?ui-7C_*kNm!y$szeCBIqvgKoB&p?jw0aAy z@ee?dfj-bjeNQVP>twmIf&q8tMW)$QK#Z3*iIhP%$RxTZ!3z&a)jVR@4^%9Ja6bxN zl#lR3066x+Ao7PpH0G z>#P2oG0`B}>C7LuovlZ%u@g%(MANd5P8;{~7-?Y*qg_P6$Z7<@suYnwYH`-mzZ@d% zCgBkc?{&2+(aDowOig)_8e<)tI|ZzHnN&dYX()eObO@9e=DEdpacW$hn%?ouv zMVp~Kc4K9=EJ}O|XCeZc;NmKj3um6Qg2D=QeLXy~Y0=EAll}Hyw3o^^^xC>N5xXjb zYo}`+ga`D5EfaGOegwe6{Yc<5`TCv@fhf2(Mqbv*@}QLso3qT;7qb4Pa5wZJ00aCh z?WWC@69KTDMv<_MHPSddAmM&Q`%HW1jso_ebUktIv2AQPXmAOQ7 z)2^x$&lqjIqCvF%U$UOAb})i&+YQi)#S*&os}A3O=%ReM13F&}B7X#QU#jLIbd~eq z5OfslVS-^Zi8O|$2|UU%+U~T1%$1eDFi6aV?BVG8z0{^@24 z03#o~*~LW9mSQ5r)8G64w}79f2mqw(azL6!2bPkK5N*E%)Hy&0NCW^1z$=r)fUm41 znLx08x-4oEF!^P0M^3}UFws9gO#JWTPV{}Ptb`$;pmh#N0E;AZ|J`wXj3*<6Z5M#` zJ3rM*z)eAs0otLb<|Y$pyu?P;h&nL%+{zb1mQOhnn*iZLz&7aUj~h7t5*O8@4+ghR zv=??ZQDC{Q091e#;1cYZiDw}lWBgHjcy!!Ecrdti_U_|$7S{<0fSSK^9AFQ)1+AkG zCKG5>RaI@s{Hm%d6C;^G)PbSfrU3Vkk9#LyCQMM>CV&qp06V~`3+@A7u85{yJs3BT zMlcaf3=3@hpUgtsf#L1f15d7c{h%Y(9=pjY1(szwAOeUO&LG=gW Ss)~rSKoL>pk*gNS2Xqm@LMWC1 literal 3844 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 + + + \ 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