emu: complete gui overhaul
@@ -51,13 +51,9 @@ jobs:
|
||||
cmake_preset: windows-ninja
|
||||
|
||||
- os: windows-11-arm
|
||||
arch: arm64
|
||||
cache_path: |
|
||||
C:\vcpkg\installed
|
||||
C:\vcpkg\packages
|
||||
vcpkg_triplet: arm64-windows-static-md
|
||||
extra_cmake_args: -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache -DCMAKE_TOOLCHAIN_FILE=C:\vcpkg\scripts\buildsystems\vcpkg.cmake -DVCPKG_TARGET_TRIPLET=arm64-windows-static-md
|
||||
cmake_preset: windows-ninja-clang
|
||||
cache_path: C:\msys2-ccache
|
||||
msys2_env: CLANGARM64
|
||||
cmake_preset: windows-ninja
|
||||
|
||||
- os: macos-intel
|
||||
runs_on: macos-latest
|
||||
@@ -112,17 +108,22 @@ jobs:
|
||||
curl -sLO https://github.com/linuxdeploy/linuxdeploy/releases/latest/download/linuxdeploy-${{ matrix.arch }}.AppImage
|
||||
sudo cp -f linuxdeploy-${{ matrix.arch }}.AppImage /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/linuxdeploy-${{ matrix.arch }}.AppImage
|
||||
|
||||
# Set up linuxdeploy-plugin-qt for Qt bundling in AppImage
|
||||
curl -sLO https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/latest/download/linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage
|
||||
sudo cp -f linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/linuxdeploy-plugin-qt-${{ matrix.arch }}.AppImage
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
|
||||
- name: Set up build environment (Windows)
|
||||
run: |
|
||||
vcpkg install zlib boost-system boost-filesystem boost-program-options boost-icl boost-variant curl openssl --triplet=${{ matrix.vcpkg_triplet }}
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
if: startsWith(matrix.os, 'windows') && !matrix.msys2_env
|
||||
|
||||
- uses: ilammy/msvc-dev-cmd@v1
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
if: startsWith(matrix.os, 'windows') && !matrix.msys2_env
|
||||
|
||||
- name: Set up build environment (Android)
|
||||
run: |
|
||||
@@ -134,18 +135,39 @@ jobs:
|
||||
if: matrix.os == 'android'
|
||||
|
||||
- uses: mozilla-actions/sccache-action@v0.0.10
|
||||
if: startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'ubuntu')
|
||||
if: (startsWith(matrix.os, 'windows') && !matrix.msys2_env) || startsWith(matrix.os, 'ubuntu')
|
||||
with:
|
||||
disable_annotations: true
|
||||
|
||||
- name: Set up MSYS2 (Windows ARM)
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: ${{ matrix.msys2_env }}
|
||||
update: true
|
||||
cache: true
|
||||
install: |
|
||||
git
|
||||
pacboy: |
|
||||
clang:p
|
||||
ccache:p
|
||||
cmake:p
|
||||
lld:p
|
||||
ninja:p
|
||||
boost:p
|
||||
openssl:p
|
||||
curl:p
|
||||
zlib:p
|
||||
qt6-base:p
|
||||
qt6-svg:p
|
||||
qt6-tools:p
|
||||
if: matrix.msys2_env
|
||||
|
||||
- name: Restore Gradle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: '~/.gradle/'
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-${{ hashFiles('android/**/*.xml') }}-${{ hashFiles('android/**.java') }}
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties', 'android/**/*.xml', 'android/**/*.java', 'android/**/*.kt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-${{ hashFiles('android/**/*.xml') }}-
|
||||
${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}-
|
||||
${{ runner.os }}-gradle-
|
||||
if: matrix.os == 'android'
|
||||
|
||||
@@ -162,11 +184,42 @@ jobs:
|
||||
run: ccache -z
|
||||
if: startsWith(matrix.os, 'macos')
|
||||
|
||||
- name: Install Qt6
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: '6.8.2'
|
||||
cache: true
|
||||
if: matrix.os != 'android' && !matrix.msys2_env
|
||||
|
||||
- name: Build Vita3K
|
||||
run: |
|
||||
cmake ${{ matrix.extra_cmake_args }} --preset ${{ matrix.cmake_preset }}
|
||||
cmake --build build/${{ matrix.cmake_preset }} --config ${{ env.BUILD_CONFIG }}
|
||||
if: matrix.os != 'android'
|
||||
if: matrix.os != 'android' && !matrix.msys2_env
|
||||
|
||||
- name: Build Vita3K (MSYS2)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export CCACHE_DIR=$(cygpath -u "C:/msys2-ccache")
|
||||
cmake -G "Ninja Multi-Config" -B build/${{ matrix.cmake_preset }} \
|
||||
-DCMAKE_C_COMPILER=clang \
|
||||
-DCMAKE_CXX_COMPILER=clang++ \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
|
||||
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
|
||||
cmake --build build/${{ matrix.cmake_preset }} --config ${{ env.BUILD_CONFIG }}
|
||||
if: matrix.msys2_env
|
||||
|
||||
- name: Deploy MSYS2 runtime libraries
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
BINDIR="build/${{ matrix.cmake_preset }}/bin/${{ env.BUILD_CONFIG }}"
|
||||
MSYS2_BINDIR="/${MSYSTEM,,}/bin"
|
||||
for dll in libc++.dll libunwind.dll; do
|
||||
if [ -f "$MSYS2_BINDIR/$dll" ]; then
|
||||
cp "$MSYS2_BINDIR/$dll" "$BINDIR/"
|
||||
fi
|
||||
done
|
||||
if: matrix.msys2_env
|
||||
|
||||
- name: Print ccache statistics
|
||||
run: ccache -s
|
||||
@@ -174,7 +227,7 @@ jobs:
|
||||
|
||||
- name: Print sccache statistics
|
||||
run: sccache --show-stats
|
||||
if: startsWith(matrix.os, 'windows') || startsWith(matrix.os, 'ubuntu')
|
||||
if: (startsWith(matrix.os, 'windows') && !matrix.msys2_env) || startsWith(matrix.os, 'ubuntu')
|
||||
|
||||
- name: Build Vita3K (Android)
|
||||
env:
|
||||
@@ -185,23 +238,26 @@ jobs:
|
||||
JVM_OPTS: -Xmx6G
|
||||
VCPKG_ROOT: /usr/local/share/vcpkg
|
||||
run: |
|
||||
cp -r data android/assets
|
||||
cp -r lang android/assets
|
||||
cp -r vita3k/shaders-builtin android/assets
|
||||
export JAVA_HOME=$(echo $JAVA_HOME_17_X64)
|
||||
mkdir -p android/app/assets
|
||||
rm -rf android/app/assets/data android/app/assets/shaders-builtin
|
||||
cp -r data android/app/assets/data
|
||||
cp -r vita3k/shaders-builtin android/app/assets/shaders-builtin
|
||||
export JAVA_HOME="$JAVA_HOME_17_X64"
|
||||
chmod +x android/gradlew
|
||||
if [ -e "$SIGNING_STORE_PATH" ]; then
|
||||
BUILD_TYPE=Release
|
||||
else
|
||||
BUILD_TYPE=Reldebug
|
||||
fi
|
||||
./gradlew --stacktrace --configuration-cache --build-cache --parallel --configure-on-demand assemble${BUILD_TYPE}
|
||||
echo "APK_PATH=android/build/outputs/apk/${BUILD_TYPE,,}/android-${BUILD_TYPE,,}.apk" >> $GITHUB_ENV
|
||||
cd android
|
||||
./gradlew --stacktrace --configuration-cache --build-cache --parallel --configure-on-demand :app:assemble${BUILD_TYPE}
|
||||
echo "APK_PATH=android/app/build/outputs/apk/${BUILD_TYPE,,}/app-${BUILD_TYPE,,}.apk" >> $GITHUB_ENV
|
||||
if: matrix.os == 'android'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: build/${{ matrix.cmake_preset }}
|
||||
run: ctest --build-config ${{ env.BUILD_CONFIG }} --output-on-failure
|
||||
if: matrix.os != 'android'
|
||||
if: matrix.os != 'android' && !matrix.msys2_env
|
||||
|
||||
- name: Compute git short SHA
|
||||
shell: bash
|
||||
|
||||
@@ -56,6 +56,13 @@ jobs:
|
||||
with:
|
||||
disable_annotations: true
|
||||
|
||||
- name: Install Qt6
|
||||
uses: jurplel/install-qt-action@v4
|
||||
with:
|
||||
version: '6.8.2'
|
||||
cache: true
|
||||
install-deps: 'true'
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
|
||||
@@ -53,6 +53,9 @@ proguard/
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# Kotlin
|
||||
.kotlin/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
@@ -64,9 +67,6 @@ captures/
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
|
||||
# CXX compile cache
|
||||
android/.cxx
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
|
||||
@@ -23,12 +23,6 @@
|
||||
[submodule "external/googletest"]
|
||||
path = external/googletest
|
||||
url = https://github.com/google/googletest
|
||||
[submodule "external/imgui"]
|
||||
path = external/imgui
|
||||
url = https://github.com/ocornut/imgui
|
||||
[submodule "external/imgui_club"]
|
||||
path = external/imgui_club
|
||||
url = https://github.com/ocornut/imgui_club
|
||||
[submodule "external/libfat16"]
|
||||
path = external/libfat16
|
||||
url = https://github.com/Vita3K/libfat16
|
||||
|
||||
@@ -51,7 +51,6 @@ else()
|
||||
option(BUILD_APPIMAGE "Build an AppImage." OFF)
|
||||
endif()
|
||||
|
||||
option(USE_VITA3K_UPDATE "Build Vita3K with updater." ON)
|
||||
option(FORCE_BUILD_OPENSSL_MAC OFF)
|
||||
|
||||
if("${CMAKE_CXX_COMPILER_LAUNCHER}" STREQUAL "")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Build artifacts
|
||||
.gradle/
|
||||
build/
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
.kotlin/
|
||||
|
||||
# Local configuration
|
||||
local.properties
|
||||
|
||||
# IDE specific
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.navigation/
|
||||
captures/
|
||||
.cache/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,139 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||
|
||||
def signingStorePath = System.getenv("SIGNING_STORE_PATH")
|
||||
def signingStorePassword = System.getenv("SIGNING_STORE_PASSWORD")
|
||||
def signingKeyAlias = System.getenv("SIGNING_KEY_ALIAS")
|
||||
def signingKeyPassword = System.getenv("SIGNING_KEY_PASSWORD")
|
||||
def hasCiSigning = signingStorePath && signingStorePassword && signingKeyAlias && signingKeyPassword
|
||||
|
||||
android {
|
||||
namespace "org.vita3k.emulator"
|
||||
compileSdk 35
|
||||
ndkVersion "27.3.13750724"
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
compose true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.vita3k.emulator"
|
||||
minSdk 28
|
||||
targetSdk 35
|
||||
versionCode 21
|
||||
versionName "0.2.1"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
abiFilters.addAll( 'arm64-v8a' )
|
||||
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" // required until NDK r28
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
ci {
|
||||
if (hasCiSigning) {
|
||||
storeFile file(signingStorePath)
|
||||
storePassword signingStorePassword
|
||||
keyAlias signingKeyAlias
|
||||
keyPassword signingKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
debuggable true
|
||||
jniDebuggable true
|
||||
applicationIdSuffix '.debug'
|
||||
}
|
||||
|
||||
reldebug {
|
||||
debuggable true
|
||||
jniDebuggable true
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments "-DCMAKE_BUILD_TYPE=RelWithDebInfo"
|
||||
}
|
||||
}
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
applicationIdSuffix '.debug'
|
||||
signingConfig = signingConfigs.debug
|
||||
}
|
||||
|
||||
release {
|
||||
debuggable false
|
||||
jniDebuggable false
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments "-DCMAKE_BUILD_TYPE=Release"
|
||||
}
|
||||
}
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig = hasCiSigning ? signingConfigs.ci : signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['../prebuilt']
|
||||
assets {
|
||||
srcDirs 'assets'
|
||||
}
|
||||
java {
|
||||
srcDirs += ['../../external/sdl/android-project/app/src/main/java']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path '../../CMakeLists.txt'
|
||||
version '3.22.1+'
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
checkReleaseBuilds false
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation "androidx.core:core:1.12.0"
|
||||
implementation 'androidx.core:core-google-shortcuts:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'com.getkeepsafe.relinker:relinker:1.4.5'
|
||||
|
||||
implementation platform('androidx.compose:compose-bom:2025.04.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.activity:activity-compose:1.9.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
|
||||
implementation 'androidx.navigation:navigation-compose:2.8.5'
|
||||
implementation 'io.coil-kt:coil-compose:2.7.0'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# this is necessary so that native functions can call Java ones using JNI
|
||||
-keep class org.libsdl.app** { *; }
|
||||
-keep class org.vita3k.emulator.NativeLib { *; }
|
||||
-keep class org.vita3k.emulator.Emulator { *; }
|
||||
-keep class org.vita3k.emulator.EmuSurface { *; }
|
||||
-keep class org.vita3k.emulator.overlay.** { *; }
|
||||
-keep class org.vita3k.emulator.data.EmulatorConfig { *; }
|
||||
-keep class org.vita3k.emulator.data.NativeAppInfo { *; }
|
||||
-keep class org.vita3k.emulator.data.NativeImeState { *; }
|
||||
-keep class org.vita3k.emulator.data.NativeUser { *; }
|
||||
-keep interface org.vita3k.emulator.data.InstallCallback { *; }
|
||||
@@ -61,11 +61,6 @@
|
||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
android:minSdkVersion="30" />
|
||||
|
||||
<!-- Allow the app to request installing APKs from unknown sources (introduced in Android 8 / API 26) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
android:minSdkVersion="26"/>
|
||||
|
||||
<!-- Allow access to Bluetooth devices -->
|
||||
<!-- Currently this is just for Steam Controller support and requires setting SDL_HINT_JOYSTICK_HIDAPI_STEAM -->
|
||||
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> -->
|
||||
@@ -73,6 +68,9 @@
|
||||
|
||||
<!-- Allow access to the vibrator -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<uses-permission android:name = "android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name = "android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -90,7 +88,8 @@
|
||||
|
||||
The requestLegacyExternalStorage parameter only has an effect when running on Android <= 10
|
||||
-->
|
||||
<application android:label="@string/app_name"
|
||||
<application android:name=".Vita3KApplication"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
|
||||
@@ -98,62 +97,44 @@
|
||||
android:appCategory="game"
|
||||
android:isGame="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
tools:targetApi="q">
|
||||
|
||||
<!-- Example of setting SDL hints from AndroidManifest.xml:
|
||||
<meta-data android:name="SDL_ENV.SDL_ACCELEROMETER_AS_JOYSTICK" android:value="0"/>
|
||||
-->
|
||||
|
||||
<activity android:name="Emulator"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:theme="@style/Theme.Design.NoActionBar"
|
||||
<!-- Main Compose UI activity (launcher) -->
|
||||
<activity android:name=".MainActivity"
|
||||
android:theme="@style/Theme.Vita3K.Starting"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode|layoutDirection|locale|keyboard|keyboardHidden"
|
||||
tools:targetApi="r">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Let Android know that we can handle some USB devices and should receive this event -->
|
||||
</activity>
|
||||
|
||||
<!-- SDL game rendering activity -->
|
||||
<activity android:name=".Emulator"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:theme="@style/Theme.Design.NoActionBar"
|
||||
android:exported="true"
|
||||
tools:targetApi="r">
|
||||
|
||||
<!-- USB device handling -->
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<!-- Drop file event -->
|
||||
<!--
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
-->
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="org.vita3k.emulator.provider.VitaDocumentsProvider"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
<service
|
||||
android:name=".InstallForegroundService"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths">
|
||||
</meta-data>
|
||||
</provider>
|
||||
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -44,5 +44,4 @@ public class EmuSurface extends SDLSurface {
|
||||
}
|
||||
|
||||
public native void setSurfaceStatus(boolean surface_present);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.view.InputDevice
|
||||
|
||||
enum class ControllerFamily {
|
||||
Standard,
|
||||
Xbox,
|
||||
Nintendo
|
||||
}
|
||||
|
||||
data class ConnectedGamepad(
|
||||
val deviceId: Int,
|
||||
val name: String,
|
||||
val family: ControllerFamily
|
||||
)
|
||||
|
||||
object InputDeviceUtils {
|
||||
@JvmStatic
|
||||
fun hasPhysicalGamepadConnected(): Boolean {
|
||||
return getPhysicalGamepads().isNotEmpty()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getPhysicalGamepads(): List<ConnectedGamepad> {
|
||||
val controllers = mutableListOf<ConnectedGamepad>()
|
||||
val deviceIds = InputDevice.getDeviceIds()
|
||||
for (deviceId in deviceIds) {
|
||||
val device = InputDevice.getDevice(deviceId) ?: continue
|
||||
if (device.isVirtual) {
|
||||
continue
|
||||
}
|
||||
|
||||
val name = device.name.orEmpty()
|
||||
if (name.startsWith("uinput-") || name.startsWith("gf_")) {
|
||||
continue
|
||||
}
|
||||
|
||||
val sources = device.sources
|
||||
val isGamepad = (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
if (isGamepad) {
|
||||
controllers += ConnectedGamepad(
|
||||
deviceId = deviceId,
|
||||
name = name,
|
||||
family = inferControllerFamily(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return controllers.sortedBy { it.deviceId }
|
||||
}
|
||||
|
||||
private fun inferControllerFamily(name: String): ControllerFamily {
|
||||
val normalized = name.lowercase()
|
||||
return when {
|
||||
normalized.contains("xbox") || normalized.contains("x-input") -> ControllerFamily.Xbox
|
||||
normalized.contains("switch") || normalized.contains("joy-con") || normalized.contains("nintendo") -> ControllerFamily.Nintendo
|
||||
else -> ControllerFamily.Standard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.vita3k.emulator.data.InstallRepository
|
||||
import org.vita3k.emulator.ui.viewmodel.DeleteSourceOption
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallResult
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallResultStatus
|
||||
import java.io.File
|
||||
|
||||
class InstallForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var activeOperationId: Long = 0L
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val operationId = intent?.getLongExtra(InstallServiceController.EXTRA_OPERATION_ID, 0L) ?: 0L
|
||||
val operationTypeName = intent?.getStringExtra(InstallServiceController.EXTRA_OPERATION_TYPE)
|
||||
val operationType = operationTypeName?.let(InstallOperationType::valueOf)
|
||||
if (operationId == 0L || operationType == null) {
|
||||
stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (activeOperationId != 0L && activeOperationId != operationId) {
|
||||
stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
activeOperationId = operationId
|
||||
ensureNotificationChannel()
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
buildNotification(
|
||||
InstallServiceController.state.value.statusMessage.ifBlank {
|
||||
getString(R.string.install_progress_title)
|
||||
}
|
||||
)
|
||||
)
|
||||
acquireWakeLock()
|
||||
NativeLib.prepareFrontend()
|
||||
|
||||
serviceScope.launch {
|
||||
val result = runOperation(operationId, operationType, intent)
|
||||
InstallServiceController.finish(operationId, result)
|
||||
updateNotification(result.message)
|
||||
releaseWakeLock()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelfResult(startId)
|
||||
activeOperationId = 0L
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseWakeLock()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private suspend fun runOperation(
|
||||
operationId: Long,
|
||||
operationType: InstallOperationType,
|
||||
intent: Intent
|
||||
): InstallResult = try {
|
||||
when (operationType) {
|
||||
InstallOperationType.FIRMWARE -> installFirmware(
|
||||
operationId = operationId,
|
||||
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
|
||||
)
|
||||
InstallOperationType.PKG -> installPkg(
|
||||
operationId = operationId,
|
||||
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty(),
|
||||
zrif = intent.getStringExtra(InstallServiceController.EXTRA_ZRIF).orEmpty(),
|
||||
licensePath = intent.getStringExtra(InstallServiceController.EXTRA_LICENSE_PATH)
|
||||
)
|
||||
InstallOperationType.ARCHIVE -> installArchive(
|
||||
operationId = operationId,
|
||||
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
|
||||
)
|
||||
InstallOperationType.ARCHIVE_FOLDER -> installArchiveFolder(
|
||||
operationId = operationId,
|
||||
paths = intent.getStringArrayListExtra(InstallServiceController.EXTRA_PATHS).orEmpty()
|
||||
)
|
||||
InstallOperationType.LICENSE_FILE -> installLicense(
|
||||
path = intent.getStringExtra(InstallServiceController.EXTRA_PATH).orEmpty()
|
||||
)
|
||||
InstallOperationType.LICENSE_ZRIF -> installLicenseFromZrif(
|
||||
zrif = intent.getStringExtra(InstallServiceController.EXTRA_ZRIF).orEmpty()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_error_generic, e.message ?: "")
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun installFirmware(operationId: Long, path: String): InstallResult {
|
||||
val deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_firmware_file, path)
|
||||
)
|
||||
val version = InstallRepository.installFirmware(path) { pct, status ->
|
||||
onProgress(operationId, pct, status)
|
||||
}
|
||||
return if (version.isNotEmpty()) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_firmware, version),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
} else {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_firmware),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installPkg(
|
||||
operationId: Long,
|
||||
path: String,
|
||||
zrif: String,
|
||||
licensePath: String?
|
||||
): InstallResult {
|
||||
val deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_package_file, path),
|
||||
deleteOption(R.string.install_delete_license_file, licensePath)
|
||||
)
|
||||
val success = InstallRepository.installPkg(path, zrif) { pct, status ->
|
||||
onProgress(operationId, pct, status)
|
||||
}
|
||||
return if (success) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_package),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
} else {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_package),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installArchive(operationId: Long, path: String): InstallResult {
|
||||
val deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_archive_files, path)
|
||||
)
|
||||
val success = InstallRepository.installArchive(path, forceReinstall = true) { pct, status ->
|
||||
onProgress(operationId, pct, status)
|
||||
}
|
||||
return if (success) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_archive),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
} else {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_archive),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installArchiveFolder(operationId: Long, paths: List<String>): InstallResult {
|
||||
if (paths.isEmpty()) {
|
||||
return InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_error_archive_folder_empty)
|
||||
)
|
||||
}
|
||||
|
||||
val deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_archive_files, paths)
|
||||
)
|
||||
val total = paths.size
|
||||
var successCount = 0
|
||||
|
||||
paths.forEachIndexed { index, path ->
|
||||
val archiveName = File(path).name.ifBlank { path }
|
||||
val batchStatus = getString(
|
||||
R.string.install_status_archive_batch,
|
||||
index + 1,
|
||||
total,
|
||||
archiveName
|
||||
)
|
||||
InstallServiceController.updateProgress(operationId, ((index * 100f) / total).toInt(), batchStatus)
|
||||
val success = InstallRepository.installArchive(path, forceReinstall = true) { pct, _ ->
|
||||
val overall = ((index + (pct / 100f)) / total.toFloat()) * 100f
|
||||
onProgress(operationId, overall.toInt(), batchStatus)
|
||||
}
|
||||
if (success) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
val failureCount = total - successCount
|
||||
return when {
|
||||
successCount == total -> InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_archive_batch, successCount),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
successCount > 0 -> InstallResult(
|
||||
status = InstallResultStatus.PARTIAL,
|
||||
message = getString(R.string.install_partial_archive_batch, successCount, failureCount),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
else -> InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_archive_batch, total),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installLicense(path: String): InstallResult {
|
||||
val deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_license_file, path)
|
||||
)
|
||||
val success = InstallRepository.copyLicense(path)
|
||||
return if (success) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_license),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
} else {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_license),
|
||||
deleteOptions = deleteOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun installLicenseFromZrif(zrif: String): InstallResult {
|
||||
val success = InstallRepository.createLicense(zrif)
|
||||
return if (success) {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.SUCCESS,
|
||||
message = getString(R.string.install_success_license)
|
||||
)
|
||||
} else {
|
||||
InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = getString(R.string.install_failed_license_zrif)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onProgress(operationId: Long, percent: Int, status: String) {
|
||||
InstallServiceController.updateProgress(operationId, percent, status)
|
||||
updateNotification(status)
|
||||
}
|
||||
|
||||
private fun buildNotification(status: String) =
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.install_progress_title))
|
||||
.setContentText(status)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
private fun updateNotification(status: String) {
|
||||
ensureNotificationChannel()
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, buildNotification(status))
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.install_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.install_notification_channel_desc)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) {
|
||||
return
|
||||
}
|
||||
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"org.vita3k.emulator:install"
|
||||
).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire(WAKE_LOCK_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
wakeLock?.let { lock ->
|
||||
if (lock.isHeld) {
|
||||
lock.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun deleteOption(labelResId: Int, path: String?): DeleteSourceOption? =
|
||||
deleteOption(labelResId, listOfNotNull(path))
|
||||
|
||||
private fun deleteOption(labelResId: Int, paths: List<String>): DeleteSourceOption? {
|
||||
val normalized = paths
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
if (normalized.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return DeleteSourceOption(
|
||||
label = getString(labelResId),
|
||||
paths = normalized
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceDeleteOptions(vararg options: DeleteSourceOption?): List<DeleteSourceOption> =
|
||||
options.filterNotNull()
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "install_progress"
|
||||
private const val NOTIFICATION_ID = 0x317
|
||||
private const val WAKE_LOCK_TIMEOUT_MS = 6L * 60L * 60L * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallResult
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
enum class InstallOperationType {
|
||||
FIRMWARE,
|
||||
PKG,
|
||||
ARCHIVE,
|
||||
ARCHIVE_FOLDER,
|
||||
LICENSE_FILE,
|
||||
LICENSE_ZRIF
|
||||
}
|
||||
|
||||
data class InstallServiceState(
|
||||
val operationId: Long = 0L,
|
||||
val operationType: InstallOperationType? = null,
|
||||
val installing: Boolean = false,
|
||||
val progress: Int = 0,
|
||||
val statusMessage: String = "",
|
||||
val installResult: InstallResult? = null
|
||||
)
|
||||
|
||||
object InstallServiceController {
|
||||
const val EXTRA_OPERATION_ID = "operation_id"
|
||||
const val EXTRA_OPERATION_TYPE = "operation_type"
|
||||
const val EXTRA_PATH = "path"
|
||||
const val EXTRA_PATHS = "paths"
|
||||
const val EXTRA_ZRIF = "zrif"
|
||||
const val EXTRA_LICENSE_PATH = "license_path"
|
||||
|
||||
private val nextOperationId = AtomicLong(1L)
|
||||
private val mutableState = MutableStateFlow(InstallServiceState())
|
||||
|
||||
val state: StateFlow<InstallServiceState> = mutableState
|
||||
|
||||
fun startFirmware(context: Context, path: String): Long =
|
||||
start(context, InstallOperationType.FIRMWARE, context.getString(org.vita3k.emulator.R.string.install_status_firmware)) {
|
||||
putExtra(EXTRA_PATH, path)
|
||||
}
|
||||
|
||||
fun startPkg(context: Context, path: String, zrif: String, licensePath: String?): Long =
|
||||
start(context, InstallOperationType.PKG, context.getString(org.vita3k.emulator.R.string.install_status_package)) {
|
||||
putExtra(EXTRA_PATH, path)
|
||||
putExtra(EXTRA_ZRIF, zrif)
|
||||
putExtra(EXTRA_LICENSE_PATH, licensePath)
|
||||
}
|
||||
|
||||
fun startArchive(context: Context, path: String): Long =
|
||||
start(context, InstallOperationType.ARCHIVE, context.getString(org.vita3k.emulator.R.string.install_status_archive)) {
|
||||
putExtra(EXTRA_PATH, path)
|
||||
}
|
||||
|
||||
fun startArchiveFolder(context: Context, paths: List<String>): Long =
|
||||
start(context, InstallOperationType.ARCHIVE_FOLDER, context.getString(org.vita3k.emulator.R.string.install_status_archive)) {
|
||||
putStringArrayListExtra(EXTRA_PATHS, ArrayList(paths))
|
||||
}
|
||||
|
||||
fun startLicenseFile(context: Context, path: String): Long =
|
||||
start(context, InstallOperationType.LICENSE_FILE, context.getString(org.vita3k.emulator.R.string.install_status_license)) {
|
||||
putExtra(EXTRA_PATH, path)
|
||||
}
|
||||
|
||||
fun startLicenseZrif(context: Context, zrif: String): Long =
|
||||
start(context, InstallOperationType.LICENSE_ZRIF, context.getString(org.vita3k.emulator.R.string.install_status_license)) {
|
||||
putExtra(EXTRA_ZRIF, zrif)
|
||||
}
|
||||
|
||||
fun updateProgress(operationId: Long, percent: Int, status: String) {
|
||||
mutableState.update { state ->
|
||||
if (state.operationId != operationId) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
installing = true,
|
||||
progress = percent.coerceIn(0, 100),
|
||||
statusMessage = status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finish(operationId: Long, result: InstallResult, progress: Int = 100, status: String = "") {
|
||||
mutableState.update { state ->
|
||||
if (state.operationId != operationId) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
installing = false,
|
||||
progress = progress.coerceIn(0, 100),
|
||||
statusMessage = status.ifBlank { state.statusMessage },
|
||||
installResult = result
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearResult() {
|
||||
mutableState.update { state ->
|
||||
state.copy(installResult = null)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun start(
|
||||
context: Context,
|
||||
type: InstallOperationType,
|
||||
initialStatus: String,
|
||||
extras: Intent.() -> Unit
|
||||
): Long {
|
||||
val operationId = nextOperationId.getAndIncrement()
|
||||
mutableState.value = InstallServiceState(
|
||||
operationId = operationId,
|
||||
operationType = type,
|
||||
installing = true,
|
||||
statusMessage = initialStatus
|
||||
)
|
||||
|
||||
val intent = Intent(context, InstallForegroundService::class.java).apply {
|
||||
putExtra(EXTRA_OPERATION_ID, operationId)
|
||||
putExtra(EXTRA_OPERATION_TYPE, type.name)
|
||||
extras()
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
return operationId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.vita3k.emulator.data.AppStorage
|
||||
import org.vita3k.emulator.ui.navigation.AppNavigation
|
||||
import org.vita3k.emulator.ui.theme.Vita3KTheme
|
||||
import org.vita3k.emulator.ui.viewmodel.AppsListViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val appsListViewModel: AppsListViewModel by viewModels()
|
||||
private val installViewModel: InstallViewModel by viewModels()
|
||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||
private val userManagementViewModel: UserManagementViewModel by viewModels()
|
||||
|
||||
private val emulatorLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (appsListViewModel.initialized) {
|
||||
appsListViewModel.reloadAppsList()
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingFolderCallback: ((String?) -> Unit)? = null
|
||||
private var pendingFileCallback: ((String?) -> Unit)? = null
|
||||
private var pendingArchiveFolderCallback: ((List<String>) -> Unit)? = null
|
||||
private var pendingInstallFileExtensions: Set<String>? = null
|
||||
private var pendingArchiveFolderExtensions: Set<String>? = null
|
||||
private var pendingStorageAction: (() -> Unit)? = null
|
||||
|
||||
private val folderPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) {
|
||||
if (StorageAccess.hasStorageAccess(this)) {
|
||||
launchPendingStorageAction()
|
||||
} else {
|
||||
cancelPendingStorageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private val manageFolderAccessLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (StorageAccess.hasStorageAccess(this)) {
|
||||
launchPendingStorageAction()
|
||||
} else {
|
||||
cancelPendingStorageRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private val filePickerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val selectedPath = if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> StorageAccess.resolveUriToPath(this, uri) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val validatedPath = selectedPath?.takeIf { path ->
|
||||
pendingInstallFileExtensions?.let { allowed ->
|
||||
StorageAccess.matchesAllowedExtension(path, allowed)
|
||||
} ?: true
|
||||
}
|
||||
dispatchFileResult(validatedPath)
|
||||
}
|
||||
|
||||
private val folderPickerLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (pendingArchiveFolderCallback != null) {
|
||||
val selectedPaths = if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { treeUri ->
|
||||
StorageAccess.resolveTreeFilePaths(
|
||||
context = this,
|
||||
treeUri = treeUri,
|
||||
allowedExtensions = pendingArchiveFolderExtensions ?: emptySet()
|
||||
)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
dispatchArchiveFolderResult(selectedPaths)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val selectedPath = if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri -> StorageAccess.resolveTreeUriToPath(this, uri) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
dispatchFolderResult(selectedPath)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
prepareFrontendRuntime()
|
||||
setTheme(R.style.Theme_Vita3K)
|
||||
|
||||
val storagePath = AppStorage.storageRootPath(this)
|
||||
appsListViewModel.initialize(storagePath)
|
||||
|
||||
setContent {
|
||||
Vita3KTheme {
|
||||
AppNavigation(
|
||||
appsListViewModel = appsListViewModel,
|
||||
installViewModel = installViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
userManagementViewModel = userManagementViewModel,
|
||||
onAppLaunch = { app -> launchApp(app.titleId, app.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStorageFolderChange(onResult: (String?) -> Unit) {
|
||||
requestFolderPath(onResult)
|
||||
}
|
||||
|
||||
fun requestFolderPath(onResult: (String?) -> Unit) {
|
||||
pendingFolderCallback = onResult
|
||||
pendingFileCallback = null
|
||||
pendingArchiveFolderCallback = null
|
||||
pendingInstallFileExtensions = null
|
||||
pendingArchiveFolderExtensions = null
|
||||
pendingStorageAction = { launchFolderPicker() }
|
||||
ensureStorageAccess()
|
||||
}
|
||||
|
||||
fun requestFilePath(mimeTypes: Array<String>, onResult: (String?) -> Unit) {
|
||||
pendingFileCallback = onResult
|
||||
pendingFolderCallback = null
|
||||
pendingArchiveFolderCallback = null
|
||||
pendingInstallFileExtensions = null
|
||||
pendingArchiveFolderExtensions = null
|
||||
pendingStorageAction = { launchFilePicker(mimeTypes) }
|
||||
ensureStorageAccess()
|
||||
}
|
||||
|
||||
fun requestInstallFilePath(
|
||||
allowedExtensions: Set<String>,
|
||||
onResult: (String?) -> Unit
|
||||
) {
|
||||
pendingFileCallback = onResult
|
||||
pendingFolderCallback = null
|
||||
pendingArchiveFolderCallback = null
|
||||
pendingInstallFileExtensions = allowedExtensions
|
||||
pendingArchiveFolderExtensions = null
|
||||
pendingStorageAction = { launchInstallFilePicker() }
|
||||
ensureStorageAccess()
|
||||
}
|
||||
|
||||
fun requestArchiveFolderPaths(
|
||||
allowedExtensions: Set<String>,
|
||||
onResult: (List<String>) -> Unit
|
||||
) {
|
||||
pendingFileCallback = null
|
||||
pendingFolderCallback = null
|
||||
pendingArchiveFolderCallback = onResult
|
||||
pendingInstallFileExtensions = null
|
||||
pendingArchiveFolderExtensions = allowedExtensions
|
||||
pendingStorageAction = { launchInstallFolderPicker() }
|
||||
ensureStorageAccess()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
prepareFrontendRuntime()
|
||||
}
|
||||
|
||||
private fun ensureStorageAccess() {
|
||||
if (StorageAccess.hasStorageAccess(this)) {
|
||||
launchPendingStorageAction()
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
manageFolderAccessLauncher.launch(StorageAccess.createManageAllFilesIntent(this))
|
||||
return
|
||||
}
|
||||
|
||||
folderPermissionLauncher.launch(StorageAccess.missingStoragePermissions(this))
|
||||
}
|
||||
|
||||
private fun launchApp(titleId: String, appTitle: String) {
|
||||
emulatorLauncher.launch(Emulator.createLaunchIntent(this, titleId, appTitle))
|
||||
}
|
||||
|
||||
private fun launchFilePicker(mimeTypes: Array<String>) {
|
||||
filePickerLauncher.launch(StorageAccess.createFilePickerIntent(mimeTypes))
|
||||
}
|
||||
|
||||
private fun launchInstallFilePicker() {
|
||||
filePickerLauncher.launch(StorageAccess.createInstallFilePickerIntent())
|
||||
}
|
||||
|
||||
private fun launchFolderPicker() {
|
||||
folderPickerLauncher.launch(StorageAccess.createFolderPickerIntent())
|
||||
}
|
||||
|
||||
private fun launchInstallFolderPicker() {
|
||||
folderPickerLauncher.launch(StorageAccess.createInstallFolderPickerIntent())
|
||||
}
|
||||
|
||||
private fun launchPendingStorageAction() {
|
||||
val action = pendingStorageAction ?: return
|
||||
pendingStorageAction = null
|
||||
action.invoke()
|
||||
}
|
||||
|
||||
private fun cancelPendingStorageRequest() {
|
||||
pendingStorageAction = null
|
||||
dispatchFileResult(null)
|
||||
dispatchFolderResult(null)
|
||||
dispatchArchiveFolderResult(emptyList())
|
||||
}
|
||||
|
||||
private fun dispatchFolderResult(path: String?) {
|
||||
val callback = pendingFolderCallback
|
||||
pendingFolderCallback = null
|
||||
callback?.invoke(path?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
private fun dispatchFileResult(path: String?) {
|
||||
val callback = pendingFileCallback
|
||||
pendingFileCallback = null
|
||||
pendingInstallFileExtensions = null
|
||||
callback?.invoke(path?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
private fun dispatchArchiveFolderResult(paths: List<String>) {
|
||||
val callback = pendingArchiveFolderCallback
|
||||
pendingArchiveFolderCallback = null
|
||||
pendingArchiveFolderExtensions = null
|
||||
callback?.invoke(paths)
|
||||
}
|
||||
|
||||
private fun prepareFrontendRuntime() {
|
||||
NativeLib.prepareFrontend()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import org.vita3k.emulator.data.EmulatorConfig
|
||||
import org.vita3k.emulator.data.InstallCallback
|
||||
import org.vita3k.emulator.data.NativeAppInfo
|
||||
import org.vita3k.emulator.data.NativeUser
|
||||
|
||||
/**
|
||||
* JNI bridge to the Vita3K C++ backend.
|
||||
* All methods are implemented in the native Android JNI modules under vita3k/android/jni/.
|
||||
* Game boot/stop is handled by the SDL-based Emulator activity.
|
||||
*/
|
||||
object NativeLib {
|
||||
const val PAUSE_REASON_USER = 1 shl 0
|
||||
const val PAUSE_REASON_MENU = 1 shl 1
|
||||
const val PAUSE_REASON_BACKGROUND = 1 shl 2
|
||||
|
||||
// --- Initialization ---
|
||||
external fun prepareFrontend(): Boolean
|
||||
external fun init(storagePath: String): Boolean
|
||||
external fun isInitialized(): Boolean
|
||||
external fun isOfficialBuild(): Boolean
|
||||
|
||||
// --- Apps list ---
|
||||
/** Returns detailed app info objects. */
|
||||
external fun getAppListDetailed(): Array<NativeAppInfo>
|
||||
external fun refreshAppsList()
|
||||
|
||||
// --- App actions ---
|
||||
/**
|
||||
* Dispatches a single app action identified by its AppActionMask bit.
|
||||
* See AppAction.maskBit for the values.
|
||||
*/
|
||||
external fun performAppAction(titleId: String, actionBit: Int): Boolean
|
||||
/** Returns a bitmask of available actions for the given title (AppActionMask bits). */
|
||||
external fun getAppActionAvailabilityMask(titleId: String): Int
|
||||
|
||||
// --- Firmware / Info ---
|
||||
external fun getAppVersion(): String
|
||||
external fun getFirmwareInstallStateMask(): Int
|
||||
external fun getCompatibilityDatabaseVersion(): String
|
||||
external fun installCompatibilityDatabase(zipData: ByteArray, version: String): Boolean
|
||||
/** Returns the installed application size in bytes, or 0 on error. */
|
||||
external fun getAppInstallSize(titleId: String): Long
|
||||
|
||||
// --- Installation ---
|
||||
/** Installs firmware from PUP file. Returns version string on success, empty on failure. */
|
||||
external fun installFirmware(path: String, callback: InstallCallback): String
|
||||
/** Installs a .pkg file. Pass zrif="" to skip license. */
|
||||
external fun installPkg(path: String, zrif: String, callback: InstallCallback): Boolean
|
||||
/**
|
||||
* Installs a .zip or .vpk archive.
|
||||
* @param forceReinstall If true, silently reinstalls if already present (no prompt).
|
||||
*/
|
||||
external fun installArchive(path: String, callback: InstallCallback, forceReinstall: Boolean): Boolean
|
||||
/** Copies a .rif or .bin license file. */
|
||||
external fun copyLicense(path: String): Boolean
|
||||
/** Creates and installs a license from a zRIF key. */
|
||||
external fun createLicense(zrif: String): Boolean
|
||||
/** Auto-finds a zRIF for a PKG from existing licenses. Returns empty string if not found. */
|
||||
external fun findPkgZrif(pkgPath: String): String
|
||||
/** Converts a .bin/.rif license file to a zRIF string. Returns empty string on failure. */
|
||||
external fun convertRifToZrif(path: String): String
|
||||
|
||||
// --- Settings / Config ---
|
||||
/** Returns the current global EmulatorConfig (all CurrentConfig + global fields). */
|
||||
external fun getGlobalConfig(): EmulatorConfig
|
||||
/** Returns the currently active emulated storage folder path. */
|
||||
external fun getCurrentEmulatorPath(): String
|
||||
/** Updates the active emulated storage folder path without changing the app storage root. */
|
||||
external fun setCurrentEmulatorPath(path: String): Boolean
|
||||
/** Returns a fresh EmulatorConfig populated with emulator default values. */
|
||||
external fun getDefaultConfig(): EmulatorConfig
|
||||
/** Returns true when a per-app custom config file exists for [titleId]. */
|
||||
external fun hasCustomConfig(titleId: String): Boolean
|
||||
/** Returns the saved custom config for [titleId], or null when none exists. */
|
||||
external fun getCustomConfig(titleId: String): EmulatorConfig?
|
||||
/**
|
||||
* Saves either the global config (when [titleId] is null) or a per-app custom config.
|
||||
* Returns the ids of the changed settings that still need a game restart when this save affected the active session.
|
||||
*/
|
||||
external fun saveSettings(titleId: String?, config: EmulatorConfig): IntArray
|
||||
/**
|
||||
* Deletes the per-app custom config file for [titleId].
|
||||
* Returns the ids of the changed settings that still need a game restart when this reset affected the active session.
|
||||
*/
|
||||
external fun deleteCustomConfig(titleId: String): IntArray
|
||||
/** Deletes all per-app custom config files and returns how many were removed. */
|
||||
external fun clearAllCustomConfigs(): Int
|
||||
/** Returns the support bitmask for Vulkan memory-mapping methods for the selected Android GPU driver. */
|
||||
external fun getSupportedMemoryMappingMask(customDriverName: String): Int
|
||||
/**
|
||||
* Returns [supportedMask, status] for the selected Android GPU driver.
|
||||
* status: 0 = default driver, 1 = custom driver loaded, 2 = custom driver fell back to default.
|
||||
*/
|
||||
external fun getCustomDriverSupportInfo(customDriverName: String): IntArray
|
||||
/** Returns true when the Android custom GPU driver option should be shown on this device. */
|
||||
external fun shouldShowCustomDriverOptions(): Boolean
|
||||
/** Returns the names of the camera devices currently visible to SDL. */
|
||||
external fun getAvailableCameras(): Array<String>
|
||||
/** Returns a flat array [label0, mask0, label1, mask1, ...] of available ad-hoc addresses. */
|
||||
external fun getAvailableAdhocAddresses(): Array<String>
|
||||
/** Returns the names of all installed custom GPU drivers. */
|
||||
external fun getInstalledCustomDrivers(): Array<String>
|
||||
/** Installs a custom GPU driver from a ZIP archive and returns its installed name, or empty on failure. */
|
||||
external fun installCustomDriver(path: String): String
|
||||
/** Removes an installed custom GPU driver by name. */
|
||||
external fun removeCustomDriver(driverName: String): Boolean
|
||||
|
||||
// --- Users ---
|
||||
/** Returns all frontend-visible Vita users with their active state. */
|
||||
external fun getUsers(): Array<NativeUser>
|
||||
/** Creates a user and returns its ID, or an empty string on failure. */
|
||||
external fun createUser(name: String): String
|
||||
/** Activates the selected user and persists the choice. */
|
||||
external fun activateUser(userId: String): Boolean
|
||||
/** Deletes the selected user and updates the active user when needed. */
|
||||
external fun deleteUser(userId: String): Boolean
|
||||
|
||||
// --- Running app session ---
|
||||
/** Blocks or releases gameplay input without changing the native pause state. */
|
||||
external fun setInputIntercepted(intercepted: Boolean): Boolean
|
||||
/** Enables or clears a specific native pause reason bit for the running app session. */
|
||||
external fun setPauseReasonEnabled(reasonMask: Int, enabled: Boolean): Boolean
|
||||
/** Returns true when an app session is currently active. */
|
||||
external fun isAppRunning(): Boolean
|
||||
/** Requests a graceful shutdown of the currently running app session. */
|
||||
external fun requestAppQuit(): Boolean
|
||||
/** Requests an orderly in-process relaunch into another app or executable. */
|
||||
external fun requestAppRelaunch(
|
||||
titleId: String,
|
||||
selfPath: String,
|
||||
args: Array<String>,
|
||||
loadExecReason: Boolean
|
||||
): Boolean
|
||||
/** Returns true when the currently running app session is paused. */
|
||||
external fun isAppPaused(): Boolean
|
||||
/** Returns the title of the currently running app session, or an empty string when unavailable. */
|
||||
external fun getRunningAppTitle(): String
|
||||
/** Returns true while either SceIme or CommonDialog IME is active. */
|
||||
external fun isImeActive(): Boolean
|
||||
/** Submits the currently active IME session as if the user pressed Enter/OK. */
|
||||
external fun submitIme(): Boolean
|
||||
/** Dismisses the currently active IME session as if the user pressed Close/Back. */
|
||||
external fun dismissIme(): Boolean
|
||||
/**
|
||||
* Returns a flat array [name0, "true"/"false", name1, "true"/"false", ...] listing all LLE
|
||||
* modules available in the installed firmware. Each pair is (module name, enabled state),
|
||||
* where "true" means the module is in [currentModules].
|
||||
*/
|
||||
external fun getModulesList(currentModules: Array<String>): Array<String>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
|
||||
object StorageAccess {
|
||||
|
||||
@JvmStatic
|
||||
fun hasStorageAccess(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
missingStoragePermissions(context).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun missingStoragePermissions(context: Context): Array<String> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return emptyArray()
|
||||
}
|
||||
|
||||
return arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
).filter { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createManageAllFilesIntent(context: Context): Intent {
|
||||
return Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
.setData(Uri.parse("package:${context.packageName}"))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFilePickerIntent(mimeTypes: Array<String>): Intent {
|
||||
val normalizedMimeTypes = mimeTypes
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.distinct()
|
||||
|
||||
val primaryMimeType = if (normalizedMimeTypes.size == 1) {
|
||||
normalizedMimeTypes.first()
|
||||
} else {
|
||||
"*/*"
|
||||
}
|
||||
|
||||
return Intent(Intent.ACTION_GET_CONTENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType(primaryMimeType)
|
||||
.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
|
||||
.apply {
|
||||
if (normalizedMimeTypes.size > 1) {
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, normalizedMimeTypes.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createInstallFilePickerIntent(): Intent {
|
||||
return Intent(Intent.ACTION_GET_CONTENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFolderPickerIntent(): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
.putExtra(Intent.EXTRA_LOCAL_ONLY, true)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createInstallFolderPickerIntent(): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resolveUriToPath(context: Context, uri: Uri): String? {
|
||||
return runCatching {
|
||||
val descriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: return null
|
||||
val rawPath = descriptor.use { android.system.Os.readlink("/proc/self/fd/${it.fd}") }
|
||||
normalizeResolvedPath(rawPath)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resolveTreeUriToPath(context: Context, treeUri: Uri): String? {
|
||||
val resolvedTreeUri = DocumentFile.fromTreeUri(context, treeUri)?.uri ?: treeUri
|
||||
return resolveUriToPath(context, resolvedTreeUri)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resolveTreeFilePaths(context: Context, treeUri: Uri, allowedExtensions: Set<String>): List<String> {
|
||||
val tree = DocumentFile.fromTreeUri(context, treeUri) ?: return emptyList()
|
||||
val normalizedExtensions = allowedExtensions
|
||||
.map(String::lowercase)
|
||||
.toSet()
|
||||
|
||||
return tree.listFiles()
|
||||
.asSequence()
|
||||
.filter(DocumentFile::isFile)
|
||||
.filter { document -> matchesAllowedExtension(document.name, normalizedExtensions) }
|
||||
.mapNotNull { document -> resolveUriToPath(context, document.uri) }
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun matchesAllowedExtension(pathOrName: String?, allowedExtensions: Set<String>): Boolean {
|
||||
if (allowedExtensions.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
val lowerName = pathOrName?.lowercase() ?: return false
|
||||
return allowedExtensions.any(lowerName::endsWith)
|
||||
}
|
||||
|
||||
private fun normalizeResolvedPath(rawPath: String): String {
|
||||
if (!rawPath.startsWith("/mnt/user/")) {
|
||||
return rawPath
|
||||
}
|
||||
|
||||
val relativePath = rawPath.removePrefix("/mnt/user/")
|
||||
return "/storage" + relativePath.substring(relativePath.indexOf('/'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.vita3k.emulator
|
||||
|
||||
import android.app.Application
|
||||
import org.libsdl.app.SDLActivity
|
||||
import org.vita3k.emulator.data.UiLanguages
|
||||
|
||||
class Vita3KApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
System.loadLibrary("Vita3K")
|
||||
SDLActivity.nativeSetenv("SDL_ANDROID_ALLOW_RECREATE_ACTIVITY", "1")
|
||||
UiLanguages.applyStored(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.vita3k.emulator;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
|
||||
import org.libsdl.app.SDLInputConnection;
|
||||
|
||||
public class VitaInputConnection extends SDLInputConnection {
|
||||
public VitaInputConnection(View targetView, boolean fullEditor) {
|
||||
super(targetView, fullEditor);
|
||||
}
|
||||
|
||||
private boolean isNativeImeActive() {
|
||||
try {
|
||||
return NativeLib.INSTANCE.isImeActive();
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Emulator getEmulator() {
|
||||
Context context = mEditText.getContext();
|
||||
return context instanceof Emulator ? (Emulator) context : null;
|
||||
}
|
||||
|
||||
public void clearConnectionState() {
|
||||
android.text.Editable content = getEditable();
|
||||
if (content != null) {
|
||||
content.clear();
|
||||
removeComposingSpans(content);
|
||||
}
|
||||
mCommittedText = "";
|
||||
}
|
||||
|
||||
private void submitFromKeyboard() {
|
||||
Emulator emulator = getEmulator();
|
||||
if (emulator != null) {
|
||||
emulator.completeImeFromKeyboard(mEditText);
|
||||
} else {
|
||||
try {
|
||||
NativeLib.INSTANCE.submitIme();
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
clearConnectionState();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performEditorAction(int actionCode) {
|
||||
if (isNativeImeActive()) {
|
||||
submitFromKeyboard();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.performEditorAction(actionCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean sendKeyEvent(KeyEvent event) {
|
||||
if (isNativeImeActive()
|
||||
&& event.getAction() == KeyEvent.ACTION_UP
|
||||
&& (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|
||||
|| event.getKeyCode() == KeyEvent.KEYCODE_NUMPAD_ENTER)) {
|
||||
submitFromKeyboard();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.sendKeyEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.vita3k.emulator;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import org.libsdl.app.SDLDummyEdit;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
public class VitaTextEdit extends SDLDummyEdit {
|
||||
private static final Field IC_FIELD = resolveField("ic");
|
||||
private static final Field INPUT_TYPE_FIELD = resolveField("input_type");
|
||||
|
||||
public VitaTextEdit(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
|
||||
InputConnection connection = new VitaInputConnection(this, true);
|
||||
setFieldValue(IC_FIELD, this, connection);
|
||||
|
||||
outAttrs.inputType = getInputTypeValue(this);
|
||||
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN;
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyPreIme(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && event.getAction() == KeyEvent.ACTION_UP) {
|
||||
try {
|
||||
if (NativeLib.INSTANCE.isImeActive()) {
|
||||
Context context = getContext();
|
||||
if (context instanceof Emulator) {
|
||||
((Emulator) context).dismissImeFromKeyboard(this);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return super.onKeyPreIme(keyCode, event);
|
||||
}
|
||||
|
||||
public void clearConnectionState() {
|
||||
Object connection = getFieldValue(IC_FIELD, this);
|
||||
if (connection instanceof VitaInputConnection) {
|
||||
((VitaInputConnection) connection).clearConnectionState();
|
||||
}
|
||||
}
|
||||
|
||||
public static int getInputTypeValue(SDLDummyEdit edit) {
|
||||
Object value = getFieldValue(INPUT_TYPE_FIELD, edit);
|
||||
return value instanceof Integer ? (Integer) value : 0;
|
||||
}
|
||||
|
||||
private static Field resolveField(String name) {
|
||||
try {
|
||||
Field field = SDLDummyEdit.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object getFieldValue(Field field, Object target) {
|
||||
if (field == null || target == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return field.get(target);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void setFieldValue(Field field, Object target, Object value) {
|
||||
if (field == null || target == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
field.set(target, value);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import java.io.File
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
enum class CompatibilityState(
|
||||
val value: Int,
|
||||
val colorHex: Long,
|
||||
val onColorHex: Long,
|
||||
@StringRes val labelResId: Int
|
||||
) {
|
||||
UNKNOWN(-1, 0xFF8A8A8A, 0xFFFFFFFF, R.string.compat_unknown),
|
||||
NOTHING(0, 0xFFFF0000, 0xFFFFFFFF, R.string.compat_nothing),
|
||||
BOOTABLE(1, 0xFF621FA5, 0xFFFFFFFF, R.string.compat_bootable),
|
||||
INTRO(2, 0xFFC71585, 0xFFFFFFFF, R.string.compat_intro),
|
||||
MENU(3, 0xFF1D76DB, 0xFFFFFFFF, R.string.compat_menu),
|
||||
INGAME_LESS(4, 0xFFE08A1E, 0xFFFFFFFF, R.string.compat_ingame_less),
|
||||
INGAME_MORE(5, 0xFFFFD700, 0xFF000000, R.string.compat_ingame_more),
|
||||
PLAYABLE(6, 0xFF0E8A16, 0xFFFFFFFF, R.string.compat_playable);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): CompatibilityState =
|
||||
entries.find { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
data class AppInfo(
|
||||
val titleId: String,
|
||||
val title: String,
|
||||
val category: String = "",
|
||||
val appVer: String = "",
|
||||
val iconPath: String? = null,
|
||||
val hasCustomConfig: Boolean = false,
|
||||
val compatibility: CompatibilityState = CompatibilityState.UNKNOWN,
|
||||
val lastPlayed: Long = 0,
|
||||
val playtime: Long = 0
|
||||
) {
|
||||
val iconFile: File?
|
||||
get() = iconPath?.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
enum class SortOption {
|
||||
TITLE, LAST_PLAYED, COMPATIBILITY
|
||||
}
|
||||
|
||||
enum class ViewMode {
|
||||
GRID, LIST
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.vita3k.emulator.NativeLib
|
||||
import org.vita3k.emulator.ui.viewmodel.AppAction
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
internal object AppRepository {
|
||||
private const val TAG = "Vita3K"
|
||||
private const val COMPAT_VERSION_URL =
|
||||
"https://api.github.com/repos/Vita3K/compatibility/releases/latest"
|
||||
private const val COMPAT_DB_URL =
|
||||
"https://github.com/Vita3K/compatibility/releases/download/compat_db/app_compat_db.xml.zip"
|
||||
private const val UPDATE_RELEASE_URL =
|
||||
"https://api.github.com/repos/Vita3K/Vita3K/releases/tags/continuous"
|
||||
private const val UPDATE_PAGE_URL =
|
||||
"https://github.com/Vita3K/Vita3K/releases/tag/continuous"
|
||||
private val compatVersionRegex =
|
||||
Regex("""Last updated: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}Z)""")
|
||||
private val updateBuildRegex = Regex("""Vita3K Build:\s*(\d+)""")
|
||||
private val currentBuildRegex = Regex("""^[^-]+-(\d+)(?:-|$)""")
|
||||
private val updateMetadataLineRegex =
|
||||
Regex("""^\s*(Corresponding commit:|Vita3K Build:)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
suspend fun initialize(storagePath: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.init(storagePath)
|
||||
}
|
||||
|
||||
suspend fun getAppVersion(): String = withContext(Dispatchers.IO) {
|
||||
NativeLib.getAppVersion()
|
||||
}
|
||||
|
||||
suspend fun getFirmwareInstallState(): FirmwareInstallState = withContext(Dispatchers.IO) {
|
||||
FirmwareInstallState.fromMask(NativeLib.getFirmwareInstallStateMask())
|
||||
}
|
||||
|
||||
suspend fun syncCompatibilityDatabase(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val currentVersion = NativeLib.getCompatibilityDatabaseVersion()
|
||||
val versionResponse = httpGetString(COMPAT_VERSION_URL) ?: return@withContext false
|
||||
val latestVersion = compatVersionRegex.find(versionResponse)?.groupValues?.getOrNull(1)
|
||||
?: return@withContext false
|
||||
|
||||
if (latestVersion == currentVersion) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
val zipData = httpGetBytes(COMPAT_DB_URL) ?: return@withContext false
|
||||
NativeLib.installCompatibilityDatabase(zipData, latestVersion)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Failed to sync compatibility database", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAppList(): List<AppInfo> = withContext(Dispatchers.IO) {
|
||||
NativeLib.getAppListDetailed().map(::toAppInfo)
|
||||
}
|
||||
|
||||
suspend fun refreshAppsList() = withContext(Dispatchers.IO) {
|
||||
NativeLib.refreshAppsList()
|
||||
}
|
||||
|
||||
suspend fun runAppAction(titleId: String, action: AppAction): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeLib.performAppAction(titleId, action.maskBit)
|
||||
}
|
||||
|
||||
suspend fun getAvailableAppActions(titleId: String): Set<AppAction> =
|
||||
withContext(Dispatchers.IO) {
|
||||
AppAction.fromMask(NativeLib.getAppActionAvailabilityMask(titleId))
|
||||
}
|
||||
|
||||
suspend fun getAppInstallSize(titleId: String): Long = withContext(Dispatchers.IO) {
|
||||
NativeLib.getAppInstallSize(titleId)
|
||||
}
|
||||
|
||||
suspend fun checkForUpdates(
|
||||
appVersion: String,
|
||||
officialBuild: Boolean
|
||||
): UpdateCheckResult = withContext(Dispatchers.IO) {
|
||||
val currentDisplayVersion = currentDisplayVersion(appVersion)
|
||||
val response = httpGetString(UPDATE_RELEASE_URL)
|
||||
?: return@withContext UpdateCheckResult(
|
||||
status = UpdateCheckStatus.Failed,
|
||||
message = "Could not retrieve the latest Vita3K release information. Please try again later.",
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
|
||||
val root = try {
|
||||
JSONObject(response)
|
||||
} catch (_: Exception) {
|
||||
return@withContext UpdateCheckResult(
|
||||
status = UpdateCheckStatus.Failed,
|
||||
message = "The GitHub release response could not be parsed.",
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
}
|
||||
|
||||
val body = root.optString("body").orEmpty()
|
||||
val latestBuildNumber = updateBuildRegex.find(body)?.groupValues?.getOrNull(1)?.toLongOrNull()
|
||||
?: return@withContext UpdateCheckResult(
|
||||
status = UpdateCheckStatus.Failed,
|
||||
message = "The latest release does not advertise a usable Vita3K build number.",
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
|
||||
val releaseName = root.optString("name").trim()
|
||||
val tagName = root.optString("tag_name").trim()
|
||||
val releaseUrl = root.optString("html_url").trim().ifBlank { UPDATE_PAGE_URL }
|
||||
val info = UpdateInfo(
|
||||
version = releaseName.ifBlank { tagName },
|
||||
buildNumber = latestBuildNumber,
|
||||
releaseUrl = releaseUrl,
|
||||
publishedAt = root.optString("published_at").trim(),
|
||||
notes = normalizedUpdateNotes(body)
|
||||
)
|
||||
|
||||
val currentBuildNumber = currentBuildRegex.find(appVersion)?.groupValues?.getOrNull(1)?.toLongOrNull() ?: 0L
|
||||
val buildDelta = latestBuildNumber - currentBuildNumber
|
||||
|
||||
when {
|
||||
!officialBuild -> UpdateCheckResult(
|
||||
status = UpdateCheckStatus.CustomBuildCanUpdate,
|
||||
message = "",
|
||||
info = info,
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
buildDelta > 0L -> UpdateCheckResult(
|
||||
status = UpdateCheckStatus.UpdateAvailable,
|
||||
message = "",
|
||||
info = info,
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
buildDelta < 0L -> UpdateCheckResult(
|
||||
status = UpdateCheckStatus.CurrentBuildNewerThanLatest,
|
||||
message = "This installation appears newer than the latest official continuous build.",
|
||||
info = info,
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
else -> UpdateCheckResult(
|
||||
status = UpdateCheckStatus.UpToDate,
|
||||
message = "You already have the latest official build installed.",
|
||||
info = info,
|
||||
currentDisplayVersion = currentDisplayVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toAppInfo(native: NativeAppInfo): AppInfo = AppInfo(
|
||||
titleId = native.titleId,
|
||||
title = native.title,
|
||||
category = native.category,
|
||||
appVer = native.appVer,
|
||||
iconPath = native.iconPath.ifEmpty { null },
|
||||
hasCustomConfig = native.hasCustomConfig,
|
||||
compatibility = CompatibilityState.fromValue(native.compatibility),
|
||||
lastPlayed = native.lastPlayed,
|
||||
playtime = native.playtime
|
||||
)
|
||||
|
||||
private fun currentDisplayVersion(appVersion: String): String {
|
||||
if (appVersion.isBlank()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val parts = appVersion.split("-", limit = 3)
|
||||
val version = parts.getOrNull(0).orEmpty().ifBlank { appVersion }
|
||||
val build = parts.getOrNull(1).orEmpty()
|
||||
return if (build.isBlank()) version else "$version ($build)"
|
||||
}
|
||||
|
||||
private fun normalizedUpdateNotes(body: String): String {
|
||||
val cleaned = body
|
||||
.replace("\r\n", "\n")
|
||||
.lineSequence()
|
||||
.filterNot { line -> updateMetadataLineRegex.containsMatchIn(line.trim()) }
|
||||
.dropWhile { it.isBlank() }
|
||||
.toList()
|
||||
.dropLastWhile { it.isBlank() }
|
||||
|
||||
return cleaned.joinToString("\n").trim()
|
||||
}
|
||||
|
||||
private fun httpGetString(url: String): String? =
|
||||
httpGetBytes(url)?.toString(Charsets.UTF_8)
|
||||
|
||||
private fun httpGetBytes(url: String): ByteArray? {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 10000
|
||||
readTimeout = 15000
|
||||
setRequestProperty("User-Agent", "Vita3K-Android")
|
||||
setRequestProperty("Accept", "application/vnd.github+json")
|
||||
}
|
||||
|
||||
return try {
|
||||
connection.connect()
|
||||
if (connection.responseCode !in 200..299) {
|
||||
null
|
||||
} else {
|
||||
connection.inputStream.use { it.readBytes() }
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
internal object AppStorage {
|
||||
private const val PREFS_NAME = "vita3k_app"
|
||||
private const val KEY_INITIAL_SETUP_COMPLETED = "initial_setup_completed"
|
||||
private const val DEFAULT_STORAGE_DIR_NAME = "vita"
|
||||
|
||||
fun storageRootPath(context: Context): String {
|
||||
return (context.getExternalFilesDir(null) ?: context.filesDir ?: File(context.cacheDir, "files")).absolutePath
|
||||
}
|
||||
|
||||
fun defaultStoragePath(context: Context): String {
|
||||
val baseDir = File(storageRootPath(context))
|
||||
return File(baseDir, DEFAULT_STORAGE_DIR_NAME).absolutePath
|
||||
}
|
||||
|
||||
fun isInitialSetupCompleted(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_INITIAL_SETUP_COMPLETED, false)
|
||||
|
||||
fun setInitialSetupCompleted(context: Context, completed: Boolean) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_INITIAL_SETUP_COMPLETED, completed)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
/**
|
||||
* Mirrors the JNI-backed emulator settings object.
|
||||
*
|
||||
* All properties are exposed as @JvmField vars so the native layer can access
|
||||
* them directly after constructing this class with the no-arg constructor.
|
||||
*/
|
||||
class EmulatorConfig {
|
||||
|
||||
// Core
|
||||
@JvmField var modulesMode: Int = 0
|
||||
@JvmField var lleModules: Array<String> = emptyArray()
|
||||
@JvmField var cpuOpt: Boolean = true
|
||||
|
||||
// GPU
|
||||
@JvmField var backendRenderer: String = "Vulkan"
|
||||
@JvmField var highAccuracy: Boolean = false
|
||||
@JvmField var resolutionMultiplier: Float = 1.0f
|
||||
@JvmField var disableSurfaceSync: Boolean = true
|
||||
@JvmField var screenFilter: String = "Bilinear"
|
||||
@JvmField var memoryMapping: String = "double-buffer"
|
||||
@JvmField var vSync: Boolean = true
|
||||
@JvmField var anisotropicFiltering: Int = 1
|
||||
@JvmField var asyncPipelineCompilation: Boolean = true
|
||||
@JvmField var exportTextures: Boolean = false
|
||||
@JvmField var importTextures: Boolean = false
|
||||
@JvmField var exportAsPng: Boolean = true
|
||||
@JvmField var fpsHack: Boolean = false
|
||||
@JvmField var turboMode: Boolean = false
|
||||
@JvmField var shaderCache: Boolean = true
|
||||
@JvmField var spirvShader: Boolean = false
|
||||
@JvmField var customDriverName: String = ""
|
||||
|
||||
// Audio
|
||||
@JvmField var audioBackend: String = "SDL"
|
||||
@JvmField var audioVolume: Int = 100
|
||||
@JvmField var ngsEnable: Boolean = true
|
||||
@JvmField var disableMotion: Boolean = false
|
||||
@JvmField var controllerAnalogMultiplier: Float = 1.0f
|
||||
@JvmField var controllerBinds: IntArray = defaultControllerBinds()
|
||||
@JvmField var controllerAxisBinds: IntArray = defaultControllerAxisBinds()
|
||||
|
||||
// Camera
|
||||
@JvmField var frontCameraType: Int = 2
|
||||
@JvmField var frontCameraId: String = ""
|
||||
@JvmField var frontCameraImage: String = ""
|
||||
@JvmField var frontCameraColor: Long = 0L
|
||||
@JvmField var backCameraType: Int = 2
|
||||
@JvmField var backCameraId: String = ""
|
||||
@JvmField var backCameraImage: String = ""
|
||||
@JvmField var backCameraColor: Long = 0L
|
||||
|
||||
// System
|
||||
@JvmField var pstvMode: Boolean = false
|
||||
@JvmField var showMode: Boolean = false
|
||||
@JvmField var demoMode: Boolean = false
|
||||
@JvmField var sysButton: Int = 1
|
||||
@JvmField var sysLang: Int = 1
|
||||
@JvmField var sysDateFormat: Int = 2
|
||||
@JvmField var sysTimeFormat: Int = 0
|
||||
@JvmField var imeLangs: Long = 4L
|
||||
@JvmField var userLang: String = ""
|
||||
|
||||
// Network
|
||||
@JvmField var psnSignedIn: Boolean = false
|
||||
@JvmField var httpEnable: Boolean = true
|
||||
@JvmField var httpTimeoutAttempts: Int = 50
|
||||
@JvmField var httpTimeoutSleepMs: Int = 100
|
||||
@JvmField var httpReadEndAttempts: Int = 10
|
||||
@JvmField var httpReadEndSleepMs: Int = 250
|
||||
@JvmField var adhocAddr: Int = 0
|
||||
|
||||
// Debug
|
||||
@JvmField var logImports: Boolean = false
|
||||
@JvmField var logExports: Boolean = false
|
||||
@JvmField var logActiveShaders: Boolean = false
|
||||
@JvmField var logUniforms: Boolean = false
|
||||
@JvmField var colorSurfaceDebug: Boolean = false
|
||||
@JvmField var dumpElfs: Boolean = false
|
||||
@JvmField var validationLayer: Boolean = true
|
||||
@JvmField var textureCache: Boolean = true
|
||||
@JvmField var stretchDisplayArea: Boolean = false
|
||||
@JvmField var fullscreenHdResPixelPerfect: Boolean = false
|
||||
@JvmField var fileLoadingDelay: Int = 0
|
||||
|
||||
// Emulator
|
||||
@JvmField var showLiveAreaScreen: Boolean = false
|
||||
@JvmField var showCompileShaders: Boolean = true
|
||||
@JvmField var checkForUpdates: Boolean = true
|
||||
@JvmField var checkForUpdatesMode: Int = 1
|
||||
@JvmField var archiveLog: Boolean = false
|
||||
@JvmField var logCompatWarn: Boolean = false
|
||||
@JvmField var logLevel: Int = 0
|
||||
@JvmField var performanceOverlay: Boolean = false
|
||||
@JvmField var performanceOverlayDetail: Int = 0
|
||||
@JvmField var performanceOverlayPosition: Int = 0
|
||||
@JvmField var screenshotFormat: Int = 1
|
||||
|
||||
fun copy(): EmulatorConfig = EmulatorConfig().also { config ->
|
||||
config.modulesMode = modulesMode
|
||||
config.lleModules = lleModules.copyOf()
|
||||
config.cpuOpt = cpuOpt
|
||||
config.backendRenderer = backendRenderer
|
||||
config.highAccuracy = highAccuracy
|
||||
config.resolutionMultiplier = resolutionMultiplier
|
||||
config.disableSurfaceSync = disableSurfaceSync
|
||||
config.screenFilter = screenFilter
|
||||
config.memoryMapping = memoryMapping
|
||||
config.vSync = vSync
|
||||
config.anisotropicFiltering = anisotropicFiltering
|
||||
config.asyncPipelineCompilation = asyncPipelineCompilation
|
||||
config.exportTextures = exportTextures
|
||||
config.importTextures = importTextures
|
||||
config.exportAsPng = exportAsPng
|
||||
config.fpsHack = fpsHack
|
||||
config.turboMode = turboMode
|
||||
config.shaderCache = shaderCache
|
||||
config.spirvShader = spirvShader
|
||||
config.customDriverName = customDriverName
|
||||
config.audioBackend = audioBackend
|
||||
config.audioVolume = audioVolume
|
||||
config.ngsEnable = ngsEnable
|
||||
config.disableMotion = disableMotion
|
||||
config.controllerAnalogMultiplier = controllerAnalogMultiplier
|
||||
config.controllerBinds = controllerBinds.copyOf()
|
||||
config.controllerAxisBinds = controllerAxisBinds.copyOf()
|
||||
config.frontCameraType = frontCameraType
|
||||
config.frontCameraId = frontCameraId
|
||||
config.frontCameraImage = frontCameraImage
|
||||
config.frontCameraColor = frontCameraColor
|
||||
config.backCameraType = backCameraType
|
||||
config.backCameraId = backCameraId
|
||||
config.backCameraImage = backCameraImage
|
||||
config.backCameraColor = backCameraColor
|
||||
config.pstvMode = pstvMode
|
||||
config.showMode = showMode
|
||||
config.demoMode = demoMode
|
||||
config.sysButton = sysButton
|
||||
config.sysLang = sysLang
|
||||
config.sysDateFormat = sysDateFormat
|
||||
config.sysTimeFormat = sysTimeFormat
|
||||
config.imeLangs = imeLangs
|
||||
config.userLang = userLang
|
||||
config.psnSignedIn = psnSignedIn
|
||||
config.httpEnable = httpEnable
|
||||
config.httpTimeoutAttempts = httpTimeoutAttempts
|
||||
config.httpTimeoutSleepMs = httpTimeoutSleepMs
|
||||
config.httpReadEndAttempts = httpReadEndAttempts
|
||||
config.httpReadEndSleepMs = httpReadEndSleepMs
|
||||
config.adhocAddr = adhocAddr
|
||||
config.logImports = logImports
|
||||
config.logExports = logExports
|
||||
config.logActiveShaders = logActiveShaders
|
||||
config.logUniforms = logUniforms
|
||||
config.colorSurfaceDebug = colorSurfaceDebug
|
||||
config.dumpElfs = dumpElfs
|
||||
config.validationLayer = validationLayer
|
||||
config.textureCache = textureCache
|
||||
config.stretchDisplayArea = stretchDisplayArea
|
||||
config.fullscreenHdResPixelPerfect = fullscreenHdResPixelPerfect
|
||||
config.fileLoadingDelay = fileLoadingDelay
|
||||
config.showLiveAreaScreen = showLiveAreaScreen
|
||||
config.showCompileShaders = showCompileShaders
|
||||
config.checkForUpdates = checkForUpdates
|
||||
config.checkForUpdatesMode = checkForUpdatesMode
|
||||
config.archiveLog = archiveLog
|
||||
config.logCompatWarn = logCompatWarn
|
||||
config.logLevel = logLevel
|
||||
config.performanceOverlay = performanceOverlay
|
||||
config.performanceOverlayDetail = performanceOverlayDetail
|
||||
config.performanceOverlayPosition = performanceOverlayPosition
|
||||
config.screenshotFormat = screenshotFormat
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is EmulatorConfig) return false
|
||||
|
||||
return modulesMode == other.modulesMode &&
|
||||
lleModules.contentEquals(other.lleModules) &&
|
||||
cpuOpt == other.cpuOpt &&
|
||||
backendRenderer == other.backendRenderer &&
|
||||
highAccuracy == other.highAccuracy &&
|
||||
resolutionMultiplier == other.resolutionMultiplier &&
|
||||
disableSurfaceSync == other.disableSurfaceSync &&
|
||||
screenFilter == other.screenFilter &&
|
||||
memoryMapping == other.memoryMapping &&
|
||||
vSync == other.vSync &&
|
||||
anisotropicFiltering == other.anisotropicFiltering &&
|
||||
asyncPipelineCompilation == other.asyncPipelineCompilation &&
|
||||
exportTextures == other.exportTextures &&
|
||||
importTextures == other.importTextures &&
|
||||
exportAsPng == other.exportAsPng &&
|
||||
fpsHack == other.fpsHack &&
|
||||
turboMode == other.turboMode &&
|
||||
shaderCache == other.shaderCache &&
|
||||
spirvShader == other.spirvShader &&
|
||||
customDriverName == other.customDriverName &&
|
||||
audioBackend == other.audioBackend &&
|
||||
audioVolume == other.audioVolume &&
|
||||
ngsEnable == other.ngsEnable &&
|
||||
disableMotion == other.disableMotion &&
|
||||
controllerAnalogMultiplier == other.controllerAnalogMultiplier &&
|
||||
controllerBinds.contentEquals(other.controllerBinds) &&
|
||||
controllerAxisBinds.contentEquals(other.controllerAxisBinds) &&
|
||||
frontCameraType == other.frontCameraType &&
|
||||
frontCameraId == other.frontCameraId &&
|
||||
frontCameraImage == other.frontCameraImage &&
|
||||
frontCameraColor == other.frontCameraColor &&
|
||||
backCameraType == other.backCameraType &&
|
||||
backCameraId == other.backCameraId &&
|
||||
backCameraImage == other.backCameraImage &&
|
||||
backCameraColor == other.backCameraColor &&
|
||||
pstvMode == other.pstvMode &&
|
||||
showMode == other.showMode &&
|
||||
demoMode == other.demoMode &&
|
||||
sysButton == other.sysButton &&
|
||||
sysLang == other.sysLang &&
|
||||
sysDateFormat == other.sysDateFormat &&
|
||||
sysTimeFormat == other.sysTimeFormat &&
|
||||
imeLangs == other.imeLangs &&
|
||||
userLang == other.userLang &&
|
||||
psnSignedIn == other.psnSignedIn &&
|
||||
httpEnable == other.httpEnable &&
|
||||
httpTimeoutAttempts == other.httpTimeoutAttempts &&
|
||||
httpTimeoutSleepMs == other.httpTimeoutSleepMs &&
|
||||
httpReadEndAttempts == other.httpReadEndAttempts &&
|
||||
httpReadEndSleepMs == other.httpReadEndSleepMs &&
|
||||
adhocAddr == other.adhocAddr &&
|
||||
logImports == other.logImports &&
|
||||
logExports == other.logExports &&
|
||||
logActiveShaders == other.logActiveShaders &&
|
||||
logUniforms == other.logUniforms &&
|
||||
colorSurfaceDebug == other.colorSurfaceDebug &&
|
||||
dumpElfs == other.dumpElfs &&
|
||||
validationLayer == other.validationLayer &&
|
||||
textureCache == other.textureCache &&
|
||||
stretchDisplayArea == other.stretchDisplayArea &&
|
||||
fullscreenHdResPixelPerfect == other.fullscreenHdResPixelPerfect &&
|
||||
fileLoadingDelay == other.fileLoadingDelay &&
|
||||
showLiveAreaScreen == other.showLiveAreaScreen &&
|
||||
showCompileShaders == other.showCompileShaders &&
|
||||
checkForUpdates == other.checkForUpdates &&
|
||||
checkForUpdatesMode == other.checkForUpdatesMode &&
|
||||
archiveLog == other.archiveLog &&
|
||||
logCompatWarn == other.logCompatWarn &&
|
||||
logLevel == other.logLevel &&
|
||||
performanceOverlay == other.performanceOverlay &&
|
||||
performanceOverlayDetail == other.performanceOverlayDetail &&
|
||||
performanceOverlayPosition == other.performanceOverlayPosition &&
|
||||
screenshotFormat == other.screenshotFormat
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = modulesMode
|
||||
result = 31 * result + lleModules.contentHashCode()
|
||||
result = 31 * result + cpuOpt.hashCode()
|
||||
result = 31 * result + backendRenderer.hashCode()
|
||||
result = 31 * result + highAccuracy.hashCode()
|
||||
result = 31 * result + resolutionMultiplier.hashCode()
|
||||
result = 31 * result + disableSurfaceSync.hashCode()
|
||||
result = 31 * result + screenFilter.hashCode()
|
||||
result = 31 * result + memoryMapping.hashCode()
|
||||
result = 31 * result + vSync.hashCode()
|
||||
result = 31 * result + anisotropicFiltering
|
||||
result = 31 * result + asyncPipelineCompilation.hashCode()
|
||||
result = 31 * result + exportTextures.hashCode()
|
||||
result = 31 * result + importTextures.hashCode()
|
||||
result = 31 * result + exportAsPng.hashCode()
|
||||
result = 31 * result + fpsHack.hashCode()
|
||||
result = 31 * result + turboMode.hashCode()
|
||||
result = 31 * result + shaderCache.hashCode()
|
||||
result = 31 * result + spirvShader.hashCode()
|
||||
result = 31 * result + customDriverName.hashCode()
|
||||
result = 31 * result + audioBackend.hashCode()
|
||||
result = 31 * result + audioVolume
|
||||
result = 31 * result + ngsEnable.hashCode()
|
||||
result = 31 * result + disableMotion.hashCode()
|
||||
result = 31 * result + controllerAnalogMultiplier.hashCode()
|
||||
result = 31 * result + controllerBinds.contentHashCode()
|
||||
result = 31 * result + controllerAxisBinds.contentHashCode()
|
||||
result = 31 * result + frontCameraType
|
||||
result = 31 * result + frontCameraId.hashCode()
|
||||
result = 31 * result + frontCameraImage.hashCode()
|
||||
result = 31 * result + frontCameraColor.hashCode()
|
||||
result = 31 * result + backCameraType
|
||||
result = 31 * result + backCameraId.hashCode()
|
||||
result = 31 * result + backCameraImage.hashCode()
|
||||
result = 31 * result + backCameraColor.hashCode()
|
||||
result = 31 * result + pstvMode.hashCode()
|
||||
result = 31 * result + showMode.hashCode()
|
||||
result = 31 * result + demoMode.hashCode()
|
||||
result = 31 * result + sysButton
|
||||
result = 31 * result + sysLang
|
||||
result = 31 * result + sysDateFormat
|
||||
result = 31 * result + sysTimeFormat
|
||||
result = 31 * result + imeLangs.hashCode()
|
||||
result = 31 * result + userLang.hashCode()
|
||||
result = 31 * result + psnSignedIn.hashCode()
|
||||
result = 31 * result + httpEnable.hashCode()
|
||||
result = 31 * result + httpTimeoutAttempts
|
||||
result = 31 * result + httpTimeoutSleepMs
|
||||
result = 31 * result + httpReadEndAttempts
|
||||
result = 31 * result + httpReadEndSleepMs
|
||||
result = 31 * result + adhocAddr
|
||||
result = 31 * result + logImports.hashCode()
|
||||
result = 31 * result + logExports.hashCode()
|
||||
result = 31 * result + logActiveShaders.hashCode()
|
||||
result = 31 * result + logUniforms.hashCode()
|
||||
result = 31 * result + colorSurfaceDebug.hashCode()
|
||||
result = 31 * result + dumpElfs.hashCode()
|
||||
result = 31 * result + validationLayer.hashCode()
|
||||
result = 31 * result + textureCache.hashCode()
|
||||
result = 31 * result + stretchDisplayArea.hashCode()
|
||||
result = 31 * result + fullscreenHdResPixelPerfect.hashCode()
|
||||
result = 31 * result + fileLoadingDelay
|
||||
result = 31 * result + showLiveAreaScreen.hashCode()
|
||||
result = 31 * result + showCompileShaders.hashCode()
|
||||
result = 31 * result + checkForUpdates.hashCode()
|
||||
result = 31 * result + checkForUpdatesMode
|
||||
result = 31 * result + archiveLog.hashCode()
|
||||
result = 31 * result + logCompatWarn.hashCode()
|
||||
result = 31 * result + logLevel
|
||||
result = 31 * result + performanceOverlay.hashCode()
|
||||
result = 31 * result + performanceOverlayDetail
|
||||
result = 31 * result + performanceOverlayPosition
|
||||
result = 31 * result + screenshotFormat
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultControllerBinds(): IntArray = intArrayOf(
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
|
||||
)
|
||||
|
||||
private fun defaultControllerAxisBinds(): IntArray = intArrayOf(
|
||||
0, 1, 2, 3, 4, 5
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
enum class FirmwareComponent {
|
||||
Preinstalled,
|
||||
Main,
|
||||
FontPackage
|
||||
}
|
||||
|
||||
data class FirmwareComponentPresence(
|
||||
val preinstalled: Boolean = false,
|
||||
val main: Boolean = false,
|
||||
val fontPackage: Boolean = false
|
||||
) {
|
||||
val hasAnyInstalled: Boolean
|
||||
get() = preinstalled || main || fontPackage
|
||||
|
||||
val missingComponents: Set<FirmwareComponent>
|
||||
get() = buildSet {
|
||||
if (!preinstalled) add(FirmwareComponent.Preinstalled)
|
||||
if (!main) add(FirmwareComponent.Main)
|
||||
if (!fontPackage) add(FirmwareComponent.FontPackage)
|
||||
}
|
||||
|
||||
val missingComponentsInOrder: List<FirmwareComponent>
|
||||
get() = buildList {
|
||||
if (!preinstalled) add(FirmwareComponent.Preinstalled)
|
||||
if (!main) add(FirmwareComponent.Main)
|
||||
if (!fontPackage) add(FirmwareComponent.FontPackage)
|
||||
}
|
||||
|
||||
val missingRequiredComponentsInOrder: List<FirmwareComponent>
|
||||
get() = buildList {
|
||||
if (!main) add(FirmwareComponent.Main)
|
||||
if (!fontPackage) add(FirmwareComponent.FontPackage)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface FirmwareInstallState {
|
||||
val components: FirmwareComponentPresence
|
||||
val hasRequiredComponents: Boolean
|
||||
|
||||
val hasAnyInstalled: Boolean
|
||||
get() = components.hasAnyInstalled
|
||||
|
||||
val hasMainFirmware: Boolean
|
||||
get() = components.main
|
||||
|
||||
val hasFontPackage: Boolean
|
||||
get() = components.fontPackage
|
||||
|
||||
val isComplete: Boolean
|
||||
get() = this is Complete
|
||||
|
||||
data object Missing : FirmwareInstallState {
|
||||
override val components = FirmwareComponentPresence()
|
||||
override val hasRequiredComponents = false
|
||||
}
|
||||
|
||||
data class Partial(
|
||||
override val components: FirmwareComponentPresence,
|
||||
override val hasRequiredComponents: Boolean
|
||||
) : FirmwareInstallState
|
||||
|
||||
data class Complete(
|
||||
override val components: FirmwareComponentPresence
|
||||
) : FirmwareInstallState {
|
||||
override val hasRequiredComponents = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREINSTALLED_MASK = 1 shl 0
|
||||
private const val MAIN_MASK = 1 shl 1
|
||||
private const val FONT_MASK = 1 shl 2
|
||||
|
||||
fun fromMask(mask: Int): FirmwareInstallState {
|
||||
val components = FirmwareComponentPresence(
|
||||
preinstalled = mask and PREINSTALLED_MASK != 0,
|
||||
main = mask and MAIN_MASK != 0,
|
||||
fontPackage = mask and FONT_MASK != 0
|
||||
)
|
||||
|
||||
val hasRequiredComponents = components.main && components.fontPackage
|
||||
|
||||
return when {
|
||||
!components.hasAnyInstalled -> Missing
|
||||
components.missingComponents.isEmpty() -> Complete(components)
|
||||
else -> Partial(components, hasRequiredComponents)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
data class FirmwareLocale(
|
||||
@StringRes val nameResId: Int,
|
||||
val code: String
|
||||
)
|
||||
|
||||
object FirmwareLinks {
|
||||
const val PREINSTALL_URL = "https://bit.ly/4hlePsX"
|
||||
const val FONT_PACKAGE_URL = "https://bit.ly/2P2rb0r"
|
||||
|
||||
val locales = listOf(
|
||||
FirmwareLocale(R.string.settings_lang_japanese, "ja-jp"),
|
||||
FirmwareLocale(R.string.settings_lang_english_us, "en-us"),
|
||||
FirmwareLocale(R.string.settings_lang_french, "fr-fr"),
|
||||
FirmwareLocale(R.string.settings_lang_spanish, "es-es"),
|
||||
FirmwareLocale(R.string.settings_lang_german, "de-de"),
|
||||
FirmwareLocale(R.string.settings_lang_italian, "it-it"),
|
||||
FirmwareLocale(R.string.settings_lang_dutch, "nl-nl"),
|
||||
FirmwareLocale(R.string.settings_lang_portuguese_pt, "pt-pt"),
|
||||
FirmwareLocale(R.string.settings_lang_russian, "ru-ru"),
|
||||
FirmwareLocale(R.string.settings_lang_korean, "ko-kr"),
|
||||
FirmwareLocale(R.string.settings_lang_chinese_trad, "zh-hant-hk"),
|
||||
FirmwareLocale(R.string.settings_lang_chinese_simp, "zh-hans-cn"),
|
||||
FirmwareLocale(R.string.settings_lang_finnish, "fi-fi"),
|
||||
FirmwareLocale(R.string.settings_lang_swedish, "sv-se"),
|
||||
FirmwareLocale(R.string.settings_lang_danish, "da-dk"),
|
||||
FirmwareLocale(R.string.settings_lang_norwegian, "no-no"),
|
||||
FirmwareLocale(R.string.settings_lang_polish, "pl-pl"),
|
||||
FirmwareLocale(R.string.settings_lang_portuguese_br, "pt-br"),
|
||||
FirmwareLocale(R.string.settings_lang_english_gb, "en-gb"),
|
||||
FirmwareLocale(R.string.settings_lang_turkish, "tr-tr")
|
||||
)
|
||||
|
||||
fun coerceLocaleIndex(index: Int): Int =
|
||||
index.coerceIn(0, locales.lastIndex)
|
||||
|
||||
fun firmwareDownloadUrl(index: Int): String {
|
||||
val locale = locales[coerceLocaleIndex(index)]
|
||||
return "https://www.playstation.com/${locale.code}/support/hardware/psvita/system-software/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
interface InstallCallback {
|
||||
fun onProgress(percent: Int, status: String)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.vita3k.emulator.NativeLib
|
||||
|
||||
internal object InstallRepository {
|
||||
|
||||
suspend fun installFirmware(path: String, onProgress: (Int, String) -> Unit): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeLib.installFirmware(path, progressCallback(onProgress))
|
||||
}
|
||||
|
||||
suspend fun installPkg(path: String, zrif: String = "", onProgress: (Int, String) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeLib.installPkg(path, zrif, progressCallback(onProgress))
|
||||
}
|
||||
|
||||
suspend fun installArchive(path: String, forceReinstall: Boolean, onProgress: (Int, String) -> Unit): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
NativeLib.installArchive(path, progressCallback(onProgress), forceReinstall)
|
||||
}
|
||||
|
||||
suspend fun copyLicense(path: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.copyLicense(path)
|
||||
}
|
||||
|
||||
suspend fun createLicense(zrif: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.createLicense(zrif)
|
||||
}
|
||||
|
||||
suspend fun findPkgZrif(pkgPath: String): String = withContext(Dispatchers.IO) {
|
||||
NativeLib.findPkgZrif(pkgPath)
|
||||
}
|
||||
|
||||
suspend fun convertRifToZrif(path: String): String = withContext(Dispatchers.IO) {
|
||||
NativeLib.convertRifToZrif(path)
|
||||
}
|
||||
|
||||
private fun progressCallback(onProgress: (Int, String) -> Unit) = object : InstallCallback {
|
||||
override fun onProgress(percent: Int, status: String) {
|
||||
onProgress(percent, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
data class NativeAppInfo(
|
||||
val titleId: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val appVer: String,
|
||||
val iconPath: String,
|
||||
val hasCustomConfig: Boolean,
|
||||
val compatibility: Int,
|
||||
val lastPlayed: Long,
|
||||
val playtime: Long
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
data class NativeImeState(
|
||||
val sceImeActive: Boolean,
|
||||
val dialogActive: Boolean,
|
||||
val text: String,
|
||||
val preeditStart: Int,
|
||||
val preeditLength: Int,
|
||||
val caretIndex: Int,
|
||||
val multiline: Boolean,
|
||||
val enterLabel: String
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
data class NativeUser(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val active: Boolean
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
enum class RestartRequiredSetting(val nativeId: Int, @StringRes val labelResId: Int) {
|
||||
CpuOpt(0, R.string.settings_cpu_opt),
|
||||
BackendRenderer(1, R.string.settings_gpu_backend),
|
||||
GraphicsDevice(2, R.string.settings_gpu_graphics_device),
|
||||
CustomDriver(3, R.string.settings_gpu_custom_driver),
|
||||
HighAccuracy(4, R.string.settings_gpu_accuracy),
|
||||
ResolutionMultiplier(5, R.string.settings_gpu_resolution),
|
||||
MemoryMapping(6, R.string.settings_gpu_memory_mapping),
|
||||
AudioBackend(7, R.string.settings_audio_backend),
|
||||
ValidationLayer(8, R.string.settings_debug_validation_layer);
|
||||
|
||||
companion object {
|
||||
fun fromNative(values: IntArray): List<RestartRequiredSetting> {
|
||||
if (values.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val byId = entries.associateBy(RestartRequiredSetting::nativeId)
|
||||
return buildList(values.size) {
|
||||
values.forEach { nativeId ->
|
||||
byId[nativeId]?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.vita3k.emulator.NativeLib
|
||||
|
||||
internal data class SettingsSnapshot(
|
||||
val config: EmulatorConfig,
|
||||
val emulatorStoragePath: String,
|
||||
val hasCustomConfig: Boolean,
|
||||
val modulesList: List<Pair<String, Boolean>>,
|
||||
val installedCustomDrivers: List<String>,
|
||||
val showCustomDriverOptions: Boolean,
|
||||
val supportedMemoryMappingMask: Int,
|
||||
val customDriverLoadStatus: CustomDriverLoadStatus,
|
||||
val availableCameras: List<String>,
|
||||
val availableAdhocAddresses: List<Pair<String, String>>
|
||||
)
|
||||
|
||||
enum class CustomDriverLoadStatus {
|
||||
Default,
|
||||
Loaded,
|
||||
Fallback;
|
||||
|
||||
companion object {
|
||||
fun fromNative(value: Int): CustomDriverLoadStatus = when (value) {
|
||||
1 -> Loaded
|
||||
2 -> Fallback
|
||||
else -> Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CustomDriverSupportInfo(
|
||||
val supportedMemoryMappingMask: Int,
|
||||
val loadStatus: CustomDriverLoadStatus
|
||||
)
|
||||
|
||||
internal data class SettingsSaveResult(
|
||||
val hasCustomConfig: Boolean,
|
||||
val restartRequiredSettings: List<RestartRequiredSetting>
|
||||
)
|
||||
|
||||
internal data class DeleteCustomConfigResult(
|
||||
val snapshot: SettingsSnapshot,
|
||||
val restartRequiredSettings: List<RestartRequiredSetting>
|
||||
)
|
||||
|
||||
internal object SettingsRepository {
|
||||
private val customDriverSupportInfoCache = mutableMapOf<String, CustomDriverSupportInfo>()
|
||||
|
||||
|
||||
suspend fun load(titleId: String?): SettingsSnapshot = withContext(Dispatchers.IO) {
|
||||
val hasCustomConfig = titleId != null && NativeLib.hasCustomConfig(titleId)
|
||||
val loadedConfig = when {
|
||||
titleId == null -> NativeLib.getGlobalConfig()
|
||||
hasCustomConfig -> requireNotNull(NativeLib.getCustomConfig(titleId))
|
||||
else -> NativeLib.getGlobalConfig()
|
||||
}
|
||||
buildSnapshot(loadedConfig, hasCustomConfig)
|
||||
}
|
||||
|
||||
suspend fun save(routeTitleId: String?, effectiveTitleId: String?, config: EmulatorConfig): SettingsSaveResult {
|
||||
val restartRequiredSettings = RestartRequiredSetting.fromNative(NativeLib.saveSettings(effectiveTitleId, config))
|
||||
return SettingsSaveResult(
|
||||
hasCustomConfig = routeTitleId != null && NativeLib.hasCustomConfig(routeTitleId),
|
||||
restartRequiredSettings = restartRequiredSettings
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadDefaults(): SettingsSnapshot = withContext(Dispatchers.IO) {
|
||||
buildSnapshot(NativeLib.getDefaultConfig(), hasCustomConfig = false)
|
||||
}
|
||||
|
||||
suspend fun setCurrentEmulatorPath(path: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.setCurrentEmulatorPath(path)
|
||||
}
|
||||
|
||||
suspend fun deleteCustomConfig(titleId: String): DeleteCustomConfigResult {
|
||||
val restartRequiredSettings = RestartRequiredSetting.fromNative(NativeLib.deleteCustomConfig(titleId))
|
||||
return DeleteCustomConfigResult(
|
||||
snapshot = buildSnapshot(NativeLib.getGlobalConfig(), hasCustomConfig = false),
|
||||
restartRequiredSettings = restartRequiredSettings
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun clearAllCustomConfigs(): Int = withContext(Dispatchers.IO) {
|
||||
NativeLib.clearAllCustomConfigs()
|
||||
}
|
||||
|
||||
suspend fun getInstalledCustomDrivers(): List<String> = withContext(Dispatchers.IO) {
|
||||
NativeLib.getInstalledCustomDrivers().toList()
|
||||
}
|
||||
|
||||
suspend fun installCustomDriver(path: String): String = withContext(Dispatchers.IO) {
|
||||
NativeLib.installCustomDriver(path).also { installedName ->
|
||||
if (installedName.isNotEmpty()) {
|
||||
synchronized(customDriverSupportInfoCache) {
|
||||
customDriverSupportInfoCache.remove(installedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeCustomDriver(driverName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.removeCustomDriver(driverName).also { removed ->
|
||||
if (removed) {
|
||||
synchronized(customDriverSupportInfoCache) {
|
||||
customDriverSupportInfoCache.remove(driverName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSupportedMemoryMappingMask(customDriverName: String): Int = withContext(Dispatchers.IO) {
|
||||
NativeLib.getSupportedMemoryMappingMask(customDriverName)
|
||||
}
|
||||
|
||||
suspend fun getCustomDriverSupportInfo(customDriverName: String): CustomDriverSupportInfo = withContext(Dispatchers.IO) {
|
||||
getCustomDriverSupportInfoInternal(customDriverName)
|
||||
}
|
||||
|
||||
private fun getCustomDriverSupportInfoInternal(customDriverName: String): CustomDriverSupportInfo {
|
||||
if (customDriverName.isNotEmpty()) {
|
||||
synchronized(customDriverSupportInfoCache) {
|
||||
customDriverSupportInfoCache[customDriverName]?.let { return it }
|
||||
}
|
||||
}
|
||||
|
||||
val raw = NativeLib.getCustomDriverSupportInfo(customDriverName)
|
||||
val supportedMask = raw.getOrNull(0) ?: NativeLib.getSupportedMemoryMappingMask(customDriverName)
|
||||
val loadStatus = CustomDriverLoadStatus.fromNative(raw.getOrNull(1) ?: 0)
|
||||
val supportInfo = CustomDriverSupportInfo(
|
||||
supportedMemoryMappingMask = supportedMask,
|
||||
loadStatus = loadStatus
|
||||
)
|
||||
|
||||
if (customDriverName.isNotEmpty()) {
|
||||
synchronized(customDriverSupportInfoCache) {
|
||||
customDriverSupportInfoCache.putIfAbsent(customDriverName, supportInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return supportInfo
|
||||
}
|
||||
|
||||
private fun buildSnapshot(config: EmulatorConfig, hasCustomConfig: Boolean): SettingsSnapshot {
|
||||
val driverSupportInfo = getCustomDriverSupportInfoInternal(config.customDriverName)
|
||||
return SettingsSnapshot(
|
||||
config = config,
|
||||
emulatorStoragePath = NativeLib.getCurrentEmulatorPath(),
|
||||
hasCustomConfig = hasCustomConfig,
|
||||
modulesList = parseModules(NativeLib.getModulesList(config.lleModules)),
|
||||
installedCustomDrivers = NativeLib.getInstalledCustomDrivers().toList(),
|
||||
showCustomDriverOptions = NativeLib.shouldShowCustomDriverOptions(),
|
||||
supportedMemoryMappingMask = driverSupportInfo.supportedMemoryMappingMask,
|
||||
customDriverLoadStatus = driverSupportInfo.loadStatus,
|
||||
availableCameras = NativeLib.getAvailableCameras().toList(),
|
||||
availableAdhocAddresses = parsePairs(NativeLib.getAvailableAdhocAddresses())
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseModules(rawModules: Array<String>): List<Pair<String, Boolean>> = buildList {
|
||||
var index = 0
|
||||
while (index + 1 < rawModules.size) {
|
||||
add(rawModules[index] to (rawModules[index + 1] == "true"))
|
||||
index += 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePairs(rawPairs: Array<String>): List<Pair<String, String>> = buildList {
|
||||
var index = 0
|
||||
while (index + 1 < rawPairs.size) {
|
||||
add(rawPairs[index] to rawPairs[index + 1])
|
||||
index += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
|
||||
data class UiLanguageOption(
|
||||
val tag: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
object UiLanguages {
|
||||
private const val PREFS_NAME = "vita3k_frontend"
|
||||
private const val PREF_UI_LANGUAGE = "ui_language"
|
||||
|
||||
val options: List<UiLanguageOption> = listOf(
|
||||
UiLanguageOption("", "System Default"),
|
||||
UiLanguageOption("en", "English")
|
||||
)
|
||||
|
||||
fun currentTag(context: Context): String =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREF_UI_LANGUAGE, "") ?: ""
|
||||
|
||||
fun applyStored(context: Context) {
|
||||
apply(currentTag(context))
|
||||
}
|
||||
|
||||
fun applyAndPersist(context: Context, tag: String) {
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(PREF_UI_LANGUAGE, tag)
|
||||
.apply()
|
||||
apply(tag)
|
||||
}
|
||||
|
||||
fun apply(tag: String) {
|
||||
val locales = if (tag.isBlank()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(tag)
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
enum class UpdateCheckStatus {
|
||||
Failed,
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
CurrentBuildNewerThanLatest,
|
||||
CustomBuildCanUpdate
|
||||
}
|
||||
|
||||
data class UpdateInfo(
|
||||
// Human-readable release name or tag.
|
||||
val version: String = "",
|
||||
// Numeric build identifier parsed from the release metadata.
|
||||
val buildNumber: Long = 0L,
|
||||
// Browser URL for the release page.
|
||||
val releaseUrl: String = "",
|
||||
// GitHub publication timestamp in ISO-8601 format.
|
||||
val publishedAt: String = "",
|
||||
// Release notes with updater metadata lines removed.
|
||||
val notes: String = ""
|
||||
)
|
||||
|
||||
data class UpdateCheckResult(
|
||||
val status: UpdateCheckStatus,
|
||||
val message: String,
|
||||
// Parsed metadata for the latest available release.
|
||||
val info: UpdateInfo = UpdateInfo(),
|
||||
// Display version string for the installed build.
|
||||
val currentDisplayVersion: String = ""
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.vita3k.emulator.data
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.vita3k.emulator.NativeLib
|
||||
|
||||
internal object UserRepository {
|
||||
|
||||
suspend fun getUsers(): List<NativeUser> = withContext(Dispatchers.IO) {
|
||||
NativeLib.getUsers().toList()
|
||||
}
|
||||
|
||||
suspend fun createUser(name: String): String = withContext(Dispatchers.IO) {
|
||||
NativeLib.createUser(name)
|
||||
}
|
||||
|
||||
suspend fun activateUser(userId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.activateUser(userId)
|
||||
}
|
||||
|
||||
suspend fun deleteUser(userId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
NativeLib.deleteUser(userId)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,6 @@
|
||||
|
||||
package org.vita3k.emulator.overlay;
|
||||
|
||||
import org.libsdl.app.SDL;
|
||||
import org.libsdl.app.SDLActivity;
|
||||
import org.vita3k.emulator.Emulator;
|
||||
import org.vita3k.emulator.R;
|
||||
|
||||
import android.app.Activity;
|
||||
@@ -19,10 +16,8 @@ import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.Display;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
@@ -30,8 +25,6 @@ import android.view.View.OnTouchListener;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
/**
|
||||
* Draws the interactive input overlay on top of the
|
||||
@@ -42,7 +35,11 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
// mirror what is in controller_dialog.cpp
|
||||
public final static int OVERLAY_MASK_BASIC = 1;
|
||||
public final static int OVERLAY_MASK_L2R2 = 2;
|
||||
public final static int OVERLAY_MASK_TOUCH_SCREEN_SWITCH = 4;
|
||||
public final static int OVERLAY_MASK_L3R3 = 4;
|
||||
public final static int OVERLAY_MASK_TOUCH_SCREEN_SWITCH = 8;
|
||||
public final static int OVERLAY_MASK_HIDE_TOGGLE = 16;
|
||||
private final static int OVERLAY_MASK_UTILITY =
|
||||
OVERLAY_MASK_TOUCH_SCREEN_SWITCH | OVERLAY_MASK_HIDE_TOGGLE;
|
||||
|
||||
// wait 10 seconds without inputs before hiding
|
||||
private final static int OVERLAY_TIME_BEFORE_HIDE = 10;
|
||||
@@ -50,27 +47,32 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
private final Set<InputOverlayDrawableButton> overlayButtons = new HashSet<>();
|
||||
private final Set<InputOverlayDrawableDpad> overlayDpads = new HashSet<>();
|
||||
private final Set<InputOverlayDrawableJoystick> overlayJoysticks = new HashSet<>();
|
||||
|
||||
private Rect mSurfacePosition = null;
|
||||
private final Runnable mHideOverlayTicker = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
tick();
|
||||
postDelayed(this, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
private int mOverlayMask = 0;
|
||||
private boolean mIsInEditMode = false;
|
||||
private boolean mAutoHideEnabled = true;
|
||||
private boolean mAllowVirtualController = true;
|
||||
private boolean mControllerAttached = false;
|
||||
private InputOverlayDrawableButton mButtonBeingConfigured;
|
||||
private InputOverlayDrawableDpad mDpadBeingConfigured;
|
||||
private InputOverlayDrawableJoystick mJoystickBeingConfigured;
|
||||
private static float mGlobalScale = 1.0f;
|
||||
private static int mGlobalOpacity = 100;
|
||||
|
||||
private Timer mTimer;
|
||||
private String mLayoutProfileId = "";
|
||||
|
||||
// last Time the screen was touched
|
||||
private long mlastTouchTime;
|
||||
// is the overlay hidden because we didn't used it for long enough ?
|
||||
private boolean mShowingOverlay = true;
|
||||
// hide overlay manually with touch overlay
|
||||
private static boolean hide_overlay = false;
|
||||
|
||||
private final SharedPreferences mPreferences;
|
||||
// hide overlay manually while keeping the touch utility buttons available
|
||||
private boolean mHideOverlayButtons = false;
|
||||
|
||||
/**
|
||||
* Resizes a {@link Bitmap} by a given scale factor
|
||||
@@ -103,9 +105,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
{
|
||||
super(context/*, attrs*/);
|
||||
|
||||
mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext());
|
||||
if (!mPreferences.getBoolean("OverlayInit", false))
|
||||
defaultOverlay();
|
||||
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
|
||||
|
||||
// Set the on touch listener.
|
||||
// Do not register the overlay as a touch listener
|
||||
@@ -118,29 +118,35 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
// Request focus for the overlay so it has priority on presses.
|
||||
requestFocus();
|
||||
|
||||
/*SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
|
||||
sPrefsEditor.putBoolean("OverlayInit", true);
|
||||
sPrefsEditor.apply();*/
|
||||
refreshControls();
|
||||
|
||||
mTimer = new Timer();
|
||||
|
||||
// call tick every second to check if we should stop displaying the overlay
|
||||
mTimer.scheduleAtFixedRate(new TimerTask()
|
||||
{
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
Emulator emu = (Emulator) SDL.getContext();
|
||||
emu.getmOverlay().tick();
|
||||
}
|
||||
}, 1000, 1000);
|
||||
|
||||
resetHideTimer();
|
||||
}
|
||||
|
||||
private void resetHideTimer()
|
||||
{
|
||||
private void startHideTimer() {
|
||||
removeCallbacks(mHideOverlayTicker);
|
||||
postDelayed(mHideOverlayTicker, 1000);
|
||||
}
|
||||
|
||||
private void stopHideTimer() {
|
||||
removeCallbacks(mHideOverlayTicker);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
startHideTimer();
|
||||
updateVirtualControllerState();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
stopHideTimer();
|
||||
releaseAllInputs();
|
||||
detachVirtualController();
|
||||
super.onDetachedFromWindow();
|
||||
}
|
||||
|
||||
private void resetHideTimer(){
|
||||
if(!mShowingOverlay)
|
||||
invalidate();
|
||||
|
||||
@@ -148,9 +154,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
mlastTouchTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public void tick()
|
||||
{
|
||||
if (mOverlayMask == 0 || !mShowingOverlay || isInEditMode())
|
||||
public void tick(){
|
||||
if(mOverlayMask == 0 || !mShowingOverlay || isInEditMode() || !mAutoHideEnabled)
|
||||
return;
|
||||
|
||||
long current_time = System.currentTimeMillis();
|
||||
@@ -160,42 +165,46 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
}
|
||||
}
|
||||
|
||||
public void setState(int overlay_mask)
|
||||
{
|
||||
boolean was_showing = mOverlayMask != 0;
|
||||
|
||||
if (hide_overlay && mOverlayMask != 4)
|
||||
{
|
||||
mOverlayMask = 4;
|
||||
refreshControls();
|
||||
invalidate();
|
||||
}
|
||||
else if (mOverlayMask != overlay_mask)
|
||||
{
|
||||
public void setState(int overlay_mask){
|
||||
if(mOverlayMask != overlay_mask){
|
||||
mOverlayMask = overlay_mask;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
resetHideTimer();
|
||||
updateVirtualControllerState();
|
||||
}
|
||||
|
||||
boolean is_showing = overlay_mask != 0;
|
||||
if (is_showing == was_showing)
|
||||
return;
|
||||
public int getOverlayMask() {
|
||||
return mOverlayMask;
|
||||
}
|
||||
|
||||
if (is_showing)
|
||||
attachController();
|
||||
else
|
||||
detachController();
|
||||
public void releaseAllInputs() {
|
||||
for (InputOverlayDrawableButton button : overlayButtons) {
|
||||
if ((button.getRole() & OVERLAY_MASK_UTILITY) == 0) {
|
||||
setButton(button.getControl(), false);
|
||||
}
|
||||
button.setPressedState(false);
|
||||
button.setTrackId(-1);
|
||||
}
|
||||
|
||||
for (InputOverlayDrawableDpad dpad : overlayDpads) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
setButton(dpad.getControl(i), false);
|
||||
}
|
||||
dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
|
||||
dpad.setTrackId(-1);
|
||||
}
|
||||
|
||||
for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
|
||||
setAxis(joystick.getXControl(), (short)0);
|
||||
setAxis(joystick.getYControl(), (short)0);
|
||||
joystick.release();
|
||||
}
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setSurfacePosition(Rect rect)
|
||||
{
|
||||
mSurfacePosition = rect;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(Canvas canvas)
|
||||
{
|
||||
super.draw(canvas);
|
||||
@@ -207,17 +216,17 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
{
|
||||
int role = button.getRole();
|
||||
|
||||
if (hide_overlay) {
|
||||
if (role == OVERLAY_MASK_TOUCH_SCREEN_SWITCH)
|
||||
button.draw(canvas);
|
||||
else
|
||||
if (mHideOverlayButtons) {
|
||||
if ((role & OVERLAY_MASK_UTILITY) == 0 || (role & mOverlayMask) == 0)
|
||||
continue;
|
||||
} else if ((role & mOverlayMask) == 0) {
|
||||
continue;
|
||||
} else if ((role & mOverlayMask) != 0) {
|
||||
button.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hide_overlay)
|
||||
button.draw(canvas);
|
||||
}
|
||||
|
||||
if (!mHideOverlayButtons)
|
||||
{
|
||||
for (InputOverlayDrawableDpad dpad : overlayDpads)
|
||||
{
|
||||
@@ -237,6 +246,9 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
if (mOverlayMask == 0)
|
||||
return false;
|
||||
|
||||
if(!mAllowVirtualController && !isInEditMode())
|
||||
return false;
|
||||
|
||||
resetHideTimer();
|
||||
|
||||
if (isInEditMode())
|
||||
@@ -253,12 +265,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
|
||||
for (InputOverlayDrawableButton button : overlayButtons)
|
||||
{
|
||||
int btn_role = button.getRole();
|
||||
int legacy_id = button.getLegacyId();
|
||||
int buttonRole = button.getRole();
|
||||
int legacyId = button.getLegacyId();
|
||||
|
||||
if (hide_overlay && btn_role != OVERLAY_MASK_TOUCH_SCREEN_SWITCH)
|
||||
if (mHideOverlayButtons &&
|
||||
(((buttonRole & OVERLAY_MASK_UTILITY) == 0) || (buttonRole & mOverlayMask) == 0))
|
||||
continue;
|
||||
else if ((btn_role & mOverlayMask) == 0)
|
||||
else if ((buttonRole & mOverlayMask) == 0)
|
||||
continue;
|
||||
|
||||
// Determine the button state to apply based on the MotionEvent action flag.
|
||||
@@ -274,10 +287,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
button.setTrackId(event.getPointerId(pointerIndex));
|
||||
concerned = true;
|
||||
|
||||
if (legacy_id == ButtonType.BUTTON_TOUCH_SWITCH)
|
||||
if (legacyId == ButtonType.BUTTON_TOUCH_SWITCH)
|
||||
setTouchState(button.getPressed());
|
||||
else if (legacy_id == ButtonType.BUTTON_TOUCH_HIDE)
|
||||
hide_overlay = !hide_overlay;
|
||||
else if (legacyId == ButtonType.BUTTON_TOUCH_HIDE)
|
||||
mHideOverlayButtons = !mHideOverlayButtons;
|
||||
else
|
||||
setButton(button.getControl(), true);
|
||||
}
|
||||
@@ -289,15 +302,9 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
{
|
||||
button.setPressedState(false);
|
||||
|
||||
if (legacy_id != ButtonType.BUTTON_TOUCH_SWITCH)
|
||||
if (legacyId != ButtonType.BUTTON_TOUCH_SWITCH &&
|
||||
legacyId != ButtonType.BUTTON_TOUCH_HIDE)
|
||||
setButton(button.getControl(), false);
|
||||
else if (legacy_id == ButtonType.BUTTON_TOUCH_HIDE && hide_overlay)
|
||||
if (mOverlayMask != 4) {
|
||||
mOverlayMask = 4;
|
||||
detachController();
|
||||
refreshControls();
|
||||
attachController();
|
||||
}
|
||||
|
||||
button.setTrackId(-1);
|
||||
concerned = true;
|
||||
@@ -306,7 +313,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
}
|
||||
}
|
||||
|
||||
if (!hide_overlay)
|
||||
if (!mHideOverlayButtons)
|
||||
{
|
||||
for (InputOverlayDrawableDpad dpad : overlayDpads)
|
||||
{
|
||||
@@ -517,10 +524,6 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
return intersect;
|
||||
}
|
||||
|
||||
public void onDestroy()
|
||||
{
|
||||
}
|
||||
|
||||
private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left,
|
||||
boolean right)
|
||||
{
|
||||
@@ -557,85 +560,83 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
{
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_cross,
|
||||
R.drawable.button_cross_pressed, ButtonType.BUTTON_CROSS, ControlId.a,
|
||||
orientation, OVERLAY_MASK_BASIC));
|
||||
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_circle,
|
||||
R.drawable.button_circle_pressed, ButtonType.BUTTON_CIRCLE, ControlId.b,
|
||||
orientation, OVERLAY_MASK_BASIC));
|
||||
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_square,
|
||||
R.drawable.button_square_pressed, ButtonType.BUTTON_SQUARE, ControlId.x,
|
||||
orientation, OVERLAY_MASK_BASIC));
|
||||
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_triangle,
|
||||
R.drawable.button_triangle_pressed, ButtonType.BUTTON_TRIANGLE, ControlId.y,
|
||||
orientation, OVERLAY_MASK_BASIC));
|
||||
|
||||
orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start,
|
||||
R.drawable.button_start_pressed, ButtonType.BUTTON_START,
|
||||
ControlId.start, orientation, OVERLAY_MASK_BASIC));
|
||||
ControlId.start, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_ps,
|
||||
R.drawable.button_ps_pressed, ButtonType.BUTTON_PS,
|
||||
ControlId.guide, orientation, OVERLAY_MASK_BASIC));
|
||||
ControlId.guide, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select,
|
||||
R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT,
|
||||
ControlId.select, orientation, OVERLAY_MASK_BASIC));
|
||||
ControlId.select, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l,
|
||||
R.drawable.button_l_pressed, ButtonType.TRIGGER_L,
|
||||
ControlId.l1, orientation, OVERLAY_MASK_BASIC));
|
||||
|
||||
ControlId.l1, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r,
|
||||
R.drawable.button_r_pressed, ButtonType.TRIGGER_R,
|
||||
ControlId.r1, orientation, OVERLAY_MASK_BASIC));
|
||||
ControlId.r1, orientation, OVERLAY_MASK_BASIC, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l2,
|
||||
R.drawable.button_l2_pressed, ButtonType.TRIGGER_L2,
|
||||
ControlId.l2, orientation, OVERLAY_MASK_L2R2));
|
||||
ControlId.l2, orientation, OVERLAY_MASK_L2R2, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r2,
|
||||
R.drawable.button_r2_pressed, ButtonType.TRIGGER_R2,
|
||||
ControlId.r2, orientation, OVERLAY_MASK_L2R2));
|
||||
ControlId.r2, orientation, OVERLAY_MASK_L2R2, mLayoutProfileId));
|
||||
|
||||
// L3 and R3
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l3,
|
||||
R.drawable.button_l3_pressed, ButtonType.TRIGGER_L3,
|
||||
ControlId.l3, orientation, OVERLAY_MASK_L2R2));
|
||||
ControlId.l3, orientation, OVERLAY_MASK_L3R3, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r3,
|
||||
R.drawable.button_r3_pressed, ButtonType.TRIGGER_R3,
|
||||
ControlId.r3, orientation, OVERLAY_MASK_L2R2));
|
||||
ControlId.r3, orientation, OVERLAY_MASK_L3R3, mLayoutProfileId));
|
||||
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_touch_f,
|
||||
R.drawable.button_touch_b, ButtonType.BUTTON_TOUCH_SWITCH,
|
||||
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH));
|
||||
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH, mLayoutProfileId));
|
||||
|
||||
// show hide button
|
||||
overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_hide,
|
||||
R.drawable.button_hide_pressed, ButtonType.BUTTON_TOUCH_HIDE,
|
||||
ControlId.touch, orientation, OVERLAY_MASK_TOUCH_SCREEN_SWITCH));
|
||||
ControlId.touch, orientation, OVERLAY_MASK_HIDE_TOGGLE, mLayoutProfileId));
|
||||
|
||||
overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad_idle,
|
||||
R.drawable.dpad_up,
|
||||
R.drawable.dpad_up_left,
|
||||
ButtonType.DPAD_UP, ControlId.dup, ControlId.ddown,
|
||||
ControlId.dleft, ControlId.dright, orientation));
|
||||
ControlId.dleft, ControlId.dright, orientation, mLayoutProfileId));
|
||||
|
||||
overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.joystick_range,
|
||||
R.drawable.joystick, R.drawable.joystick_pressed,
|
||||
ButtonType.STICK_LEFT, ControlId.axis_left_x,
|
||||
ControlId.axis_left_y, orientation));
|
||||
|
||||
ControlId.axis_left_y, orientation, mLayoutProfileId));
|
||||
overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.joystick_range,
|
||||
R.drawable.joystick, R.drawable.joystick_pressed,
|
||||
ButtonType.STICK_RIGHT, ControlId.axis_right_x,
|
||||
ControlId.axis_right_y, orientation));
|
||||
ControlId.axis_right_y, orientation, mLayoutProfileId));
|
||||
}
|
||||
|
||||
public void refreshControls()
|
||||
{
|
||||
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
|
||||
// Remove all the overlay buttons from the HashSet.
|
||||
overlayButtons.clear();
|
||||
overlayDpads.clear();
|
||||
@@ -649,7 +650,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
|
||||
public void resetButtonPlacement()
|
||||
{
|
||||
vitaDefaultOverlay();
|
||||
OverlayLayoutStore.resetToDefaults(getContext(), mLayoutProfileId);
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
@@ -670,28 +671,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
private void saveControlPosition(int sharedPrefsId, int x, int y,
|
||||
String orientation)
|
||||
{
|
||||
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext());
|
||||
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(getContext());
|
||||
SharedPreferences.Editor sPrefsEditor = sPrefs.edit();
|
||||
sPrefsEditor.putFloat(getXKey(sharedPrefsId, orientation), x);
|
||||
sPrefsEditor.putFloat(getYKey(sharedPrefsId, orientation), y);
|
||||
sPrefsEditor.putFloat(OverlayLayoutStore.positionXKey(sharedPrefsId, orientation, mLayoutProfileId), x);
|
||||
sPrefsEditor.putFloat(OverlayLayoutStore.positionYKey(sharedPrefsId, orientation, mLayoutProfileId), y);
|
||||
sPrefsEditor.apply();
|
||||
}
|
||||
|
||||
private static String getKey(int sharedPrefsId, String orientation, String suffix)
|
||||
{
|
||||
return sharedPrefsId + orientation + suffix;
|
||||
}
|
||||
|
||||
private static String getXKey(int sharedPrefsId, String orientation)
|
||||
{
|
||||
return getKey(sharedPrefsId, orientation, "-X");
|
||||
}
|
||||
|
||||
private static String getYKey(int sharedPrefsId, String orientation)
|
||||
{
|
||||
return getKey(sharedPrefsId, orientation, "-Y");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an InputOverlayDrawableButton, given by resId, with all of the
|
||||
* parameters set for it to be properly shown on the InputOverlay.
|
||||
@@ -724,13 +710,14 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
* @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set.
|
||||
*/
|
||||
private static InputOverlayDrawableButton initializeOverlayButton(Context context,
|
||||
int defaultResId, int pressedResId, int legacyId, int control, String orientation, int role)
|
||||
int defaultResId, int pressedResId, int legacyId, int control, String orientation, int role,
|
||||
String layoutProfileId)
|
||||
{
|
||||
// Resources handle for fetching the initial Drawable resource.
|
||||
final Resources res = context.getResources();
|
||||
|
||||
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton.
|
||||
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
|
||||
|
||||
// Decide scale based on button ID and user preference
|
||||
float scale = 0.15f;
|
||||
@@ -760,8 +747,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
|
||||
// The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
|
||||
// These were set in the input overlay configuration menu.
|
||||
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
|
||||
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
|
||||
int width = overlayDrawable.getWidth();
|
||||
int height = overlayDrawable.getHeight();
|
||||
@@ -800,13 +787,14 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
int downControl,
|
||||
int leftControl,
|
||||
int rightControl,
|
||||
String orientation)
|
||||
String orientation,
|
||||
String layoutProfileId)
|
||||
{
|
||||
// Resources handle for fetching the initial Drawable resource.
|
||||
final Resources res = context.getResources();
|
||||
|
||||
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad.
|
||||
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
|
||||
|
||||
// Decide scale based on button ID and user preference
|
||||
float scale = 0.35f;
|
||||
@@ -829,8 +817,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
|
||||
// The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay.
|
||||
// These were set in the input overlay configuration menu.
|
||||
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
|
||||
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
|
||||
int width = overlayDrawable.getWidth();
|
||||
int height = overlayDrawable.getHeight();
|
||||
@@ -860,13 +848,13 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
*/
|
||||
private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context,
|
||||
int resOuter, int defaultResInner, int pressedResInner, int legacyId, int xControl,
|
||||
int yControl, String orientation)
|
||||
int yControl, String orientation, String layoutProfileId)
|
||||
{
|
||||
// Resources handle for fetching the initial Drawable resource.
|
||||
final Resources res = context.getResources();
|
||||
|
||||
// SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
|
||||
final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final SharedPreferences sPrefs = OverlayLayoutStore.preferences(context);
|
||||
|
||||
// Decide scale based on user preference
|
||||
float scale = 0.275f;
|
||||
@@ -880,8 +868,8 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
|
||||
// The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
|
||||
// These were set in the input overlay configuration menu.
|
||||
int drawableX = (int) sPrefs.getFloat(getXKey(legacyId, orientation), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(getYKey(legacyId, orientation), 0f);
|
||||
int drawableX = (int) sPrefs.getFloat(OverlayLayoutStore.positionXKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
int drawableY = (int) sPrefs.getFloat(OverlayLayoutStore.positionYKey(legacyId, orientation, layoutProfileId), 0f);
|
||||
|
||||
// Decide inner scale based on joystick ID
|
||||
float innerScale = 1.375f;
|
||||
@@ -907,6 +895,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
public void setIsInEditMode(boolean isInEditMode)
|
||||
{
|
||||
mIsInEditMode = isInEditMode;
|
||||
if (mIsInEditMode) {
|
||||
mHideOverlayButtons = false;
|
||||
mShowingOverlay = true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isInEditMode()
|
||||
@@ -914,113 +906,62 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
return mIsInEditMode;
|
||||
}
|
||||
|
||||
private void defaultOverlay()
|
||||
public void setAutoHideEnabled(boolean autoHideEnabled)
|
||||
{
|
||||
if (!mPreferences.getBoolean("OverlayInit", false))
|
||||
{
|
||||
vitaDefaultOverlay();
|
||||
mAutoHideEnabled = autoHideEnabled;
|
||||
if(!mAutoHideEnabled){
|
||||
mShowingOverlay = true;
|
||||
invalidate();
|
||||
} else {
|
||||
resetHideTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private void vitaDefaultOverlay()
|
||||
public void setAllowVirtualController(boolean allowVirtualController)
|
||||
{
|
||||
SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
|
||||
if(mAllowVirtualController == allowVirtualController)
|
||||
return;
|
||||
|
||||
// Get screen size
|
||||
Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
|
||||
DisplayMetrics outMetrics = new DisplayMetrics();
|
||||
display.getMetrics(outMetrics);
|
||||
float maxX = outMetrics.heightPixels;
|
||||
float maxY = outMetrics.widthPixels;
|
||||
// Height and width changes depending on orientation. Use the larger value for maxX.
|
||||
if (maxY > maxX)
|
||||
{
|
||||
float tmp = maxX;
|
||||
maxX = maxY;
|
||||
maxY = tmp;
|
||||
mAllowVirtualController = allowVirtualController;
|
||||
updateVirtualControllerState();
|
||||
}
|
||||
Resources res = getResources();
|
||||
|
||||
// Each value is a percent from max X/Y stored as an int. Have to bring that value down
|
||||
// to a decimal before multiplying by MAX X/Y.
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_CROSS + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_CROSS_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_CROSS + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_CROSS_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_CIRCLE + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_CIRCLE_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_CIRCLE + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_CIRCLE_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_SQUARE + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_SQUARE_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_SQUARE + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_SQUARE_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TRIANGLE + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TRIANGLE_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TRIANGLE + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TRIANGLE_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_SELECT_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_SELECT_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_START_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_START_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_PS + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_PS_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_PS + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_PS_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X",
|
||||
(((float) res.getInteger(R.integer.DPAD_UP_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y",
|
||||
(((float) res.getInteger(R.integer.DPAD_UP_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X",
|
||||
(((float) res.getInteger(R.integer.STICK_LEFT_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y",
|
||||
(((float) res.getInteger(R.integer.STICK_LEFT_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.STICK_RIGHT + "-X",
|
||||
(((float) res.getInteger(R.integer.STICK_RIGHT_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.STICK_RIGHT + "-Y",
|
||||
(((float) res.getInteger(R.integer.STICK_RIGHT_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L2 + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L2_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L2 + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L2_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R2 + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R2_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R2 + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R2_Y) / 1000) * maxY));
|
||||
public void setLayoutProfileId(String layoutProfileId)
|
||||
{
|
||||
String normalizedProfileId = OverlayLayoutStore.normalizeProfileId(layoutProfileId);
|
||||
if(mLayoutProfileId.equals(normalizedProfileId))
|
||||
return;
|
||||
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L3 + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L3_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_L3 + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_L3_Y) / 1000) * maxY));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R3 + "-X",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R3_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.TRIGGER_R3 + "-Y",
|
||||
(((float) res.getInteger(R.integer.TRIGGER_R3_Y) / 1000) * maxY));
|
||||
mLayoutProfileId = normalizedProfileId;
|
||||
OverlayLayoutStore.ensureInitialized(getContext(), mLayoutProfileId);
|
||||
refreshControls();
|
||||
}
|
||||
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_SWITCH + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TOUCH_SWITCH_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_SWITCH + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TOUCH_SWITCH_Y) / 1000) * maxY));
|
||||
public void rebindController()
|
||||
{
|
||||
releaseAllInputs();
|
||||
detachVirtualController();
|
||||
updateVirtualControllerState();
|
||||
}
|
||||
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_HIDE + "-X",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TOUCH_HIDE_X) / 1000) * maxX));
|
||||
sPrefsEditor.putFloat(ButtonType.BUTTON_TOUCH_HIDE + "-Y",
|
||||
(((float) res.getInteger(R.integer.BUTTON_TOUCH_HIDE_Y) / 1000) * maxY));
|
||||
private void updateVirtualControllerState()
|
||||
{
|
||||
boolean shouldAttach = mAllowVirtualController && mOverlayMask != 0 && getWindowToken() != null;
|
||||
if (shouldAttach == mControllerAttached)
|
||||
return;
|
||||
|
||||
// We want to commit right away, otherwise the overlay could load before this is saved.
|
||||
sPrefsEditor.commit();
|
||||
if (shouldAttach) {
|
||||
attachController();
|
||||
mControllerAttached = true;
|
||||
} else {
|
||||
detachVirtualController();
|
||||
}
|
||||
}
|
||||
|
||||
private void detachVirtualController()
|
||||
{
|
||||
detachController();
|
||||
mControllerAttached = false;
|
||||
}
|
||||
|
||||
public native void attachController();
|
||||
@@ -1075,7 +1016,6 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener
|
||||
public static final int r2 = -5;
|
||||
|
||||
// button to switch between front and back touch
|
||||
public static final int hide = 1023;
|
||||
public static final int touch = 1024;
|
||||
|
||||
public static final int axis_left_x = 0;
|
||||
@@ -331,4 +331,19 @@ public final class InputOverlayDrawableJoystick
|
||||
{
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public void release()
|
||||
{
|
||||
mPressedState = false;
|
||||
mCurrentX = mCurrentY = 0.0f;
|
||||
mJoystickX = mJoystickY = 0.0f;
|
||||
mOuterBitmap.setAlpha(mOpacity);
|
||||
mBoundsBoxBitmap.setAlpha(0);
|
||||
setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
|
||||
mOrigBounds.bottom));
|
||||
setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
|
||||
mOrigBounds.bottom));
|
||||
SetInnerBounds();
|
||||
trackId = -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package org.vita3k.emulator.overlay
|
||||
|
||||
import android.content.Context
|
||||
|
||||
val DEFAULT_OVERLAY_MASK: Int =
|
||||
InputOverlay.OVERLAY_MASK_BASIC or InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH
|
||||
|
||||
data class OverlayConfig(
|
||||
val overlayMask: Int = DEFAULT_OVERLAY_MASK,
|
||||
val overlayScale: Int = 100,
|
||||
val overlayOpacity: Int = 100,
|
||||
val hideOverlayWhenControllerConnected: Boolean = true
|
||||
) {
|
||||
val controlsVisible: Boolean
|
||||
get() = overlayMask != 0
|
||||
|
||||
val l2r2Visible: Boolean
|
||||
get() = overlayMask and InputOverlay.OVERLAY_MASK_L2R2 != 0
|
||||
|
||||
val l3r3Visible: Boolean
|
||||
get() = overlayMask and InputOverlay.OVERLAY_MASK_L3R3 != 0
|
||||
|
||||
val touchSwitchVisible: Boolean
|
||||
get() = overlayMask and InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH != 0
|
||||
|
||||
val hideToggleVisible: Boolean
|
||||
get() = overlayMask and InputOverlay.OVERLAY_MASK_HIDE_TOGGLE != 0
|
||||
|
||||
fun activeMask(): Int = if (overlayMask == 0) DEFAULT_OVERLAY_MASK else overlayMask
|
||||
|
||||
fun withControlsVisible(visible: Boolean): OverlayConfig {
|
||||
return copy(overlayMask = if (visible) activeMask() else 0).normalized()
|
||||
}
|
||||
|
||||
fun withL2R2Visible(visible: Boolean): OverlayConfig {
|
||||
val baseMask = activeMask()
|
||||
val updatedMask = if (visible) {
|
||||
baseMask or InputOverlay.OVERLAY_MASK_L2R2
|
||||
} else {
|
||||
baseMask and InputOverlay.OVERLAY_MASK_L2R2.inv()
|
||||
}
|
||||
return copy(overlayMask = updatedMask).normalized()
|
||||
}
|
||||
|
||||
fun withL3R3Visible(visible: Boolean): OverlayConfig {
|
||||
val baseMask = activeMask()
|
||||
val updatedMask = if (visible) {
|
||||
baseMask or InputOverlay.OVERLAY_MASK_L3R3
|
||||
} else {
|
||||
baseMask and InputOverlay.OVERLAY_MASK_L3R3.inv()
|
||||
}
|
||||
return copy(overlayMask = updatedMask).normalized()
|
||||
}
|
||||
|
||||
fun withTouchSwitchVisible(visible: Boolean): OverlayConfig {
|
||||
val baseMask = activeMask()
|
||||
val updatedMask = if (visible) {
|
||||
baseMask or InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH
|
||||
} else {
|
||||
baseMask and InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH.inv()
|
||||
}
|
||||
return copy(overlayMask = updatedMask).normalized()
|
||||
}
|
||||
|
||||
fun withHideToggleVisible(visible: Boolean): OverlayConfig {
|
||||
val baseMask = activeMask()
|
||||
val updatedMask = if (visible) {
|
||||
baseMask or InputOverlay.OVERLAY_MASK_HIDE_TOGGLE
|
||||
} else {
|
||||
baseMask and InputOverlay.OVERLAY_MASK_HIDE_TOGGLE.inv()
|
||||
}
|
||||
return copy(overlayMask = updatedMask).normalized()
|
||||
}
|
||||
|
||||
fun normalized(): OverlayConfig {
|
||||
val knownMask = InputOverlay.OVERLAY_MASK_BASIC or
|
||||
InputOverlay.OVERLAY_MASK_L2R2 or
|
||||
InputOverlay.OVERLAY_MASK_L3R3 or
|
||||
InputOverlay.OVERLAY_MASK_TOUCH_SCREEN_SWITCH or
|
||||
InputOverlay.OVERLAY_MASK_HIDE_TOGGLE
|
||||
return copy(
|
||||
overlayMask = overlayMask.coerceAtLeast(0) and knownMask,
|
||||
overlayScale = overlayScale.coerceIn(50, 150),
|
||||
overlayOpacity = overlayOpacity.coerceIn(20, 100)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object OverlayConfigStore {
|
||||
private const val PREFS_NAME = "emulation_session"
|
||||
private const val KEY_OVERLAY_MASK = "overlay_mask"
|
||||
private const val KEY_OVERLAY_SCALE = "overlay_scale"
|
||||
private const val KEY_OVERLAY_OPACITY = "overlay_opacity"
|
||||
private const val KEY_HIDE_ON_CONTROLLER = "hide_overlay_on_controller"
|
||||
|
||||
fun load(context: Context): OverlayConfig {
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return OverlayConfig(
|
||||
overlayMask = prefs.getInt(KEY_OVERLAY_MASK, DEFAULT_OVERLAY_MASK),
|
||||
overlayScale = prefs.getInt(KEY_OVERLAY_SCALE, 100),
|
||||
overlayOpacity = prefs.getInt(KEY_OVERLAY_OPACITY, 100),
|
||||
hideOverlayWhenControllerConnected = prefs.getBoolean(KEY_HIDE_ON_CONTROLLER, true)
|
||||
).normalized()
|
||||
}
|
||||
|
||||
fun save(context: Context, config: OverlayConfig) {
|
||||
val normalized = config.normalized()
|
||||
context.applicationContext
|
||||
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(KEY_OVERLAY_MASK, normalized.overlayMask)
|
||||
.putInt(KEY_OVERLAY_SCALE, normalized.overlayScale)
|
||||
.putInt(KEY_OVERLAY_OPACITY, normalized.overlayOpacity)
|
||||
.putBoolean(KEY_HIDE_ON_CONTROLLER, normalized.hideOverlayWhenControllerConnected)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.vita3k.emulator.overlay
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.util.DisplayMetrics
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
object OverlayLayoutStore {
|
||||
private const val KEY_OVERLAY_INIT = "OverlayInit"
|
||||
|
||||
private data class OverlayPositionDefaults(
|
||||
val buttonType: Int,
|
||||
val xResId: Int,
|
||||
val yResId: Int
|
||||
)
|
||||
|
||||
private val defaultEntries = listOf(
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_CROSS, R.integer.BUTTON_CROSS_X, R.integer.BUTTON_CROSS_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_CIRCLE, R.integer.BUTTON_CIRCLE_X, R.integer.BUTTON_CIRCLE_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_SQUARE, R.integer.BUTTON_SQUARE_X, R.integer.BUTTON_SQUARE_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TRIANGLE, R.integer.BUTTON_TRIANGLE_X, R.integer.BUTTON_TRIANGLE_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_SELECT, R.integer.BUTTON_SELECT_X, R.integer.BUTTON_SELECT_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_START, R.integer.BUTTON_START_X, R.integer.BUTTON_START_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_PS, R.integer.BUTTON_PS_X, R.integer.BUTTON_PS_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.DPAD_UP, R.integer.DPAD_UP_X, R.integer.DPAD_UP_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.STICK_LEFT, R.integer.STICK_LEFT_X, R.integer.STICK_LEFT_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.STICK_RIGHT, R.integer.STICK_RIGHT_X, R.integer.STICK_RIGHT_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L, R.integer.TRIGGER_L_X, R.integer.TRIGGER_L_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R, R.integer.TRIGGER_R_X, R.integer.TRIGGER_R_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L2, R.integer.TRIGGER_L2_X, R.integer.TRIGGER_L2_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R2, R.integer.TRIGGER_R2_X, R.integer.TRIGGER_R2_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_L3, R.integer.TRIGGER_L3_X, R.integer.TRIGGER_L3_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.TRIGGER_R3, R.integer.TRIGGER_R3_X, R.integer.TRIGGER_R3_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TOUCH_HIDE, R.integer.BUTTON_TOUCH_HIDE_X, R.integer.BUTTON_TOUCH_HIDE_Y),
|
||||
OverlayPositionDefaults(InputOverlay.ButtonType.BUTTON_TOUCH_SWITCH, R.integer.BUTTON_TOUCH_SWITCH_X, R.integer.BUTTON_TOUCH_SWITCH_Y)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun ensureInitialized(context: Context, profileId: String? = null) {
|
||||
val prefs = preferences(context)
|
||||
val initKey = initKey(profileId)
|
||||
if (!prefs.getBoolean(initKey, false)) {
|
||||
resetToDefaults(context, profileId)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun resetToDefaults(context: Context, profileId: String? = null) {
|
||||
val prefs = preferences(context)
|
||||
val editor = prefs.edit()
|
||||
val resources = context.resources
|
||||
val (maxX, maxY) = resolveOverlayBounds(context)
|
||||
|
||||
defaultEntries.forEach { entry ->
|
||||
editor.putFloat(
|
||||
positionXKey(entry.buttonType, "", profileId),
|
||||
(resources.getInteger(entry.xResId) / 1000f) * maxX
|
||||
)
|
||||
editor.putFloat(
|
||||
positionYKey(entry.buttonType, "", profileId),
|
||||
(resources.getInteger(entry.yResId) / 1000f) * maxY
|
||||
)
|
||||
}
|
||||
|
||||
editor.putBoolean(initKey(profileId), true)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun positionXKey(buttonType: Int, orientation: String, profileId: String? = null): String =
|
||||
scopedKey("$buttonType$orientation-X", profileId)
|
||||
|
||||
@JvmStatic
|
||||
fun positionYKey(buttonType: Int, orientation: String, profileId: String? = null): String =
|
||||
scopedKey("$buttonType$orientation-Y", profileId)
|
||||
|
||||
@JvmStatic
|
||||
fun initKey(profileId: String? = null): String = scopedKey(KEY_OVERLAY_INIT, profileId)
|
||||
|
||||
@JvmStatic
|
||||
fun normalizeProfileId(profileId: String?): String = profileId.orEmpty().trim()
|
||||
|
||||
@JvmStatic
|
||||
fun preferences(context: Context): SharedPreferences =
|
||||
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
private fun scopedKey(rawKey: String, profileId: String?): String {
|
||||
val normalizedProfile = normalizeProfileId(profileId)
|
||||
return if (normalizedProfile.isEmpty()) rawKey else "overlay:$normalizedProfile:$rawKey"
|
||||
}
|
||||
|
||||
private fun resolveOverlayBounds(context: Context): Pair<Float, Float> {
|
||||
val activity = context as? Activity
|
||||
if (activity != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val bounds = activity.windowManager.currentWindowMetrics.bounds
|
||||
val width = bounds.width().toFloat()
|
||||
val height = bounds.height().toFloat()
|
||||
return if (width >= height) {
|
||||
width to height
|
||||
} else {
|
||||
height to width
|
||||
}
|
||||
}
|
||||
|
||||
val metrics = DisplayMetrics()
|
||||
if (activity != null) {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.windowManager.defaultDisplay.getMetrics(metrics)
|
||||
} else {
|
||||
metrics.setTo(context.resources.displayMetrics)
|
||||
}
|
||||
|
||||
var maxX = metrics.heightPixels.toFloat()
|
||||
var maxY = metrics.widthPixels.toFloat()
|
||||
if (maxY > maxX) {
|
||||
val tmp = maxX
|
||||
maxX = maxY
|
||||
maxY = tmp
|
||||
}
|
||||
return maxX to maxY
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.vita3k.emulator.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.vita3k.emulator.ConnectedGamepad
|
||||
import org.vita3k.emulator.InputDeviceUtils
|
||||
|
||||
const val CUSTOM_DRIVER_DOWNLOAD_URL = "https://github.com/K11MCH1/AdrenoToolsDrivers/releases/"
|
||||
|
||||
@Composable
|
||||
fun rememberConnectedGamepads(context: Context): List<ConnectedGamepad> {
|
||||
var connectedGamepads by remember { mutableStateOf(InputDeviceUtils.getPhysicalGamepads()) }
|
||||
|
||||
DisposableEffect(context) {
|
||||
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager
|
||||
val listener = object : InputManager.InputDeviceListener {
|
||||
private fun refresh() {
|
||||
connectedGamepads = InputDeviceUtils.getPhysicalGamepads()
|
||||
}
|
||||
|
||||
override fun onInputDeviceAdded(deviceId: Int) = refresh()
|
||||
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
|
||||
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refresh()
|
||||
}
|
||||
|
||||
connectedGamepads = InputDeviceUtils.getPhysicalGamepads()
|
||||
inputManager?.registerInputDeviceListener(listener, null)
|
||||
onDispose {
|
||||
inputManager?.unregisterInputDeviceListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
return connectedGamepads
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.vita3k.emulator.ui.components
|
||||
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.text.HtmlCompat
|
||||
|
||||
@Composable
|
||||
fun HtmlText(
|
||||
html: String,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
textColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
linkColor: Color = MaterialTheme.colorScheme.primary,
|
||||
gravity: Int = Gravity.START
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
highlightColor = android.graphics.Color.TRANSPARENT
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
linksClickable = true
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
view.setTextColor(textColor.toArgb())
|
||||
view.setLinkTextColor(linkColor.toArgb())
|
||||
view.textSize = textStyle.fontSize.value
|
||||
view.gravity = gravity
|
||||
view.textAlignment = if (gravity == Gravity.CENTER) {
|
||||
View.TEXT_ALIGNMENT_CENTER
|
||||
} else {
|
||||
View.TEXT_ALIGNMENT_VIEW_START
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.vita3k.emulator.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun OverlayEditorPalette(
|
||||
onDone: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
var offsetX by rememberSaveable { mutableIntStateOf(0) }
|
||||
var offsetY by rememberSaveable { mutableIntStateOf(0) }
|
||||
var panelSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
val maxOffsetX = ((constraints.maxWidth - panelSize.width) / 2).coerceAtLeast(0)
|
||||
val maxOffsetY = ((constraints.maxHeight - panelSize.height) / 2).coerceAtLeast(0)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset { IntOffset(offsetX, offsetY) }
|
||||
.widthIn(min = 220.dp, max = 280.dp)
|
||||
.onSizeChanged { panelSize = it }
|
||||
.pointerInput(maxOffsetX, maxOffsetY) {
|
||||
detectDragGestures { change, dragAmount ->
|
||||
change.consume()
|
||||
offsetX = (offsetX + dragAmount.x.roundToInt())
|
||||
.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = (offsetY + dragAmount.y.roundToInt())
|
||||
.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onReset,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.action_reset),
|
||||
modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = onDone,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.Done, contentDescription = null)
|
||||
Text(
|
||||
text = stringResource(R.string.action_done),
|
||||
modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package org.vita3k.emulator.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import org.vita3k.emulator.MainActivity
|
||||
import org.vita3k.emulator.data.AppStorage
|
||||
import org.vita3k.emulator.data.AppInfo
|
||||
import org.vita3k.emulator.ui.screens.AppInfoSheet
|
||||
import org.vita3k.emulator.ui.screens.AppsListScreen
|
||||
import org.vita3k.emulator.ui.screens.InitialSetupScreen
|
||||
import org.vita3k.emulator.ui.screens.ArchiveInstallSourceDialog
|
||||
import org.vita3k.emulator.ui.screens.InstallBottomSheet
|
||||
import org.vita3k.emulator.ui.screens.InstallProgressDialog
|
||||
import org.vita3k.emulator.ui.screens.InstallResultDialog
|
||||
import org.vita3k.emulator.ui.screens.LicenseSourceDialog
|
||||
import org.vita3k.emulator.ui.screens.UserManagementScreen
|
||||
import org.vita3k.emulator.ui.screens.settings.SettingsRoute
|
||||
import org.vita3k.emulator.ui.viewmodel.AppsListViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallType
|
||||
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
|
||||
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
|
||||
|
||||
private const val ROUTE_INITIAL_SETUP = "initial_setup"
|
||||
private const val ROUTE_INITIAL_SETUP_MANUAL = "initial_setup/manual"
|
||||
private const val ROUTE_APPS_LIST = "apps_list"
|
||||
private const val ROUTE_SETTINGS = "settings"
|
||||
private const val ROUTE_USER_MANAGEMENT = "users"
|
||||
private const val ROUTE_CUSTOM_CONFIG = "settings/custom/{titleId}?appName={appName}"
|
||||
private const val ARG_TITLE_ID = "titleId"
|
||||
private const val ARG_APP_NAME = "appName"
|
||||
private val FIRMWARE_EXTENSIONS = setOf(".pup")
|
||||
private val PKG_EXTENSIONS = setOf(".pkg")
|
||||
private val ARCHIVE_EXTENSIONS = setOf(".zip", ".vpk")
|
||||
private val LICENSE_EXTENSIONS = setOf(".bin", ".rif")
|
||||
|
||||
private fun customConfigRoute(titleId: String, appName: String): String {
|
||||
return "settings/custom/${Uri.encode(titleId)}?appName=${Uri.encode(appName)}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
appsListViewModel: AppsListViewModel,
|
||||
installViewModel: InstallViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
userManagementViewModel: UserManagementViewModel,
|
||||
onAppLaunch: (AppInfo) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
var showArchiveSourceDialog by remember { mutableStateOf(false) }
|
||||
var showStandaloneLicenseDialog by remember { mutableStateOf(false) }
|
||||
val startDestination by produceState<String?>(initialValue = null, key1 = context) {
|
||||
value = if (AppStorage.isInitialSetupCompleted(context)) ROUTE_APPS_LIST else ROUTE_INITIAL_SETUP
|
||||
}
|
||||
|
||||
LaunchedEffect(appsListViewModel.initialized) {
|
||||
if (appsListViewModel.initialized) {
|
||||
settingsViewModel.preloadGlobalSettings()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(installViewModel.completedInstallToken) {
|
||||
if (installViewModel.completedInstallToken != 0L) {
|
||||
appsListViewModel.reloadAppsList()
|
||||
settingsViewModel.load(titleId = null, force = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestInstallPicker(type: InstallType) {
|
||||
val hostActivity = activity ?: return
|
||||
when (type) {
|
||||
InstallType.FIRMWARE -> hostActivity.requestInstallFilePath(FIRMWARE_EXTENSIONS) { path ->
|
||||
path?.let { installViewModel.installFirmware(it) }
|
||||
}
|
||||
InstallType.PKG -> hostActivity.requestInstallFilePath(PKG_EXTENSIONS) { path ->
|
||||
path?.let { installViewModel.onPkgPicked(it) }
|
||||
}
|
||||
InstallType.ARCHIVE -> hostActivity.requestInstallFilePath(ARCHIVE_EXTENSIONS) { path ->
|
||||
path?.let { installViewModel.installArchive(it) }
|
||||
}
|
||||
InstallType.LICENSE -> hostActivity.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
|
||||
path?.let { installViewModel.installLicense(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (installViewModel.showPkgLicenseDialog) {
|
||||
LicenseSourceDialog(
|
||||
title = stringResource(org.vita3k.emulator.R.string.license_required_title),
|
||||
message = stringResource(org.vita3k.emulator.R.string.license_required_message),
|
||||
onSelectLicenseFile = {
|
||||
activity?.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
|
||||
if (path != null) {
|
||||
installViewModel.onPkgLicenseFilePicked(path)
|
||||
} else {
|
||||
installViewModel.cancelPkgInstall()
|
||||
}
|
||||
}
|
||||
},
|
||||
onEnterZrif = { zrif -> installViewModel.confirmPkgInstall(zrif) },
|
||||
onDismiss = { installViewModel.cancelPkgInstall() }
|
||||
)
|
||||
}
|
||||
|
||||
if (showArchiveSourceDialog) {
|
||||
ArchiveInstallSourceDialog(
|
||||
onSelectFile = {
|
||||
showArchiveSourceDialog = false
|
||||
requestInstallPicker(InstallType.ARCHIVE)
|
||||
},
|
||||
onSelectFolder = {
|
||||
showArchiveSourceDialog = false
|
||||
activity?.requestArchiveFolderPaths(ARCHIVE_EXTENSIONS) { paths ->
|
||||
if (paths.isNotEmpty()) {
|
||||
installViewModel.installArchiveFolder(paths)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { showArchiveSourceDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showStandaloneLicenseDialog) {
|
||||
LicenseSourceDialog(
|
||||
title = stringResource(org.vita3k.emulator.R.string.install_license_source_title),
|
||||
message = stringResource(org.vita3k.emulator.R.string.install_license_source_message),
|
||||
onSelectLicenseFile = {
|
||||
showStandaloneLicenseDialog = false
|
||||
activity?.requestInstallFilePath(LICENSE_EXTENSIONS) { path ->
|
||||
path?.let { installViewModel.installLicense(it) }
|
||||
}
|
||||
},
|
||||
onEnterZrif = { zrif ->
|
||||
showStandaloneLicenseDialog = false
|
||||
installViewModel.installLicenseFromZrif(zrif)
|
||||
},
|
||||
onDismiss = { showStandaloneLicenseDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (installViewModel.showInstallSheet) {
|
||||
InstallBottomSheet(
|
||||
onDismiss = { installViewModel.hideSheet() },
|
||||
onSelectType = {
|
||||
installViewModel.hideSheet()
|
||||
when (it) {
|
||||
InstallType.ARCHIVE -> showArchiveSourceDialog = true
|
||||
InstallType.LICENSE -> showStandaloneLicenseDialog = true
|
||||
else -> requestInstallPicker(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (installViewModel.installing) {
|
||||
InstallProgressDialog(
|
||||
progress = installViewModel.progress,
|
||||
statusMessage = installViewModel.statusMessage
|
||||
)
|
||||
}
|
||||
|
||||
installViewModel.installResult?.let { result ->
|
||||
InstallResultDialog(
|
||||
result = result,
|
||||
onConfirm = { selectedOptions -> installViewModel.confirmInstallResult(selectedOptions) }
|
||||
)
|
||||
}
|
||||
|
||||
appsListViewModel.infoDialogApp?.let { app ->
|
||||
AppInfoSheet(
|
||||
app = app,
|
||||
installSizeBytes = appsListViewModel.infoAppInstallSizeBytes,
|
||||
onDismiss = { appsListViewModel.dismissAppInfo() }
|
||||
)
|
||||
}
|
||||
|
||||
if (startDestination == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
enterTransition = { EnterTransition.None },
|
||||
exitTransition = { ExitTransition.None },
|
||||
popEnterTransition = { EnterTransition.None },
|
||||
popExitTransition = { ExitTransition.None }
|
||||
) {
|
||||
composable(ROUTE_INITIAL_SETUP) {
|
||||
val completeSetup: () -> Unit = {
|
||||
AppStorage.setInitialSetupCompleted(context, true)
|
||||
navController.navigate(ROUTE_APPS_LIST) {
|
||||
launchSingleTop = true
|
||||
popUpTo(ROUTE_INITIAL_SETUP) { inclusive = true }
|
||||
}
|
||||
}
|
||||
|
||||
InitialSetupScreen(
|
||||
firmwareInstallState = appsListViewModel.firmwareInstallState,
|
||||
preferredLanguageIndex = settingsViewModel.config.sysLang,
|
||||
onInstallFirmware = { requestInstallPicker(InstallType.FIRMWARE) },
|
||||
dismissLabel = stringResource(org.vita3k.emulator.R.string.initial_setup_skip),
|
||||
onSkip = completeSetup,
|
||||
onFinish = completeSetup
|
||||
)
|
||||
}
|
||||
|
||||
composable(ROUTE_INITIAL_SETUP_MANUAL) {
|
||||
InitialSetupScreen(
|
||||
firmwareInstallState = appsListViewModel.firmwareInstallState,
|
||||
preferredLanguageIndex = settingsViewModel.config.sysLang,
|
||||
onInstallFirmware = { requestInstallPicker(InstallType.FIRMWARE) },
|
||||
dismissLabel = stringResource(org.vita3k.emulator.R.string.emulation_exit),
|
||||
onSkip = { navController.popBackStack() },
|
||||
onFinish = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(ROUTE_APPS_LIST) {
|
||||
AppsListScreen(
|
||||
apps = appsListViewModel.apps,
|
||||
initialized = appsListViewModel.initialized,
|
||||
loading = appsListViewModel.loading,
|
||||
appVersion = appsListViewModel.appVersion,
|
||||
searchQuery = appsListViewModel.searchQuery,
|
||||
sortOption = appsListViewModel.sortOption,
|
||||
viewMode = appsListViewModel.viewMode,
|
||||
updateCheckInProgress = appsListViewModel.updateCheckInProgress,
|
||||
updateCheckResult = appsListViewModel.updateCheckResult,
|
||||
firmwareInstallState = appsListViewModel.firmwareInstallState,
|
||||
actionInProgress = appsListViewModel.actionInProgress,
|
||||
actionResultMessage = appsListViewModel.actionResultMessage,
|
||||
selectionMode = appsListViewModel.selectionMode,
|
||||
selectedAppIds = appsListViewModel.selectedAppIds,
|
||||
resolveAvailableActions = { app -> appsListViewModel.getAvailableActions(app.titleId) },
|
||||
onAppSelected = { app -> onAppLaunch(app) },
|
||||
onRunAppAction = { app, action -> appsListViewModel.runAppAction(app, action) },
|
||||
onPrepareAppActions = { app -> appsListViewModel.prepareAppActions(app) },
|
||||
onShowAppInfo = { app -> appsListViewModel.showAppInfo(app) },
|
||||
onToggleAppSelection = { app -> appsListViewModel.toggleAppSelection(app) },
|
||||
onSetSelectionMode = { enabled -> appsListViewModel.updateSelectionMode(enabled) },
|
||||
onSelectAllVisible = { appsListViewModel.selectAllVisibleApps() },
|
||||
onRunBatchDeleteSelected = { appsListViewModel.runBatchDeleteSelected() },
|
||||
onDismissActionResult = { appsListViewModel.dismissActionResult() },
|
||||
onSearchChanged = { appsListViewModel.setSearch(it) },
|
||||
onSortChanged = { appsListViewModel.setSort(it) },
|
||||
onViewModeToggle = { appsListViewModel.toggleViewMode() },
|
||||
onCheckForUpdates = { appsListViewModel.checkForUpdates() },
|
||||
onDismissUpdateCheckResult = { appsListViewModel.dismissUpdateCheckResult() },
|
||||
onRefresh = { appsListViewModel.refreshAppsList(syncCompatibility = true) },
|
||||
onInstallClick = { installViewModel.showSheet() },
|
||||
onOpenSettings = {
|
||||
navController.navigate(ROUTE_SETTINGS) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onOpenUserManagement = {
|
||||
navController.navigate(ROUTE_USER_MANAGEMENT) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onOpenWelcomeScreen = {
|
||||
navController.navigate(ROUTE_INITIAL_SETUP_MANUAL) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onOpenCustomConfig = { app ->
|
||||
navController.navigate(customConfigRoute(app.titleId, app.title)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(ROUTE_SETTINGS) {
|
||||
SettingsRoute(
|
||||
titleId = null,
|
||||
appName = null,
|
||||
viewModel = settingsViewModel,
|
||||
onStorageChanged = { appsListViewModel.refreshAppsList() },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(ROUTE_USER_MANAGEMENT) {
|
||||
UserManagementScreen(
|
||||
viewModel = userManagementViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onUsersChanged = { appsListViewModel.refreshAppsList() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = ROUTE_CUSTOM_CONFIG,
|
||||
arguments = listOf(
|
||||
navArgument(ARG_TITLE_ID) { type = NavType.StringType },
|
||||
navArgument(ARG_APP_NAME) {
|
||||
type = NavType.StringType
|
||||
defaultValue = ""
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val titleId = backStackEntry.arguments?.getString(ARG_TITLE_ID).orEmpty()
|
||||
val appName = backStackEntry.arguments?.getString(ARG_APP_NAME)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: appsListViewModel.apps.firstOrNull { it.titleId == titleId }?.title
|
||||
?: titleId
|
||||
|
||||
SettingsRoute(
|
||||
titleId = titleId,
|
||||
appName = appName,
|
||||
viewModel = settingsViewModel,
|
||||
onStorageChanged = { appsListViewModel.refreshAppsList() },
|
||||
onBack = {
|
||||
appsListViewModel.refreshAppsList()
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.vita3k.emulator.ui.screens
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.AppInfo
|
||||
import org.vita3k.emulator.ui.theme.SCRIM_ALPHA
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppInfoSheet(
|
||||
app: AppInfo,
|
||||
installSizeBytes: Long,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.app_info_title), style = MaterialTheme.typography.titleLarge)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.app_info_cd_close))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
AppIcon(app = app, size = 80)
|
||||
Column {
|
||||
Text(app.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
app.titleId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CompatBadge(app)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp))
|
||||
|
||||
InfoRow(label = stringResource(R.string.app_info_field_title), value = app.title)
|
||||
InfoRow(label = stringResource(R.string.app_info_field_serial), value = app.titleId)
|
||||
InfoRow(label = stringResource(R.string.app_info_field_app_version), value = app.appVer.ifEmpty { "\u2014" })
|
||||
InfoRow(label = stringResource(R.string.app_info_field_category), value = app.category.ifEmpty { "\u2014" })
|
||||
InfoRow(
|
||||
label = stringResource(R.string.app_info_field_install_size),
|
||||
value = when {
|
||||
installSizeBytes == -1L -> stringResource(R.string.app_info_size_calculating)
|
||||
installSizeBytes == 0L -> stringResource(R.string.app_info_size_unknown)
|
||||
installSizeBytes < 1024L * 1024L ->
|
||||
stringResource(R.string.app_info_size_kb, (installSizeBytes / 1024L).toInt())
|
||||
installSizeBytes < 1024L * 1024L * 1024L ->
|
||||
stringResource(R.string.app_info_size_mb, (installSizeBytes / (1024.0 * 1024.0)).toFloat())
|
||||
else ->
|
||||
stringResource(R.string.app_info_size_gb, (installSizeBytes / (1024.0 * 1024.0 * 1024.0)).toFloat())
|
||||
}
|
||||
)
|
||||
InfoRow(label = stringResource(R.string.app_info_field_play_time), value = formatPlaytime(app.playtime))
|
||||
InfoRow(label = stringResource(R.string.app_info_field_last_played), value = formatLastPlayed(app.lastPlayed))
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatPlaytime(seconds: Long): String {
|
||||
if (seconds <= 0) return stringResource(R.string.app_info_playtime_never)
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
val s = seconds % 60
|
||||
return when {
|
||||
h > 0 -> stringResource(R.string.app_info_playtime_hm, h.toInt(), m.toInt())
|
||||
m > 0 -> stringResource(R.string.app_info_playtime_ms, m.toInt(), s.toInt())
|
||||
else -> stringResource(R.string.app_info_playtime_s, s.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatLastPlayed(timestampSecs: Long): String {
|
||||
if (timestampSecs <= 0) return stringResource(R.string.app_info_last_played_never)
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
return formatter.format(Instant.ofEpochSecond(timestampSecs))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(value)) }
|
||||
)
|
||||
.padding(horizontal = 24.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.vita3k.emulator.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.SortOption
|
||||
import org.vita3k.emulator.data.ViewMode
|
||||
import org.vita3k.emulator.ui.theme.SCRIM_ALPHA
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FilterSheet(
|
||||
sortOption: SortOption,
|
||||
viewMode: ViewMode,
|
||||
onSortChanged: (SortOption) -> Unit,
|
||||
onViewModeToggle: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.filter_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Sort section
|
||||
Text(
|
||||
stringResource(R.string.filter_section_sort),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
SortOption.entries.forEach { option ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option == sortOption,
|
||||
onClick = { onSortChanged(option) }
|
||||
)
|
||||
Text(
|
||||
text = when (option) {
|
||||
SortOption.TITLE -> stringResource(R.string.apps_list_sort_title)
|
||||
SortOption.LAST_PLAYED -> stringResource(R.string.apps_list_sort_last_played)
|
||||
SortOption.COMPATIBILITY -> stringResource(R.string.apps_list_sort_compatibility)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// View mode section
|
||||
Text(
|
||||
stringResource(R.string.filter_section_view),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = viewMode == ViewMode.LIST,
|
||||
onClick = { if (viewMode != ViewMode.LIST) onViewModeToggle() },
|
||||
label = { Text(stringResource(R.string.filter_view_list)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
FilterChip(
|
||||
selected = viewMode == ViewMode.GRID,
|
||||
onClick = { if (viewMode != ViewMode.GRID) onViewModeToggle() },
|
||||
label = { Text(stringResource(R.string.filter_view_grid)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.GridView,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package org.vita3k.emulator.ui.screens
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.FirmwareInstallState
|
||||
import org.vita3k.emulator.data.FirmwareLinks
|
||||
import org.vita3k.emulator.ui.components.HtmlText
|
||||
|
||||
private const val INITIAL_SETUP_INFO_HTML =
|
||||
"""<div align="center">To get started, please install all PS Vita firmware files.<br><br>A comprehensive guide is available on the <a href="https://vita3k.org/quickstart.html">Quickstart</a> page.<br>Check the <a href="https://vita3k.org/compatibility.html">commercial</a> and <a href="https://vita3k.org/compatibility-homebrew.html">homebrew</a> compatibility lists to see what currently runs.<br><br>Contributions are welcome on <a href="https://github.com/Vita3K/Vita3K">GitHub</a>, and additional help is available on <a href="https://discord.gg/6aGwQzh">Discord</a>.</div>"""
|
||||
|
||||
private val setupPanelColor = Color(0xFF1F1D1C)
|
||||
private val setupCardColor = Color(0xFF2B2B2B)
|
||||
private val setupTextColor = Color(0xFFF7F3EC)
|
||||
|
||||
@Composable
|
||||
fun InitialSetupScreen(
|
||||
firmwareInstallState: FirmwareInstallState,
|
||||
preferredLanguageIndex: Int,
|
||||
onInstallFirmware: () -> Unit,
|
||||
dismissLabel: String,
|
||||
onSkip: () -> Unit,
|
||||
onFinish: () -> Unit
|
||||
) {
|
||||
val systemBars = WindowInsets.systemBars.asPaddingValues()
|
||||
var page by rememberSaveable { mutableIntStateOf(0) }
|
||||
var firmwareLocaleIndex by rememberSaveable {
|
||||
mutableIntStateOf(FirmwareLinks.coerceLocaleIndex(preferredLanguageIndex))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.16f),
|
||||
MaterialTheme.colorScheme.background,
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
BackgroundOrb(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(start = 24.dp, top = 88.dp),
|
||||
size = 180.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.09f)
|
||||
)
|
||||
BackgroundOrb(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 8.dp, bottom = 72.dp),
|
||||
size = 220.dp,
|
||||
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.08f)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
top = systemBars.calculateTopPadding() + 8.dp,
|
||||
bottom = systemBars.calculateBottomPadding() + 20.dp
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (page > 0) {
|
||||
TextButton(onClick = { page-- }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.initial_setup_back))
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(96.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onSkip) {
|
||||
Text(dismissLabel)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
color = setupPanelColor,
|
||||
contentColor = setupTextColor,
|
||||
tonalElevation = 6.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
|
||||
)
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = page,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "initial-setup-page"
|
||||
) { currentPage ->
|
||||
when (currentPage) {
|
||||
0 -> WelcomePage()
|
||||
else -> FirmwareSetupPage(
|
||||
firmwareInstallState = firmwareInstallState,
|
||||
firmwareLocaleIndex = firmwareLocaleIndex,
|
||||
onFirmwareLocaleSelected = { firmwareLocaleIndex = it },
|
||||
onInstallFirmware = onInstallFirmware
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
SetupPageIndicator(
|
||||
currentPage = page,
|
||||
totalPages = 2,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
if (page == 0) {
|
||||
Button(onClick = { page = 1 }) {
|
||||
Text(stringResource(R.string.initial_setup_next))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onFinish) {
|
||||
Text(stringResource(R.string.initial_setup_open_library))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundOrb(
|
||||
modifier: Modifier = Modifier,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
color: Color
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp, vertical = 32.dp)
|
||||
.heightIn(min = maxHeight - 64.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.ic_launcher),
|
||||
contentDescription = stringResource(R.string.apps_list_app_title),
|
||||
modifier = Modifier
|
||||
.size(112.dp)
|
||||
.alpha(0.96f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_welcome_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = setupTextColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_welcome_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = setupTextColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
HtmlText(
|
||||
html = INITIAL_SETUP_INFO_HTML,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
textColor = setupTextColor,
|
||||
gravity = Gravity.CENTER
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_piracy_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun FirmwareSetupPage(
|
||||
firmwareInstallState: FirmwareInstallState,
|
||||
firmwareLocaleIndex: Int,
|
||||
onFirmwareLocaleSelected: (Int) -> Unit,
|
||||
onInstallFirmware: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_firmware_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = setupTextColor
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_firmware_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = setupTextColor
|
||||
)
|
||||
|
||||
FirmwareCard(
|
||||
title = stringResource(R.string.initial_setup_preinstall_title),
|
||||
installed = firmwareInstallState.components.preinstalled,
|
||||
missingStatusText = stringResource(R.string.initial_setup_status_optional)
|
||||
) {
|
||||
if (!firmwareInstallState.components.preinstalled) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilledTonalButton(onClick = { uriHandler.openUri(FirmwareLinks.PREINSTALL_URL) }) {
|
||||
Text(stringResource(R.string.initial_setup_download))
|
||||
}
|
||||
Button(onClick = onInstallFirmware) {
|
||||
Text(stringResource(R.string.initial_setup_install_pup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FirmwareCard(
|
||||
title = stringResource(R.string.initial_setup_main_title),
|
||||
installed = firmwareInstallState.components.main
|
||||
) {
|
||||
if (!firmwareInstallState.components.main) {
|
||||
FirmwareLanguagePicker(
|
||||
selectedIndex = firmwareLocaleIndex,
|
||||
onSelected = onFirmwareLocaleSelected
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilledTonalButton(onClick = {
|
||||
uriHandler.openUri(FirmwareLinks.firmwareDownloadUrl(firmwareLocaleIndex))
|
||||
}) {
|
||||
Text(stringResource(R.string.initial_setup_download))
|
||||
}
|
||||
Button(onClick = onInstallFirmware) {
|
||||
Text(stringResource(R.string.initial_setup_install_pup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FirmwareCard(
|
||||
title = stringResource(R.string.initial_setup_font_title),
|
||||
installed = firmwareInstallState.components.fontPackage
|
||||
) {
|
||||
if (!firmwareInstallState.components.fontPackage) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilledTonalButton(onClick = { uriHandler.openUri(FirmwareLinks.FONT_PACKAGE_URL) }) {
|
||||
Text(stringResource(R.string.initial_setup_download))
|
||||
}
|
||||
Button(onClick = onInstallFirmware) {
|
||||
Text(stringResource(R.string.initial_setup_install_pup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareLanguagePicker(
|
||||
selectedIndex: Int,
|
||||
onSelected: (Int) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.initial_setup_select_firmware_language),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = setupTextColor
|
||||
)
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }) {
|
||||
Text(stringResource(FirmwareLinks.locales[FirmwareLinks.coerceLocaleIndex(selectedIndex)].nameResId))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
FirmwareLinks.locales.forEachIndexed { index, locale ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(locale.nameResId)) },
|
||||
onClick = {
|
||||
onSelected(index)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirmwareCard(
|
||||
title: String,
|
||||
installed: Boolean,
|
||||
missingStatusText: String? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val resolvedMissingStatusText = missingStatusText ?: stringResource(R.string.initial_setup_status_missing)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = setupCardColor,
|
||||
contentColor = setupTextColor,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = setupTextColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
StatusBadge(
|
||||
installed = installed,
|
||||
missingStatusText = resolvedMissingStatusText
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(installed: Boolean, missingStatusText: String) {
|
||||
val color = if (installed) Color(0xFF1B8A5A) else MaterialTheme.colorScheme.tertiary
|
||||
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = color.copy(alpha = 0.13f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
Text(
|
||||
text = if (installed) {
|
||||
stringResource(R.string.initial_setup_status_installed)
|
||||
} else {
|
||||
missingStatusText
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = color,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupPageIndicator(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
repeat(totalPages) { page ->
|
||||
val width by animateDpAsState(
|
||||
targetValue = if (page == currentPage) 22.dp else 8.dp,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "initial-setup-indicator"
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.height(8.dp)
|
||||
.width(width)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (page == currentPage) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package org.vita3k.emulator.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FolderZip
|
||||
import androidx.compose.material.icons.filled.Inventory2
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material3.*
|
||||
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 org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.ui.viewmodel.DeleteSourceOption
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallResult
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallResultStatus
|
||||
import org.vita3k.emulator.ui.viewmodel.InstallType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InstallBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onSelectType: (InstallType) -> Unit
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val itemColors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
headlineColor = MaterialTheme.colorScheme.onSurface,
|
||||
supportingColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.install_sheet_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.install_firmware_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.install_firmware_desc)) },
|
||||
leadingContent = { Icon(Icons.Default.SystemUpdate, contentDescription = null) },
|
||||
colors = itemColors,
|
||||
modifier = Modifier.clickable { onSelectType(InstallType.FIRMWARE) }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.install_pkg_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.install_pkg_desc)) },
|
||||
leadingContent = { Icon(Icons.Default.Inventory2, contentDescription = null) },
|
||||
colors = itemColors,
|
||||
modifier = Modifier.clickable { onSelectType(InstallType.PKG) }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.install_archive_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.install_archive_desc)) },
|
||||
leadingContent = { Icon(Icons.Default.FolderZip, contentDescription = null) },
|
||||
colors = itemColors,
|
||||
modifier = Modifier.clickable { onSelectType(InstallType.ARCHIVE) }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.install_license_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.install_license_desc)) },
|
||||
leadingContent = { Icon(Icons.Default.Key, contentDescription = null) },
|
||||
colors = itemColors,
|
||||
modifier = Modifier.clickable { onSelectType(InstallType.LICENSE) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InstallProgressDialog(
|
||||
progress: Int,
|
||||
statusMessage: String
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
title = { Text(stringResource(R.string.install_progress_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(statusMessage)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (progress > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
drawStopIndicator = {}
|
||||
)
|
||||
Text(
|
||||
"$progress%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InstallResultDialog(
|
||||
result: InstallResult,
|
||||
onConfirm: (List<DeleteSourceOption>) -> Unit
|
||||
) {
|
||||
var selectedDeleteOptions by remember(result) {
|
||||
mutableStateOf(emptySet<Int>())
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onConfirm(emptyList()) },
|
||||
title = {
|
||||
Text(
|
||||
when (result.status) {
|
||||
InstallResultStatus.SUCCESS -> stringResource(R.string.install_result_success_title)
|
||||
InstallResultStatus.PARTIAL -> stringResource(R.string.install_result_partial_title)
|
||||
InstallResultStatus.ERROR -> stringResource(R.string.install_result_failed_title)
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(result.message)
|
||||
if (result.deleteOptions.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
result.deleteOptions.forEachIndexed { index, option ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = index in selectedDeleteOptions,
|
||||
onCheckedChange = { checked ->
|
||||
selectedDeleteOptions = if (checked) {
|
||||
selectedDeleteOptions + index
|
||||
} else {
|
||||
selectedDeleteOptions - index
|
||||
}
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = option.label,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(
|
||||
selectedDeleteOptions
|
||||
.sorted()
|
||||
.map(result.deleteOptions::get)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LicenseSourceDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onSelectLicenseFile: () -> Unit,
|
||||
onEnterZrif: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var showManualEntry by remember { mutableStateOf(false) }
|
||||
var zrif by remember { mutableStateOf("") }
|
||||
|
||||
if (showManualEntry) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showManualEntry = false },
|
||||
title = { Text(stringResource(R.string.zrif_dialog_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = zrif,
|
||||
onValueChange = { zrif = it.trim() },
|
||||
label = { Text(stringResource(R.string.zrif_dialog_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onEnterZrif(zrif) },
|
||||
enabled = zrif.isNotEmpty()
|
||||
) { Text(stringResource(R.string.action_install)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showManualEntry = false }) { Text(stringResource(R.string.action_back)) }
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onSelectLicenseFile,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(stringResource(R.string.license_select_file)) }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { showManualEntry = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(stringResource(R.string.license_enter_zrif)) }
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArchiveInstallSourceDialog(
|
||||
onSelectFile: () -> Unit,
|
||||
onSelectFolder: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.install_archive_source_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.install_archive_source_message),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onSelectFile,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(stringResource(R.string.install_archive_source_file)) }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onSelectFolder,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(stringResource(R.string.install_archive_source_folder)) }
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package org.vita3k.emulator.ui.screens
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.NativeUser
|
||||
import org.vita3k.emulator.ui.theme.ApplyDialogDim
|
||||
import org.vita3k.emulator.ui.viewmodel.UserManagementViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun UserManagementScreen(
|
||||
viewModel: UserManagementViewModel,
|
||||
onBack: () -> Unit,
|
||||
onUsersChanged: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingDeletion by remember { mutableStateOf<NativeUser?>(null) }
|
||||
val activeUser = viewModel.users.firstOrNull { it.active }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
title = { Text(stringResource(R.string.user_management_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = viewModel::load, enabled = !viewModel.loading && !viewModel.busy) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = stringResource(R.string.user_management_refresh)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.user_management_create_user)) },
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
onClick = { showCreateDialog = true },
|
||||
expanded = true
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
when {
|
||||
viewModel.loading && viewModel.users.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 96.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
item {
|
||||
ActiveUserHero(
|
||||
activeUser = activeUser,
|
||||
onCreateUser = { showCreateDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.users.isEmpty()) {
|
||||
item {
|
||||
EmptyUsersCard(onCreateUser = { showCreateDialog = true })
|
||||
}
|
||||
} else {
|
||||
items(viewModel.users, key = { it.id }) { user ->
|
||||
UserCard(
|
||||
user = user,
|
||||
busy = viewModel.busy,
|
||||
onActivate = { viewModel.activateUser(user, onUsersChanged) },
|
||||
onDelete = { pendingDeletion = user }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateUserDialog(
|
||||
busy = viewModel.busy,
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name ->
|
||||
showCreateDialog = false
|
||||
viewModel.createUser(name, onUsersChanged)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingDeletion?.let { user ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDeletion = null },
|
||||
title = { Text(stringResource(R.string.user_management_delete_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(stringResource(R.string.user_management_delete_message, user.name))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pendingDeletion = null
|
||||
viewModel.deleteUser(user, onUsersChanged)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_delete),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDeletion = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.operationResult?.let { result ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissOperationResult() },
|
||||
title = {
|
||||
Text(
|
||||
if (result.isError) {
|
||||
stringResource(R.string.settings_opt_error)
|
||||
} else {
|
||||
stringResource(R.string.settings_success_title)
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(result.message)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.dismissOperationResult() }) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveUserHero(
|
||||
activeUser: NativeUser?,
|
||||
onCreateUser: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.user_management_active_user),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.72f)
|
||||
)
|
||||
Text(
|
||||
text = activeUser?.name ?: stringResource(R.string.user_management_active_none),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
activeUser?.let {
|
||||
Text(
|
||||
text = it.id,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
FilledTonalButton(onClick = onCreateUser) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.user_management_create_user))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyUsersCard(
|
||||
onCreateUser: () -> Unit
|
||||
) {
|
||||
ElevatedCard(shape = RoundedCornerShape(24.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.user_management_empty_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.user_management_empty_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FilledTonalButton(onClick = onCreateUser) {
|
||||
Text(stringResource(R.string.user_management_create_user))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserCard(
|
||||
user: NativeUser,
|
||||
busy: Boolean,
|
||||
onActivate: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
ElevatedCard(shape = RoundedCornerShape(24.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = user.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = user.id,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (user.active) {
|
||||
AssistChip(
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
label = { Text(stringResource(R.string.user_management_active_badge)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onActivate,
|
||||
enabled = !busy && !user.active,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = if (user.active) {
|
||||
stringResource(R.string.user_management_active_badge)
|
||||
} else {
|
||||
stringResource(R.string.user_management_switch_user)
|
||||
}
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onDelete,
|
||||
enabled = !busy,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = null)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(R.string.user_management_delete_user))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateUserDialog(
|
||||
busy: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (String) -> Unit
|
||||
) {
|
||||
var userName by rememberSaveable { mutableStateOf("Vita3K") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.user_management_create_user)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.user_management_create_prompt))
|
||||
OutlinedTextField(
|
||||
value = userName,
|
||||
onValueChange = { userName = it },
|
||||
singleLine = true,
|
||||
label = { Text(stringResource(R.string.user_management_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = !busy && userName.trim().isNotEmpty(),
|
||||
onClick = { onCreate(userName.trim()) }
|
||||
) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package org.vita3k.emulator.ui.screens.emulation
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.Emulator
|
||||
import org.vita3k.emulator.NativeLib
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.NativeImeState
|
||||
import org.vita3k.emulator.ui.theme.Vita3KTheme
|
||||
import org.vita3k.emulator.ui.viewmodel.EmulationSessionViewModel
|
||||
|
||||
private val ImeOverlayBackground = Color.Black.copy(alpha = 0.30f)
|
||||
private val ImeOverlayText = Color.Black.copy(alpha = 0.96f)
|
||||
private val ImeOverlayPlaceholder = Color.Black.copy(alpha = 0.70f)
|
||||
private val ImeOverlayAccent = Color.Black.copy(alpha = 0.96f)
|
||||
|
||||
object NativeImeOverlayHost {
|
||||
@JvmStatic
|
||||
fun attach(
|
||||
composeView: ComposeView,
|
||||
sessionViewModel: EmulationSessionViewModel
|
||||
) {
|
||||
composeView.setContent {
|
||||
Vita3KTheme {
|
||||
NativeImeOverlay(
|
||||
sessionViewModel = sessionViewModel,
|
||||
hostView = composeView
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NativeImeOverlay(
|
||||
sessionViewModel: EmulationSessionViewModel,
|
||||
hostView: ComposeView
|
||||
) {
|
||||
val uiState = sessionViewModel.uiState
|
||||
val imeState = sessionViewModel.imeState
|
||||
val context = LocalContext.current
|
||||
|
||||
val state = imeState
|
||||
val visible = state?.sceImeActive == true
|
||||
&& state.dialogActive.not()
|
||||
&& !uiState.showMenu
|
||||
&& !uiState.isEditingControls
|
||||
|
||||
SideEffect {
|
||||
hostView.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(180)) + slideInVertically(initialOffsetY = { -it / 2 }),
|
||||
exit = fadeOut(tween(140)) + slideOutVertically(targetOffsetY = { -it / 3 })
|
||||
) {
|
||||
state?.let { current ->
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(WindowInsets.displayCutout.asPaddingValues())
|
||||
.padding(WindowInsets.statusBars.asPaddingValues())
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
val gameViewportWidth = minOf(maxWidth, maxHeight * (960f / 544f))
|
||||
val preview = buildImePreview(current)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.width(gameViewportWidth),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = ImeOverlayBackground,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
) {
|
||||
val buttonInset = if (current.enterLabel.isNotBlank()) 96.dp else 0.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = buttonInset, end = buttonInset),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (preview.text.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.emulation_ime_placeholder),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = ImeOverlayPlaceholder,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = ImeOverlayText,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.enterLabel.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier.align(Alignment.CenterEnd)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val activity = context as? Emulator
|
||||
if (activity != null) {
|
||||
activity.completeImeFromKeyboard(null)
|
||||
} else {
|
||||
runCatching { NativeLib.submitIme() }
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(ImeOverlayBackground)
|
||||
) {
|
||||
Text(
|
||||
text = current.enterLabel,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = ImeOverlayText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildImePreview(state: NativeImeState): AnnotatedString {
|
||||
val text = state.text
|
||||
val caretIndex = state.caretIndex.coerceIn(0, text.length)
|
||||
val preeditStart = state.preeditStart.coerceIn(0, text.length)
|
||||
val preeditEnd = (preeditStart + state.preeditLength).coerceIn(preeditStart, text.length)
|
||||
val caretStyle = SpanStyle(
|
||||
color = ImeOverlayAccent,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
val preeditStyle = SpanStyle(
|
||||
color = ImeOverlayAccent,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
return buildAnnotatedString {
|
||||
fun appendCaret(position: Int) {
|
||||
if (caretIndex == position) {
|
||||
withStyle(caretStyle) {
|
||||
append('|')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isEmpty()) {
|
||||
appendCaret(0)
|
||||
return@buildAnnotatedString
|
||||
}
|
||||
|
||||
appendCaret(0)
|
||||
|
||||
var cursor = 0
|
||||
while (cursor < text.length) {
|
||||
val segmentEnd = when {
|
||||
cursor < preeditStart -> preeditStart
|
||||
cursor < preeditEnd -> preeditEnd
|
||||
else -> text.length
|
||||
}
|
||||
val segment = text.substring(cursor, segmentEnd)
|
||||
if (cursor >= preeditStart && cursor < preeditEnd) {
|
||||
withStyle(preeditStyle) {
|
||||
append(segment)
|
||||
}
|
||||
} else {
|
||||
append(segment)
|
||||
}
|
||||
cursor = segmentEnd
|
||||
appendCaret(cursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import org.vita3k.emulator.ConnectedGamepad
|
||||
import org.vita3k.emulator.ControllerFamily
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.EmulatorConfig
|
||||
import org.vita3k.emulator.ui.theme.ApplyDialogDim
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val SDL_GAMEPAD_BUTTON_SOUTH = 0
|
||||
private const val SDL_GAMEPAD_BUTTON_EAST = 1
|
||||
private const val SDL_GAMEPAD_BUTTON_WEST = 2
|
||||
private const val SDL_GAMEPAD_BUTTON_NORTH = 3
|
||||
private const val SDL_GAMEPAD_BUTTON_BACK = 4
|
||||
private const val SDL_GAMEPAD_BUTTON_GUIDE = 5
|
||||
private const val SDL_GAMEPAD_BUTTON_START = 6
|
||||
private const val SDL_GAMEPAD_BUTTON_LEFT_STICK = 7
|
||||
private const val SDL_GAMEPAD_BUTTON_RIGHT_STICK = 8
|
||||
private const val SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
|
||||
private const val SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
|
||||
private const val SDL_GAMEPAD_BUTTON_DPAD_UP = 11
|
||||
private const val SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
|
||||
private const val SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
|
||||
private const val SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
|
||||
|
||||
private const val SDL_GAMEPAD_AXIS_LEFTX = 0
|
||||
private const val SDL_GAMEPAD_AXIS_LEFTY = 1
|
||||
private const val SDL_GAMEPAD_AXIS_RIGHTX = 2
|
||||
private const val SDL_GAMEPAD_AXIS_RIGHTY = 3
|
||||
private const val SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
|
||||
private const val SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
|
||||
|
||||
private data class ButtonBindingRow(
|
||||
@StringRes val labelRes: Int,
|
||||
val bindIndex: Int
|
||||
)
|
||||
|
||||
private data class AxisBindingRow(
|
||||
@StringRes val labelRes: Int,
|
||||
val bindIndex: Int
|
||||
)
|
||||
|
||||
private data class ManualBindingOption(
|
||||
val label: String,
|
||||
val value: Int
|
||||
)
|
||||
|
||||
private enum class CaptureMode {
|
||||
Button,
|
||||
Axis
|
||||
}
|
||||
|
||||
private val buttonBindingRows = listOf(
|
||||
ButtonBindingRow(R.string.settings_controls_bind_dpad_up, SDL_GAMEPAD_BUTTON_DPAD_UP),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_dpad_down, SDL_GAMEPAD_BUTTON_DPAD_DOWN),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_dpad_left, SDL_GAMEPAD_BUTTON_DPAD_LEFT),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_dpad_right, SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_triangle, SDL_GAMEPAD_BUTTON_NORTH),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_circle, SDL_GAMEPAD_BUTTON_EAST),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_cross, SDL_GAMEPAD_BUTTON_SOUTH),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_square, SDL_GAMEPAD_BUTTON_WEST),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_l1, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_r1, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_l3, SDL_GAMEPAD_BUTTON_LEFT_STICK),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_r3, SDL_GAMEPAD_BUTTON_RIGHT_STICK),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_select, SDL_GAMEPAD_BUTTON_BACK),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_start, SDL_GAMEPAD_BUTTON_START),
|
||||
ButtonBindingRow(R.string.settings_controls_bind_ps_button, SDL_GAMEPAD_BUTTON_GUIDE)
|
||||
)
|
||||
|
||||
private val axisBindingRows = listOf(
|
||||
AxisBindingRow(R.string.settings_controls_bind_left_stick_up, SDL_GAMEPAD_AXIS_LEFTY),
|
||||
AxisBindingRow(R.string.settings_controls_bind_left_stick_down, SDL_GAMEPAD_AXIS_LEFTY),
|
||||
AxisBindingRow(R.string.settings_controls_bind_left_stick_left, SDL_GAMEPAD_AXIS_LEFTX),
|
||||
AxisBindingRow(R.string.settings_controls_bind_left_stick_right, SDL_GAMEPAD_AXIS_LEFTX),
|
||||
AxisBindingRow(R.string.settings_controls_bind_right_stick_up, SDL_GAMEPAD_AXIS_RIGHTY),
|
||||
AxisBindingRow(R.string.settings_controls_bind_right_stick_down, SDL_GAMEPAD_AXIS_RIGHTY),
|
||||
AxisBindingRow(R.string.settings_controls_bind_right_stick_left, SDL_GAMEPAD_AXIS_RIGHTX),
|
||||
AxisBindingRow(R.string.settings_controls_bind_right_stick_right, SDL_GAMEPAD_AXIS_RIGHTX),
|
||||
AxisBindingRow(R.string.settings_controls_bind_l2, SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
|
||||
AxisBindingRow(R.string.settings_controls_bind_r2, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ControllerMappingSections(
|
||||
cfg: EmulatorConfig,
|
||||
connectedGamepads: List<ConnectedGamepad>,
|
||||
onUpdate: (EmulatorConfig.() -> Unit) -> Unit,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val controllerFamily = connectedGamepads.firstOrNull()?.family ?: ControllerFamily.Standard
|
||||
var pendingButtonBinding by remember { mutableStateOf<ButtonBindingRow?>(null) }
|
||||
var pendingAxisBinding by remember { mutableStateOf<AxisBindingRow?>(null) }
|
||||
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_controls_connected_title),
|
||||
summary = if (connectedGamepads.isEmpty()) {
|
||||
stringResource(R.string.settings_controls_connected_none)
|
||||
} else {
|
||||
connectedGamepads.joinToString(separator = "\n") { it.name }
|
||||
},
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_connected_title),
|
||||
body = stringResource(R.string.settings_controls_connected_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
if (connectedGamepads.isEmpty()) {
|
||||
SettingsNote(text = stringResource(R.string.settings_controls_manual_available))
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_controls_mapping_buttons_title),
|
||||
summary = stringResource(R.string.settings_controls_mapping_buttons_desc),
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_mapping_buttons_title),
|
||||
body = stringResource(R.string.settings_controls_mapping_buttons_help),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
buttonBindingRows.forEach { row ->
|
||||
SettingsActionRow(
|
||||
title = stringResource(row.labelRes),
|
||||
value = buttonLabel(controllerFamily, currentButtonBinding(cfg, row.bindIndex)),
|
||||
onClick = { pendingButtonBinding = row },
|
||||
help = null,
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_controls_mapping_axes_title),
|
||||
summary = stringResource(R.string.settings_controls_mapping_axes_desc),
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_mapping_axes_title),
|
||||
body = stringResource(R.string.settings_controls_mapping_axes_help),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
SettingsNote(text = stringResource(R.string.settings_controls_mapping_axes_note))
|
||||
axisBindingRows.forEach { row ->
|
||||
SettingsActionRow(
|
||||
title = stringResource(row.labelRes),
|
||||
value = axisLabel(controllerFamily, currentAxisBinding(cfg, row.bindIndex)),
|
||||
onClick = { pendingAxisBinding = row },
|
||||
help = null,
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_controls_behavior_title),
|
||||
summary = null,
|
||||
help = null,
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
SettingsSliderRow(
|
||||
title = stringResource(R.string.settings_controls_analog_multiplier),
|
||||
valueLabel = stringResource(
|
||||
R.string.settings_controls_multiplier_value,
|
||||
cfg.controllerAnalogMultiplier.coerceIn(0.1f, 2.0f)
|
||||
),
|
||||
value = (cfg.controllerAnalogMultiplier * 100f).coerceIn(10f, 200f),
|
||||
onValueChange = {
|
||||
onUpdate {
|
||||
controllerAnalogMultiplier = (it.roundToInt() / 100f).coerceIn(0.1f, 2.0f)
|
||||
}
|
||||
},
|
||||
valueRange = 10f..200f,
|
||||
steps = 189,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_analog_multiplier),
|
||||
body = stringResource(R.string.settings_controls_analog_multiplier_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_disable_motion),
|
||||
checked = cfg.disableMotion,
|
||||
onCheckedChange = { checked ->
|
||||
onUpdate { disableMotion = checked }
|
||||
},
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_disable_motion),
|
||||
body = stringResource(R.string.settings_controls_disable_motion_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
|
||||
pendingButtonBinding?.let { row ->
|
||||
BindingCaptureDialog(
|
||||
title = stringResource(row.labelRes),
|
||||
prompt = stringResource(R.string.settings_controls_capture_button_prompt),
|
||||
options = buttonManualOptions(controllerFamily),
|
||||
selectedValue = currentButtonBinding(cfg, row.bindIndex),
|
||||
captureMode = CaptureMode.Button,
|
||||
connectedGamepads = connectedGamepads,
|
||||
onDismiss = { pendingButtonBinding = null },
|
||||
onSelected = { mappedButton ->
|
||||
onUpdate {
|
||||
controllerBinds = controllerBinds.copyOf().also { binds ->
|
||||
binds[row.bindIndex] = mappedButton
|
||||
}
|
||||
}
|
||||
pendingButtonBinding = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingAxisBinding?.let { row ->
|
||||
BindingCaptureDialog(
|
||||
title = stringResource(row.labelRes),
|
||||
prompt = stringResource(R.string.settings_controls_capture_axis_prompt),
|
||||
options = axisManualOptions(controllerFamily),
|
||||
selectedValue = currentAxisBinding(cfg, row.bindIndex),
|
||||
captureMode = CaptureMode.Axis,
|
||||
connectedGamepads = connectedGamepads,
|
||||
onDismiss = { pendingAxisBinding = null },
|
||||
onSelected = { mappedAxis ->
|
||||
onUpdate {
|
||||
controllerAxisBinds = controllerAxisBinds.copyOf().also { binds ->
|
||||
binds[row.bindIndex] = mappedAxis
|
||||
}
|
||||
}
|
||||
pendingAxisBinding = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BindingCaptureDialog(
|
||||
title: String,
|
||||
prompt: String,
|
||||
options: List<ManualBindingOption>,
|
||||
selectedValue: Int,
|
||||
captureMode: CaptureMode,
|
||||
connectedGamepads: List<ConnectedGamepad>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelected: (Int) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
ApplyDialogDim()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = prompt,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (connectedGamepads.isNotEmpty()) {
|
||||
Text(
|
||||
text = connectedGamepads.joinToString(separator = "\n") { it.name },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
CaptureInputSurface(
|
||||
captureMode = captureMode,
|
||||
enabled = connectedGamepads.isNotEmpty(),
|
||||
onCaptured = onSelected
|
||||
)
|
||||
SettingsScrollableChoiceSelector(
|
||||
title = stringResource(R.string.settings_controls_manual_selection),
|
||||
options = options.map { it.label },
|
||||
selectedIndex = options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0),
|
||||
onSelected = { index -> onSelected(options[index].value) },
|
||||
summary = options.firstOrNull { it.value == selectedValue }?.label,
|
||||
help = null,
|
||||
onShowHelp = {}
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaptureInputSurface(
|
||||
captureMode: CaptureMode,
|
||||
enabled: Boolean,
|
||||
onCaptured: (Int) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
shape = MaterialTheme.shapes.large
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (enabled) {
|
||||
stringResource(R.string.settings_controls_capture_listening)
|
||||
} else {
|
||||
stringResource(R.string.settings_controls_capture_waiting_for_controller)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
GamepadCaptureView(context).apply {
|
||||
this.captureMode = captureMode
|
||||
this.onCaptured = onCaptured
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.captureMode = captureMode
|
||||
view.onCaptured = onCaptured
|
||||
if (enabled) {
|
||||
view.post {
|
||||
view.requestFocusFromTouch()
|
||||
view.requestFocus()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GamepadCaptureView(context: Context) : View(context) {
|
||||
var captureMode: CaptureMode = CaptureMode.Button
|
||||
var onCaptured: (Int) -> Unit = {}
|
||||
|
||||
init {
|
||||
isFocusable = true
|
||||
isFocusableInTouchMode = true
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
post {
|
||||
requestFocusFromTouch()
|
||||
requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (captureMode != CaptureMode.Button) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0 || !isGamepadDevice(event.device)) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
val mapped = keyCodeToSdlButton(event.keyCode) ?: return super.dispatchKeyEvent(event)
|
||||
onCaptured(mapped)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (captureMode != CaptureMode.Axis || !event.isFromSource(InputDevice.SOURCE_JOYSTICK)) {
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
val mapped = dominantAxisMapping(event) ?: return super.onGenericMotionEvent(event)
|
||||
onCaptured(mapped)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGamepadDevice(device: InputDevice?): Boolean {
|
||||
device ?: return false
|
||||
val sources = device.sources
|
||||
return (sources and InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD ||
|
||||
(sources and InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK
|
||||
}
|
||||
|
||||
private fun dominantAxisMapping(event: MotionEvent): Int? {
|
||||
val candidates = listOf(
|
||||
MotionEvent.AXIS_X to SDL_GAMEPAD_AXIS_LEFTX,
|
||||
MotionEvent.AXIS_Y to SDL_GAMEPAD_AXIS_LEFTY,
|
||||
MotionEvent.AXIS_Z to SDL_GAMEPAD_AXIS_RIGHTX,
|
||||
MotionEvent.AXIS_RZ to SDL_GAMEPAD_AXIS_RIGHTY,
|
||||
MotionEvent.AXIS_RX to SDL_GAMEPAD_AXIS_RIGHTX,
|
||||
MotionEvent.AXIS_RY to SDL_GAMEPAD_AXIS_RIGHTY,
|
||||
MotionEvent.AXIS_LTRIGGER to SDL_GAMEPAD_AXIS_LEFT_TRIGGER,
|
||||
MotionEvent.AXIS_BRAKE to SDL_GAMEPAD_AXIS_LEFT_TRIGGER,
|
||||
MotionEvent.AXIS_RTRIGGER to SDL_GAMEPAD_AXIS_RIGHT_TRIGGER,
|
||||
MotionEvent.AXIS_GAS to SDL_GAMEPAD_AXIS_RIGHT_TRIGGER
|
||||
)
|
||||
|
||||
var bestValue = 0f
|
||||
var bestAxis: Int? = null
|
||||
for ((androidAxis, sdlAxis) in candidates) {
|
||||
val value = abs(event.getAxisValue(androidAxis))
|
||||
if (value > 0.6f && value > bestValue) {
|
||||
bestValue = value
|
||||
bestAxis = sdlAxis
|
||||
}
|
||||
}
|
||||
return bestAxis
|
||||
}
|
||||
|
||||
private fun keyCodeToSdlButton(keyCode: Int): Int? {
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_A -> SDL_GAMEPAD_BUTTON_SOUTH
|
||||
KeyEvent.KEYCODE_BUTTON_B -> SDL_GAMEPAD_BUTTON_EAST
|
||||
KeyEvent.KEYCODE_BUTTON_X -> SDL_GAMEPAD_BUTTON_WEST
|
||||
KeyEvent.KEYCODE_BUTTON_Y -> SDL_GAMEPAD_BUTTON_NORTH
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT -> SDL_GAMEPAD_BUTTON_BACK
|
||||
KeyEvent.KEYCODE_BUTTON_MODE -> SDL_GAMEPAD_BUTTON_GUIDE
|
||||
KeyEvent.KEYCODE_BUTTON_START -> SDL_GAMEPAD_BUTTON_START
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBL -> SDL_GAMEPAD_BUTTON_LEFT_STICK
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBR -> SDL_GAMEPAD_BUTTON_RIGHT_STICK
|
||||
KeyEvent.KEYCODE_BUTTON_L1 -> SDL_GAMEPAD_BUTTON_LEFT_SHOULDER
|
||||
KeyEvent.KEYCODE_BUTTON_R1 -> SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER
|
||||
KeyEvent.KEYCODE_DPAD_UP -> SDL_GAMEPAD_BUTTON_DPAD_UP
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> SDL_GAMEPAD_BUTTON_DPAD_DOWN
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> SDL_GAMEPAD_BUTTON_DPAD_LEFT
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> SDL_GAMEPAD_BUTTON_DPAD_RIGHT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentButtonBinding(cfg: EmulatorConfig, index: Int): Int {
|
||||
return if (index in cfg.controllerBinds.indices) {
|
||||
cfg.controllerBinds[index]
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentAxisBinding(cfg: EmulatorConfig, index: Int): Int {
|
||||
return if (index in cfg.controllerAxisBinds.indices) {
|
||||
cfg.controllerAxisBinds[index]
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
private fun buttonManualOptions(family: ControllerFamily): List<ManualBindingOption> = listOf(
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_UP), SDL_GAMEPAD_BUTTON_DPAD_UP),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_DOWN), SDL_GAMEPAD_BUTTON_DPAD_DOWN),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_LEFT), SDL_GAMEPAD_BUTTON_DPAD_LEFT),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_DPAD_RIGHT), SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), SDL_GAMEPAD_BUTTON_LEFT_SHOULDER),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER), SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_LEFT_STICK), SDL_GAMEPAD_BUTTON_LEFT_STICK),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_RIGHT_STICK), SDL_GAMEPAD_BUTTON_RIGHT_STICK),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_BACK), SDL_GAMEPAD_BUTTON_BACK),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_GUIDE), SDL_GAMEPAD_BUTTON_GUIDE),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_START), SDL_GAMEPAD_BUTTON_START),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_NORTH), SDL_GAMEPAD_BUTTON_NORTH),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_EAST), SDL_GAMEPAD_BUTTON_EAST),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_SOUTH), SDL_GAMEPAD_BUTTON_SOUTH),
|
||||
ManualBindingOption(buttonLabel(family, SDL_GAMEPAD_BUTTON_WEST), SDL_GAMEPAD_BUTTON_WEST)
|
||||
)
|
||||
|
||||
private fun axisManualOptions(family: ControllerFamily): List<ManualBindingOption> = listOf(
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFTX), SDL_GAMEPAD_AXIS_LEFTX),
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFTY), SDL_GAMEPAD_AXIS_LEFTY),
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHTX), SDL_GAMEPAD_AXIS_RIGHTX),
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHTY), SDL_GAMEPAD_AXIS_RIGHTY),
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_LEFT_TRIGGER), SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
|
||||
ManualBindingOption(axisLabel(family, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER), SDL_GAMEPAD_AXIS_RIGHT_TRIGGER)
|
||||
)
|
||||
|
||||
private fun buttonLabel(family: ControllerFamily, button: Int): String {
|
||||
return when (button) {
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP -> "D-Pad Up"
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN -> "D-Pad Down"
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT -> "D-Pad Left"
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT -> "D-Pad Right"
|
||||
SDL_GAMEPAD_BUTTON_BACK -> when (family) {
|
||||
ControllerFamily.Xbox -> "Back"
|
||||
ControllerFamily.Nintendo -> "-"
|
||||
ControllerFamily.Standard -> "Select"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_GUIDE -> when (family) {
|
||||
ControllerFamily.Xbox -> "Guide"
|
||||
ControllerFamily.Nintendo -> "Home"
|
||||
ControllerFamily.Standard -> "PS"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_START -> when (family) {
|
||||
ControllerFamily.Nintendo -> "+"
|
||||
else -> "Start"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_LEFT_STICK -> if (family == ControllerFamily.Standard) "L3" else "LS"
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_STICK -> if (family == ControllerFamily.Standard) "R3" else "RS"
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER -> when (family) {
|
||||
ControllerFamily.Xbox -> "LB"
|
||||
ControllerFamily.Nintendo -> "L"
|
||||
ControllerFamily.Standard -> "L1"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER -> when (family) {
|
||||
ControllerFamily.Xbox -> "RB"
|
||||
ControllerFamily.Nintendo -> "R"
|
||||
ControllerFamily.Standard -> "R1"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_SOUTH -> when (family) {
|
||||
ControllerFamily.Xbox -> "A"
|
||||
ControllerFamily.Nintendo -> "B"
|
||||
ControllerFamily.Standard -> "Cross"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_EAST -> when (family) {
|
||||
ControllerFamily.Xbox -> "B"
|
||||
ControllerFamily.Nintendo -> "A"
|
||||
ControllerFamily.Standard -> "Circle"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_WEST -> when (family) {
|
||||
ControllerFamily.Xbox -> "X"
|
||||
ControllerFamily.Nintendo -> "Y"
|
||||
ControllerFamily.Standard -> "Square"
|
||||
}
|
||||
SDL_GAMEPAD_BUTTON_NORTH -> when (family) {
|
||||
ControllerFamily.Xbox -> "Y"
|
||||
ControllerFamily.Nintendo -> "X"
|
||||
ControllerFamily.Standard -> "Triangle"
|
||||
}
|
||||
else -> "Button $button"
|
||||
}
|
||||
}
|
||||
|
||||
private fun axisLabel(family: ControllerFamily, axis: Int): String {
|
||||
return when (axis) {
|
||||
SDL_GAMEPAD_AXIS_LEFTX -> "Left X"
|
||||
SDL_GAMEPAD_AXIS_LEFTY -> "Left Y"
|
||||
SDL_GAMEPAD_AXIS_RIGHTX -> "Right X"
|
||||
SDL_GAMEPAD_AXIS_RIGHTY -> "Right Y"
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER -> when (family) {
|
||||
ControllerFamily.Xbox -> "LT"
|
||||
ControllerFamily.Nintendo -> "ZL"
|
||||
ControllerFamily.Standard -> "L2"
|
||||
}
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER -> when (family) {
|
||||
ControllerFamily.Xbox -> "RT"
|
||||
ControllerFamily.Nintendo -> "ZR"
|
||||
ControllerFamily.Standard -> "R2"
|
||||
}
|
||||
else -> "Axis $axis"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Build
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import org.vita3k.emulator.overlay.InputOverlay
|
||||
import org.vita3k.emulator.overlay.OverlayConfig
|
||||
import org.vita3k.emulator.ui.components.OverlayEditorPalette
|
||||
|
||||
@Composable
|
||||
internal fun OverlayLayoutEditorDialog(
|
||||
overlayConfig: OverlayConfig,
|
||||
onDismiss: () -> Unit,
|
||||
layoutProfileId: String = ""
|
||||
) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
|
||||
val activity = LocalContext.current.findActivity()
|
||||
var overlayView by remember { mutableStateOf<InputOverlay?>(null) }
|
||||
OverlayEditorWindowEffect(activity = activity)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
InputOverlay(context).apply {
|
||||
overlayView = this
|
||||
setLayoutProfileId(layoutProfileId)
|
||||
setAllowVirtualController(false)
|
||||
setAutoHideEnabled(false)
|
||||
setIsInEditMode(true)
|
||||
setScale(overlayConfig.overlayScale / 100f)
|
||||
setOpacity(overlayConfig.overlayOpacity)
|
||||
setState(overlayConfig.activeMask())
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
overlayView = view
|
||||
view.setLayoutProfileId(layoutProfileId)
|
||||
view.setAllowVirtualController(false)
|
||||
view.setAutoHideEnabled(false)
|
||||
view.setIsInEditMode(true)
|
||||
view.setScale(overlayConfig.overlayScale / 100f)
|
||||
view.setOpacity(overlayConfig.overlayOpacity)
|
||||
view.setState(overlayConfig.activeMask())
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
OverlayEditorPalette(
|
||||
onDone = onDismiss,
|
||||
onReset = { overlayView?.resetButtonPlacement() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayEditorWindowEffect(activity: Activity?) {
|
||||
DisposableEffect(activity) {
|
||||
if (activity == null) {
|
||||
return@DisposableEffect onDispose { }
|
||||
}
|
||||
|
||||
val window = activity.window
|
||||
val decorView = window.decorView
|
||||
val previousOrientation = activity.requestedOrientation
|
||||
val previousAttributes = WindowManager.LayoutParams().also { it.copyFrom(window.attributes) }
|
||||
val systemBars = WindowInsetsCompat.Type.systemBars()
|
||||
val barsWereVisible = ViewCompat.getRootWindowInsets(decorView)?.isVisible(systemBars) ?: true
|
||||
val controller = WindowCompat.getInsetsController(window, decorView)
|
||||
val focusListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
|
||||
if (hasFocus) {
|
||||
WindowCompat.getInsetsController(window, decorView)?.hide(systemBars)
|
||||
}
|
||||
}
|
||||
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
controller?.setSystemBarsBehavior(
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
)
|
||||
controller?.hide(systemBars)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
||||
if (insets.isVisible(systemBars)) {
|
||||
WindowCompat.getInsetsController(window, view)?.hide(systemBars)
|
||||
}
|
||||
insets
|
||||
}
|
||||
if (decorView.viewTreeObserver.isAlive) {
|
||||
decorView.viewTreeObserver.addOnWindowFocusChangeListener(focusListener)
|
||||
}
|
||||
decorView.post {
|
||||
WindowCompat.getInsetsController(window, decorView)?.hide(systemBars)
|
||||
ViewCompat.requestApplyInsets(decorView)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val updatedAttributes = window.attributes
|
||||
updatedAttributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
window.attributes = updatedAttributes
|
||||
}
|
||||
|
||||
onDispose {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(decorView, null)
|
||||
if (decorView.viewTreeObserver.isAlive) {
|
||||
decorView.viewTreeObserver.removeOnWindowFocusChangeListener(focusListener)
|
||||
}
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
if (barsWereVisible) {
|
||||
controller?.show(systemBars)
|
||||
} else {
|
||||
controller?.hide(systemBars)
|
||||
}
|
||||
window.attributes = previousAttributes
|
||||
activity.requestedOrientation = previousOrientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.ConnectedGamepad
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.EmulatorConfig
|
||||
import org.vita3k.emulator.overlay.OverlayConfig
|
||||
|
||||
@Composable
|
||||
internal fun OverlaySettingsSection(
|
||||
cfg: EmulatorConfig,
|
||||
overlayConfig: OverlayConfig,
|
||||
isPerApp: Boolean,
|
||||
connectedGamepads: List<ConnectedGamepad>,
|
||||
onUpdate: (EmulatorConfig.() -> Unit) -> Unit,
|
||||
onOverlayConfigChange: (OverlayConfig) -> Unit,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit,
|
||||
controllerConnected: Boolean = false,
|
||||
onStartControlsEditor: (() -> Unit)? = null,
|
||||
onResetControlsLayout: (() -> Unit)? = null
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_tab_controls),
|
||||
summary = null,
|
||||
help = null,
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
OverlaySettingsRows(
|
||||
overlayConfig = overlayConfig,
|
||||
onOverlayConfigChange = onOverlayConfigChange,
|
||||
onShowHelp = onShowHelp,
|
||||
controllerConnected = controllerConnected,
|
||||
onStartControlsEditor = onStartControlsEditor,
|
||||
onResetControlsLayout = onResetControlsLayout
|
||||
)
|
||||
}
|
||||
|
||||
if (!isPerApp) {
|
||||
ControllerMappingSections(
|
||||
cfg = cfg,
|
||||
connectedGamepads = connectedGamepads,
|
||||
onUpdate = onUpdate,
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OverlaySettingsRows(
|
||||
overlayConfig: OverlayConfig,
|
||||
onOverlayConfigChange: (OverlayConfig) -> Unit,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit,
|
||||
controllerConnected: Boolean = false,
|
||||
onStartControlsEditor: (() -> Unit)? = null,
|
||||
onResetControlsLayout: (() -> Unit)? = null
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (onStartControlsEditor != null || onResetControlsLayout != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onStartControlsEditor != null) {
|
||||
FilledTonalButton(
|
||||
onClick = onStartControlsEditor,
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.TouchApp, contentDescription = null)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
androidx.compose.material3.Text(stringResource(R.string.emulation_controls_edit))
|
||||
}
|
||||
}
|
||||
if (onResetControlsLayout != null) {
|
||||
OutlinedButton(
|
||||
onClick = onResetControlsLayout,
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = null)
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
androidx.compose.material3.Text(stringResource(R.string.action_reset))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_show),
|
||||
checked = overlayConfig.controlsVisible,
|
||||
onCheckedChange = { onOverlayConfigChange(overlayConfig.withControlsVisible(it)) },
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_show),
|
||||
body = stringResource(R.string.settings_controls_show_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_hide_on_controller),
|
||||
checked = overlayConfig.hideOverlayWhenControllerConnected,
|
||||
onCheckedChange = {
|
||||
onOverlayConfigChange(overlayConfig.copy(hideOverlayWhenControllerConnected = it))
|
||||
},
|
||||
summary = if (controllerConnected) {
|
||||
stringResource(R.string.settings_controls_controller_connected)
|
||||
} else {
|
||||
stringResource(R.string.settings_controls_controller_disconnected)
|
||||
},
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_hide_on_controller),
|
||||
body = stringResource(R.string.settings_controls_hide_on_controller_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_l2r2),
|
||||
checked = overlayConfig.l2r2Visible,
|
||||
onCheckedChange = { onOverlayConfigChange(overlayConfig.withL2R2Visible(it)) },
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_l2r2),
|
||||
body = stringResource(R.string.settings_controls_l2r2_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_l3r3),
|
||||
checked = overlayConfig.l3r3Visible,
|
||||
onCheckedChange = { onOverlayConfigChange(overlayConfig.withL3R3Visible(it)) },
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_l3r3),
|
||||
body = stringResource(R.string.settings_controls_l3r3_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_touch_switch),
|
||||
checked = overlayConfig.touchSwitchVisible,
|
||||
onCheckedChange = { onOverlayConfigChange(overlayConfig.withTouchSwitchVisible(it)) },
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_touch_switch),
|
||||
body = stringResource(R.string.settings_controls_touch_switch_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.settings_controls_hide_toggle),
|
||||
checked = overlayConfig.hideToggleVisible,
|
||||
onCheckedChange = { onOverlayConfigChange(overlayConfig.withHideToggleVisible(it)) },
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_hide_toggle),
|
||||
body = stringResource(R.string.settings_controls_hide_toggle_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsSliderRow(
|
||||
title = stringResource(R.string.settings_controls_scale),
|
||||
valueLabel = stringResource(R.string.settings_controls_percent_value, overlayConfig.overlayScale),
|
||||
value = overlayConfig.overlayScale.toFloat(),
|
||||
onValueChange = {
|
||||
onOverlayConfigChange(overlayConfig.copy(overlayScale = it.toInt()))
|
||||
},
|
||||
valueRange = 50f..150f,
|
||||
steps = 99,
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_scale),
|
||||
body = stringResource(R.string.settings_controls_scale_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
SettingsSliderRow(
|
||||
title = stringResource(R.string.settings_controls_opacity),
|
||||
valueLabel = stringResource(R.string.settings_controls_percent_value, overlayConfig.overlayOpacity),
|
||||
value = overlayConfig.overlayOpacity.toFloat(),
|
||||
onValueChange = {
|
||||
onOverlayConfigChange(overlayConfig.copy(overlayOpacity = it.toInt()))
|
||||
},
|
||||
valueRange = 20f..100f,
|
||||
steps = 79,
|
||||
enabled = overlayConfig.controlsVisible,
|
||||
help = SettingsHelpEntry(
|
||||
title = stringResource(R.string.settings_controls_opacity),
|
||||
body = stringResource(R.string.settings_controls_opacity_desc),
|
||||
scope = SettingsScope.Global
|
||||
),
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
private val SectionShape = RoundedCornerShape(28.dp)
|
||||
private val TileShape = RoundedCornerShape(20.dp)
|
||||
|
||||
@Composable
|
||||
internal fun settingsFilterChipColors() = FilterChipDefaults.filterChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
selectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
selectedLabelColor = MaterialTheme.colorScheme.primary,
|
||||
selectedLeadingIconColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun SettingsCategoryStrip(
|
||||
categories: List<SettingsCategory>,
|
||||
selectedCategory: SettingsCategory,
|
||||
onCategorySelected: (SettingsCategory) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(categories, key = { it.name }) { category ->
|
||||
FilterChip(
|
||||
selected = category == selectedCategory,
|
||||
onClick = { onCategorySelected(category) },
|
||||
colors = settingsFilterChipColors(),
|
||||
label = { Text(category.label()) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = category.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsSectionCard(
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = SectionShape,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
help?.let {
|
||||
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsSubsectionTitle(
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsToggleRow(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
summary: String? = null,
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val valueLabel = summary ?: androidx.compose.ui.res.stringResource(
|
||||
if (checked) R.string.settings_value_enabled else R.string.settings_value_disabled
|
||||
)
|
||||
SettingsRowContainer(
|
||||
title = title,
|
||||
summary = valueLabel,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = { if (enabled) onCheckedChange(!checked) },
|
||||
help = help,
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SettingsChoiceSelector(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
summary: String? = options.getOrNull(selectedIndex.coerceAtLeast(0)),
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val clampedSelectedIndex = if (options.isEmpty()) 0 else selectedIndex.coerceIn(0, options.lastIndex)
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
|
||||
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = TileShape
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
help?.let {
|
||||
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
|
||||
}
|
||||
}
|
||||
if (options.isNotEmpty()) {
|
||||
SettingsChoiceChips(
|
||||
options = options,
|
||||
selectedIndex = clampedSelectedIndex,
|
||||
onSelected = onSelected,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsScrollableChoiceSelector(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
summary: String? = options.getOrNull(selectedIndex.coerceAtLeast(0)),
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val clampedSelectedIndex = if (options.isEmpty()) 0 else selectedIndex.coerceIn(0, options.lastIndex)
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
|
||||
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = TileShape
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
help?.let {
|
||||
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
|
||||
}
|
||||
}
|
||||
if (options.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = options[clampedSelectedIndex],
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.rotate(if (expanded) 90f else 0f),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f),
|
||||
focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { expanded = true }
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.heightIn(max = 320.dp)
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = option,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelected(index)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsActionRow(
|
||||
title: String,
|
||||
value: String? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
actionLabel: String? = null,
|
||||
onActionClick: (() -> Unit)? = null,
|
||||
actionEnabled: Boolean = true,
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
SettingsRowContainer(
|
||||
title = title,
|
||||
summary = value,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
help = help,
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
if (!actionLabel.isNullOrBlank() && onActionClick != null) {
|
||||
TextButton(
|
||||
onClick = onActionClick,
|
||||
enabled = enabled && actionEnabled
|
||||
) {
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsSliderRow(
|
||||
title: String,
|
||||
valueLabel: String,
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
steps: Int = 0,
|
||||
enabled: Boolean = true,
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f),
|
||||
contentColor = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
shape = TileShape
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = valueLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
help?.let {
|
||||
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
|
||||
}
|
||||
}
|
||||
androidx.compose.material3.Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
valueRange = valueRange,
|
||||
steps = steps,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsNote(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SettingsChoiceChips(
|
||||
options: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
FilterChip(
|
||||
selected = index == selectedIndex,
|
||||
onClick = { onSelected(index) },
|
||||
colors = settingsFilterChipColors(),
|
||||
label = { Text(option) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsSearchResultRow(
|
||||
entry: SettingsSearchEntry,
|
||||
onOpen: (SettingsCategory) -> Unit,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
SettingsActionRow(
|
||||
title = entry.title,
|
||||
value = entry.summary,
|
||||
onClick = { onOpen(entry.category) },
|
||||
help = entry.help,
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsHelpSheet(
|
||||
help: SettingsHelpEntry,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(help.title, style = MaterialTheme.typography.headlineSmall) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
help.scope.labelRes?.let { labelRes ->
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(999.dp)
|
||||
) {
|
||||
Text(
|
||||
text = androidx.compose.ui.res.stringResource(labelRes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = help.body,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(androidx.compose.ui.res.stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsLoadingState(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsHelpButton(
|
||||
help: SettingsHelpEntry,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
IconButton(onClick = { onShowHelp(help) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = androidx.compose.ui.res.stringResource(R.string.settings_cd_show_info),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRowContainer(
|
||||
title: String,
|
||||
summary: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
help: SettingsHelpEntry? = null,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit,
|
||||
trailing: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (enabled) 0.35f else 0.18f)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(TileShape)
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
shape = TileShape,
|
||||
color = containerColor
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (!summary.isNullOrBlank()) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
help?.let {
|
||||
SettingsHelpButton(help = it, onShowHelp = onShowHelp)
|
||||
}
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.SettingsSuggest
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
internal enum class SettingsScope(@StringRes val labelRes: Int?) {
|
||||
Both(labelRes = null),
|
||||
Global(labelRes = R.string.settings_scope_global),
|
||||
PerApp(labelRes = R.string.settings_scope_per_app)
|
||||
}
|
||||
|
||||
internal enum class SettingsCategory(
|
||||
@StringRes val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
Core(R.string.settings_tab_core, Icons.Default.Tune),
|
||||
Cpu(R.string.settings_tab_cpu, Icons.Default.Speed),
|
||||
Gpu(R.string.settings_tab_gpu, Icons.Default.GraphicEq),
|
||||
Audio(R.string.settings_tab_audio, Icons.AutoMirrored.Filled.VolumeUp),
|
||||
Camera(R.string.settings_tab_camera, Icons.Default.PhotoCamera),
|
||||
System(R.string.settings_tab_system, Icons.Default.Language),
|
||||
Controls(R.string.settings_tab_controls, Icons.Default.TouchApp),
|
||||
Interface(R.string.settings_tab_interface, Icons.Default.Settings),
|
||||
Emulator(R.string.settings_tab_emulator, Icons.Default.SettingsSuggest),
|
||||
Network(R.string.settings_tab_network, Icons.Default.Public),
|
||||
Debug(R.string.settings_tab_debug, Icons.Default.BugReport)
|
||||
}
|
||||
|
||||
internal fun settingsCategories(isPerApp: Boolean): List<SettingsCategory> {
|
||||
return if (isPerApp) {
|
||||
SettingsCategory.entries.filter { it != SettingsCategory.Camera && it != SettingsCategory.Interface }
|
||||
} else {
|
||||
SettingsCategory.entries.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsCategory.label(): String = stringResource(labelRes)
|
||||
|
||||
internal data class SettingsHelpEntry(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val scope: SettingsScope = SettingsScope.Both
|
||||
)
|
||||
|
||||
internal data class SettingsSearchEntry(
|
||||
val category: SettingsCategory,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val keywords: String,
|
||||
val help: SettingsHelpEntry? = null
|
||||
)
|
||||
@@ -0,0 +1,777 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.background
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import org.vita3k.emulator.MainActivity
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.CustomDriverLoadStatus
|
||||
import org.vita3k.emulator.data.RestartRequiredSetting
|
||||
import org.vita3k.emulator.overlay.OverlayConfig
|
||||
import org.vita3k.emulator.overlay.OverlayConfigStore
|
||||
import org.vita3k.emulator.overlay.OverlayLayoutStore
|
||||
import org.vita3k.emulator.ui.CUSTOM_DRIVER_DOWNLOAD_URL
|
||||
import org.vita3k.emulator.ui.rememberConnectedGamepads
|
||||
import org.vita3k.emulator.ui.theme.ApplyDialogDim
|
||||
import org.vita3k.emulator.ui.viewmodel.SettingsViewModel
|
||||
|
||||
private val CUSTOM_DRIVER_MIME_TYPES = arrayOf(
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/octet-stream"
|
||||
)
|
||||
private val IMAGE_MIME_TYPES = arrayOf("image/*")
|
||||
|
||||
private fun formatRestartRequiredSettings(
|
||||
context: android.content.Context,
|
||||
settings: List<RestartRequiredSetting>
|
||||
): String = settings.joinToString(separator = "\n- ", prefix = "- ") { setting ->
|
||||
context.getString(setting.labelResId)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
titleId: String?,
|
||||
appName: String? = null,
|
||||
viewModel: SettingsViewModel,
|
||||
onStorageChanged: () -> Unit = {},
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
LaunchedEffect(titleId) {
|
||||
if (!viewModel.isLoaded(titleId)) {
|
||||
viewModel.load(titleId)
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isPerApp = titleId != null
|
||||
val settingsLoaded = viewModel.isLoaded(titleId)
|
||||
val categories = remember(isPerApp) { settingsCategories(isPerApp) }
|
||||
val scrollState = rememberScrollState()
|
||||
val overlayLayoutProfileId = titleId.orEmpty()
|
||||
var overlayConfig by remember(titleId) { mutableStateOf(OverlayConfigStore.load(context)) }
|
||||
val connectedGamepads = rememberConnectedGamepads(context)
|
||||
val controllerConnected = connectedGamepads.isNotEmpty()
|
||||
|
||||
var selectedCategory by rememberSaveable(titleId) { mutableStateOf(SettingsCategory.Core) }
|
||||
var searchQuery by rememberSaveable(titleId) { mutableStateOf("") }
|
||||
var showDiscardDialog by remember { mutableStateOf(false) }
|
||||
var showResetDefaultsDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteCustomConfigDialog by remember { mutableStateOf(false) }
|
||||
var showClearAllCustomConfigsDialog by remember { mutableStateOf(false) }
|
||||
var pendingCustomDriverRemoval by remember { mutableStateOf<String?>(null) }
|
||||
var pendingCameraImageTarget by remember { mutableStateOf<Boolean?>(null) }
|
||||
var activeHelp by remember { mutableStateOf<SettingsHelpEntry?>(null) }
|
||||
var searchActive by rememberSaveable(titleId) { mutableStateOf(false) }
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
var showOverlayEditor by remember(titleId) { mutableStateOf(false) }
|
||||
var pendingRestartSettings by remember { mutableStateOf<List<RestartRequiredSetting>?>(null) }
|
||||
var closeAfterRestartNotice by remember { mutableStateOf(false) }
|
||||
|
||||
fun requestStorageFolderChange() {
|
||||
val activity = context as? MainActivity ?: return
|
||||
activity.requestStorageFolderChange { path ->
|
||||
path?.let { viewModel.changeStorageFolder(it, onStorageChanged) }
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCustomDriverInstall() {
|
||||
val activity = context as? MainActivity ?: return
|
||||
activity.requestFilePath(CUSTOM_DRIVER_MIME_TYPES) { path ->
|
||||
path?.let(viewModel::installCustomDriver)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSearch() {
|
||||
searchActive = false
|
||||
searchQuery = ""
|
||||
}
|
||||
|
||||
fun pickCameraImage(isFront: Boolean) {
|
||||
val activity = context as? MainActivity ?: return
|
||||
pendingCameraImageTarget = isFront
|
||||
activity.requestFilePath(IMAGE_MIME_TYPES) { path ->
|
||||
val target = pendingCameraImageTarget
|
||||
pendingCameraImageTarget = null
|
||||
if (target != null && path != null) {
|
||||
viewModel.setCameraImage(target, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openUrl(url: String) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
|
||||
fun tryBack() {
|
||||
if (viewModel.isDirty) showDiscardDialog = true else onBack()
|
||||
}
|
||||
|
||||
BackHandler { tryBack() }
|
||||
|
||||
LaunchedEffect(categories, selectedCategory) {
|
||||
if (selectedCategory !in categories) {
|
||||
selectedCategory = categories.firstOrNull() ?: SettingsCategory.Core
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedCategory, searchQuery) {
|
||||
scrollState.scrollTo(0)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
title = {
|
||||
if (searchActive) {
|
||||
SettingsTopBarSearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
if (isPerApp && appName != null) {
|
||||
stringResource(R.string.settings_custom_config_label, appName)
|
||||
} else {
|
||||
stringResource(R.string.settings_title)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { tryBack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!searchActive && viewModel.isDirty && !viewModel.saving && !viewModel.customDriverBusy) {
|
||||
IconButton(onClick = {
|
||||
viewModel.save(forceCustomConfig = isPerApp) { restartRequired ->
|
||||
if (restartRequired.isNotEmpty()) {
|
||||
pendingRestartSettings = restartRequired
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Save,
|
||||
contentDescription = stringResource(R.string.settings_save_cd)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (searchActive) {
|
||||
closeSearch()
|
||||
} else {
|
||||
searchActive = true
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (searchActive) Icons.Default.Close else Icons.Default.Search,
|
||||
contentDescription = if (searchActive) {
|
||||
stringResource(R.string.apps_list_cd_close_search)
|
||||
} else {
|
||||
stringResource(R.string.apps_list_cd_search)
|
||||
}
|
||||
)
|
||||
}
|
||||
SettingsOverflowMenu(
|
||||
expanded = showOverflowMenu,
|
||||
onExpandedChange = { showOverflowMenu = it },
|
||||
showDeleteCustomConfig = isPerApp && viewModel.hasCustomConfig,
|
||||
onResetDefaults = {
|
||||
showOverflowMenu = false
|
||||
showResetDefaultsDialog = true
|
||||
},
|
||||
onDeleteCustomConfig = {
|
||||
showOverflowMenu = false
|
||||
showDeleteCustomConfigDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (!settingsLoaded) {
|
||||
SettingsLoadingState(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
)
|
||||
return@Scaffold
|
||||
}
|
||||
|
||||
val searchEntries = rememberSettingsSearchEntries(
|
||||
isPerApp = isPerApp,
|
||||
backendRenderer = viewModel.config.backendRenderer,
|
||||
showCustomDriverOptions = viewModel.showCustomDriverOptions,
|
||||
showTurboModeOption = viewModel.showCustomDriverOptions
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
SettingsContentPane(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
titleId = titleId,
|
||||
isPerApp = isPerApp,
|
||||
viewModel = viewModel,
|
||||
overlayConfig = overlayConfig,
|
||||
supportedMemoryMappingMask = viewModel.supportedMemoryMappingMask,
|
||||
customDriverLoadStatus = viewModel.customDriverLoadStatus,
|
||||
availableCameras = viewModel.availableCameras,
|
||||
availableAdhocAddresses = viewModel.availableAdhocAddresses,
|
||||
selectedCategory = selectedCategory,
|
||||
searchQuery = searchQuery,
|
||||
onCloseSearch = ::closeSearch,
|
||||
onSelectedCategoryChange = { selectedCategory = it },
|
||||
searchEntries = searchEntries,
|
||||
currentStoragePath = viewModel.currentStoragePath,
|
||||
scrollState = scrollState,
|
||||
controllerConnected = controllerConnected,
|
||||
connectedGamepads = connectedGamepads,
|
||||
categories = categories,
|
||||
onChangeStorageFolder = ::requestStorageFolderChange,
|
||||
onResetStorageFolder = { viewModel.resetStorageFolder(onStorageChanged) },
|
||||
onInstallCustomDriver = ::requestCustomDriverInstall,
|
||||
onDownloadCustomDriver = { openUrl(CUSTOM_DRIVER_DOWNLOAD_URL) },
|
||||
onPickCameraImage = ::pickCameraImage,
|
||||
onRequestRemoveCustomDriver = { pendingCustomDriverRemoval = it },
|
||||
onClearAllCustomConfigs = { showClearAllCustomConfigsDialog = true },
|
||||
onOverlayConfigChange = { updated ->
|
||||
overlayConfig = updated.normalized()
|
||||
OverlayConfigStore.save(context, overlayConfig)
|
||||
},
|
||||
onStartControlsEditor = {
|
||||
showOverlayEditor = true
|
||||
},
|
||||
onResetControlsLayout = {
|
||||
OverlayLayoutStore.resetToDefaults(context, overlayLayoutProfileId)
|
||||
overlayConfig = OverlayConfigStore.load(context)
|
||||
},
|
||||
onShowHelp = { activeHelp = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showOverlayEditor) {
|
||||
OverlayLayoutEditorDialog(
|
||||
overlayConfig = overlayConfig,
|
||||
layoutProfileId = overlayLayoutProfileId,
|
||||
onDismiss = {
|
||||
showOverlayEditor = false
|
||||
overlayConfig = OverlayConfigStore.load(context)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiscardDialog) {
|
||||
SettingsUnsavedChangesDialog(
|
||||
saving = viewModel.saving,
|
||||
onCancel = { showDiscardDialog = false },
|
||||
onSaveAndExit = {
|
||||
showDiscardDialog = false
|
||||
viewModel.save(forceCustomConfig = isPerApp) { restartRequired ->
|
||||
if (restartRequired.isEmpty()) {
|
||||
onBack()
|
||||
} else {
|
||||
closeAfterRestartNotice = true
|
||||
pendingRestartSettings = restartRequired
|
||||
}
|
||||
}
|
||||
},
|
||||
onDiscard = {
|
||||
showDiscardDialog = false
|
||||
viewModel.discardChanges()
|
||||
onBack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showResetDefaultsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDefaultsDialog = false },
|
||||
title = { Text(stringResource(R.string.settings_confirm_reset_defaults_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(stringResource(R.string.settings_confirm_reset_defaults_message))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showResetDefaultsDialog = false
|
||||
viewModel.resetToDefaults()
|
||||
}) {
|
||||
Text(stringResource(R.string.settings_reset_defaults))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDefaultsDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteCustomConfigDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteCustomConfigDialog = false },
|
||||
title = { Text(stringResource(R.string.settings_confirm_delete_custom_config_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_confirm_delete_custom_config_message,
|
||||
appName ?: titleId ?: ""
|
||||
)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showDeleteCustomConfigDialog = false
|
||||
viewModel.deleteCustomConfig { restartRequired ->
|
||||
if (restartRequired.isNotEmpty()) {
|
||||
pendingRestartSettings = restartRequired
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_delete),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteCustomConfigDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showClearAllCustomConfigsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearAllCustomConfigsDialog = false },
|
||||
title = { Text(stringResource(R.string.settings_confirm_clear_all_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(stringResource(R.string.settings_confirm_clear_all_message))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showClearAllCustomConfigsDialog = false
|
||||
viewModel.clearAllCustomConfigs()
|
||||
}) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_delete),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearAllCustomConfigsDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingCustomDriverRemoval?.let { driverName ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingCustomDriverRemoval = null },
|
||||
title = { Text(stringResource(R.string.settings_confirm_remove_custom_driver_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(stringResource(R.string.settings_confirm_remove_custom_driver_message, driverName))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingCustomDriverRemoval = null
|
||||
viewModel.removeCustomDriver(driverName)
|
||||
}) {
|
||||
Text(
|
||||
text = stringResource(R.string.action_delete),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingCustomDriverRemoval = null }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.operationResult?.let { result ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.dismissOperationResult() },
|
||||
title = {
|
||||
Text(
|
||||
if (result.isError) {
|
||||
stringResource(R.string.settings_opt_error)
|
||||
} else {
|
||||
stringResource(R.string.settings_success_title)
|
||||
}
|
||||
)
|
||||
},
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Text(result.message)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.dismissOperationResult() }) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pendingRestartSettings?.let { settings ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = { Text(stringResource(R.string.settings_restart_required_title)) },
|
||||
text = {
|
||||
ApplyDialogDim()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.settings_restart_required_summary))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.settings_restart_required_message,
|
||||
formatRestartRequiredSettings(context, settings)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingRestartSettings = null
|
||||
val shouldClose = closeAfterRestartNotice
|
||||
closeAfterRestartNotice = false
|
||||
if (shouldClose) {
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
activeHelp?.let { help ->
|
||||
SettingsHelpSheet(help = help, onDismiss = { activeHelp = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsContentPane(
|
||||
modifier: Modifier,
|
||||
titleId: String?,
|
||||
isPerApp: Boolean,
|
||||
viewModel: SettingsViewModel,
|
||||
overlayConfig: OverlayConfig,
|
||||
supportedMemoryMappingMask: Int,
|
||||
customDriverLoadStatus: CustomDriverLoadStatus,
|
||||
availableCameras: List<String>,
|
||||
availableAdhocAddresses: List<Pair<String, String>>,
|
||||
selectedCategory: SettingsCategory,
|
||||
searchQuery: String,
|
||||
onCloseSearch: () -> Unit,
|
||||
onSelectedCategoryChange: (SettingsCategory) -> Unit,
|
||||
searchEntries: List<SettingsSearchEntry>,
|
||||
currentStoragePath: String,
|
||||
scrollState: androidx.compose.foundation.ScrollState,
|
||||
controllerConnected: Boolean,
|
||||
connectedGamepads: List<org.vita3k.emulator.ConnectedGamepad>,
|
||||
categories: List<SettingsCategory>,
|
||||
onChangeStorageFolder: () -> Unit,
|
||||
onResetStorageFolder: () -> Unit,
|
||||
onInstallCustomDriver: () -> Unit,
|
||||
onDownloadCustomDriver: () -> Unit,
|
||||
onPickCameraImage: (Boolean) -> Unit,
|
||||
onRequestRemoveCustomDriver: (String) -> Unit,
|
||||
onClearAllCustomConfigs: () -> Unit,
|
||||
onOverlayConfigChange: (OverlayConfig) -> Unit,
|
||||
onStartControlsEditor: (() -> Unit)?,
|
||||
onResetControlsLayout: (() -> Unit)?,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (isPerApp && viewModel.hasCustomConfig) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_custom_config_banner),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCategoryStrip(
|
||||
categories = categories,
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelected = onSelectedCategoryChange
|
||||
)
|
||||
|
||||
if (searchQuery.isBlank()) {
|
||||
SettingsCategoryBody(
|
||||
category = selectedCategory,
|
||||
cfg = viewModel.config,
|
||||
overlayConfig = overlayConfig,
|
||||
modulesList = viewModel.modulesList,
|
||||
modulesSearch = viewModel.modulesSearch,
|
||||
onModulesSearchChange = viewModel::onModulesSearchChange,
|
||||
onToggleModule = viewModel::toggleModule,
|
||||
supportedMemoryMappingMask = supportedMemoryMappingMask,
|
||||
customDriverLoadStatus = customDriverLoadStatus,
|
||||
availableCameras = availableCameras,
|
||||
availableAdhocAddresses = availableAdhocAddresses,
|
||||
currentStoragePath = currentStoragePath,
|
||||
defaultStoragePath = viewModel.defaultStoragePath,
|
||||
onChangeStorageFolder = onChangeStorageFolder,
|
||||
onResetStorageFolder = onResetStorageFolder,
|
||||
installedCustomDrivers = viewModel.installedCustomDrivers,
|
||||
customDriverBusy = viewModel.customDriverBusy,
|
||||
onInstallCustomDriver = onInstallCustomDriver,
|
||||
onDownloadCustomDriver = onDownloadCustomDriver,
|
||||
onPickCameraImage = onPickCameraImage,
|
||||
onRequestRemoveCustomDriver = onRequestRemoveCustomDriver,
|
||||
isPerApp = isPerApp,
|
||||
showCustomDriverManagement = viewModel.showCustomDriverOptions,
|
||||
showTurboModeOption = viewModel.showCustomDriverOptions,
|
||||
onClearAllCustomConfigs = onClearAllCustomConfigs,
|
||||
onSelectCustomDriver = viewModel::selectCustomDriver,
|
||||
onUpdate = viewModel::update,
|
||||
onOverlayConfigChange = onOverlayConfigChange,
|
||||
onStartControlsEditor = onStartControlsEditor,
|
||||
onResetControlsLayout = onResetControlsLayout,
|
||||
controllerConnected = controllerConnected,
|
||||
connectedGamepads = connectedGamepads,
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
} else {
|
||||
SettingsSearchResults(
|
||||
query = searchQuery,
|
||||
entries = searchEntries,
|
||||
onOpen = {
|
||||
onSelectedCategoryChange(it)
|
||||
onCloseSearch()
|
||||
},
|
||||
onShowHelp = onShowHelp
|
||||
)
|
||||
}
|
||||
|
||||
if (titleId == null && viewModel.isDirty) {
|
||||
Spacer(modifier = Modifier.width(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsOverflowMenu(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
showDeleteCustomConfig: Boolean,
|
||||
onResetDefaults: () -> Unit,
|
||||
onDeleteCustomConfig: () -> Unit
|
||||
) {
|
||||
Box {
|
||||
IconButton(onClick = { onExpandedChange(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.settings_more_options)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { onExpandedChange(false) }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_reset_defaults)) },
|
||||
onClick = onResetDefaults
|
||||
)
|
||||
if (showDeleteCustomConfig) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.settings_delete_custom_config)) },
|
||||
onClick = onDeleteCustomConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsTopBarSearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textStyle = MaterialTheme.typography.bodyLarge.merge(
|
||||
TextStyle(color = MaterialTheme.colorScheme.onSurface)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(start = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp)
|
||||
) {
|
||||
if (query.isBlank()) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_search_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = textStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsUnsavedChangesDialog(
|
||||
saving: Boolean,
|
||||
onCancel: () -> Unit,
|
||||
onSaveAndExit: () -> Unit,
|
||||
onDiscard: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onCancel) {
|
||||
ApplyDialogDim()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_unsaved_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_unsaved_message),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onCancel, enabled = !saving) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
TextButton(onClick = onDiscard, enabled = !saving) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_discard),
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onSaveAndExit, enabled = !saving) {
|
||||
Text(stringResource(R.string.settings_save_and_exit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.vita3k.emulator.ui.screens.settings
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
@Composable
|
||||
internal fun rememberSettingsSearchEntries(
|
||||
isPerApp: Boolean,
|
||||
backendRenderer: String,
|
||||
showCustomDriverOptions: Boolean,
|
||||
showTurboModeOption: Boolean = showCustomDriverOptions
|
||||
): List<SettingsSearchEntry> {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
|
||||
fun help(
|
||||
@StringRes titleRes: Int,
|
||||
@StringRes bodyRes: Int,
|
||||
scope: SettingsScope = SettingsScope.Both
|
||||
): SettingsHelpEntry {
|
||||
return SettingsHelpEntry(
|
||||
title = context.getString(titleRes),
|
||||
body = context.getString(bodyRes),
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
|
||||
fun entry(
|
||||
category: SettingsCategory,
|
||||
@StringRes titleRes: Int,
|
||||
@StringRes bodyRes: Int? = null,
|
||||
scope: SettingsScope = SettingsScope.Both,
|
||||
keywords: String = ""
|
||||
): SettingsSearchEntry {
|
||||
val title = context.getString(titleRes)
|
||||
val categoryLabel = context.getString(category.labelRes)
|
||||
val helpEntry = bodyRes?.let { help(titleRes, it, scope) }
|
||||
val searchableBody = bodyRes?.let { context.getString(it) }.orEmpty()
|
||||
return SettingsSearchEntry(
|
||||
category = category,
|
||||
title = title,
|
||||
summary = categoryLabel,
|
||||
keywords = listOf(title, categoryLabel, searchableBody, keywords).joinToString(" "),
|
||||
help = helpEntry
|
||||
)
|
||||
}
|
||||
|
||||
val isVulkan = remember(backendRenderer) { isVulkanBackend(backendRenderer) }
|
||||
|
||||
return remember(context, configuration, isPerApp, isVulkan, showCustomDriverOptions, showTurboModeOption) { buildList {
|
||||
add(entry(SettingsCategory.Core, R.string.settings_modules_mode, R.string.settings_modules_automatic_desc))
|
||||
add(entry(SettingsCategory.Core, R.string.settings_modules_list_title, keywords = "lle modules firmware"))
|
||||
add(entry(SettingsCategory.Cpu, R.string.settings_cpu_opt, R.string.settings_cpu_opt_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_backend, R.string.settings_gpu_backend_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_accuracy, R.string.settings_gpu_accuracy_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_vsync, R.string.settings_gpu_vsync_desc))
|
||||
if (isVulkan) {
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_disable_surface_sync, R.string.settings_gpu_disable_surface_sync_desc))
|
||||
}
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_async_pipeline, R.string.settings_gpu_async_pipeline_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_screen_filter, R.string.settings_gpu_screen_filter_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_resolution, R.string.settings_gpu_resolution_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_anisotropic, R.string.settings_gpu_anisotropic_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_export_textures, R.string.settings_gpu_export_textures_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_texture_format, R.string.settings_gpu_texture_format_desc, keywords = "png dds export textures"))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_import_textures, R.string.settings_gpu_import_textures_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_shader_cache, R.string.settings_gpu_shader_cache_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_spirv_shader, R.string.settings_gpu_spirv_shader_desc))
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_fps_hack, R.string.settings_gpu_fps_hack_desc, keywords = "fps framerate 30 60 game hack"))
|
||||
if (!isPerApp && showTurboModeOption) {
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_turbo_mode, R.string.settings_gpu_turbo_mode_desc, scope = SettingsScope.Global, keywords = "gpu clocks adreno thermal"))
|
||||
}
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_memory_mapping, R.string.settings_gpu_memory_mapping_desc))
|
||||
if (showCustomDriverOptions) {
|
||||
add(entry(SettingsCategory.Gpu, R.string.settings_gpu_custom_driver, R.string.settings_gpu_custom_driver_desc, keywords = "adreno driver zip"))
|
||||
}
|
||||
add(entry(SettingsCategory.Audio, R.string.settings_audio_backend, R.string.settings_audio_backend_desc))
|
||||
add(entry(SettingsCategory.Audio, R.string.settings_audio_volume, R.string.settings_audio_volume_desc))
|
||||
add(entry(SettingsCategory.Audio, R.string.settings_audio_ngs, R.string.settings_audio_ngs_desc))
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.Camera, R.string.settings_camera_front, R.string.settings_camera_front_desc, scope = SettingsScope.Global, keywords = "camera source solid color static image"))
|
||||
add(entry(SettingsCategory.Camera, R.string.settings_camera_back, R.string.settings_camera_back_desc, scope = SettingsScope.Global, keywords = "camera source solid color static image"))
|
||||
add(entry(SettingsCategory.Camera, R.string.settings_camera_image, R.string.settings_camera_image_desc, scope = SettingsScope.Global, keywords = "front back camera picture image"))
|
||||
add(entry(SettingsCategory.Camera, R.string.settings_camera_color, R.string.settings_camera_color_desc, scope = SettingsScope.Global, keywords = "front back camera color red green blue alpha"))
|
||||
}
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_pstv_mode, R.string.settings_system_pstv_mode_desc))
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_show_mode, R.string.settings_system_show_mode_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_demo_mode, R.string.settings_system_demo_mode_desc, scope = SettingsScope.Global))
|
||||
}
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_enter_button, R.string.settings_system_enter_button_desc))
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_language, R.string.settings_system_language_desc))
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_date_format, R.string.settings_system_date_format_desc))
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_time_format, R.string.settings_system_time_format_desc))
|
||||
add(entry(SettingsCategory.System, R.string.settings_system_ime_langs, R.string.settings_system_ime_langs_desc))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_show, R.string.settings_controls_show_desc, scope = SettingsScope.Global, keywords = "overlay touch buttons"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_hide_on_controller, R.string.settings_controls_hide_on_controller_desc, scope = SettingsScope.Global, keywords = "gamepad controller overlay hide"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_l2r2, R.string.settings_controls_l2r2_desc, scope = SettingsScope.Global, keywords = "touch triggers shoulder buttons"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_l3r3, R.string.settings_controls_l3r3_desc, scope = SettingsScope.Global, keywords = "touch stick clicks l3 r3"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_touch_switch, R.string.settings_controls_touch_switch_desc, scope = SettingsScope.Global, keywords = "front rear touchscreen switch"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_hide_toggle, R.string.settings_controls_hide_toggle_desc, scope = SettingsScope.Global, keywords = "hide overlay button toggle"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_scale, R.string.settings_controls_scale_desc, scope = SettingsScope.Global, keywords = "overlay size"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_opacity, R.string.settings_controls_opacity_desc, scope = SettingsScope.Global, keywords = "overlay transparency"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_mapping_buttons_title, R.string.settings_controls_mapping_buttons_desc, scope = SettingsScope.Global, keywords = "controller remap mapping buttons gamepad"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_mapping_axes_title, R.string.settings_controls_mapping_axes_desc, scope = SettingsScope.Global, keywords = "controller remap sticks triggers axes gamepad"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_analog_multiplier, R.string.settings_controls_analog_multiplier_desc, scope = SettingsScope.Global, keywords = "analog stick sensitivity multiplier"))
|
||||
add(entry(SettingsCategory.Controls, R.string.settings_controls_disable_motion, R.string.settings_controls_disable_motion_desc, scope = SettingsScope.Global, keywords = "gyro accelerometer motion controls"))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_psn_signed_in, R.string.settings_network_psn_signed_in_desc, scope = SettingsScope.PerApp))
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_http_enable, R.string.settings_network_http_enable_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_http_timeout_attempts, R.string.settings_network_http_timeout_attempts_desc, scope = SettingsScope.Global, keywords = "retry timeout http"))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_http_timeout_sleep, R.string.settings_network_http_timeout_sleep_desc, scope = SettingsScope.Global, keywords = "retry delay timeout http ms"))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_http_read_end_attempts, R.string.settings_network_http_read_end_attempts_desc, scope = SettingsScope.Global, keywords = "read end retry http"))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_http_read_end_sleep, R.string.settings_network_http_read_end_sleep_desc, scope = SettingsScope.Global, keywords = "read end delay http ms"))
|
||||
add(entry(SettingsCategory.Network, R.string.settings_network_adhoc_address, R.string.settings_network_adhoc_address_desc, scope = SettingsScope.Global, keywords = "adhoc subnet local address"))
|
||||
}
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_imports, R.string.settings_debug_log_imports_desc, scope = SettingsScope.Global, keywords = "import symbols hle"))
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_exports, R.string.settings_debug_log_exports_desc, scope = SettingsScope.Global, keywords = "export symbols hle"))
|
||||
}
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_active_shaders, R.string.settings_debug_log_active_shaders_desc))
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_log_uniforms, R.string.settings_debug_log_uniforms_desc))
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_color_surface, R.string.settings_debug_color_surface_desc))
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_validation_layer, R.string.settings_debug_validation_layer_desc))
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.Debug, R.string.settings_debug_dump_elfs, R.string.settings_debug_dump_elfs_desc, scope = SettingsScope.Global, keywords = "elf dump loaded code"))
|
||||
}
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_texture_cache, R.string.settings_emulator_texture_cache_desc))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_stretch_display, R.string.settings_emulator_stretch_display_desc))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_pixel_perfect, R.string.settings_emulator_pixel_perfect_desc))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_file_loading_delay, R.string.settings_emulator_file_loading_delay_desc))
|
||||
if (!isPerApp) {
|
||||
add(entry(SettingsCategory.Interface, R.string.settings_emulator_ui_language, R.string.settings_emulator_ui_language_desc, scope = SettingsScope.Global, keywords = "interface ui app language locale"))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_storage_folder, R.string.settings_emulator_storage_folder_desc, scope = SettingsScope.Global, keywords = "storage folder path data directory"))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_show_compile_shaders, R.string.settings_emulator_show_compile_shaders_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_check_updates, R.string.settings_emulator_check_updates_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_archive_log, R.string.settings_emulator_archive_log_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_log_compat_warn, R.string.settings_emulator_log_compat_warn_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_log_level, R.string.settings_emulator_log_level_desc, scope = SettingsScope.Global, keywords = "logging trace debug info warning error critical off"))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay, R.string.settings_emulator_perf_overlay_desc, scope = SettingsScope.Global))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay_detail, R.string.settings_emulator_perf_overlay_detail_desc, scope = SettingsScope.Global, keywords = "minimum low medium maximum"))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_perf_overlay_position, R.string.settings_emulator_perf_overlay_position_desc, scope = SettingsScope.Global, keywords = "top bottom left center right"))
|
||||
add(entry(SettingsCategory.Emulator, R.string.settings_emulator_screenshot_format, R.string.settings_emulator_screenshot_format_desc, scope = SettingsScope.Global, keywords = "jpeg png none"))
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsSearchResults(
|
||||
query: String,
|
||||
entries: List<SettingsSearchEntry>,
|
||||
onOpen: (SettingsCategory) -> Unit,
|
||||
onShowHelp: (SettingsHelpEntry) -> Unit
|
||||
) {
|
||||
val normalizedQuery = remember(query) { normalizeSettingsSearchToken(query) }
|
||||
val filtered = remember(entries, normalizedQuery) {
|
||||
entries.filter { entry ->
|
||||
normalizeSettingsSearchToken(entry.keywords).contains(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionCard(
|
||||
title = stringResource(R.string.settings_search_results),
|
||||
summary = if (filtered.isEmpty()) stringResource(R.string.settings_search_no_results) else null,
|
||||
help = null,
|
||||
onShowHelp = onShowHelp
|
||||
) {
|
||||
if (filtered.isEmpty()) {
|
||||
SettingsNote(text = stringResource(R.string.settings_search_no_results))
|
||||
} else {
|
||||
filtered.forEach { entry ->
|
||||
SettingsSearchResultRow(entry = entry, onOpen = onOpen, onShowHelp = onShowHelp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeSettingsSearchToken(value: String): String {
|
||||
return value
|
||||
.lowercase()
|
||||
.replace(Regex("[^a-z0-9]+"), " ")
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.vita3k.emulator.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
private val AppBackgroundColor = Color(0xFF111111)
|
||||
private val AppTopBarColor = Color(0xFF1C1C1C)
|
||||
|
||||
private val AppColorScheme = darkColorScheme(
|
||||
primary = Color(0xFFFF9800),
|
||||
onPrimary = Color(0xFF1A0D00),
|
||||
primaryContainer = Color(0xFF3D1F00),
|
||||
onPrimaryContainer = Color(0xFFFFD8A8),
|
||||
secondary = Color(0xFF9E9E9E),
|
||||
onSecondary = Color(0xFF1A1A1A),
|
||||
secondaryContainer = Color(0xFF2C2C2C),
|
||||
onSecondaryContainer = Color(0xFFE0E0E0),
|
||||
tertiary = Color(0xFFBDBDBD),
|
||||
onTertiary = Color(0xFF1A1A1A),
|
||||
background = AppBackgroundColor,
|
||||
onBackground = Color(0xFFE8E8E8),
|
||||
surface = AppTopBarColor,
|
||||
onSurface = Color(0xFFE8E8E8),
|
||||
surfaceVariant = Color(0xFF2A2A2A),
|
||||
onSurfaceVariant = Color(0xFFB0B0B0),
|
||||
surfaceContainerHigh = Color(0xFF222222),
|
||||
surfaceContainerHighest = Color(0xFF2A2A2A),
|
||||
outline = Color(0xFF444444),
|
||||
error = Color(0xFFFF6B6B),
|
||||
onError = Color(0xFF1A0000)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@Suppress("DEPRECATION")
|
||||
fun Vita3KTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
val transparentSystemBar = Color.Transparent.toArgb()
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
window.statusBarColor = transparentSystemBar
|
||||
window.navigationBarColor = transparentSystemBar
|
||||
WindowInsetsControllerCompat(window, view).apply {
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isStatusBarContrastEnforced = false
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = AppColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A fraction in [0, 1] that is used uniformly for all modal scrim / dialog dim effects.
|
||||
* Apply it to:
|
||||
* - ModalBottomSheet via `scrimColor = Color.Black.copy(alpha = SCRIM_ALPHA)`
|
||||
* - Dialog / AlertDialog via [ApplyDialogDim]
|
||||
*/
|
||||
const val SCRIM_ALPHA = 0.4f
|
||||
|
||||
/**
|
||||
* Must be called inside a Dialog or AlertDialog composable scope to reduce the system dim amount
|
||||
* to the app-wide [SCRIM_ALPHA] value. Place it at the beginning of the dialog's content lambda.
|
||||
*/
|
||||
@Composable
|
||||
fun ApplyDialogDim() {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
// Check both the view itself and its parent — Compose's Dialog sets DialogWindowProvider
|
||||
// on the DialogLayout (the direct parent of the composable view tree), but the exact
|
||||
// position in the hierarchy can vary by API level and Material3 version.
|
||||
DisposableEffect(Unit) {
|
||||
val provider = (view as? DialogWindowProvider)
|
||||
?: (view.parent as? DialogWindowProvider)
|
||||
provider?.window?.setDimAmount(SCRIM_ALPHA)
|
||||
onDispose {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.vita3k.emulator.R
|
||||
|
||||
enum class AppActionGroup(
|
||||
@StringRes val labelResId: Int
|
||||
) {
|
||||
DELETE(R.string.action_group_delete),
|
||||
OTHER(R.string.action_group_other)
|
||||
}
|
||||
|
||||
enum class AppAction(
|
||||
@StringRes val labelResId: Int,
|
||||
val destructive: Boolean,
|
||||
val group: AppActionGroup,
|
||||
val maskBit: Int
|
||||
) {
|
||||
DELETE_APPLICATION(R.string.app_action_application, true, AppActionGroup.DELETE, 1 shl 0),
|
||||
DELETE_SAVE_DATA(R.string.app_action_save_data, true, AppActionGroup.DELETE, 1 shl 1),
|
||||
DELETE_PATCH(R.string.app_action_patch, true, AppActionGroup.DELETE, 1 shl 2),
|
||||
DELETE_DLC(R.string.app_action_dlc, true, AppActionGroup.DELETE, 1 shl 3),
|
||||
DELETE_LICENSE(R.string.app_action_license, true, AppActionGroup.DELETE, 1 shl 4),
|
||||
DELETE_SHADER_CACHE(R.string.app_action_shader_cache, true, AppActionGroup.DELETE, 1 shl 5),
|
||||
DELETE_SHADER_LOG(R.string.app_action_shader_log, true, AppActionGroup.DELETE, 1 shl 6),
|
||||
DELETE_EXPORT_TEXTURES(R.string.app_action_export_textures, true, AppActionGroup.DELETE, 1 shl 7),
|
||||
DELETE_IMPORT_TEXTURES(R.string.app_action_import_textures, true, AppActionGroup.DELETE, 1 shl 8),
|
||||
RESET_LAST_PLAYED(R.string.app_action_reset_last_played, false, AppActionGroup.OTHER, 1 shl 9);
|
||||
|
||||
companion object {
|
||||
val all: Set<AppAction> = entries.toSet()
|
||||
|
||||
fun fromMask(mask: Int): Set<AppAction> {
|
||||
return entries.filterTo(linkedSetOf()) { action ->
|
||||
(mask and action.maskBit) != 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import org.vita3k.emulator.NativeLib
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.FirmwareInstallState
|
||||
import org.vita3k.emulator.data.AppInfo
|
||||
import org.vita3k.emulator.data.AppRepository
|
||||
import org.vita3k.emulator.data.SortOption
|
||||
import org.vita3k.emulator.data.UpdateCheckResult
|
||||
import org.vita3k.emulator.data.UpdateCheckStatus
|
||||
import org.vita3k.emulator.data.ViewMode
|
||||
|
||||
class AppsListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun str(@StringRes id: Int, vararg args: Any): String =
|
||||
getApplication<Application>().getString(id, *args)
|
||||
|
||||
private fun qty(@PluralsRes id: Int, quantity: Int, vararg args: Any): String =
|
||||
getApplication<Application>().resources.getQuantityString(id, quantity, *args)
|
||||
|
||||
private val _allApps = mutableListOf<AppInfo>()
|
||||
val apps = mutableStateListOf<AppInfo>()
|
||||
private val _selectedAppIds = mutableStateListOf<String>()
|
||||
// Stable snapshot of selected IDs avoids .toSet() allocation on every recomposition.
|
||||
val selectedAppIds: Set<String> by derivedStateOf { _selectedAppIds.toHashSet() }
|
||||
|
||||
private val availableActionsByTitleId = mutableStateMapOf<String, Set<AppAction>>()
|
||||
private var compatSyncStarted = false
|
||||
private var compatSyncInProgress = false
|
||||
private var startupUpdateCheckStarted = false
|
||||
|
||||
var initialized by mutableStateOf(false)
|
||||
private set
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
var firmwareInstallState by mutableStateOf<FirmwareInstallState>(FirmwareInstallState.Missing)
|
||||
private set
|
||||
var actionInProgress by mutableStateOf(false)
|
||||
private set
|
||||
var actionResultMessage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var selectionMode by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var searchQuery by mutableStateOf("")
|
||||
private set
|
||||
var sortOption by mutableStateOf(SortOption.TITLE)
|
||||
private set
|
||||
var viewMode by mutableStateOf(ViewMode.LIST)
|
||||
private set
|
||||
var appVersion by mutableStateOf("")
|
||||
private set
|
||||
var updateCheckInProgress by mutableStateOf(false)
|
||||
private set
|
||||
var updateCheckResult by mutableStateOf<UpdateCheckResult?>(null)
|
||||
private set
|
||||
|
||||
// --- App info dialog ---
|
||||
var infoDialogApp by mutableStateOf<AppInfo?>(null)
|
||||
private set
|
||||
var infoAppInstallSizeBytes by mutableStateOf(-1L)
|
||||
private set
|
||||
|
||||
fun initialize(storagePath: String) {
|
||||
if (initialized || loading) return
|
||||
|
||||
loading = true
|
||||
viewModelScope.launch {
|
||||
appVersion = AppRepository.getAppVersion()
|
||||
val success = AppRepository.initialize(storagePath)
|
||||
initialized = success
|
||||
if (success) {
|
||||
firmwareInstallState = AppRepository.getFirmwareInstallState()
|
||||
loadApps()
|
||||
startCompatibilitySync()
|
||||
startUpdateCheckIfEnabled()
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshAppsList(syncCompatibility: Boolean = false) {
|
||||
if (!initialized) return
|
||||
viewModelScope.launch {
|
||||
loading = true
|
||||
AppRepository.refreshAppsList()
|
||||
if (syncCompatibility) {
|
||||
syncCompatibilityDatabase()
|
||||
}
|
||||
firmwareInstallState = AppRepository.getFirmwareInstallState()
|
||||
loadApps()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadAppsList() {
|
||||
if (!initialized) return
|
||||
viewModelScope.launch {
|
||||
loading = true
|
||||
firmwareInstallState = AppRepository.getFirmwareInstallState()
|
||||
loadApps()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearch(query: String) {
|
||||
searchQuery = query
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
fun setSort(option: SortOption) {
|
||||
sortOption = option
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
fun toggleViewMode() {
|
||||
viewMode = if (viewMode == ViewMode.LIST) ViewMode.GRID else ViewMode.LIST
|
||||
}
|
||||
|
||||
fun dismissActionResult() {
|
||||
actionResultMessage = null
|
||||
}
|
||||
|
||||
fun dismissUpdateCheckResult() {
|
||||
updateCheckResult = null
|
||||
}
|
||||
|
||||
// Returns the actions available for a title, defaulting to emptySet() until the
|
||||
// async fetch completes (prevents showing all actions as enabled before data arrives).
|
||||
fun getAvailableActions(titleId: String): Set<AppAction> {
|
||||
return availableActionsByTitleId[titleId] ?: emptySet()
|
||||
}
|
||||
|
||||
fun prepareAppActions(app: AppInfo) {
|
||||
if (!initialized) return
|
||||
viewModelScope.launch {
|
||||
availableActionsByTitleId[app.titleId] = AppRepository.getAvailableAppActions(app.titleId)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSelectionMode(enabled: Boolean) {
|
||||
selectionMode = enabled
|
||||
if (!enabled) {
|
||||
_selectedAppIds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAppSelection(app: AppInfo) {
|
||||
if (!selectionMode) selectionMode = true
|
||||
if (_selectedAppIds.contains(app.titleId)) {
|
||||
_selectedAppIds.remove(app.titleId)
|
||||
} else {
|
||||
_selectedAppIds.add(app.titleId)
|
||||
}
|
||||
if (_selectedAppIds.isEmpty()) selectionMode = false
|
||||
}
|
||||
|
||||
fun selectAllVisibleApps() {
|
||||
if (apps.isEmpty()) {
|
||||
selectionMode = false
|
||||
_selectedAppIds.clear()
|
||||
return
|
||||
}
|
||||
selectionMode = true
|
||||
_selectedAppIds.clear()
|
||||
_selectedAppIds.addAll(apps.map { it.titleId })
|
||||
}
|
||||
|
||||
fun runBatchDeleteSelected() {
|
||||
if (!initialized || actionInProgress || _selectedAppIds.isEmpty()) return
|
||||
|
||||
runActionWithProgress {
|
||||
var successCount = 0
|
||||
val selected = _selectedAppIds.toList()
|
||||
for (titleId in selected) {
|
||||
if (AppRepository.runAppAction(titleId, AppAction.DELETE_APPLICATION)) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
refreshAppsList()
|
||||
_selectedAppIds.clear()
|
||||
selectionMode = false
|
||||
if (successCount == selected.size)
|
||||
qty(R.plurals.batch_delete_success, successCount, successCount)
|
||||
else
|
||||
str(R.string.batch_delete_partial, successCount, selected.size)
|
||||
}
|
||||
}
|
||||
|
||||
fun runAppAction(app: AppInfo, action: AppAction) {
|
||||
if (!initialized || actionInProgress) return
|
||||
|
||||
runActionWithProgress {
|
||||
val success = AppRepository.runAppAction(app.titleId, action)
|
||||
if (success) {
|
||||
if (action.group == AppActionGroup.DELETE) refreshAppsList()
|
||||
availableActionsByTitleId[app.titleId] = AppRepository.getAvailableAppActions(app.titleId)
|
||||
str(R.string.action_deleted_success, str(action.labelResId), app.title)
|
||||
} else {
|
||||
str(R.string.action_deleted_failed, str(action.labelResId), app.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showAppInfo(app: AppInfo) {
|
||||
infoDialogApp = app
|
||||
infoAppInstallSizeBytes = -1L
|
||||
viewModelScope.launch {
|
||||
infoAppInstallSizeBytes = AppRepository.getAppInstallSize(app.titleId)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAppInfo() {
|
||||
infoDialogApp = null
|
||||
}
|
||||
|
||||
fun checkForUpdates(manual: Boolean = true) {
|
||||
if (updateCheckInProgress) {
|
||||
if (manual) {
|
||||
updateCheckResult = UpdateCheckResult(
|
||||
status = UpdateCheckStatus.Failed,
|
||||
message = str(R.string.updates_check_in_progress),
|
||||
currentDisplayVersion = appVersion
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateCheckInProgress = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = AppRepository.checkForUpdates(
|
||||
appVersion = appVersion,
|
||||
officialBuild = NativeLib.isOfficialBuild()
|
||||
)
|
||||
if (manual || result.status == UpdateCheckStatus.UpdateAvailable || result.status == UpdateCheckStatus.CustomBuildCanUpdate) {
|
||||
updateCheckResult = result
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
if (manual) {
|
||||
updateCheckResult = UpdateCheckResult(
|
||||
status = UpdateCheckStatus.Failed,
|
||||
message = str(R.string.install_error_generic, e.message ?: ""),
|
||||
currentDisplayVersion = appVersion
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
updateCheckInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runActionWithProgress(block: suspend () -> String) {
|
||||
actionInProgress = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
actionResultMessage = block()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
actionResultMessage = str(R.string.install_error_generic, e.message ?: "")
|
||||
} finally {
|
||||
actionInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadApps() {
|
||||
val list = AppRepository.getAppList()
|
||||
_allApps.clear()
|
||||
_allApps.addAll(list)
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
private fun startCompatibilitySync() {
|
||||
if (compatSyncStarted) return
|
||||
|
||||
compatSyncStarted = true
|
||||
viewModelScope.launch {
|
||||
if (syncCompatibilityDatabase()) {
|
||||
loadApps()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startUpdateCheckIfEnabled() {
|
||||
if (startupUpdateCheckStarted) return
|
||||
|
||||
startupUpdateCheckStarted = true
|
||||
viewModelScope.launch {
|
||||
val enabled = runCatching { NativeLib.getGlobalConfig().checkForUpdatesMode != 0 }.getOrDefault(true)
|
||||
if (enabled) {
|
||||
checkForUpdates(manual = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncCompatibilityDatabase(): Boolean {
|
||||
if (compatSyncInProgress) return false
|
||||
|
||||
compatSyncInProgress = true
|
||||
return try {
|
||||
AppRepository.syncCompatibilityDatabase()
|
||||
} finally {
|
||||
compatSyncInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFilterAndSort() {
|
||||
val filtered = if (searchQuery.isBlank()) {
|
||||
_allApps
|
||||
} else {
|
||||
_allApps.filter {
|
||||
it.title.contains(searchQuery, ignoreCase = true) ||
|
||||
it.titleId.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
val sorted = when (sortOption) {
|
||||
SortOption.TITLE -> filtered.sortedBy { it.title.lowercase() }
|
||||
SortOption.LAST_PLAYED -> filtered.sortedByDescending { it.lastPlayed }
|
||||
SortOption.COMPATIBILITY -> filtered.sortedByDescending { it.compatibility.value }
|
||||
}
|
||||
|
||||
apps.clear()
|
||||
apps.addAll(sorted)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import org.vita3k.emulator.Emulator
|
||||
import org.vita3k.emulator.NativeLib
|
||||
import org.vita3k.emulator.data.NativeImeState
|
||||
import org.vita3k.emulator.overlay.DEFAULT_OVERLAY_MASK
|
||||
import org.vita3k.emulator.overlay.OverlayConfig
|
||||
import org.vita3k.emulator.overlay.OverlayConfigStore
|
||||
|
||||
data class EmulationSessionUiState(
|
||||
val titleId: String = "",
|
||||
val gameTitle: String = "",
|
||||
val showMenu: Boolean = false,
|
||||
val isPaused: Boolean = false,
|
||||
val isEditingControls: Boolean = false,
|
||||
val showExitConfirmation: Boolean = false,
|
||||
val controlsVisible: Boolean = true,
|
||||
val overlayMask: Int = DEFAULT_OVERLAY_MASK,
|
||||
val l2r2Visible: Boolean = false,
|
||||
val l3r3Visible: Boolean = false,
|
||||
val touchSwitchVisible: Boolean = true,
|
||||
val hideToggleVisible: Boolean = false,
|
||||
val overlayScale: Int = 100,
|
||||
val overlayOpacity: Int = 100,
|
||||
val hideOverlayWhenControllerConnected: Boolean = true,
|
||||
val controllerConnected: Boolean = false,
|
||||
val statusMessage: String? = null
|
||||
)
|
||||
|
||||
class EmulationSessionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private var initialized = false
|
||||
|
||||
var uiState by mutableStateOf(EmulationSessionUiState())
|
||||
private set
|
||||
|
||||
var imeState by mutableStateOf<NativeImeState?>(null)
|
||||
private set
|
||||
|
||||
fun initialize(titleId: String?, gameTitle: String?) {
|
||||
if (initialized && uiState.titleId == titleId.orEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val overlayConfig = OverlayConfigStore.load(getApplication())
|
||||
uiState = uiState.withOverlayConfig(overlayConfig).copy(
|
||||
titleId = titleId.orEmpty(),
|
||||
gameTitle = gameTitle.orEmpty()
|
||||
)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
fun updateRunningApp(titleId: String?, gameTitle: String?) {
|
||||
uiState = uiState.copy(
|
||||
titleId = titleId.orEmpty(),
|
||||
gameTitle = gameTitle.orEmpty()
|
||||
)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
fun applyOverlayState(emulator: Emulator) {
|
||||
emulator.setControllerOverlayScale(uiState.overlayScale / 100f)
|
||||
emulator.setControllerOverlayOpacity(uiState.overlayOpacity)
|
||||
val visibleMask = when {
|
||||
uiState.showMenu -> 0
|
||||
uiState.isEditingControls -> activeOverlayMask()
|
||||
uiState.hideOverlayWhenControllerConnected && uiState.controllerConnected -> 0
|
||||
else -> uiState.overlayMask
|
||||
}
|
||||
emulator.setControllerOverlayState(visibleMask, uiState.isEditingControls, false)
|
||||
}
|
||||
|
||||
fun handleBackPressed(emulator: Emulator): Boolean {
|
||||
return when {
|
||||
uiState.isEditingControls -> {
|
||||
finishControlsEditor(emulator)
|
||||
true
|
||||
}
|
||||
uiState.showMenu -> {
|
||||
closeMenu(emulator)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
openMenu(emulator, pauseGame = false)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun openMenu(emulator: Emulator, pauseGame: Boolean = true) {
|
||||
if (uiState.showMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
emulator.prepareImeForPauseMenu()
|
||||
emulator.ensurePauseMenuOnTop()
|
||||
releaseInputs(emulator)
|
||||
val paused = if (pauseGame) {
|
||||
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
|
||||
return
|
||||
}
|
||||
true
|
||||
} else {
|
||||
isNativeAppPaused()
|
||||
}
|
||||
if (!setNativeInputIntercepted(true)) {
|
||||
if (pauseGame) {
|
||||
setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
uiState = uiState.copy(
|
||||
showMenu = true,
|
||||
isPaused = paused,
|
||||
isEditingControls = false,
|
||||
showExitConfirmation = false,
|
||||
statusMessage = null
|
||||
)
|
||||
emulator.setControllerOverlayState(0, false, false)
|
||||
}
|
||||
|
||||
fun closeMenu(emulator: Emulator) {
|
||||
releaseInputs(emulator)
|
||||
if (uiState.isPaused) {
|
||||
setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, false)
|
||||
}
|
||||
setNativeInputIntercepted(false)
|
||||
uiState = uiState.copy(
|
||||
showMenu = false,
|
||||
isPaused = false,
|
||||
isEditingControls = false,
|
||||
showExitConfirmation = false,
|
||||
statusMessage = null
|
||||
)
|
||||
applyOverlayState(emulator)
|
||||
emulator.restoreImeAfterPauseMenu()
|
||||
}
|
||||
|
||||
fun togglePause(emulator: Emulator) {
|
||||
if (uiState.isPaused) {
|
||||
closeMenu(emulator)
|
||||
} else {
|
||||
releaseInputs(emulator)
|
||||
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
|
||||
return
|
||||
}
|
||||
uiState = uiState.copy(isPaused = true, statusMessage = null)
|
||||
emulator.setControllerOverlayState(0, false, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestExit() {
|
||||
uiState = uiState.copy(showExitConfirmation = true)
|
||||
}
|
||||
|
||||
fun dismissExitConfirmation() {
|
||||
uiState = uiState.copy(showExitConfirmation = false)
|
||||
}
|
||||
|
||||
fun confirmExit(emulator: Emulator) {
|
||||
releaseInputs(emulator)
|
||||
uiState = uiState.copy(showExitConfirmation = false, statusMessage = null)
|
||||
emulator.requestNativeQuit()
|
||||
}
|
||||
|
||||
fun setControlsVisible(emulator: Emulator, visible: Boolean) {
|
||||
updateOverlayConfig(emulator) { it.withControlsVisible(visible) }
|
||||
}
|
||||
|
||||
fun setL2R2Visible(emulator: Emulator, visible: Boolean) {
|
||||
updateOverlayConfig(emulator) { it.withL2R2Visible(visible) }
|
||||
}
|
||||
|
||||
fun setL3R3Visible(emulator: Emulator, visible: Boolean) {
|
||||
updateOverlayConfig(emulator) { it.withL3R3Visible(visible) }
|
||||
}
|
||||
|
||||
fun setTouchSwitchVisible(emulator: Emulator, visible: Boolean) {
|
||||
updateOverlayConfig(emulator) { it.withTouchSwitchVisible(visible) }
|
||||
}
|
||||
|
||||
fun setHideToggleVisible(emulator: Emulator, visible: Boolean) {
|
||||
updateOverlayConfig(emulator) { it.withHideToggleVisible(visible) }
|
||||
}
|
||||
|
||||
fun setOverlayScale(emulator: Emulator, value: Int) {
|
||||
updateOverlayConfig(emulator) { it.copy(overlayScale = value) }
|
||||
}
|
||||
|
||||
fun setOverlayOpacity(emulator: Emulator, value: Int) {
|
||||
updateOverlayConfig(emulator) { it.copy(overlayOpacity = value) }
|
||||
}
|
||||
|
||||
fun setControllerConnected(emulator: Emulator, connected: Boolean) {
|
||||
if (uiState.controllerConnected == connected) {
|
||||
return
|
||||
}
|
||||
|
||||
uiState = uiState.copy(controllerConnected = connected)
|
||||
applyOverlayState(emulator)
|
||||
}
|
||||
|
||||
fun startControlsEditor(emulator: Emulator) {
|
||||
val mask = activeOverlayMask()
|
||||
releaseInputs(emulator)
|
||||
if (!setNativePauseReasonEnabled(NativeLib.PAUSE_REASON_MENU, true)) {
|
||||
return
|
||||
}
|
||||
persistOverlayConfig(currentOverlayConfig().copy(overlayMask = mask))
|
||||
uiState = uiState.copy(
|
||||
showMenu = false,
|
||||
isPaused = true,
|
||||
isEditingControls = true,
|
||||
controlsVisible = true,
|
||||
overlayMask = mask,
|
||||
showExitConfirmation = false,
|
||||
statusMessage = null
|
||||
)
|
||||
emulator.setControllerOverlayState(mask, true, false)
|
||||
}
|
||||
|
||||
fun finishControlsEditor(emulator: Emulator) {
|
||||
releaseInputs(emulator)
|
||||
uiState = uiState.copy(
|
||||
showMenu = true,
|
||||
isPaused = true,
|
||||
isEditingControls = false,
|
||||
statusMessage = null
|
||||
)
|
||||
emulator.ensurePauseMenuOnTop()
|
||||
emulator.setControllerOverlayState(0, false, false)
|
||||
}
|
||||
|
||||
fun resetControlsLayout(emulator: Emulator) {
|
||||
emulator.setControllerOverlayState(activeOverlayMask(), uiState.isEditingControls, true)
|
||||
uiState = uiState.copy(statusMessage = null)
|
||||
}
|
||||
|
||||
fun clearStatusMessage() {
|
||||
uiState = uiState.copy(statusMessage = null)
|
||||
}
|
||||
|
||||
fun showStatusMessage(message: String) {
|
||||
uiState = uiState.copy(statusMessage = message)
|
||||
}
|
||||
|
||||
fun updateImeState(state: NativeImeState?) {
|
||||
if (imeState == state) {
|
||||
return
|
||||
}
|
||||
|
||||
imeState = state
|
||||
}
|
||||
|
||||
fun currentOverlayConfig(): OverlayConfig {
|
||||
return OverlayConfig(
|
||||
overlayMask = uiState.overlayMask,
|
||||
overlayScale = uiState.overlayScale,
|
||||
overlayOpacity = uiState.overlayOpacity,
|
||||
hideOverlayWhenControllerConnected = uiState.hideOverlayWhenControllerConnected
|
||||
).normalized()
|
||||
}
|
||||
|
||||
fun updateOverlayConfig(emulator: Emulator, transform: (OverlayConfig) -> OverlayConfig) {
|
||||
val updatedConfig = transform(currentOverlayConfig()).normalized()
|
||||
persistOverlayConfig(updatedConfig)
|
||||
uiState = uiState.withOverlayConfig(updatedConfig)
|
||||
applyOverlayState(emulator)
|
||||
}
|
||||
|
||||
private fun activeOverlayMask(): Int {
|
||||
return if (uiState.overlayMask == 0) DEFAULT_OVERLAY_MASK else uiState.overlayMask
|
||||
}
|
||||
|
||||
private fun persistOverlayConfig(config: OverlayConfig) {
|
||||
OverlayConfigStore.save(getApplication(), config)
|
||||
}
|
||||
|
||||
private fun releaseInputs(emulator: Emulator) {
|
||||
runCatching { emulator.releaseControllerOverlayInputs() }
|
||||
}
|
||||
|
||||
private fun setNativePauseReasonEnabled(reasonMask: Int, enabled: Boolean): Boolean {
|
||||
return runCatching { NativeLib.setPauseReasonEnabled(reasonMask, enabled) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun setNativeInputIntercepted(intercepted: Boolean): Boolean {
|
||||
return runCatching { NativeLib.setInputIntercepted(intercepted) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun isNativeAppPaused(): Boolean {
|
||||
return runCatching { NativeLib.isAppPaused() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun EmulationSessionUiState.withOverlayConfig(config: OverlayConfig): EmulationSessionUiState {
|
||||
val normalized = config.normalized()
|
||||
return copy(
|
||||
controlsVisible = normalized.controlsVisible,
|
||||
overlayMask = normalized.overlayMask,
|
||||
l2r2Visible = normalized.l2r2Visible,
|
||||
l3r3Visible = normalized.l3r3Visible,
|
||||
touchSwitchVisible = normalized.touchSwitchVisible,
|
||||
hideToggleVisible = normalized.hideToggleVisible,
|
||||
overlayScale = normalized.overlayScale,
|
||||
overlayOpacity = normalized.overlayOpacity,
|
||||
hideOverlayWhenControllerConnected = normalized.hideOverlayWhenControllerConnected
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.vita3k.emulator.InstallServiceController
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.InstallRepository
|
||||
import java.io.File
|
||||
|
||||
enum class InstallType {
|
||||
FIRMWARE, PKG, ARCHIVE, LICENSE
|
||||
}
|
||||
|
||||
enum class InstallResultStatus {
|
||||
SUCCESS, PARTIAL, ERROR
|
||||
}
|
||||
|
||||
data class DeleteSourceOption(
|
||||
val label: String,
|
||||
val paths: List<String>
|
||||
)
|
||||
|
||||
data class InstallResult(
|
||||
val status: InstallResultStatus,
|
||||
val message: String,
|
||||
val deleteOptions: List<DeleteSourceOption> = emptyList()
|
||||
)
|
||||
|
||||
class InstallViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun str(@StringRes id: Int, vararg args: Any): String =
|
||||
getApplication<Application>().getString(id, *args)
|
||||
|
||||
var showInstallSheet by mutableStateOf(false)
|
||||
private set
|
||||
var installing by mutableStateOf(false)
|
||||
private set
|
||||
var progress by mutableStateOf(0)
|
||||
private set
|
||||
var statusMessage by mutableStateOf("")
|
||||
private set
|
||||
var installResult by mutableStateOf<InstallResult?>(null)
|
||||
private set
|
||||
var showPkgLicenseDialog by mutableStateOf(false)
|
||||
private set
|
||||
var completedInstallToken by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
private var pendingPkgPath: String? = null
|
||||
private var pendingPkgLicensePath: String? = null
|
||||
private var lastObservedCompletionId = 0L
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
InstallServiceController.state.collectLatest { state ->
|
||||
if (state.operationId == 0L) {
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
installing = state.installing
|
||||
progress = state.progress
|
||||
statusMessage = state.statusMessage
|
||||
installResult = state.installResult
|
||||
|
||||
if (!state.installing && state.installResult != null && state.operationId != lastObservedCompletionId) {
|
||||
lastObservedCompletionId = state.operationId
|
||||
completedInstallToken = state.operationId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showSheet() {
|
||||
showInstallSheet = true
|
||||
}
|
||||
|
||||
fun hideSheet() {
|
||||
showInstallSheet = false
|
||||
}
|
||||
|
||||
fun confirmInstallResult(selectedDeleteOptions: List<DeleteSourceOption>) {
|
||||
val paths = selectedDeleteOptions
|
||||
.flatMap(DeleteSourceOption::paths)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
|
||||
installResult = null
|
||||
InstallServiceController.clearResult()
|
||||
if (paths.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
paths.forEach { path ->
|
||||
runCatching { File(path).delete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun installFirmware(path: String) {
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startFirmware(getApplication(), path)
|
||||
}
|
||||
|
||||
fun onPkgPicked(path: String) {
|
||||
InstallServiceController.clearResult()
|
||||
pendingPkgLicensePath = null
|
||||
beginInstall(R.string.install_status_preparing)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
pendingPkgPath = path
|
||||
val autoZrif = InstallRepository.findPkgZrif(path)
|
||||
installing = false
|
||||
if (autoZrif.isNotEmpty()) {
|
||||
confirmPkgInstall(autoZrif)
|
||||
} else {
|
||||
showPkgLicenseDialog = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pendingPkgPath = null
|
||||
pendingPkgLicensePath = null
|
||||
installing = false
|
||||
installResult = InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = str(R.string.install_error_generic, e.message ?: ""),
|
||||
deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_package_file, path)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPkgLicenseFilePicked(path: String) {
|
||||
pendingPkgLicensePath = path
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val zrif = InstallRepository.convertRifToZrif(path)
|
||||
if (zrif.isEmpty()) {
|
||||
installResult = InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = str(R.string.install_error_read_license),
|
||||
deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_package_file, pendingPkgPath),
|
||||
deleteOption(R.string.install_delete_license_file, path)
|
||||
)
|
||||
)
|
||||
cancelPkgInstall()
|
||||
return@launch
|
||||
}
|
||||
|
||||
confirmPkgInstall(zrif)
|
||||
} catch (e: Exception) {
|
||||
installResult = InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = str(R.string.install_error_generic, e.message ?: ""),
|
||||
deleteOptions = sourceDeleteOptions(
|
||||
deleteOption(R.string.install_delete_package_file, pendingPkgPath),
|
||||
deleteOption(R.string.install_delete_license_file, path)
|
||||
)
|
||||
)
|
||||
cancelPkgInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmPkgInstall(zrif: String) {
|
||||
showPkgLicenseDialog = false
|
||||
val path = pendingPkgPath ?: run {
|
||||
installResult = InstallResult(
|
||||
status = InstallResultStatus.ERROR,
|
||||
message = str(R.string.install_error_pkg_path_lost)
|
||||
)
|
||||
pendingPkgLicensePath = null
|
||||
return
|
||||
}
|
||||
val licensePath = pendingPkgLicensePath
|
||||
pendingPkgPath = null
|
||||
pendingPkgLicensePath = null
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startPkg(getApplication(), path, zrif, licensePath)
|
||||
}
|
||||
|
||||
fun cancelPkgInstall() {
|
||||
showPkgLicenseDialog = false
|
||||
pendingPkgPath = null
|
||||
pendingPkgLicensePath = null
|
||||
}
|
||||
|
||||
fun installArchive(path: String) {
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startArchive(getApplication(), path)
|
||||
}
|
||||
|
||||
fun installArchiveFolder(paths: List<String>) {
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startArchiveFolder(getApplication(), paths)
|
||||
}
|
||||
|
||||
fun installLicense(path: String) {
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startLicenseFile(getApplication(), path)
|
||||
}
|
||||
|
||||
fun installLicenseFromZrif(zrif: String) {
|
||||
InstallServiceController.clearResult()
|
||||
InstallServiceController.startLicenseZrif(getApplication(), zrif)
|
||||
}
|
||||
|
||||
private fun beginInstall(@StringRes statusResId: Int) {
|
||||
installResult = null
|
||||
installing = true
|
||||
progress = 0
|
||||
statusMessage = str(statusResId)
|
||||
}
|
||||
|
||||
private fun deleteOption(@StringRes labelResId: Int, path: String?): DeleteSourceOption? =
|
||||
deleteOption(labelResId, listOfNotNull(path))
|
||||
|
||||
private fun deleteOption(
|
||||
@StringRes labelResId: Int,
|
||||
paths: List<String>
|
||||
): DeleteSourceOption? {
|
||||
val normalized = paths
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
if (normalized.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return DeleteSourceOption(
|
||||
label = str(labelResId),
|
||||
paths = normalized
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceDeleteOptions(vararg options: DeleteSourceOption?): List<DeleteSourceOption> =
|
||||
options.filterNotNull()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.vita3k.emulator.NativeLib
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.AppStorage
|
||||
import org.vita3k.emulator.data.CustomDriverLoadStatus
|
||||
import org.vita3k.emulator.data.CustomDriverSupportInfo
|
||||
import org.vita3k.emulator.data.EmulatorConfig
|
||||
import org.vita3k.emulator.data.RestartRequiredSetting
|
||||
import org.vita3k.emulator.data.SettingsRepository
|
||||
import org.vita3k.emulator.data.SettingsSnapshot
|
||||
import org.vita3k.emulator.data.UiLanguages
|
||||
|
||||
data class SettingsOperationResult(
|
||||
val message: String,
|
||||
val isError: Boolean
|
||||
)
|
||||
|
||||
private const val GLOBAL_SETTINGS_ROUTE_KEY = "__global__"
|
||||
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun initialConfig(): EmulatorConfig =
|
||||
if (NativeLib.isInitialized()) NativeLib.getDefaultConfig() else EmulatorConfig()
|
||||
|
||||
private fun str(resId: Int, vararg args: Any): String =
|
||||
getApplication<Application>().getString(resId, *args)
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var memoryMappingRefreshJob: Job? = null
|
||||
private var loadRequestId = 0
|
||||
private var memoryMappingRefreshRequestId = 0
|
||||
private var activeLoadRouteKey: String? = null
|
||||
private var loadedRouteKey by mutableStateOf<String?>(null)
|
||||
|
||||
var config by mutableStateOf(initialConfig())
|
||||
private set
|
||||
|
||||
var currentStoragePath by mutableStateOf(
|
||||
if (NativeLib.isInitialized()) NativeLib.getCurrentEmulatorPath() else AppStorage.defaultStoragePath(getApplication())
|
||||
)
|
||||
private set
|
||||
|
||||
val defaultStoragePath: String = AppStorage.defaultStoragePath(getApplication())
|
||||
|
||||
private var originalConfig by mutableStateOf(config.copy())
|
||||
private var originalModulesList: List<Pair<String, Boolean>> = emptyList()
|
||||
|
||||
var titleId: String? = null
|
||||
private set
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var saving by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var hasCustomConfig by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var installedCustomDrivers by mutableStateOf<List<String>>(emptyList())
|
||||
private set
|
||||
|
||||
var showCustomDriverOptions by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
var customDriverBusy by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var operationResult by mutableStateOf<SettingsOperationResult?>(null)
|
||||
private set
|
||||
|
||||
var supportedMemoryMappingMask by mutableStateOf(1)
|
||||
private set
|
||||
|
||||
var customDriverLoadStatus by mutableStateOf(CustomDriverLoadStatus.Default)
|
||||
private set
|
||||
|
||||
var availableCameras by mutableStateOf<List<String>>(emptyList())
|
||||
private set
|
||||
|
||||
var availableAdhocAddresses by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private set
|
||||
|
||||
val isDirty: Boolean
|
||||
get() = config != originalConfig
|
||||
|
||||
var modulesList by mutableStateOf<List<Pair<String, Boolean>>>(emptyList())
|
||||
private set
|
||||
|
||||
var modulesSearch by mutableStateOf("")
|
||||
private set
|
||||
|
||||
fun isLoaded(titleId: String?): Boolean = loadedRouteKey == routeKey(titleId)
|
||||
|
||||
fun preloadGlobalSettings() {
|
||||
load(titleId = null)
|
||||
}
|
||||
|
||||
fun load(titleId: String?, force: Boolean = false) {
|
||||
val routeKey = routeKey(titleId)
|
||||
this.titleId = titleId
|
||||
if (!force && loading && activeLoadRouteKey == routeKey) {
|
||||
return
|
||||
}
|
||||
|
||||
loadJob?.cancel()
|
||||
val requestId = ++loadRequestId
|
||||
activeLoadRouteKey = routeKey
|
||||
loading = true
|
||||
loadJob = viewModelScope.launch {
|
||||
try {
|
||||
val snapshot = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.load(titleId)
|
||||
}
|
||||
if (loadRequestId != requestId) return@launch
|
||||
applySnapshot(titleId, routeKey, snapshot)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
if (loadRequestId == requestId) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (loadRequestId == requestId) {
|
||||
loading = false
|
||||
activeLoadRouteKey = null
|
||||
loadJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(forceCustomConfig: Boolean = false, onSaved: ((List<RestartRequiredSetting>) -> Unit)? = null) {
|
||||
if (saving) return
|
||||
val configSnapshot = config.copy()
|
||||
val routeTitleId = titleId
|
||||
val saveTitleId = routeTitleId?.takeIf { forceCustomConfig || hasCustomConfig }
|
||||
saving = true
|
||||
viewModelScope.launch {
|
||||
var saveSucceeded = false
|
||||
var restartRequiredSettings: List<RestartRequiredSetting> = emptyList()
|
||||
try {
|
||||
val saveResult = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.save(routeTitleId, saveTitleId, configSnapshot)
|
||||
}
|
||||
hasCustomConfig = saveResult.hasCustomConfig
|
||||
restartRequiredSettings = saveResult.restartRequiredSettings
|
||||
originalConfig = configSnapshot.copy()
|
||||
originalModulesList = modulesList
|
||||
loadedRouteKey = routeKey(routeTitleId)
|
||||
saveSucceeded = true
|
||||
if (saveTitleId == null) {
|
||||
UiLanguages.applyAndPersist(getApplication(), configSnapshot.userLang)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
if (saveSucceeded) {
|
||||
onSaved?.invoke(restartRequiredSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeStorageFolder(storagePath: String, onStorageChanged: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (storagePath.isNullOrBlank()) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_emulator_storage_folder_resolve_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.setCurrentEmulatorPath(storagePath)
|
||||
}
|
||||
if (!success) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_emulator_storage_folder_change_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val routeKey = routeKey(titleId)
|
||||
val snapshot = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.load(titleId)
|
||||
}
|
||||
applySnapshot(titleId, routeKey, snapshot)
|
||||
onStorageChanged()
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_emulator_storage_folder_change_success, storagePath),
|
||||
false
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetStorageFolder(onStorageChanged: () -> Unit) {
|
||||
changeStorageFolder(defaultStoragePath, onStorageChanged)
|
||||
}
|
||||
|
||||
fun resetToDefaults() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val defaults = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.loadDefaults()
|
||||
}
|
||||
config = defaults.config.copy()
|
||||
modulesList = defaults.modulesList
|
||||
installedCustomDrivers = defaults.installedCustomDrivers
|
||||
supportedMemoryMappingMask = defaults.supportedMemoryMappingMask
|
||||
customDriverLoadStatus = defaults.customDriverLoadStatus
|
||||
availableCameras = defaults.availableCameras
|
||||
availableAdhocAddresses = defaults.availableAdhocAddresses
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_reset_defaults_done),
|
||||
false
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCustomConfig(onDeleted: ((List<RestartRequiredSetting>) -> Unit)? = null) {
|
||||
val id = titleId ?: return
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.deleteCustomConfig(id)
|
||||
}
|
||||
applySnapshot(id, routeKey(id), result.snapshot)
|
||||
onDeleted?.invoke(result.restartRequiredSettings)
|
||||
}
|
||||
}
|
||||
|
||||
fun discardChanges() {
|
||||
config = originalConfig.copy()
|
||||
modulesList = originalModulesList
|
||||
modulesSearch = ""
|
||||
}
|
||||
|
||||
fun clearAllCustomConfigs() {
|
||||
viewModelScope.launch {
|
||||
val deletedCount = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.clearAllCustomConfigs()
|
||||
}
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_clear_all_custom_configs_success, deletedCount),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun installCustomDriver(path: String) {
|
||||
if (customDriverBusy) return
|
||||
|
||||
customDriverBusy = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (path.isBlank()) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_resolve_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val installedName = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.installCustomDriver(path)
|
||||
}
|
||||
if (installedName.isEmpty()) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_install_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
installedCustomDrivers = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getInstalledCustomDrivers()
|
||||
}
|
||||
val supportInfo = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getCustomDriverSupportInfo(installedName)
|
||||
}
|
||||
applyCustomDriverSupportInfo(
|
||||
requestedDriverName = installedName,
|
||||
supportInfo = supportInfo,
|
||||
coerceSelectionOnFallback = false
|
||||
)
|
||||
operationResult = SettingsOperationResult(
|
||||
if (supportInfo.loadStatus == CustomDriverLoadStatus.Loaded) {
|
||||
str(R.string.settings_gpu_custom_driver_install_success, installedName)
|
||||
} else {
|
||||
str(R.string.settings_gpu_custom_driver_install_fallback, installedName)
|
||||
},
|
||||
supportInfo.loadStatus != CustomDriverLoadStatus.Loaded
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
customDriverBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCustomDriver(driverName: String) {
|
||||
if (customDriverBusy || driverName.isEmpty()) return
|
||||
|
||||
customDriverBusy = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val removed = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.removeCustomDriver(driverName)
|
||||
}
|
||||
if (!removed) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_remove_failed, driverName),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
installedCustomDrivers = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getInstalledCustomDrivers()
|
||||
}
|
||||
if (config.customDriverName == driverName) {
|
||||
val supportInfo = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getCustomDriverSupportInfo("")
|
||||
}
|
||||
applyCustomDriverSupportInfo(
|
||||
requestedDriverName = "",
|
||||
supportInfo = supportInfo,
|
||||
coerceSelectionOnFallback = false
|
||||
)
|
||||
}
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_remove_success, driverName),
|
||||
false
|
||||
)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
customDriverBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCameraImage(isFront: Boolean, path: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
if (path.isNullOrBlank()) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.settings_camera_image_resolve_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
update {
|
||||
if (isFront) {
|
||||
frontCameraType = 1
|
||||
frontCameraImage = path
|
||||
} else {
|
||||
backCameraType = 1
|
||||
backCameraImage = path
|
||||
}
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissOperationResult() {
|
||||
operationResult = null
|
||||
}
|
||||
|
||||
fun selectCustomDriver(driverName: String) {
|
||||
if (customDriverBusy) return
|
||||
|
||||
val normalizedDriverName = driverName.trim()
|
||||
if (config.customDriverName == normalizedDriverName) {
|
||||
refreshSupportedMemoryMappingMask(normalizedDriverName)
|
||||
return
|
||||
}
|
||||
|
||||
customDriverBusy = true
|
||||
config = config.copy().apply {
|
||||
customDriverName = normalizedDriverName
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val supportInfo = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getCustomDriverSupportInfo(normalizedDriverName)
|
||||
}
|
||||
applyCustomDriverSupportInfo(
|
||||
requestedDriverName = normalizedDriverName,
|
||||
supportInfo = supportInfo,
|
||||
coerceSelectionOnFallback = false
|
||||
)
|
||||
operationResult = when {
|
||||
normalizedDriverName.isEmpty() -> null
|
||||
supportInfo.loadStatus == CustomDriverLoadStatus.Loaded -> SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_selected_success, normalizedDriverName),
|
||||
false
|
||||
)
|
||||
else -> SettingsOperationResult(
|
||||
str(R.string.settings_gpu_custom_driver_selected_fallback, normalizedDriverName),
|
||||
true
|
||||
)
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = SettingsOperationResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
val fallbackSupportInfo = CustomDriverSupportInfo(
|
||||
supportedMemoryMappingMask = 1,
|
||||
loadStatus = if (normalizedDriverName.isEmpty()) {
|
||||
CustomDriverLoadStatus.Default
|
||||
} else {
|
||||
CustomDriverLoadStatus.Fallback
|
||||
}
|
||||
)
|
||||
applyCustomDriverSupportInfo(
|
||||
requestedDriverName = normalizedDriverName,
|
||||
supportInfo = fallbackSupportInfo,
|
||||
coerceSelectionOnFallback = false
|
||||
)
|
||||
} finally {
|
||||
customDriverBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(block: EmulatorConfig.() -> Unit) {
|
||||
val previousDriverName = config.customDriverName
|
||||
val updatedConfig = config.copy().apply(block)
|
||||
config = updatedConfig
|
||||
|
||||
if (previousDriverName != updatedConfig.customDriverName) {
|
||||
refreshSupportedMemoryMappingMask(updatedConfig.customDriverName)
|
||||
}
|
||||
}
|
||||
|
||||
fun onModulesSearchChange(query: String) {
|
||||
modulesSearch = query
|
||||
}
|
||||
|
||||
fun toggleModule(name: String) {
|
||||
val newList = modulesList.map { (n, e) -> if (n == name) n to !e else n to e }
|
||||
modulesList = newList
|
||||
update { lleModules = newList.filter { it.second }.map { it.first }.toTypedArray() }
|
||||
}
|
||||
|
||||
private fun refreshSupportedMemoryMappingMask(
|
||||
customDriverName: String,
|
||||
coerceSelectionOnFallback: Boolean = false,
|
||||
onApplied: ((CustomDriverSupportInfo) -> Unit)? = null
|
||||
) {
|
||||
memoryMappingRefreshJob?.cancel()
|
||||
val requestId = ++memoryMappingRefreshRequestId
|
||||
memoryMappingRefreshJob = viewModelScope.launch {
|
||||
try {
|
||||
val supportInfo = withContext(Dispatchers.IO) {
|
||||
SettingsRepository.getCustomDriverSupportInfo(customDriverName)
|
||||
}
|
||||
if (memoryMappingRefreshRequestId != requestId) return@launch
|
||||
if (!coerceSelectionOnFallback && config.customDriverName != customDriverName) return@launch
|
||||
|
||||
applyCustomDriverSupportInfo(customDriverName, supportInfo, coerceSelectionOnFallback)
|
||||
onApplied?.invoke(supportInfo)
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: Exception) {
|
||||
if (memoryMappingRefreshRequestId != requestId) return@launch
|
||||
if (!coerceSelectionOnFallback && config.customDriverName != customDriverName) return@launch
|
||||
|
||||
val fallbackSupportInfo = CustomDriverSupportInfo(
|
||||
supportedMemoryMappingMask = 1,
|
||||
loadStatus = if (customDriverName.isEmpty()) {
|
||||
CustomDriverLoadStatus.Default
|
||||
} else {
|
||||
CustomDriverLoadStatus.Fallback
|
||||
}
|
||||
)
|
||||
applyCustomDriverSupportInfo(customDriverName, fallbackSupportInfo, coerceSelectionOnFallback)
|
||||
onApplied?.invoke(fallbackSupportInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCustomDriverSupportInfo(
|
||||
requestedDriverName: String,
|
||||
supportInfo: CustomDriverSupportInfo,
|
||||
coerceSelectionOnFallback: Boolean
|
||||
) {
|
||||
val shouldFallbackToDefault = requestedDriverName.isNotEmpty() &&
|
||||
supportInfo.loadStatus == CustomDriverLoadStatus.Fallback &&
|
||||
coerceSelectionOnFallback
|
||||
val effectiveDriverName = if (shouldFallbackToDefault) "" else requestedDriverName
|
||||
val effectiveLoadStatus = when {
|
||||
effectiveDriverName.isEmpty() -> CustomDriverLoadStatus.Default
|
||||
else -> supportInfo.loadStatus
|
||||
}
|
||||
|
||||
supportedMemoryMappingMask = supportInfo.supportedMemoryMappingMask
|
||||
customDriverLoadStatus = effectiveLoadStatus
|
||||
|
||||
val coercedMapping = coerceMemoryMapping(config.memoryMapping, supportInfo.supportedMemoryMappingMask)
|
||||
val currentConfig = config
|
||||
if (currentConfig.customDriverName != effectiveDriverName || currentConfig.memoryMapping != coercedMapping) {
|
||||
config = currentConfig.copy().apply {
|
||||
customDriverName = effectiveDriverName
|
||||
memoryMapping = coercedMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun coerceMemoryMapping(memoryMapping: String, supportedMask: Int): String {
|
||||
if (isMemoryMappingSupported(memoryMapping, supportedMask)) {
|
||||
return memoryMapping
|
||||
}
|
||||
|
||||
return when {
|
||||
(supportedMask and (1 shl 1)) != 0 -> "double-buffer"
|
||||
(supportedMask and (1 shl 2)) != 0 -> "external-host"
|
||||
(supportedMask and (1 shl 3)) != 0 -> "page-table"
|
||||
(supportedMask and (1 shl 4)) != 0 -> "native-buffer"
|
||||
else -> "disabled"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMemoryMappingSupported(memoryMapping: String, supportedMask: Int): Boolean {
|
||||
val bit = when (memoryMapping) {
|
||||
"disabled" -> 0
|
||||
"double-buffer" -> 1
|
||||
"external-host" -> 2
|
||||
"page-table" -> 3
|
||||
"native-buffer" -> 4
|
||||
else -> return false
|
||||
}
|
||||
|
||||
return (supportedMask and (1 shl bit)) != 0
|
||||
}
|
||||
|
||||
private fun routeKey(titleId: String?): String = titleId ?: GLOBAL_SETTINGS_ROUTE_KEY
|
||||
|
||||
private fun applySnapshot(titleId: String?, routeKey: String, snapshot: SettingsSnapshot) {
|
||||
this.titleId = titleId
|
||||
currentStoragePath = snapshot.emulatorStoragePath
|
||||
hasCustomConfig = snapshot.hasCustomConfig
|
||||
originalConfig = snapshot.config.copy()
|
||||
originalModulesList = snapshot.modulesList
|
||||
config = snapshot.config.copy()
|
||||
modulesList = snapshot.modulesList
|
||||
installedCustomDrivers = snapshot.installedCustomDrivers
|
||||
showCustomDriverOptions = snapshot.showCustomDriverOptions
|
||||
supportedMemoryMappingMask = snapshot.supportedMemoryMappingMask
|
||||
customDriverLoadStatus = snapshot.customDriverLoadStatus
|
||||
availableCameras = snapshot.availableCameras
|
||||
availableAdhocAddresses = snapshot.availableAdhocAddresses
|
||||
loadedRouteKey = routeKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.vita3k.emulator.ui.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.launch
|
||||
import org.vita3k.emulator.R
|
||||
import org.vita3k.emulator.data.NativeUser
|
||||
import org.vita3k.emulator.data.UserRepository
|
||||
|
||||
data class UserManagementResult(
|
||||
val message: String,
|
||||
val isError: Boolean
|
||||
)
|
||||
|
||||
class UserManagementViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun str(resId: Int, vararg args: Any): String =
|
||||
getApplication<Application>().getString(resId, *args)
|
||||
|
||||
var users by mutableStateOf<List<NativeUser>>(emptyList())
|
||||
private set
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var busy by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var operationResult by mutableStateOf<UserManagementResult?>(null)
|
||||
private set
|
||||
|
||||
fun load() {
|
||||
if (loading) return
|
||||
|
||||
loading = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
users = UserRepository.getUsers()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createUser(name: String, onUsersChanged: () -> Unit = {}) {
|
||||
val trimmedName = name.trim()
|
||||
if (busy || trimmedName.isEmpty()) return
|
||||
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val userId = UserRepository.createUser(trimmedName)
|
||||
if (userId.isEmpty()) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_create_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
users = UserRepository.getUsers()
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_created_success, trimmedName),
|
||||
false
|
||||
)
|
||||
onUsersChanged()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun activateUser(user: NativeUser, onUsersChanged: () -> Unit = {}) {
|
||||
if (busy || user.active) return
|
||||
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val success = UserRepository.activateUser(user.id)
|
||||
if (!success) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_select_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
users = UserRepository.getUsers()
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_selected_success, user.name),
|
||||
false
|
||||
)
|
||||
onUsersChanged()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteUser(user: NativeUser, onUsersChanged: () -> Unit = {}) {
|
||||
if (busy) return
|
||||
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val success = UserRepository.deleteUser(user.id)
|
||||
if (!success) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_delete_failed),
|
||||
true
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
users = UserRepository.getUsers()
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.user_management_deleted_success, user.name),
|
||||
false
|
||||
)
|
||||
onUsersChanged()
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
operationResult = UserManagementResult(
|
||||
str(R.string.install_error_generic, e.message ?: ""),
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissOperationResult() {
|
||||
operationResult = null
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |