mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-17 12:47:21 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26fa0981c |
No files matched your search
@@ -186,7 +186,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: app/src/main/assets/linuxfs
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
|
||||
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/linuxfs/debian-image.env') }}
|
||||
|
||||
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
|
||||
if: steps.preloads.outputs.cache-hit != 'true'
|
||||
@@ -457,7 +457,6 @@ jobs:
|
||||
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
@@ -302,7 +302,4 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
// The Android archive does not include the host natives used by JVM archive tests.
|
||||
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
|
||||
}
|
||||
@@ -3600,13 +3600,6 @@ static bool wait_snapshot(std::shared_ptr<FakeController> fake,
|
||||
}
|
||||
}
|
||||
|
||||
static int forward_ioctl(int fd, ioctl_request_t op, void *argp) {
|
||||
using ioctl_fn = int (*)(int, ioctl_request_t, ...);
|
||||
// Preserve later preload hooks, including GPU allocation adapters.
|
||||
static const auto next = reinterpret_cast<ioctl_fn>(dlsym(RTLD_NEXT, "ioctl"));
|
||||
return next ? next(fd, op, argp) : static_cast<int>(syscall(SYS_ioctl, fd, op, argp));
|
||||
}
|
||||
|
||||
EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
va_list va;
|
||||
void *argp;
|
||||
@@ -3618,7 +3611,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
// The lock must not be held across the passthrough: binder's transport is a
|
||||
// blocking ioctl(BINDER_WRITE_READ), so a parked binder pool thread would own
|
||||
// controller_mutex() for as long as it waits and deadlock every other ioctl.
|
||||
if (!watched(fd)) return forward_ioctl(fd, op, argp);
|
||||
if (!watched(fd)) return syscall(SYS_ioctl, fd, op, argp);
|
||||
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
|
||||
auto maker = uinput_map().find(fd);
|
||||
if (maker != uinput_map().end()) return ioctl_uinput(*maker->second, op, argp);
|
||||
@@ -3636,7 +3629,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
auto controller = controller_map().find(fd);
|
||||
if (controller == controller_map().end()) {
|
||||
guard.unlock();
|
||||
return forward_ioctl(fd, op, argp);
|
||||
return syscall(SYS_ioctl, fd, op, argp);
|
||||
}
|
||||
|
||||
int type = (op >> 8 & 0xFF);
|
||||
@@ -3832,7 +3825,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
} else {
|
||||
Logger::log("Unhandled evdev ioctl, type %d number %d\n", type, number);
|
||||
guard.unlock();
|
||||
return forward_ioctl(fd, op, argp);
|
||||
return syscall(SYS_ioctl, fd, op, argp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
com.droiddeck.launcher.update.AppUpdates.init(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
import com.droiddeck.launcher.core.CpuCores
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
@@ -54,6 +55,7 @@ import com.droiddeck.launcher.core.WifiDiscovery
|
||||
import com.droiddeck.launcher.core.WirelessAdbPairingService
|
||||
import com.droiddeck.launcher.ui.CoreRow
|
||||
import com.droiddeck.launcher.ui.PerformancePage
|
||||
import com.droiddeck.launcher.ui.ModeSettingsPage
|
||||
import com.droiddeck.launcher.ui.ModeSettings
|
||||
import com.droiddeck.launcher.ui.ModeSettingsActions
|
||||
import com.droiddeck.launcher.ui.ConfirmDialog
|
||||
@@ -122,6 +124,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var steamRepairQueued by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
|
||||
private var showComponents by mutableStateOf(false)
|
||||
private var focusComponentsContent by mutableStateOf(true)
|
||||
@@ -163,6 +166,8 @@ class MainActivity : ComponentActivity() {
|
||||
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var animationsEnabled by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var gameStoresEnabled by mutableStateOf(false)
|
||||
private var gameStoresSpeedTier by mutableStateOf("fast")
|
||||
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
|
||||
/** A page a notification asked for ("stores:downloads", "app:<id>"), until the front end has gone there. */
|
||||
@@ -333,7 +338,7 @@ class MainActivity : ComponentActivity() {
|
||||
romsDir = path
|
||||
}
|
||||
}
|
||||
// The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings().
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
@@ -534,6 +539,8 @@ class MainActivity : ComponentActivity() {
|
||||
requestedOrientation = orientation
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
animationsEnabled = SessionPrefs.animationsEnabled(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
|
||||
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
|
||||
storesOpenTab = SessionPrefs.storesOpenTab(this)
|
||||
storesShowMature = SessionPrefs.storesShowMature(this)
|
||||
@@ -542,8 +549,11 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
DroidDeckTheme(theme, appScale = appScale) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
showPerformance -> { { PerformanceHost() } }
|
||||
showProtons -> { { ProtonHost() } }
|
||||
showComponents -> { { ComponentsHost() } }
|
||||
showMapping -> { { MappingHost() } }
|
||||
else -> null
|
||||
@@ -562,7 +572,7 @@ class MainActivity : ComponentActivity() {
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
language = com.droiddeck.launcher.core.AppLanguage.chosen(this),
|
||||
appScale = appScale,
|
||||
@@ -587,6 +597,8 @@ class MainActivity : ComponentActivity() {
|
||||
orientation = orientation,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
animationsEnabled = animationsEnabled,
|
||||
storeEnabled = storeEnabled,
|
||||
gameStoresEnabled = gameStoresEnabled,
|
||||
gameStoresSpeedTier = gameStoresSpeedTier,
|
||||
storesOpenTab = storesOpenTab,
|
||||
navigate = navRequest,
|
||||
@@ -671,10 +683,9 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
// The activity re-attaches to the session that is running; nothing restarts.
|
||||
onResume = { startActivity(Intent(this, SessionActivity::class.java)) },
|
||||
onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) },
|
||||
onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) },
|
||||
onRemoveDesktop = { removeDesktop() },
|
||||
onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) },
|
||||
modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) },
|
||||
onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) },
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
onRuntime = { onRuntimeButton() },
|
||||
@@ -687,11 +698,10 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
|
||||
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
|
||||
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
|
||||
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
|
||||
onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true },
|
||||
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
|
||||
onRoms = { showRoms = true },
|
||||
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
|
||||
onBrowseFiles = { dir ->
|
||||
@@ -726,7 +736,7 @@ class MainActivity : ComponentActivity() {
|
||||
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
|
||||
offline = OfflineMode.enabled(this)
|
||||
},
|
||||
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLanguage = { tag ->
|
||||
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
|
||||
@@ -749,6 +759,8 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = on
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) },
|
||||
onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier },
|
||||
onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab },
|
||||
onNavigated = { navRequest = null },
|
||||
@@ -834,7 +846,7 @@ class MainActivity : ComponentActivity() {
|
||||
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
|
||||
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
|
||||
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
|
||||
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
|
||||
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
|
||||
onResetAll = { ControllerPrefs.resetAll(this); refreshController() },
|
||||
),
|
||||
),
|
||||
@@ -954,12 +966,14 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -981,7 +995,7 @@ class MainActivity : ComponentActivity() {
|
||||
refreshWifiDiscovery()
|
||||
// Back from a store's sign-in page: the Stores chips show the account at once. A store
|
||||
// install finishing while this screen is up rebuilds the Games list through the listener.
|
||||
com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this)
|
||||
com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() }
|
||||
com.droiddeck.launcher.ui.Motion.refresh(this)
|
||||
// Back from a session stopped behind a flood: open on its blue, before the first frame.
|
||||
@@ -994,8 +1008,6 @@ class MainActivity : ComponentActivity() {
|
||||
updates.onResume()
|
||||
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
|
||||
drivers.ensureAuto(force = false)
|
||||
// Newer GE-Proton / proton-cachyos / DroidDeck-Proton releases: looked up every few hours at most.
|
||||
protons.checkUpdates()
|
||||
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
|
||||
// session has just ended). Cheap when nothing is queued.
|
||||
if (!SessionState.running) Thread({
|
||||
@@ -1114,8 +1126,14 @@ class MainActivity : ComponentActivity() {
|
||||
}, "game-saves-action").start()
|
||||
}
|
||||
|
||||
/** The Protons DroidDeck installs are the Components page's Proton layer. */
|
||||
private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB)
|
||||
private fun openProtons() {
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
showProtons = true
|
||||
protons.refreshProtons()
|
||||
}
|
||||
|
||||
private fun refreshController() {
|
||||
controllerSettings = ControllerPrefs.read(this)
|
||||
@@ -1135,19 +1153,13 @@ class MainActivity : ComponentActivity() {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
// The Proton layer's options for every Proton, as they are now.
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
protons.refreshProtons()
|
||||
protons.checkUpdates()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -1181,11 +1193,7 @@ class MainActivity : ComponentActivity() {
|
||||
onDownload = { components.downloadComponent(it) },
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
|
||||
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
|
||||
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
|
||||
games = steamGames,
|
||||
generalFexPreset = fexPreset,
|
||||
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
onAuto = { on -> drivers.setMode(on) },
|
||||
@@ -1202,32 +1210,23 @@ class MainActivity : ComponentActivity() {
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
protons = com.droiddeck.launcher.ui.ProtonsState(
|
||||
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy, sessionRunning = SessionState.running,
|
||||
),
|
||||
protonActions = com.droiddeck.launcher.ui.ProtonsActions(
|
||||
onInstall = { id -> protons.installProton(id) },
|
||||
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
|
||||
onRemove = { id, dir -> protons.removeProton(id, dir) },
|
||||
onRemoveOlder = { id -> protons.removeProton(id, older = true) },
|
||||
),
|
||||
protonOptions = {
|
||||
com.droiddeck.launcher.ui.ProtonOptions(
|
||||
syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs,
|
||||
onSyncBackend = { id ->
|
||||
SessionPrefs.setSyncBackend(this, id)
|
||||
fastSync = SessionPrefs.fastSync(this)
|
||||
fsyncFirst = SessionPrefs.fsyncFirst(this)
|
||||
syncFallback = SessionPrefs.syncFallback(this)
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
|
||||
)
|
||||
},
|
||||
)
|
||||
// A Proton installed or removed changes the stack's list of Protons.
|
||||
androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProtonHost() {
|
||||
ProtonPage(
|
||||
rows = protons.protonRows,
|
||||
busyId = protons.protonBusyId,
|
||||
stage = protons.protonStage,
|
||||
percent = protons.protonPercent,
|
||||
runtimeReady = ready && !busy,
|
||||
sessionRunning = SessionState.running,
|
||||
onInstall = { id -> protons.installProton(id) },
|
||||
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
|
||||
onRemove = { id -> protons.removeProton(id) },
|
||||
onBack = { showProtons = false },
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshPackages() {
|
||||
@@ -1274,10 +1273,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) {
|
||||
// Read for the other mode last (or not yet): what this one shows is read as it opens.
|
||||
if (settingsMode != mode) return
|
||||
com.droiddeck.launcher.ui.ModeSheetRows(
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolution = resolution,
|
||||
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
|
||||
@@ -1397,9 +1394,8 @@ class MainActivity : ComponentActivity() {
|
||||
onPickDeckyPluginZip = {
|
||||
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip)))
|
||||
},
|
||||
onDismiss = {},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
tab, extras,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1500,9 +1496,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var pipAutoEnter by mutableStateOf(false)
|
||||
private var unfoldedControls by mutableStateOf(false)
|
||||
|
||||
private fun loadModeSettings(mode: String) {
|
||||
private fun openModeSettings(mode: String) {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showComponents = false
|
||||
showMapping = false
|
||||
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
forceSsbs = SessionPrefs.forceSsbs(this)
|
||||
@@ -1530,8 +1530,8 @@ class MainActivity : ComponentActivity() {
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
// The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it rises.
|
||||
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
|
||||
// added-games folders, the storage volumes) lands while it animates in.
|
||||
Thread({
|
||||
drivers.refreshDrivers()
|
||||
val storage = GameStorage.options(this)
|
||||
@@ -1607,10 +1607,7 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// The emulators first: they need no walk of the games, and Desktop's dock is made of them.
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
|
||||
// Steam's own games next, from their manifests alone (quick), beside the added games
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
@@ -1631,6 +1628,7 @@ class MainActivity : ComponentActivity() {
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
@@ -1645,6 +1643,7 @@ class MainActivity : ComponentActivity() {
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
emulatorList = emus
|
||||
shortcutLibraryScanning = false
|
||||
handleGameLink(all)
|
||||
}
|
||||
|
||||
@@ -10,16 +10,14 @@ import com.droiddeck.launcher.ui.ProtonRow
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
|
||||
/**
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos, DroidDeck-Proton): the rows the page lists,
|
||||
* an install or removal in progress, and whether a newer release is out. The launcher screen keeps
|
||||
* one and shows it.
|
||||
* The optional ARM64 Protons (GE-Proton, proton-cachyos): the rows the page lists and an install or
|
||||
* removal in progress. The launcher screen keeps one and shows it.
|
||||
*/
|
||||
internal class ProtonMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
|
||||
var protonRows by mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
var protonBusyId by mutableStateOf<String?>(null)
|
||||
var protonStage by mutableStateOf<String?>(null)
|
||||
var protonPercent by mutableIntStateOf(-1)
|
||||
private val checking = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
fun installProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
@@ -42,15 +40,14 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
}, "install-proton-$id").start()
|
||||
}
|
||||
|
||||
/** Removes the build in [dir], or with [older] every build of it but the newest. */
|
||||
fun removeProton(id: String, dir: String? = null, older: Boolean = false) {
|
||||
fun removeProton(id: String) {
|
||||
val tool = ProtonExtras.tools.firstOrNull { it.id == id } ?: return
|
||||
if (protonBusyId != null || SessionState.running) return
|
||||
protonBusyId = id
|
||||
protonStage = activity.getString(R.string.main_removing_named, dir ?: tool.name)
|
||||
protonStage = activity.getString(R.string.main_removing_named, tool.name)
|
||||
protonPercent = -1
|
||||
Thread({
|
||||
if (older) ProtonExtras.removeOlder(activity, tool) else ProtonExtras.remove(activity, tool, dir)
|
||||
ProtonExtras.remove(activity, tool)
|
||||
ui.post {
|
||||
protonBusyId = null
|
||||
protonStage = null
|
||||
@@ -60,18 +57,6 @@ internal class ProtonMenu(private val activity: android.app.Activity, private va
|
||||
}
|
||||
|
||||
fun refreshProtons() {
|
||||
protonRows = ProtonExtras.tools.map {
|
||||
val all = ProtonExtras.installedAll(activity, it)
|
||||
ProtonRow(it.id, it.name, all.lastOrNull(), ProtonExtras.queued(activity, it), all, ProtonExtras.update(activity, it))
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks for newer releases, at most every few hours (see ProtonExtras.checkLatest); quiet offline. */
|
||||
fun checkUpdates(force: Boolean = false) {
|
||||
if (!checking.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
val asked = try { ProtonExtras.checkLatest(activity, force) } catch (_: Throwable) { false } finally { checking.set(false) }
|
||||
if (asked) ui.post { refreshProtons() }
|
||||
}, "proton-updates").start()
|
||||
protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(activity, it), ProtonExtras.queued(activity, it)) }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,6 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.agent.AgentGuest
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.ScreenEffectLooks
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
@@ -65,9 +64,6 @@ import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SteamRepair
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.GameProfileFollowState
|
||||
import com.droiddeck.launcher.session.GameProfileInventoryCache
|
||||
import com.droiddeck.launcher.session.GameProfileRefreshState
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import com.droiddeck.launcher.session.SessionArtifacts
|
||||
@@ -77,9 +73,6 @@ import com.droiddeck.launcher.session.SessionPaths
|
||||
import com.droiddeck.launcher.wayland.HdrSupport
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.ProtonDefault
|
||||
import com.droiddeck.launcher.session.SelectedGameProfile
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.ui.CursorOverlay
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.droiddeck.launcher.ui.DrawerActions
|
||||
@@ -93,7 +86,6 @@ import com.droiddeck.launcher.wayland.CompositorHost
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.LatestRequestCoordinator
|
||||
import com.droiddeck.launcher.ui.SessionDrawerPage
|
||||
import com.droiddeck.launcher.ui.sessionDrawerPages
|
||||
import com.droiddeck.launcher.ui.step
|
||||
@@ -146,6 +138,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun openDeckRing(why: String) {
|
||||
val deck = SessionState.deckPad
|
||||
val opened = if (deck) padBridge?.start() else null
|
||||
// Every pad already connected is a player from the start, before its first press.
|
||||
padBridge?.claimConnected()
|
||||
Log.i(TAG, "deck ring ($why): deck pad $deck" + when (opened) {
|
||||
null -> ", left for first input"
|
||||
true -> ", ring open"
|
||||
@@ -255,24 +249,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var closeAtOnce = false
|
||||
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
|
||||
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
|
||||
private var drawerGameProfileId by mutableStateOf<Long?>(null)
|
||||
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
|
||||
private var drawerGameInventoryRevision = 0L
|
||||
private var drawerGameFexPresetSaving by mutableStateOf(false)
|
||||
private val drawerGameProfileFollowState = GameProfileFollowState()
|
||||
private data class DrawerGameProfileRefresh(
|
||||
val followsSteam: Boolean,
|
||||
val requestedAppId: Long?,
|
||||
val inventoryRevision: Long,
|
||||
)
|
||||
private data class DrawerGameProfileResult(
|
||||
val profiles: List<Pair<Long, String>>,
|
||||
val selectedAppId: Long?,
|
||||
val profile: SelectedGameProfile?,
|
||||
val error: Exception? = null,
|
||||
)
|
||||
private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState<DrawerGameProfileResult>())
|
||||
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
@@ -286,8 +262,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var effects by mutableStateOf(ScreenEffects.OFF)
|
||||
private var textureAnisotropy by mutableStateOf(0)
|
||||
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
|
||||
private var drawerTextureAnisotropy by mutableStateOf<Int?>(null)
|
||||
private var drawerTextureLodBias by mutableStateOf<String?>(null)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
@@ -338,6 +312,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val deviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) {
|
||||
logInputDevice("connected", deviceId)
|
||||
padBridge?.claimConnected()
|
||||
updateOnScreenControls()
|
||||
updatePointerCapture()
|
||||
}
|
||||
@@ -572,9 +547,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
|
||||
effects = effects,
|
||||
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
|
||||
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
|
||||
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
|
||||
@@ -599,24 +572,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
|
||||
e.push(upscaler)
|
||||
},
|
||||
onTextureAnisotropy = { value ->
|
||||
drawerGameProfileId?.let { appId ->
|
||||
SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value)
|
||||
drawerTextureAnisotropy = value
|
||||
} ?: value?.let {
|
||||
SessionPrefs.setTextureAnisotropy(this@SessionActivity, it)
|
||||
textureAnisotropy = it
|
||||
}
|
||||
},
|
||||
onTextureLodBias = { value ->
|
||||
drawerGameProfileId?.let { appId ->
|
||||
SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value)
|
||||
drawerTextureLodBias = value
|
||||
} ?: value?.let {
|
||||
SessionPrefs.setTextureLodBias(this@SessionActivity, it)
|
||||
textureLodBias = it
|
||||
}
|
||||
},
|
||||
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
|
||||
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
@@ -712,22 +669,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onClose = { drawerOpen = false },
|
||||
components = drawerComponents,
|
||||
onComponentsRefresh = { refreshDrawerComponents() },
|
||||
gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(),
|
||||
selectedGameProfileId = drawerGameProfileId,
|
||||
selectedGameProfile = drawerGameProfileRefresh.data
|
||||
?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile,
|
||||
gameProfilesLoaded = drawerGameProfileRefresh.loaded,
|
||||
gameProfileRefreshFailed = drawerGameProfileRefresh.failed,
|
||||
onSelectedGameProfile = ::selectDrawerGameProfile,
|
||||
onGameProfilesPageOpened = {
|
||||
drawerGameProfileFollowState.resumeFollowingSteam()
|
||||
drawerGameInventoryRevision++
|
||||
},
|
||||
onSelectedGameProfileRefresh = ::refreshDrawerGameProfile,
|
||||
gameFexPresetSaving = drawerGameFexPresetSaving,
|
||||
onGameFexPreset = ::setDrawerGameFexPreset,
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
onGameComponent = ::setDrawerGameComponent,
|
||||
))
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
title = pausedTitle(),
|
||||
@@ -824,107 +766,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "drawer-components").start()
|
||||
}
|
||||
|
||||
private fun selectDrawerGameProfile(appId: Long?) {
|
||||
drawerGameProfileFollowState.selectManually()
|
||||
drawerGameProfileId = appId
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.retrying()
|
||||
readDrawerTextureFiltering(appId)
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
|
||||
private fun readDrawerTextureFiltering(appId: Long?) {
|
||||
val choice = appId?.let { SessionPrefs.gameTextureFiltering(this, it.toString()) }
|
||||
drawerTextureAnisotropy = choice?.anisotropy
|
||||
drawerTextureLodBias = choice?.lodBias
|
||||
}
|
||||
|
||||
private fun refreshDrawerGameProfile() {
|
||||
val refresh = DrawerGameProfileRefresh(
|
||||
drawerGameProfileFollowState.followsSteam,
|
||||
drawerGameProfileId,
|
||||
drawerGameInventoryRevision,
|
||||
)
|
||||
if (!drawerGameProfileRefreshes.submit(refresh)) return
|
||||
Thread({
|
||||
var request = drawerGameProfileRefreshes.takeLatest()
|
||||
while (request != null) {
|
||||
val current = request
|
||||
val result = resolveDrawerGameProfileRefresh(current.value)
|
||||
uiHandler.post {
|
||||
if (!drawerGameProfileRefreshes.isLatest(current)) return@post
|
||||
applyDrawerGameProfileRefresh(current.value, result)
|
||||
}
|
||||
request = drawerGameProfileRefreshes.takeLatest()
|
||||
}
|
||||
}, "drawer-game-profile").start()
|
||||
}
|
||||
|
||||
private fun resolveDrawerGameProfileRefresh(
|
||||
refresh: DrawerGameProfileRefresh,
|
||||
): DrawerGameProfileResult {
|
||||
return try {
|
||||
val games = drawerGameInventory.get(refresh.inventoryRevision) { Library.launchableGames(this) }
|
||||
val options = games.mapNotNull { game ->
|
||||
game.profileKey.toLongOrNull()?.let { it to game.name }
|
||||
}.sortedBy { it.second.lowercase() }
|
||||
val installedAppIds = options.mapTo(mutableSetOf()) { it.first }
|
||||
val selectedAppId = if (refresh.followsSteam) {
|
||||
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
|
||||
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
|
||||
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
|
||||
?: refresh.requestedAppId
|
||||
} else {
|
||||
refresh.requestedAppId
|
||||
}
|
||||
val profile = selectedAppId?.let { resolveDrawerGameProfile(it, games) }
|
||||
DrawerGameProfileResult(options, selectedAppId, profile)
|
||||
} catch (error: Exception) {
|
||||
DrawerGameProfileResult(emptyList(), refresh.requestedAppId, null, error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyDrawerGameProfileRefresh(
|
||||
refresh: DrawerGameProfileRefresh,
|
||||
result: DrawerGameProfileResult,
|
||||
) {
|
||||
if (result.error != null) {
|
||||
if (!drawerGameProfileRefresh.failed) {
|
||||
Log.w(TAG, "Could not refresh the selected game profile", result.error)
|
||||
}
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.failed()
|
||||
return
|
||||
}
|
||||
drawerGameProfileRefresh = drawerGameProfileRefresh.succeeded(result)
|
||||
if (refresh.followsSteam) {
|
||||
if (drawerGameProfileFollowState.followsSteam) {
|
||||
drawerGameProfileId = result.selectedAppId
|
||||
readDrawerTextureFiltering(result.selectedAppId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDrawerGameProfile(
|
||||
selectedAppId: Long,
|
||||
games: List<Library.SteamGame>,
|
||||
): SelectedGameProfile? {
|
||||
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
|
||||
val snapshot = drawerComponents ?: ComponentsManager.snapshot(this)
|
||||
val protons = snapshot.protons.map { it.proton }
|
||||
val gameChoice = ProtonDefault.gameChoice(this, game.profileKey)
|
||||
return SelectedGameProfile.resolve(
|
||||
selectedAppId = selectedAppId,
|
||||
games = games,
|
||||
generalFexPreset = SessionPrefs.fexPreset(this),
|
||||
environment = GameEnvironmentStore.read(this),
|
||||
snapshot = snapshot,
|
||||
generalProtonId = ProtonDefault.selectedId(this, protons),
|
||||
gameProtonChoice = gameChoice,
|
||||
gameProtonId = ProtonDefault.gameSelectedId(this, game.profileKey, protons),
|
||||
gameComponents = ComponentsManager.gameComponents(this, game.profileKey),
|
||||
gameWindowsComponents = WinComponents.picks(this, game.profileKey),
|
||||
)
|
||||
}
|
||||
|
||||
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
@@ -946,43 +787,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, "drawer-components-swap").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameComponent(appId: Long, comp: String, file: String?) {
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
Thread({
|
||||
val message = runCatching {
|
||||
ComponentsManager.setGameComponent(this, appId.toString(), comp, file)
|
||||
getString(R.string.comp_game_saved_next, name)
|
||||
}.getOrElse { e ->
|
||||
getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
uiHandler.post {
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
refreshDrawerComponents()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-component").start()
|
||||
}
|
||||
|
||||
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
|
||||
if (drawerGameFexPresetSaving) return
|
||||
drawerGameFexPresetSaving = true
|
||||
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
|
||||
Thread({
|
||||
val message = try {
|
||||
GameEnvironmentStore.setGameFexPreset(this, appId.toString(), preset)
|
||||
getString(R.string.comp_game_saved_next, name)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Could not save the game FEX preset for $appId", error)
|
||||
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
|
||||
}
|
||||
uiHandler.post {
|
||||
drawerGameFexPresetSaving = false
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
refreshDrawerGameProfile()
|
||||
}
|
||||
}, "drawer-game-fex-preset").start()
|
||||
}
|
||||
|
||||
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
|
||||
private fun loadingMode(): String =
|
||||
if (SessionState.running) SessionState.mode
|
||||
|
||||
@@ -85,63 +85,6 @@ object AgentGuest {
|
||||
fun focus(context: Context): JSONObject? =
|
||||
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
|
||||
|
||||
/** The Steam library game whose tile currently has controller or keyboard focus. */
|
||||
fun selectedSteamAppId(context: Context): Long? {
|
||||
val result = call(context, JSONObject().put("kind", "steam-selection"), 6_000)
|
||||
if (!result.optBoolean("ok") || result.isNull("appId")) return null
|
||||
return result.optLong("appId").takeIf { it in 1..4_294_967_295L }
|
||||
}
|
||||
|
||||
/** The installed game Gamescope is presenting, including while Steam's overlay has focus. */
|
||||
fun activeSteamAppId(context: Context, installedAppIds: Set<Long>): Long? {
|
||||
val snapshot = focus(context) ?: return null
|
||||
val focusedAppId = snapshot.optLong("focusedApp").takeIf { snapshot.has("focusedApp") }
|
||||
val focusableAppIds = snapshot.optJSONArray("focusableApps")?.let { apps ->
|
||||
buildList {
|
||||
for (index in 0 until apps.length()) {
|
||||
apps.optLong(index).takeIf { it > 0 }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
val baselayerAppIds = snapshot.optJSONArray("baselayerAppIds")?.let { apps ->
|
||||
buildList {
|
||||
for (index in 0 until apps.length()) {
|
||||
apps.optLong(index).takeIf { it > 0 }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
return activeInstalledSteamAppId(focusedAppId, focusableAppIds, installedAppIds, baselayerAppIds)
|
||||
}
|
||||
|
||||
internal fun focusedInstalledSteamAppId(focusedAppId: Long?, installedAppIds: Set<Long>): Long? =
|
||||
focusedAppId?.takeIf { it in installedAppIds }
|
||||
|
||||
internal fun activeInstalledSteamAppId(
|
||||
focusedAppId: Long?,
|
||||
focusableAppIds: List<Long>,
|
||||
installedAppIds: Set<Long>,
|
||||
baselayerAppIds: List<Long> = emptyList(),
|
||||
): Long? = focusedInstalledSteamAppId(focusedAppId, installedAppIds)
|
||||
?: focusableAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
|
||||
?: baselayerAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
|
||||
|
||||
internal fun preferredInstalledSteamAppId(
|
||||
selectedAppId: Long?,
|
||||
activeAppId: Long?,
|
||||
installedAppIds: Set<Long>,
|
||||
): Long? = selectedAppId?.takeIf { it in installedAppIds }
|
||||
?: activeAppId?.takeIf { it in installedAppIds }
|
||||
|
||||
internal fun selectedOrActiveInstalledSteamAppId(
|
||||
selectedAppId: Result<Long?>,
|
||||
activeAppId: Long?,
|
||||
installedAppIds: Set<Long>,
|
||||
): Long? {
|
||||
preferredInstalledSteamAppId(selectedAppId.getOrNull(), activeAppId, installedAppIds)?.let { return it }
|
||||
selectedAppId.exceptionOrNull()?.let { throw it }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
|
||||
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
|
||||
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
|
||||
|
||||
@@ -30,11 +30,4 @@ object FexPreset {
|
||||
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
|
||||
val environmentKeys: Set<String> by lazy {
|
||||
all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') }
|
||||
}
|
||||
|
||||
fun environment(id: String): Map<String, String> =
|
||||
env(id).associate { it.substringBefore('=') to it.substringAfter('=') }
|
||||
}
|
||||
@@ -46,37 +46,4 @@ object GameEnvironment {
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
|
||||
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
|
||||
fun hasOverride(config: Config, scope: String, name: String): Boolean =
|
||||
scope.isNotEmpty() && config.entries(scope).containsKey(name)
|
||||
|
||||
fun withScopeValue(config: Config, scope: String, name: String, value: String?): Config {
|
||||
require(scope.isEmpty() || validScope(scope))
|
||||
val entries = config.entries(scope).toMutableMap()
|
||||
if (value == null) entries.remove(name) else entries[name] = value
|
||||
return config.withEntries(scope, entries)
|
||||
}
|
||||
|
||||
fun gameFexPreset(config: Config, scope: String): String? {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
val entries = config.games[scope].orEmpty()
|
||||
if (FexPreset.environmentKeys.none(entries::containsKey)) return null
|
||||
return FexPreset.all.firstOrNull { preset ->
|
||||
val expected = FexPreset.environment(preset.id)
|
||||
FexPreset.environmentKeys.all { key -> entries.containsKey(key) && entries[key] == expected[key] }
|
||||
}?.id ?: CUSTOM_FEX_PRESET
|
||||
}
|
||||
|
||||
fun withGameFexPreset(config: Config, scope: String, preset: String?): Config {
|
||||
require(scope.isNotEmpty() && validScope(scope))
|
||||
require(preset == null || FexPreset.all.any { it.id == preset })
|
||||
val entries = config.entries(scope).filterKeys { it !in FexPreset.environmentKeys }.toMutableMap()
|
||||
if (preset != null) {
|
||||
val values = FexPreset.environment(preset)
|
||||
FexPreset.environmentKeys.forEach { key -> entries[key] = values[key] }
|
||||
}
|
||||
return config.withEntries(scope, entries)
|
||||
}
|
||||
|
||||
const val CUSTOM_FEX_PRESET = "__custom__"
|
||||
}
|
||||
@@ -31,8 +31,6 @@ object Library {
|
||||
) {
|
||||
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
|
||||
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
|
||||
/** Key used by per-game environment and Windows-component profiles. */
|
||||
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
|
||||
}
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
|
||||
const val ADDED = "added"
|
||||
|
||||
@@ -50,8 +50,7 @@ public final class TurnipDriver {
|
||||
/** Adreno 8xx (8 Elite, e.g. a Fold 8's A830). */
|
||||
private static final String DRIVER_A8XX = "turnip-sdk36";
|
||||
/** The two the apk carries; never removable, unpacked on demand. */
|
||||
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(
|
||||
DRIVER_A7XX, DRIVER_A8XX, com.droiddeck.launcher.session.QuestExperimental.DRIVER));
|
||||
public static final List<String> BUNDLED = Collections.unmodifiableList(Arrays.asList(DRIVER_A7XX, DRIVER_A8XX));
|
||||
/** Overrides the pick, for a device we cannot reach: a7xx, a8xx or system. */
|
||||
private static final String OVERRIDE_FILE = "Download/droiddeck-driver";
|
||||
/** Bundled drivers the user deleted: hidden from the list, their unpacked copy removed. */
|
||||
|
||||
@@ -29,7 +29,9 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*
|
||||
* <p>Each further controller is another player: slots 1 to 3, an Xbox 360 pad each, which in a
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input.
|
||||
* Steam session only the client reads (libfakeinput) and hands games through Steam Input. Slot 0
|
||||
* is the device's own controls wherever it has them: a Bluetooth pad pressed first used to take
|
||||
* it, and played as the handheld while its built-in pad became player 2.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
@@ -101,6 +103,17 @@ public final class PadBridge {
|
||||
|
||||
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
|
||||
public static final int NO_CONTROLLER = -1;
|
||||
|
||||
// AYN's handhelds (the Thor) present their own pad as a USB device Android calls external,
|
||||
// beside the 2020:0111 it re-presents each Bluetooth Xbox pad as.
|
||||
private static final int AYN_VENDOR = 0x2020;
|
||||
private static final int AYN_BUILT_IN_PAD = 0x0112;
|
||||
|
||||
/** Whether this controller is the device's own controls rather than a pad connected to it. */
|
||||
static boolean isBuiltIn(InputDevice device) {
|
||||
return !device.isExternal()
|
||||
|| (device.getVendorId() == AYN_VENDOR && device.getProductId() == AYN_BUILT_IN_PAD);
|
||||
}
|
||||
/**
|
||||
* The Android input device id of the physical controller that last drove any slot, or
|
||||
* {@link #NO_CONTROLLER}: where rumble goes when it names no slot of ours (RumbleComponent).
|
||||
@@ -411,6 +424,19 @@ public final class PadBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every connected controller its slot now, so the client lists each one as it is
|
||||
* connected instead of on its first press: a further player's pad is plugged in, at rest.
|
||||
*/
|
||||
public synchronized void claimConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (!isFromController(device)) continue;
|
||||
int slot = slotFor(device);
|
||||
if (slot > 0 && playerWriters[slot] == null) publish(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller went away: its player's buttons are let go, and a further player's pad is unplugged. */
|
||||
public synchronized void onDeviceRemoved(int deviceId) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
@@ -471,23 +497,55 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/** The slot this controller plays in: the one it has, else the first free one, else player 1's. */
|
||||
/**
|
||||
* The slot this controller plays in: the one it has, else slot 0 for the device's own controls
|
||||
* and a further player's slot for a connected pad (slot 0 too while the device has no controls
|
||||
* of its own), else player 1's.
|
||||
*/
|
||||
private int slotFor(InputDevice device) {
|
||||
int id = device.getId();
|
||||
if (owners[0] == id) return 0;
|
||||
for (int slot = 1; slot < SLOTS; slot++) {
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] == id) return slot;
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot);
|
||||
}
|
||||
for (int slot = 0; slot < SLOTS; slot++) {
|
||||
if (isBuiltIn(device)) {
|
||||
// A connected pad playing as the device's own controls moves to a player's slot of its own.
|
||||
int was = owners[0];
|
||||
if (was != NO_CONTROLLER) {
|
||||
free(0);
|
||||
InputDevice other = InputDevice.getDevice(was);
|
||||
if (other != null && !isBuiltIn(other)) {
|
||||
int to = claim(other, 1);
|
||||
if (to > 0) publish(to);
|
||||
}
|
||||
}
|
||||
owners[0] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player 1, the device's own controls");
|
||||
return 0;
|
||||
}
|
||||
int slot = claim(device, builtInConnected() ? 1 : 0);
|
||||
return slot >= 0 ? slot : 0;
|
||||
}
|
||||
|
||||
/** Takes the first free slot from {@code from} for this controller, or -1 if none is free. */
|
||||
private int claim(InputDevice device, int from) {
|
||||
for (int slot = from; slot < SLOTS; slot++) {
|
||||
if (owners[slot] != NO_CONTROLLER) continue;
|
||||
owners[slot] = id;
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
|
||||
owners[slot] = device.getId();
|
||||
Log.i(TAG, "\"" + device.getName() + "\" (id " + device.getId() + ") is player " + (slot + 1));
|
||||
return slot;
|
||||
}
|
||||
return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static boolean builtInConnected() {
|
||||
for (int id : InputDevice.getDeviceIds()) {
|
||||
InputDevice device = InputDevice.getDevice(id);
|
||||
if (isFromController(device) && isBuiltIn(device)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PadState stateFor(int slot) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
*/
|
||||
object BwrapSpawner {
|
||||
private const val TAG = "BwrapSpawner"
|
||||
fun socket(context: Context): String = "${context.packageName}.bwrap"
|
||||
const val SOCKET = "com.droiddeck.launcher.bwrap"
|
||||
private val seq = AtomicInteger()
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
|
||||
@@ -42,11 +42,10 @@ object BwrapSpawner {
|
||||
fun start(context: Context) {
|
||||
if (server != null) return
|
||||
val app = context.applicationContext
|
||||
val name = socket(app)
|
||||
val socket = try {
|
||||
LocalServerSocket(name)
|
||||
LocalServerSocket(SOCKET)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "cannot listen on $name", e)
|
||||
Log.e(TAG, "cannot listen on $SOCKET", e)
|
||||
return
|
||||
}
|
||||
server = socket
|
||||
|
||||
@@ -110,9 +110,6 @@ object DesktopCatalog {
|
||||
return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists()
|
||||
}
|
||||
|
||||
/** The installed desktop's size on disk; off the main thread. */
|
||||
fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context)
|
||||
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) =
|
||||
DesktopRemoval.cleanLegacy(context, listener)
|
||||
|
||||
|
||||
@@ -19,14 +19,6 @@ internal object DesktopRemoval {
|
||||
private fun protected(context: Context, root: File) =
|
||||
DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip")
|
||||
|
||||
/**
|
||||
* What the desktop's own files take on disk, from the same inventory its removal uses (each
|
||||
* file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset:
|
||||
* call it off the main thread.
|
||||
*/
|
||||
fun installedSize(context: Context): Long =
|
||||
lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L }
|
||||
|
||||
/** Called before a guest starts or just after KDE extraction; retries after a process death. */
|
||||
fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
|
||||
@@ -51,7 +51,6 @@ object GuestCommand {
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${LinuxRuntime.GUEST_RUNTIME_DIR}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
cmd += argv
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.Downloader
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdInputStream
|
||||
@@ -80,14 +79,7 @@ object ComponentsManager {
|
||||
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
|
||||
|
||||
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
|
||||
data class Component(
|
||||
val detected: String,
|
||||
val detail: String,
|
||||
val inUse: String,
|
||||
val selected: String,
|
||||
val activeFile: String?,
|
||||
val queued: String?,
|
||||
)
|
||||
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
|
||||
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
|
||||
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
|
||||
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
|
||||
@@ -137,121 +129,23 @@ object ComponentsManager {
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
private fun pendingStateFile(context: Context) = File(dataDir(context), "state.pending.json")
|
||||
|
||||
private fun loadState(context: Context): JSONObject {
|
||||
recoverState(context)
|
||||
return runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
|
||||
private fun loadState(context: Context): JSONObject =
|
||||
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
|
||||
if (!has("active")) put("active", JSONObject())
|
||||
if (!has("queued")) put("queued", JSONObject())
|
||||
if (!has("profiles")) put("profiles", JSONObject())
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeStateFile(file: File, text: String) {
|
||||
val tmp = File(file.parentFile, file.name + ".tmp")
|
||||
try {
|
||||
writeSynced(tmp, text)
|
||||
check(tmp.renameTo(file)) { "Could not save component selections: ${file.name}" }
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeSynced(file: File, text: String) {
|
||||
FileOutputStream(file).use {
|
||||
it.write(text.toByteArray(Charsets.UTF_8))
|
||||
it.fd.sync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recoverState(context: Context) {
|
||||
val pending = pendingStateFile(context)
|
||||
if (!pending.exists()) return
|
||||
val transaction = JSONObject(pending.readText())
|
||||
val state = transaction.getJSONObject("state")
|
||||
val expected = transaction.getString("launchSha256")
|
||||
val published = File(launchDir(context), "desired.tsv")
|
||||
val staged = File(launchDir(context), "desired.tsv.tmp")
|
||||
// The launch-table rename is the commit point. Never read stale app selections after it.
|
||||
if (!staged.exists() && published.isFile && Hashes.sha256(published) == expected) {
|
||||
writeStateFile(stateFile(context), state.toString(2))
|
||||
Log.i(TAG, "recovered published component selections")
|
||||
} else {
|
||||
Log.i(TAG, "discarded unpublished component selections")
|
||||
}
|
||||
check(pending.delete()) { "Could not finish component selection recovery" }
|
||||
if (staged.exists()) check(staged.delete()) { "Could not remove unpublished component selections" }
|
||||
}
|
||||
|
||||
private fun saveState(context: Context, state: JSONObject) {
|
||||
val prepared = prepareLaunchState(context, state)
|
||||
val dir = launchDir(context)
|
||||
val tmp = File(dir, "desired.tsv.tmp")
|
||||
try {
|
||||
writeSynced(tmp, prepared.text)
|
||||
writeStateFile(pendingStateFile(context), JSONObject()
|
||||
.put("state", state)
|
||||
.put("launchSha256", Hashes.sha256(tmp))
|
||||
.toString())
|
||||
check(tmp.renameTo(File(dir, "desired.tsv"))) { "Could not publish component selections" }
|
||||
writeStateFile(stateFile(context), state.toString(2))
|
||||
check(pendingStateFile(context).delete()) { "Could not finish saving component selections" }
|
||||
} finally {
|
||||
if (!pendingStateFile(context).exists()) tmp.delete()
|
||||
}
|
||||
File(dir, "store").listFiles()?.filter { it.isDirectory && it.name !in prepared.keep }
|
||||
?.forEach { it.deleteRecursively() }
|
||||
val f = stateFile(context)
|
||||
val tmp = File(f.parentFile, f.name + ".tmp")
|
||||
tmp.writeText(state.toString(2))
|
||||
tmp.renameTo(f)
|
||||
// Every change of choice reaches the launch wrappers at once.
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
|
||||
|
||||
// Keep launch-time restore rows after the last per-game selection is removed.
|
||||
private fun rememberProfiledComponent(state: JSONObject, comp: String) {
|
||||
val profiled = state.optJSONArray("profiledComponents")?.let { values ->
|
||||
(0 until values.length()).mapNotNull { values.optString(it).takeIf(COMPONENTS::contains) }
|
||||
}.orEmpty().toMutableSet()
|
||||
if (profiled.add(comp)) state.put("profiledComponents", JSONArray(profiled.sorted()))
|
||||
}
|
||||
|
||||
fun gameComponents(context: Context, scope: String): Map<String, String> = synchronized(lock) {
|
||||
val profile = loadState(context).sub("profiles").optJSONObject(scope) ?: return emptyMap()
|
||||
COMPONENTS.mapNotNull { comp -> profile.optString(comp).takeIf(String::isNotEmpty)?.let { comp to it } }.toMap()
|
||||
}
|
||||
|
||||
fun setGameComponent(context: Context, scope: String, comp: String, file: String?) = synchronized(lock) {
|
||||
require(scope.matches(Regex("[1-9][0-9]{0,9}")) && scope.toLong() <= 4294967295L)
|
||||
require(comp in COMPONENTS)
|
||||
val state = loadState(context)
|
||||
val profiles = state.sub("profiles")
|
||||
val profile = profiles.optJSONObject(scope) ?: JSONObject().also { profiles.put(scope, it) }
|
||||
if (file == null) {
|
||||
if (profile.optString(comp).isNotEmpty()) rememberProfiledComponent(state, comp)
|
||||
profile.remove(comp)
|
||||
if (profile.length() == 0) profiles.remove(scope)
|
||||
} else {
|
||||
val name = safeName(file)
|
||||
val wcp = File(packagesDir(context), name)
|
||||
require(packageInfo(context, wcp).comp == comp)
|
||||
for (proton in protons(context)) ensureOriginal(context, proton, comp, state)
|
||||
rememberProfiledComponent(state, comp)
|
||||
profile.put(comp, name)
|
||||
}
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
fun clearGameComponents(context: Context, scope: String) = synchronized(lock) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val state = loadState(context)
|
||||
val profiles = state.sub("profiles")
|
||||
profiles.optJSONObject(scope)?.let { profile ->
|
||||
COMPONENTS.filter { profile.optString(it).isNotEmpty() }
|
||||
.forEach { rememberProfiledComponent(state, it) }
|
||||
}
|
||||
profiles.remove(scope)
|
||||
saveState(context, state)
|
||||
}
|
||||
|
||||
/** Takes over the Decky plugin's packages, originals and swap records, once. */
|
||||
private fun migratePlugin(context: Context) {
|
||||
val marker = File(dataDir(context), ".plugin-imported")
|
||||
@@ -326,33 +220,24 @@ object ComponentsManager {
|
||||
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
|
||||
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
|
||||
*/
|
||||
fun inUse(context: Context, proton: Proton): Boolean =
|
||||
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = false) }
|
||||
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
|
||||
|
||||
/**
|
||||
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
|
||||
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
|
||||
*/
|
||||
fun gameRunning(context: Context, proton: Proton): Boolean =
|
||||
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = true) }
|
||||
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
|
||||
|
||||
internal fun processUsesProton(paths: List<String>, text: String, gameOnly: Boolean): Boolean =
|
||||
paths.any { path ->
|
||||
val needle = path.trimEnd('/') + "/"
|
||||
if (gameOnly) text.contains(needle + "proton waitforexitandrun")
|
||||
else text.contains(needle + "proton ") || text.contains(needle + "files/bin")
|
||||
}
|
||||
|
||||
private fun anyProcess(context: Context, proton: Proton, match: (List<String>, String) -> Boolean): Boolean {
|
||||
val guestPaths = listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)
|
||||
val paths = (guestPaths + guestPaths.map { host(context, it).absolutePath } + proton.dir.absolutePath).distinct()
|
||||
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
|
||||
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
|
||||
.map { it.trimEnd('/') + "/" }
|
||||
val procs = File("/proc").listFiles() ?: return false
|
||||
for (p in procs) {
|
||||
if (!p.name.all(Char::isDigit)) continue
|
||||
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
|
||||
if (cmd.isEmpty()) continue
|
||||
val text = String(cmd).replace('\u0000', ' ')
|
||||
if (match(paths, text)) return true
|
||||
if (needles.any { match(it, text) }) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -376,7 +261,7 @@ object ComponentsManager {
|
||||
private fun compFiles(dir: File, comp: String): List<String> {
|
||||
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
|
||||
val base = File(dir, COMP_DIR.getValue(comp))
|
||||
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).invariantSeparatorsPath }.sorted().toList()
|
||||
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
|
||||
}
|
||||
|
||||
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
|
||||
@@ -467,7 +352,7 @@ object ComponentsManager {
|
||||
tar.closeArchiveEntry()
|
||||
}
|
||||
}
|
||||
check(tmp.renameTo(dest)) { "Could not save original bundle ${dest.name}" }
|
||||
tmp.renameTo(dest)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ originals
|
||||
@@ -495,18 +380,8 @@ object ComponentsManager {
|
||||
if (dest.isFile) return
|
||||
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
if (active != null && active.optString("protonVersion") == p.version) return
|
||||
val published = File(launchDir(context), "desired.tsv")
|
||||
if (published.isFile) {
|
||||
val mayHaveOverride = published.useLines { rows ->
|
||||
rows.any { row ->
|
||||
val fields = row.split('\t')
|
||||
fields.size == 7 && fields[0] != "*" && fields[1] == p.dir.name &&
|
||||
fields[2] == p.version && fields[3] == comp
|
||||
}
|
||||
}
|
||||
check(!mayHaveOverride) { context.getString(R.string.cmgr_original_missing) }
|
||||
}
|
||||
val rels = compFiles(p.dir, comp)
|
||||
if (rels.isEmpty()) return
|
||||
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
|
||||
val profile = JSONObject()
|
||||
.put("type", TYPE_OF.getValue(comp))
|
||||
@@ -525,7 +400,7 @@ object ComponentsManager {
|
||||
for (p in protons(context)) for (comp in COMPONENTS) {
|
||||
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
|
||||
}
|
||||
runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ view
|
||||
@@ -556,14 +431,9 @@ object ComponentsManager {
|
||||
else context.getString(R.string.cmgr_changed_outside)
|
||||
}
|
||||
}
|
||||
val selectedLabel = when {
|
||||
active == null || active.optString("protonVersion") != p.version ->
|
||||
context.getString(R.string.comp_tag_original)
|
||||
else -> originalLabel(context, active) ?: active.optString("label", active.optString("file"))
|
||||
}
|
||||
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
|
||||
Component(
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, selectedLabel,
|
||||
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
|
||||
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
|
||||
q?.let { originalLabel(context, it) ?: it.optString("label") },
|
||||
)
|
||||
@@ -585,111 +455,64 @@ object ComponentsManager {
|
||||
|
||||
// ------------------------------------------------------------------ launch-time enforcement
|
||||
|
||||
private data class PreparedLaunchState(val text: String, val keep: Set<String>)
|
||||
|
||||
/**
|
||||
* Stages what each Proton should use for the launch wrappers. General rows use scope `*`;
|
||||
* per-game rows use the game's compatdata id and replace General for that component. Deletion
|
||||
* rows remove managed DLLs absent from the chosen package, so switching games cannot leave files
|
||||
* from the previous game's component set behind.
|
||||
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
|
||||
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
|
||||
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
|
||||
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
|
||||
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
|
||||
* what overwrote files in the first place.
|
||||
*/
|
||||
private fun prepareLaunchState(context: Context, state: JSONObject): PreparedLaunchState {
|
||||
private fun syncLaunchState(context: Context, state: JSONObject) {
|
||||
val dir = launchDir(context)
|
||||
val store = File(dir, "store").apply { mkdirs() }
|
||||
val all = protons(context)
|
||||
val lines = StringBuilder()
|
||||
val keep = HashSet<String>()
|
||||
|
||||
fun unpack(key: String, wcp: File, comp: String): File {
|
||||
check(wcp.isFile) { context.getString(R.string.cmgr_not_stored) }
|
||||
val unpacked = File(store, key)
|
||||
if (!File(unpacked, ".complete").isFile) {
|
||||
check(packageInfo(context, wcp).comp == comp) { "Wrong component in ${wcp.name}" }
|
||||
unpacked.deleteRecursively()
|
||||
check(unpacked.mkdirs()) { "Could not stage component bundle ${wcp.name}" }
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
|
||||
val out = File(unpacked, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
}
|
||||
File(unpacked, ".complete").writeText("1\n")
|
||||
}
|
||||
keep += key
|
||||
return unpacked
|
||||
}
|
||||
|
||||
fun managed(comp: String): Set<String> = if (comp == "fex") FEX_FILES.toSet() else buildSet {
|
||||
for (arch in PE_ARCHES) for (dll in COMP_DLLS.getValue(comp)) add("${COMP_DIR.getValue(comp)}/$arch/$dll")
|
||||
}
|
||||
|
||||
fun append(scope: String, p: Proton, comp: String, key: String, wcp: File) {
|
||||
val unpacked = unpack(key, wcp, comp)
|
||||
val files = unpacked.let { base ->
|
||||
File(base, "files").walkTopDown().filter { it.isFile }.map { it.relativeTo(base).invariantSeparatorsPath }
|
||||
.filter { owned(comp, it) }.associateWith { File(base, it) }
|
||||
}
|
||||
for (rel in managed(comp) - files.keys) {
|
||||
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
|
||||
.append('\t').append(rel).append("\t-\t-\n")
|
||||
}
|
||||
for ((rel, src) in files) {
|
||||
val guestSrc = "/" + src.relativeTo(root(context)).invariantSeparatorsPath
|
||||
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
|
||||
.append('\t').append(rel).append('\t').append(Hashes.sha256(src)).append('\t').append(guestSrc).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
fun choice(p: Proton, comp: String, entry: JSONObject): Pair<String, File>? {
|
||||
val file = entry.optString("file")
|
||||
return if (file.startsWith("original ")) {
|
||||
val version = safeName(file.removePrefix("original "))
|
||||
"original-${p.id}-$version-$comp" to File(originalsDir(context), "${p.id}/$version/$comp.wcp")
|
||||
} else {
|
||||
safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
|
||||
}.takeIf { it.second.isFile }
|
||||
}
|
||||
|
||||
val profiles = state.sub("profiles")
|
||||
val profiledComponents = buildSet {
|
||||
state.optJSONArray("profiledComponents")?.let { stored ->
|
||||
for (index in 0 until stored.length()) {
|
||||
stored.optString(index).takeIf(COMPONENTS::contains)?.let(::add)
|
||||
}
|
||||
}
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
COMPONENTS.filterTo(this) { profile.optString(it).isNotEmpty() }
|
||||
}
|
||||
}
|
||||
val active = state.sub("active")
|
||||
for (p in all) for (comp in COMPONENTS) {
|
||||
val entry = active.optJSONObject(p.id)?.optJSONObject(comp)
|
||||
val selected = entry?.takeIf { it.optString("protonVersion") == p.version }?.let { choice(p, comp, it) }
|
||||
if (selected != null) append("*", p, comp, selected.first, selected.second)
|
||||
else if (comp in profiledComponents) {
|
||||
ensureOriginal(context, p, comp, state)
|
||||
val original = originalFile(context, p, comp)
|
||||
check(original.isFile) { context.getString(R.string.cmgr_original_missing) }
|
||||
append("*", p, comp, "original-${p.id}-${safeName(p.version)}-$comp", original)
|
||||
}
|
||||
}
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
for (comp in COMPONENTS) {
|
||||
val file = profile.optString(comp)
|
||||
if (file.isEmpty()) continue
|
||||
val wcp = File(packagesDir(context), file)
|
||||
for (pid in active.keys()) {
|
||||
val p = all.firstOrNull { it.id == pid } ?: continue
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) {
|
||||
val a = comps.optJSONObject(comp) ?: continue
|
||||
if (a.optString("protonVersion") != p.version) continue
|
||||
val file = a.optString("file")
|
||||
val (key, wcp) = if (file.startsWith("original ")) {
|
||||
val v = file.removePrefix("original ")
|
||||
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
|
||||
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
|
||||
if (!wcp.isFile) continue
|
||||
val key = safeName(file.removeSuffix(".wcp"))
|
||||
for (p in all) append(scope, p, comp, key, wcp)
|
||||
val unpacked = File(store, key)
|
||||
if (!File(unpacked, ".complete").isFile) {
|
||||
unpacked.deleteRecursively()
|
||||
readWcp(wcp) { tar ->
|
||||
while (true) {
|
||||
val e = tar.nextTarEntry ?: break
|
||||
val rel = normalize(e.name)
|
||||
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
|
||||
val out = File(unpacked, rel)
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { tar.copyTo(it) }
|
||||
}
|
||||
}
|
||||
File(unpacked, ".complete").writeText("1\n")
|
||||
}
|
||||
keep += key
|
||||
val files = a.optJSONObject("files") ?: continue
|
||||
for (rel in files.keys()) {
|
||||
val src = File(unpacked, rel)
|
||||
if (!src.isFile) continue
|
||||
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
|
||||
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
|
||||
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
return PreparedLaunchState(lines.toString(), keep)
|
||||
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
|
||||
val list = File(dir, "desired.tsv")
|
||||
val tmp = File(dir, "desired.tsv.tmp")
|
||||
tmp.writeText(lines.toString())
|
||||
tmp.renameTo(list)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ actions
|
||||
@@ -792,11 +615,6 @@ object ComponentsManager {
|
||||
val comps = active.optJSONObject(pid) ?: continue
|
||||
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
|
||||
}
|
||||
val profiles = state.sub("profiles")
|
||||
for (scope in profiles.keys()) {
|
||||
val profile = profiles.optJSONObject(scope) ?: continue
|
||||
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
|
||||
}
|
||||
val f = File(packagesDir(context), name)
|
||||
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
|
||||
f.delete()
|
||||
|
||||
@@ -24,12 +24,6 @@ object CrashHandler {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
|
||||
try {
|
||||
Log.e(TAG, "uncaught on ${thread.name}", error)
|
||||
if (QuestExperimental.isSupported()) runCatching {
|
||||
File(context.filesDir, "quest-last-crash.txt").writeText(
|
||||
"${java.time.Instant.now()} thread=${thread.name}\n" +
|
||||
StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
||||
)
|
||||
}
|
||||
val dir = SessionPaths.current()
|
||||
if (dir != null && dir.isDirectory) {
|
||||
val trace = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
|
||||
|
||||
@@ -13,7 +13,6 @@ object GameEnvironmentStore {
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
|
||||
const val DXVK_CONFIG = "dxvkConfig"
|
||||
const val DXVK_CONFIG_GAMES = "dxvkConfigGames"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
@@ -58,13 +57,6 @@ object GameEnvironmentStore {
|
||||
publish(context, config)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setGameFexPreset(context: Context, scope: String, preset: String?): GameEnvironment.Config {
|
||||
val config = GameEnvironment.withGameFexPreset(read(context), scope, preset)
|
||||
save(context, config)
|
||||
return config
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
|
||||
@@ -76,21 +68,6 @@ object GameEnvironmentStore {
|
||||
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
|
||||
)
|
||||
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
|
||||
val autoBias = TextureFiltering.autoLodBias(SessionState.upscaleRatio)
|
||||
val generalAnisotropy = SessionPrefs.textureAnisotropy(context)
|
||||
val generalLodBias = SessionPrefs.textureLodBias(context)
|
||||
val gameDxvk = JSONObject()
|
||||
SessionPrefs.gameTextureFiltering(context).forEach { (scope, choice) ->
|
||||
gameDxvk.put(
|
||||
scope,
|
||||
TextureFiltering.dxvkOptions(
|
||||
choice.anisotropy ?: generalAnisotropy,
|
||||
choice.lodBias ?: generalLodBias,
|
||||
autoBias,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class GameProfileFollowState {
|
||||
var followsSteam = true
|
||||
private set
|
||||
|
||||
fun selectManually() {
|
||||
followsSteam = false
|
||||
}
|
||||
|
||||
fun resumeFollowingSteam() {
|
||||
followsSteam = true
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class GameProfileInventoryCache<T : Any> {
|
||||
private val lock = Any()
|
||||
|
||||
private var revision: Long? = null
|
||||
private var value: List<T>? = null
|
||||
|
||||
// Called only by the refresh worker; the UI invalidates through request revisions.
|
||||
fun get(requestedRevision: Long, load: () -> List<T>): List<T> = synchronized(lock) {
|
||||
value?.takeIf { revision == requestedRevision } ?: load().toList().also {
|
||||
value = it
|
||||
revision = requestedRevision
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
|
||||
object GameProfileManager {
|
||||
@Synchronized
|
||||
fun reset(context: Context, scope: String) {
|
||||
require(scope.isNotEmpty() && GameEnvironment.validScope(scope))
|
||||
val failures = mutableListOf<Pair<String, Throwable>>()
|
||||
fun clear(name: String, action: () -> Unit) {
|
||||
runCatching(action).onFailure { failures += name to it }
|
||||
}
|
||||
|
||||
clear("environment") {
|
||||
GameEnvironmentStore.save(
|
||||
context,
|
||||
GameEnvironmentStore.read(context).withEntries(scope, emptyMap()),
|
||||
)
|
||||
}
|
||||
clear("Proton") { ProtonDefault.requestGame(context, scope, null) }
|
||||
clear("component packages") { ComponentsManager.clearGameComponents(context, scope) }
|
||||
clear("texture filtering") { SessionPrefs.clearGameTextureFiltering(context, scope) }
|
||||
clear("Windows components") { WinComponents.setPicks(context, scope, emptyList()) }
|
||||
|
||||
if (failures.isNotEmpty()) {
|
||||
val names = failures.joinToString { it.first }
|
||||
throw IllegalStateException("Could not clear: $names", failures.first().second)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal data class GameProfileRefreshState<T>(
|
||||
val data: T? = null,
|
||||
val loaded: Boolean = false,
|
||||
val failed: Boolean = false,
|
||||
) {
|
||||
fun succeeded(data: T) = GameProfileRefreshState(data, loaded = true)
|
||||
|
||||
fun failed() = copy(failed = true)
|
||||
|
||||
fun retrying() = copy(failed = false)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
internal class LatestRequestCoordinator<T> {
|
||||
data class Request<T>(val generation: Long, val value: T)
|
||||
|
||||
private val lock = Any()
|
||||
private var generation = 0L
|
||||
private var running = false
|
||||
private var latest: Request<T>? = null
|
||||
private var pending: Request<T>? = null
|
||||
|
||||
fun submit(value: T): Boolean = synchronized(lock) {
|
||||
if (latest?.value != value) generation++
|
||||
pending = Request(generation, value).also { latest = it }
|
||||
if (running) {
|
||||
false
|
||||
} else {
|
||||
running = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun takeLatest(): Request<T>? = synchronized(lock) {
|
||||
pending.also {
|
||||
pending = null
|
||||
if (it == null) running = false
|
||||
}
|
||||
}
|
||||
|
||||
fun isLatest(request: Request<T>): Boolean = synchronized(lock) {
|
||||
request.generation == latest?.generation
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -10,12 +9,10 @@ import java.io.File
|
||||
object ProtonDefault {
|
||||
private const val TAG = "ProtonDefault"
|
||||
private const val DIR = "root/.local/share/droiddeck-compat"
|
||||
private const val GAMES = "games.json"
|
||||
|
||||
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
|
||||
data class Request(val seq: Long, val valve: Boolean, val dir: String)
|
||||
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
|
||||
data class GameChoice(val valve: Boolean, val dir: String)
|
||||
|
||||
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
|
||||
|
||||
@@ -63,33 +60,6 @@ object ProtonDefault {
|
||||
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
|
||||
}
|
||||
|
||||
internal fun parseGames(text: String?): Map<String, GameChoice?> {
|
||||
val root = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return emptyMap()
|
||||
if (root.optInt("version", 0) != 1) return emptyMap()
|
||||
val games = root.optJSONObject("games") ?: return emptyMap()
|
||||
return buildMap {
|
||||
games.keys().forEach { id ->
|
||||
if (!GameEnvironment.validScope(id)) return@forEach
|
||||
if (games.isNull(id)) {
|
||||
put(id, null)
|
||||
} else {
|
||||
val value = games.optJSONObject(id) ?: return@forEach
|
||||
val gameDir = value.optString("dir")
|
||||
val valve = value.opt("valve")
|
||||
if (gameDir.isNotEmpty() && valve is Boolean) put(id, GameChoice(valve, gameDir))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun gameChoice(context: Context, scope: String): GameChoice? =
|
||||
parseGames(read(File(dir(context), GAMES)))[scope]
|
||||
|
||||
fun gameSelectedId(context: Context, scope: String, protons: List<ComponentsManager.Proton>): String? {
|
||||
val pick = gameChoice(context, scope) ?: return null
|
||||
return protons.firstOrNull { it.valve == pick.valve && it.dir.name == pick.dir }?.id
|
||||
}
|
||||
|
||||
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
|
||||
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
|
||||
|
||||
@@ -113,33 +83,4 @@ object ProtonDefault {
|
||||
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
|
||||
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val d = dir(context)
|
||||
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
|
||||
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
|
||||
val games = JSONObject()
|
||||
choices.forEach { (id, choice) ->
|
||||
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
|
||||
}
|
||||
val staged = File(d, "$GAMES.staged")
|
||||
try {
|
||||
d.mkdirs()
|
||||
staged.writeText(JSONObject().put("version", 1).put("games", games).toString(1) + "\n")
|
||||
if (!staged.renameTo(File(d, GAMES))) throw java.io.IOException("rename failed")
|
||||
} catch (e: Exception) {
|
||||
staged.delete()
|
||||
Log.w(TAG, "could not write the per-game Proton request", e)
|
||||
throw e
|
||||
}
|
||||
val state = state(context)
|
||||
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
|
||||
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
|
||||
Outcome.LIVE
|
||||
} else {
|
||||
Outcome.NEXT_START
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,12 @@ import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Immediate download and installation of the optional ARM64 Proton builds. */
|
||||
object ProtonExtras {
|
||||
private const val TAG = "ProtonExtras"
|
||||
private const val NEED_BYTES = 4L * 1024 * 1024 * 1024
|
||||
private const val RELEASES = "https://api.github.com/repos/%s/releases?per_page=15"
|
||||
/** How long a release check stands before the next one goes to GitHub. */
|
||||
const val CHECK_EVERY_MS = 6L * 60 * 60 * 1000
|
||||
@Volatile
|
||||
var installInProgress = false
|
||||
private set
|
||||
@@ -26,122 +23,23 @@ object ProtonExtras {
|
||||
class Tool(val id: String, val name: String, val prefix: String, val repo: String, val assetPattern: Regex)
|
||||
|
||||
/** [sha256] from GitHub's asset digest; [sha512] the URL of a checksum file published beside it. */
|
||||
internal data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
private data class Asset(val tag: String, val name: String, val url: String, val sha512: String?, val size: Long,
|
||||
val sha256: String? = null)
|
||||
|
||||
val tools = listOf(
|
||||
Tool("ge", "GE-Proton", "GE-Proton", "GloriousEggroll/proton-ge-custom", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
Tool("cachyos", "proton-cachyos", "proton-cachyos", "CachyOS/proton-cachyos", Regex("arm64\\.tar\\.(gz|xz)$")),
|
||||
Tool("droiddeck", "DroidDeck-Proton", "DroidDeck-Proton", "The412Banner/DroidDeck-Proton", Regex("aarch64\\.tar\\.(gz|xz)$")),
|
||||
)
|
||||
|
||||
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
|
||||
private fun requests(context: Context) = File(home(context), ".bl-proton-extra")
|
||||
private fun toolsDir(context: Context) = File(home(context), ".local/share/Steam/compatibilitytools.d")
|
||||
/** The release tag each build was installed from, by directory name. */
|
||||
private fun releasesFile(context: Context) = File(context.filesDir, "proton-extras.json")
|
||||
/** The newest release seen per source, and when it was looked up. */
|
||||
private fun latestFile(context: Context) = File(context.filesDir, "proton-latest.json")
|
||||
|
||||
/** The installed build's directory name (e.g. GE-Proton11-7), the newest when there are several, or null. */
|
||||
fun installed(context: Context, tool: Tool): String? = installedIn(toolsDir(context), tool).lastOrNull()
|
||||
|
||||
/** Every installed build's directory name, oldest first. */
|
||||
fun installedAll(context: Context, tool: Tool): List<String> = installedIn(toolsDir(context), tool)
|
||||
|
||||
internal fun installedIn(dir: File, tool: Tool): List<String> =
|
||||
dir.listFiles()
|
||||
/** The installed build's directory name (e.g. GE-Proton11-7), or null. */
|
||||
fun installed(context: Context, tool: Tool): String? =
|
||||
toolsDir(context).listFiles()
|
||||
?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile }
|
||||
?.map { it.name }
|
||||
?.sortedWith { a, b -> compareVersions(a, b) }
|
||||
?: emptyList()
|
||||
|
||||
// Architecture names carry digits (aarch64, x86_64) that are not part of a version.
|
||||
private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64")
|
||||
private val NUMBER = Regex("\\d+")
|
||||
|
||||
/** The numbers in a release tag or build folder, in order: GE-Proton11-10 is 11, 10. */
|
||||
internal fun versionKey(name: String): List<Long> =
|
||||
NUMBER.findAll(ARCH.replace(name, " ")).mapNotNull { it.value.toLongOrNull() }.toList()
|
||||
|
||||
/** Orders by those numbers, so 11-10 is after 11-9; the name itself only breaks a tie. */
|
||||
internal fun compareVersions(a: String, b: String): Int {
|
||||
val x = versionKey(a)
|
||||
val y = versionKey(b)
|
||||
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i])
|
||||
return if (x.size != y.size) x.size.compareTo(y.size) else a.compareTo(b)
|
||||
}
|
||||
|
||||
/** [latest] when it is newer than every installed release, else null. Nothing installed is not an update. */
|
||||
internal fun updateFor(installed: List<String>, latest: String?): String? {
|
||||
if (latest == null || installed.isEmpty()) return null
|
||||
val newest = installed.maxWithOrNull { a, b -> compareVersions(a, b) } ?: return null
|
||||
val x = versionKey(latest)
|
||||
val y = versionKey(newest)
|
||||
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return if (x[i] > y[i]) latest else null
|
||||
return if (x.size > y.size) latest else null
|
||||
}
|
||||
|
||||
/** The release each installed build came from: the tag recorded at install, else its folder name. */
|
||||
fun installedTags(context: Context, tool: Tool): List<String> {
|
||||
val recorded = readJson(releasesFile(context))
|
||||
return installedAll(context, tool).map { recorded.optString(it).ifEmpty { it } }
|
||||
}
|
||||
|
||||
/** A newer release than the installed one, from the last check (no network), or null. */
|
||||
fun update(context: Context, tool: Tool): String? = updateFor(installedTags(context, tool), cachedLatest(context, tool))
|
||||
|
||||
fun cachedLatest(context: Context, tool: Tool): String? =
|
||||
readJson(latestFile(context)).optJSONObject(tool.id)?.optString("tag")?.ifEmpty { null }
|
||||
|
||||
/** Its own lock, not the install's: a check must not wait behind a download. */
|
||||
private val checkLock = Any()
|
||||
/** A failed look-up (offline) is not repeated on every resume, only after this long. */
|
||||
private const val RETRY_MS = 15L * 60 * 1000
|
||||
private var lastAttempt = 0L
|
||||
|
||||
/**
|
||||
* Looks up each source's newest release unless it was looked up within [CHECK_EVERY_MS] (or
|
||||
* [force]). Offline or rate limited, the last answer stands. True when anything was asked.
|
||||
*/
|
||||
fun checkLatest(context: Context, force: Boolean = false, now: Long = System.currentTimeMillis()): Boolean {
|
||||
synchronized(checkLock) {
|
||||
val file = latestFile(context)
|
||||
val cache = readJson(file)
|
||||
val due = tools.filter { tool ->
|
||||
val at = cache.optJSONObject(tool.id)?.optLong("at", 0L) ?: 0L
|
||||
force || (now - at) !in 0 until CHECK_EVERY_MS
|
||||
}
|
||||
if (due.isEmpty() || !force && (now - lastAttempt) in 0 until RETRY_MS) return false
|
||||
lastAttempt = now
|
||||
for (tool in due) {
|
||||
val tag = findLatestAsset(tool)?.tag ?: continue
|
||||
cache.put(tool.id, JSONObject().put("tag", tag).put("at", now))
|
||||
}
|
||||
runCatching { file.writeText(cache.toString()) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun record(context: Context, tool: Tool, dir: String?, tag: String) {
|
||||
runCatching {
|
||||
val latest = readJson(latestFile(context))
|
||||
val known = latest.optJSONObject(tool.id)?.optString("tag")
|
||||
if (known.isNullOrEmpty() || compareVersions(tag, known) >= 0) {
|
||||
latest.put(tool.id, JSONObject().put("tag", tag).put("at", System.currentTimeMillis()))
|
||||
latestFile(context).writeText(latest.toString())
|
||||
}
|
||||
if (dir != null) {
|
||||
val releases = readJson(releasesFile(context))
|
||||
val present = toolsDir(context).list()?.toSet() ?: emptySet()
|
||||
releases.keys().asSequence().toList().filter { it !in present }.forEach { releases.remove(it) }
|
||||
releasesFile(context).writeText(releases.put(dir, tag).toString())
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "record ${tool.id} $tag", it) }
|
||||
}
|
||||
|
||||
private fun readJson(file: File): JSONObject =
|
||||
runCatching { JSONObject(FileUtils.readString(file) ?: "{}") }.getOrDefault(JSONObject())
|
||||
?.maxByOrNull { it.name }?.name
|
||||
|
||||
/** A request left by an earlier app version, still consumed by the session shim. */
|
||||
fun queued(context: Context, tool: Tool): Boolean =
|
||||
@@ -154,15 +52,9 @@ object ProtonExtras {
|
||||
else file.writeText(lines.joinToString("\n") + "\n")
|
||||
}
|
||||
|
||||
/** Deletes one installed build ([dir], else the newest); Steam discovers its absence on its next start. */
|
||||
fun remove(context: Context, tool: Tool, dir: String? = null) {
|
||||
val name = if (dir != null) dir.takeIf { it in installedAll(context, tool) } ?: return else installed(context, tool) ?: return
|
||||
File(toolsDir(context), name).deleteRecursively()
|
||||
}
|
||||
|
||||
/** Deletes every build but the newest. */
|
||||
fun removeOlder(context: Context, tool: Tool) {
|
||||
installedAll(context, tool).dropLast(1).forEach { File(toolsDir(context), it).deleteRecursively() }
|
||||
/** Deletes the installed build; Steam discovers its absence on its next start. */
|
||||
fun remove(context: Context, tool: Tool) {
|
||||
installed(context, tool)?.let { File(toolsDir(context), it).deleteRecursively() }
|
||||
}
|
||||
|
||||
/** Downloads, verifies and registers the latest build without waiting for another session. */
|
||||
@@ -253,12 +145,9 @@ object ProtonExtras {
|
||||
hostEnv["PROOT_TMP_DIR"] = context.cacheDir.path
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { hostEnv["LD_LIBRARY_PATH"] = it }
|
||||
val child = process.start()
|
||||
var placed: String? = null
|
||||
child.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
Log.i(TAG, line)
|
||||
// "droiddeck-proton-extra: installed <folder>", the folder as the tarball names it.
|
||||
line.substringAfter("droiddeck-proton-extra: installed ", "").takeIf { it.isNotEmpty() }?.let { placed = it }
|
||||
if (line.contains("unpacking", ignoreCase = true) || line.contains("adopted", ignoreCase = true)) {
|
||||
onProgress(installing, -1)
|
||||
}
|
||||
@@ -269,7 +158,6 @@ object ProtonExtras {
|
||||
else {
|
||||
archive.delete()
|
||||
unqueue(context, tool)
|
||||
record(context, tool, placed, asset.tag)
|
||||
if (EsyncPacks.enabled(context)) {
|
||||
onProgress(context.getString(R.string.esync_fetching), -1)
|
||||
try {
|
||||
@@ -289,36 +177,32 @@ object ProtonExtras {
|
||||
private fun findLatestAsset(tool: Tool): Asset? {
|
||||
val releases = Downloader.downloadString(RELEASES.format(tool.repo)) ?: return null
|
||||
return try {
|
||||
pickAsset(tool, JSONArray(releases))
|
||||
val array = JSONArray(releases)
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false)) continue
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
|
||||
val name = archive.optString("name")
|
||||
val stem = name.substringBefore(".tar")
|
||||
val checksum = entries.firstOrNull {
|
||||
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
|
||||
return Asset(
|
||||
release.optString("tag_name"), name,
|
||||
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
|
||||
archive.optLong("size", 0L),
|
||||
Hashes.githubSha256(archive.optString("digest")),
|
||||
)
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release metadata for ${tool.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** The newest full release in GitHub's list (newest first) that carries [tool]'s ARM64 archive. */
|
||||
internal fun pickAsset(tool: Tool, array: JSONArray): Asset? {
|
||||
for (i in 0 until array.length()) {
|
||||
val release = array.getJSONObject(i)
|
||||
if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) continue
|
||||
val assets = release.optJSONArray("assets") ?: continue
|
||||
val entries = (0 until assets.length()).map { assets.getJSONObject(it) }
|
||||
val archive = entries.sortedBy { it.optString("name") }.firstOrNull { tool.assetPattern.containsMatchIn(it.optString("name")) } ?: continue
|
||||
val name = archive.optString("name")
|
||||
val stem = name.substringBefore(".tar")
|
||||
val checksum = entries.firstOrNull {
|
||||
it.optString("name").startsWith(stem) && it.optString("name").contains("sha512", ignoreCase = true)
|
||||
}?.optString("browser_download_url")?.takeIf { it.startsWith("http") }
|
||||
return Asset(
|
||||
release.optString("tag_name"), name,
|
||||
archive.optString("browser_download_url").takeIf { it.startsWith("http") } ?: continue, checksum,
|
||||
archive.optLong("size", 0L),
|
||||
Hashes.githubSha256(archive.optString("digest")),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun requestLines(context: Context): List<String> =
|
||||
FileUtils.readString(requests(context))?.lines()?.filter { it.isNotBlank() } ?: emptyList()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import java.io.File
|
||||
|
||||
/** Defaults and launch evidence for this explicitly experimental Quest build. */
|
||||
object QuestExperimental {
|
||||
private const val TAG = "QuestExperimental"
|
||||
const val DRIVER = "turnip25.1.0-quest1"
|
||||
|
||||
// Quest 2 has an A6xx GPU and cannot use this bundled A7xx driver.
|
||||
internal fun supported(model: String, device: String, manufacturer: String): Boolean =
|
||||
model.equals("Quest 3", ignoreCase = true) || model.equals("Quest 3S", ignoreCase = true) ||
|
||||
((manufacturer.equals("Oculus", ignoreCase = true) || manufacturer.equals("Meta", ignoreCase = true)) &&
|
||||
(device == "eureka" || device == "panther"))
|
||||
|
||||
fun isSupported(): Boolean = supported(Build.MODEL, Build.DEVICE, Build.MANUFACTURER)
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (!isSupported()) return
|
||||
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
|
||||
if (!prefs.getBoolean("questDefaultsV1", false)) {
|
||||
// Auto would download an unwrapped display driver and lose the compatibility path.
|
||||
// Apply once so deliberate changes made during headset testing remain possible.
|
||||
prefs.edit()
|
||||
.putString("gpuDriverMode", SessionPrefs.GPU_DRIVERS_MANUAL)
|
||||
.putString("androidDriver", DRIVER)
|
||||
.putString("linuxDriver", "")
|
||||
// The existing HDR option uses SurfaceControl presentation. This is a
|
||||
// corruption workaround from upstream issue #245, not a claim of HDR accuracy.
|
||||
.putBoolean("hdr.steam", true)
|
||||
.putBoolean("questDefaultsV1", true)
|
||||
.apply()
|
||||
}
|
||||
val report = buildString {
|
||||
appendLine("DroidDeck Quest Experimental ${BuildConfig.VERSION_NAME}")
|
||||
appendLine("package=${context.packageName}")
|
||||
appendLine("manufacturer=${Build.MANUFACTURER} model=${Build.MODEL}")
|
||||
appendLine("device=${Build.DEVICE} board=${Build.BOARD} hardware=${Build.HARDWARE}")
|
||||
appendLine("android=${Build.VERSION.RELEASE} sdk=${Build.VERSION.SDK_INT}")
|
||||
appendLine("abis=${Build.SUPPORTED_ABIS.joinToString()}")
|
||||
for (name in listOf("gpu_model", "gpu_chipid")) {
|
||||
val value = runCatching { File("/sys/class/kgsl/kgsl-3d0/$name").readText().trim() }
|
||||
.getOrDefault("unavailable")
|
||||
appendLine("$name=$value")
|
||||
}
|
||||
appendLine("driverMode=${SessionPrefs.gpuDriverMode(context)}")
|
||||
appendLine("androidDriver=${SessionPrefs.androidDriver(context)}")
|
||||
appendLine("steamSurfaceControlWorkaround=${SessionPrefs.hdr(context, "steam")}")
|
||||
appendLine("GPUOBJ fallback installed in Android driver and ARM64 libblsession")
|
||||
appendLine("Experimental Quest 3/3S compatibility; 2D panel application")
|
||||
}
|
||||
Log.i(TAG, report)
|
||||
runCatching { File(context.filesDir, "quest-startup.txt").writeText(report) }
|
||||
.onFailure { Log.w(TAG, "Could not write startup report", it) }
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
|
||||
data class ProfileValue(val value: String, val inherited: Boolean)
|
||||
|
||||
data class SelectedGameProfile(
|
||||
val appId: Long,
|
||||
val name: String,
|
||||
val fexPreset: ProfileValue,
|
||||
val proton: ProfileValue,
|
||||
val protonId: String?,
|
||||
val components: Map<String, ProfileValue>,
|
||||
val componentFiles: Map<String, String>,
|
||||
val environmentOverrides: Int,
|
||||
val windowsComponentNames: List<String>,
|
||||
) {
|
||||
val hasOverrides: Boolean
|
||||
get() = !fexPreset.inherited ||
|
||||
!proton.inherited ||
|
||||
components.values.any { !it.inherited } ||
|
||||
environmentOverrides > 0 ||
|
||||
windowsComponentNames.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
fun resolve(
|
||||
selectedAppId: Long,
|
||||
games: List<Library.SteamGame>,
|
||||
generalFexPreset: String,
|
||||
environment: GameEnvironment.Config,
|
||||
snapshot: ComponentsManager.Snapshot,
|
||||
generalProtonId: String?,
|
||||
gameProtonChoice: ProtonDefault.GameChoice?,
|
||||
gameProtonId: String?,
|
||||
gameComponents: Map<String, String>,
|
||||
gameWindowsComponents: List<String>,
|
||||
): SelectedGameProfile? {
|
||||
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
|
||||
val fexOverride = GameEnvironment.gameFexPreset(environment, game.profileKey)
|
||||
val selectedProtonId = if (gameProtonChoice == null) generalProtonId else gameProtonId
|
||||
val selectedProton = snapshot.protons.firstOrNull { it.proton.id == selectedProtonId }
|
||||
val packages = snapshot.packages.associateBy { it.file }
|
||||
val components = ComponentsManager.COMPONENTS.associateWith { component ->
|
||||
val override = gameComponents[component]
|
||||
ProfileValue(
|
||||
value = override?.let { packages[it]?.version ?: it }
|
||||
?: selectedProton?.components?.get(component)?.selected.orEmpty(),
|
||||
inherited = override == null,
|
||||
)
|
||||
}
|
||||
val otherEnvironment = environment.games[game.profileKey].orEmpty().keys - FexPreset.environmentKeys
|
||||
return SelectedGameProfile(
|
||||
appId = selectedAppId,
|
||||
name = game.name,
|
||||
fexPreset = ProfileValue(fexOverride ?: generalFexPreset, fexOverride == null),
|
||||
proton = ProfileValue(
|
||||
selectedProton?.proton?.name ?: gameProtonChoice?.dir.orEmpty(),
|
||||
gameProtonChoice == null,
|
||||
),
|
||||
protonId = selectedProtonId,
|
||||
components = components,
|
||||
componentFiles = gameComponents,
|
||||
environmentOverrides = otherEnvironment.size,
|
||||
windowsComponentNames = gameWindowsComponents.map(WinComponentNames::of),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,6 @@ object SessionFiles {
|
||||
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
|
||||
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
|
||||
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
|
||||
"usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores",
|
||||
"usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra",
|
||||
"usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager",
|
||||
"usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1",
|
||||
|
||||
@@ -4,15 +4,12 @@ import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.annotation.StringRes
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import org.json.JSONObject
|
||||
|
||||
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
|
||||
object SessionPrefs {
|
||||
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
|
||||
|
||||
const val SUSPEND_AUTO = "auto"
|
||||
const val SUSPEND_NATIVE = "native"
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
@@ -71,6 +68,20 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("animations", on).apply()
|
||||
}
|
||||
|
||||
/** The Flathub Store (beta): its rail item. Off by default. */
|
||||
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
|
||||
|
||||
fun setStoreEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */
|
||||
fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false)
|
||||
|
||||
fun setGameStoresEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gameStoresEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */
|
||||
fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast"
|
||||
|
||||
@@ -871,74 +882,6 @@ object SessionPrefs {
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
|
||||
}
|
||||
|
||||
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
|
||||
val json = runCatching {
|
||||
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
|
||||
}.getOrElse { JSONObject() }
|
||||
return buildMap {
|
||||
json.keys().forEach { scope ->
|
||||
if (!GameEnvironment.validScope(scope)) return@forEach
|
||||
val entry = json.optJSONObject(scope) ?: return@forEach
|
||||
val anisotropy = entry.optInt("anisotropy").takeIf {
|
||||
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
|
||||
}
|
||||
val lodBias = entry.optString("lodBias").takeIf {
|
||||
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
|
||||
}
|
||||
if (anisotropy != null || lodBias != null) {
|
||||
put(scope, GameTextureFiltering(anisotropy, lodBias))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
|
||||
require(value == null || value in TextureFiltering.ANISOTROPY)
|
||||
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
|
||||
}
|
||||
|
||||
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
|
||||
require(choice == null || choice in TextureFiltering.LOD_BIAS)
|
||||
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
|
||||
}
|
||||
|
||||
fun clearGameTextureFiltering(context: Context, scope: String) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val choices = gameTextureFiltering(context).toMutableMap()
|
||||
if (choices.remove(scope) == null) return
|
||||
saveGameTextureFiltering(context, choices)
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
private fun updateGameTextureFiltering(
|
||||
context: Context,
|
||||
scope: String,
|
||||
change: (GameTextureFiltering) -> GameTextureFiltering,
|
||||
) {
|
||||
require(GameEnvironment.validScope(scope))
|
||||
val choices = gameTextureFiltering(context).toMutableMap()
|
||||
val next = change(choices[scope] ?: GameTextureFiltering())
|
||||
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
|
||||
saveGameTextureFiltering(context, choices)
|
||||
publishGameEnvironment(context)
|
||||
}
|
||||
|
||||
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
|
||||
val json = JSONObject()
|
||||
choices.forEach { (id, choice) ->
|
||||
json.put(id, JSONObject().apply {
|
||||
choice.anisotropy?.let { put("anisotropy", it) }
|
||||
choice.lodBias?.let { put("lodBias", it) }
|
||||
})
|
||||
}
|
||||
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
|
||||
}
|
||||
|
||||
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
|
||||
private fun publishGameEnvironment(context: Context) {
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
|
||||
@@ -343,7 +343,6 @@ class SessionService : Service() {
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
guest.add("DROIDDECK_BWRAP_SOCKET=" + com.droiddeck.launcher.runtime.BwrapSpawner.socket(this))
|
||||
// The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the
|
||||
// same set-up as a Steam session: it gets what the client and its games are started with.
|
||||
val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
|
||||
|
||||
@@ -48,9 +48,6 @@ object StoreState {
|
||||
private set
|
||||
var searching by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */
|
||||
var openRequest by mutableStateOf<String?>(null)
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated)
|
||||
|
||||
@@ -548,10 +548,10 @@ public final class EpicDownloadManager {
|
||||
try {
|
||||
JSONObject root = new JSONObject(new String(bytes, StandardCharsets.UTF_8));
|
||||
int manifestVersion = 0;
|
||||
try { manifestVersion = (int) blobToNum(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { manifestVersion = Integer.parseInt(root.optString("ManifestFileVersion", "0")); } catch (NumberFormatException ignored) {}
|
||||
Manifest m = new Manifest();
|
||||
m.chunkDir = manifestVersion >= 15 ? "ChunksV4" : manifestVersion >= 6 ? "ChunksV3" : manifestVersion >= 3 ? "ChunksV2" : "ChunksV4";
|
||||
m.launchExe = root.optString("LaunchExeString", root.optString("LaunchExe", "")).replace('\\', '/');
|
||||
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
|
||||
m.launchCommand = root.optString("LaunchCommand", "");
|
||||
m.buildVersion = root.optString("BuildVersionString", "");
|
||||
m.prereqName = root.optString("PrereqName", "");
|
||||
@@ -568,9 +568,10 @@ public final class EpicDownloadManager {
|
||||
if (guidHex.length() < 32) continue;
|
||||
ChunkInfo c = new ChunkInfo();
|
||||
for (int k = 0; k < 4; k++) c.guid[k] = (int) Long.parseLong(guidHex.substring(k * 8, k * 8 + 8), 16);
|
||||
try { c.hash = blobToNum(chunkHashList.getString(guidHex)); } catch (NumberFormatException ignored) {}
|
||||
if (dataGroupList != null) { try { c.groupNum = (int) blobToNum(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
if (chunkFilesizeList != null) { try { c.fileSize = blobToNum(chunkFilesizeList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
String hashHex = chunkHashList.getString(guidHex);
|
||||
if (hashHex != null && hashHex.length() >= 16) { try { c.hash = Long.parseUnsignedLong(hashHex.substring(0, 16), 16); } catch (Exception ignored) {} }
|
||||
if (dataGroupList != null) { try { c.groupNum = Integer.parseInt(dataGroupList.optString(guidHex, "0")); } catch (NumberFormatException ignored) {} }
|
||||
if (chunkFilesizeList != null) { try { c.fileSize = Long.parseLong(chunkFilesizeList.optString(guidHex, "0"), 16); } catch (NumberFormatException ignored) {} }
|
||||
chunkMap.put(guidHex, c);
|
||||
}
|
||||
JSONArray fileList = root.optJSONArray("FileManifestList");
|
||||
@@ -588,8 +589,8 @@ public final class EpicDownloadManager {
|
||||
ChunkPart part = new ChunkPart();
|
||||
String g = po.optString("Guid", "");
|
||||
if (g.length() >= 32) for (int k = 0; k < 4; k++) part.guid[k] = (int) Long.parseLong(g.substring(k * 8, k * 8 + 8), 16);
|
||||
try { part.offset = (int) blobToNum(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.size = (int) blobToNum(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.offset = Integer.parseInt(po.optString("Offset", "0")); } catch (NumberFormatException ignored) {}
|
||||
try { part.size = Integer.parseInt(po.optString("Size", "0")); } catch (NumberFormatException ignored) {}
|
||||
fi.parts.add(part);
|
||||
}
|
||||
files.add(fi);
|
||||
@@ -603,24 +604,6 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON manifest writes every number as a "blob": each byte as three decimal digits, least
|
||||
* significant first ("013000000000" = 13), as Legendary's blob_to_num reads it. Bits past 64
|
||||
* are dropped; a group that is not digits throws.
|
||||
*/
|
||||
static long blobToNum(String blob) {
|
||||
long num = 0;
|
||||
for (int i = 0, shift = 0; i < blob.length(); i += 3, shift += 8) {
|
||||
String group = blob.substring(i, Math.min(i + 3, blob.length()));
|
||||
for (int k = 0; k < group.length(); k++) {
|
||||
char ch = group.charAt(k);
|
||||
if (ch < '0' || ch > '9') throw new NumberFormatException("not a blob: " + blob);
|
||||
}
|
||||
if (shift < 64) num += Long.parseLong(group) << shift;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
public static String readFString(ByteBuffer buf) {
|
||||
int length = buf.getInt();
|
||||
if (length == 0) return "";
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,455 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.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.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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Storefront
|
||||
import androidx.compose.material.icons.outlined.Terminal
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
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.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.store.UserAppsState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and
|
||||
// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and
|
||||
// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and
|
||||
// what can be added - and the hero folds into a slim header as it goes.
|
||||
|
||||
/** The desktop's own chips: its session settings, then Files. */
|
||||
private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES
|
||||
|
||||
@Composable
|
||||
internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
// Flathub's installed apps sit with the others on the dock.
|
||||
LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) }
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
val mode = SessionService.MODE_DESKTOP
|
||||
val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) }
|
||||
val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop)
|
||||
// Opened from a card: the page floods out of it, its picture on the tile.
|
||||
fun openFrom(at: Rect?, art: Any?, page: String) {
|
||||
at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) }
|
||||
onSelect(page)
|
||||
}
|
||||
|
||||
// The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word
|
||||
// and Open desktop at the foot, and nothing under the settings sheet but the hero itself.
|
||||
LiftHero(
|
||||
key = "desktop", tabs = tabs,
|
||||
onOpen = a.onDesktopSettings, onPrimary = a.onDesktop,
|
||||
modifier = modifier.clipToBounds(),
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon })
|
||||
},
|
||||
scrim = { lifted ->
|
||||
WallFade()
|
||||
// Over the fades, so the cards read at full strength; gone as the sheet lifts, so
|
||||
// nothing but the wall is behind it.
|
||||
Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) {
|
||||
DesktopDock(s, a, installed, apps, openFrom = ::openFrom)
|
||||
}
|
||||
},
|
||||
words = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// The word at the wordmark's size.
|
||||
Text(
|
||||
stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
|
||||
fontWeight = FontWeight.Black, color = colors.onBackground,
|
||||
)
|
||||
BusyChip(s)
|
||||
}
|
||||
},
|
||||
button = { click ->
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click)
|
||||
},
|
||||
beside = {
|
||||
// Flathub, whose apps run in the desktop: its storefront floods out of this button.
|
||||
HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") }
|
||||
// Add your own app or game.
|
||||
HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true }
|
||||
},
|
||||
rows = { tab ->
|
||||
if (tab == SheetTab.FILES) DesktopFiles(s, a)
|
||||
else a.modeSheet(mode, tab, SheetExtras())
|
||||
},
|
||||
)
|
||||
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps
|
||||
* added, Flathub's installed apps), then the emulators that can be added - one
|
||||
* row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopDock(
|
||||
s: FrontEndState, a: FrontEndActions, installed: List<Library.Emulator>, apps: List<UserApps.App>,
|
||||
openFrom: (Rect?, Any?, String) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val flatpaks = com.droiddeck.launcher.store.StoreState.installed
|
||||
val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" }
|
||||
// Where the last of your apps sits, for a new emulator's dot to land after it.
|
||||
var lastAt by remember { mutableStateOf<Rect?>(null) }
|
||||
var dockAt by remember { mutableStateOf<Rect?>(null) }
|
||||
val seen = remember { mutableSetOf<String>() }
|
||||
val mounted = remember { booleanArrayOf(false) }
|
||||
LaunchedEffect(Unit) { mounted[0] = true }
|
||||
val scroll = rememberScrollState()
|
||||
// Nothing yet (the emulators' catalog still on its way): no heading over an empty row.
|
||||
if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return
|
||||
Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() }
|
||||
.edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp),
|
||||
) {
|
||||
keys.forEachIndexed { i, k ->
|
||||
key(k) {
|
||||
// A card that arrives after the dock is up (an emulator just installed) opens in.
|
||||
val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true }
|
||||
LaunchedEffect(Unit) { seen += k }
|
||||
AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) {
|
||||
Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) {
|
||||
val e = installed.firstOrNull { "emu:${it.id}" == k }
|
||||
val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k }
|
||||
val app = apps.firstOrNull { "user:${it.key}" == k }
|
||||
if (e != null) {
|
||||
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
|
||||
DesktopCard(
|
||||
k, e.name,
|
||||
if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size),
|
||||
art = e.iconRes, first = i == 0,
|
||||
) { at -> openFrom(at, e.iconRes, k) }
|
||||
} else if (flatpak != null) DesktopCard(
|
||||
k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name),
|
||||
art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0,
|
||||
) { at ->
|
||||
// Its page on Flathub: open, update or remove it there.
|
||||
com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id
|
||||
openFrom(at, flatpak.icon, "store")
|
||||
} else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at ->
|
||||
openFrom(at, app.icon, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Between yours and what can be added: a thin rule.
|
||||
if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2))
|
||||
available.forEach { e ->
|
||||
key(e.id) {
|
||||
val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
val busyHere = pkg != null && s.packageBusyId == pkg.id
|
||||
val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning
|
||||
val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() }
|
||||
DesktopCard(
|
||||
"emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system,
|
||||
art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get),
|
||||
progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(),
|
||||
) { at ->
|
||||
// Get installs it there and then, and a dot flies to where its card will be;
|
||||
// when it cannot (no runtime, a session running), its page says why.
|
||||
if (canGet && pkg != null && at != null) {
|
||||
a.onInstallPackage(pkg.id)
|
||||
val end = lastAt
|
||||
val row = dockAt
|
||||
Flights.fly(Flight(at, to = {
|
||||
val r = row ?: return@Flight null
|
||||
Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y)
|
||||
}))
|
||||
} else openFrom(at, e.iconRes, "emu:${e.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) {
|
||||
Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A square button the big one's height beside it on the hero: an icon in [tint] (the palette's
|
||||
* signal when null). [onClick] gets its bounds, for a page to flood out of.
|
||||
*/
|
||||
@Composable
|
||||
private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon")
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val accent = tint ?: pal.signal
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() }
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f))
|
||||
.glideBorder(hot, shape, accent, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) }
|
||||
.controllerConfirm { onClick(at) }
|
||||
.semantics { contentDescription = description },
|
||||
) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a
|
||||
* bar along its foot and an emulator's or app's icon inside; blank windows when there are none.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopWall(icons: List<Any>) {
|
||||
val pal = LocalPalette.current
|
||||
val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) }
|
||||
TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i ->
|
||||
WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on
|
||||
* the art (Get, or how far an install is), and under it its name on up to two lines and what it
|
||||
* is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopCard(
|
||||
key: String, name: String, detail: String, art: Any?,
|
||||
fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps,
|
||||
first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null,
|
||||
onClick: (Rect?) -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard")
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
var artAt by remember { mutableStateOf<Rect?>(null) }
|
||||
val width = if (LocalNarrowPane.current) 116.dp else 132.dp
|
||||
val artH = width * 0.62f
|
||||
val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant
|
||||
Column(
|
||||
Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) }
|
||||
.controllerConfirm { onClick(artAt) }
|
||||
.semantics { contentDescription = name },
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() }
|
||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground),
|
||||
) {
|
||||
when (art) {
|
||||
is Int -> {
|
||||
Image(
|
||||
painterResource(art), null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
|
||||
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
|
||||
Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f))
|
||||
}
|
||||
null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f))
|
||||
else -> {
|
||||
AsyncImage(
|
||||
model = art, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f }
|
||||
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
|
||||
AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f))
|
||||
}
|
||||
}
|
||||
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
|
||||
if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
|
||||
if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
|
||||
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
else if (badge != null) Text(
|
||||
badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
|
||||
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
|
||||
Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old
|
||||
* tools), how much the desktop takes, and Uninstall - its only home - which asks in red first.
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) {
|
||||
val ctx = LocalContext.current
|
||||
SettingsGroup(stringResource(R.string.setup_tool_files)) {
|
||||
SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) {
|
||||
SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) {
|
||||
SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms)
|
||||
}
|
||||
}
|
||||
if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) {
|
||||
val size by produceState<Long?>(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } }
|
||||
SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {}
|
||||
SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) {
|
||||
UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */
|
||||
@Composable
|
||||
private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.common_uninstall)
|
||||
val title = stringResource(R.string.desktop_uninstall_title)
|
||||
val note = stringResource(R.string.desktop_remove_text)
|
||||
val keep = stringResource(R.string.flathub_keep)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled, compact = true) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) {
|
||||
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The add or remove under way, or why the last one failed. */
|
||||
@Composable
|
||||
private fun UserAppsProgressLine() {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val working = UserAppsState.working
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
if (working != null) {
|
||||
val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting)
|
||||
val percent = UserAppsState.percent
|
||||
Text(
|
||||
if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
} else UserAppsState.lastError?.let { Note(it) }
|
||||
}
|
||||
}
|
||||
@@ -56,21 +56,9 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import java.io.File
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
|
||||
// Cover art: posters, the art grid and its tiles.
|
||||
|
||||
/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */
|
||||
internal object TileBounds {
|
||||
val at = HashMap<String, androidx.compose.ui.geometry.Rect>()
|
||||
|
||||
/** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */
|
||||
fun mark(key: String, art: Any?, corner: Float) {
|
||||
at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) }
|
||||
}
|
||||
}
|
||||
|
||||
internal class Tile(
|
||||
val title: String, val sub: String?, val art: File?, val key: String,
|
||||
val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null,
|
||||
@@ -114,7 +102,6 @@ internal fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier)
|
||||
.onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() }
|
||||
Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -126,30 +124,15 @@ internal fun Pane(
|
||||
val paneAt = remember { arrayOf(Offset.Zero) }
|
||||
val livePage by rememberUpdatedState(s.pageKey)
|
||||
// Picking another game changes the detail beside the list, not the whole page.
|
||||
val railContent = if (selected.startsWith("app:")) "games" else selected
|
||||
val target = if (page != null && s.pageKey != null) s.pageKey else railContent
|
||||
val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected
|
||||
// The game the Games page shows. It must outlive the selection: while the page animates out,
|
||||
// drawing it with what is selected now (another rail item) turned it into a copy of that page.
|
||||
val gameShown = remember { arrayOf("games") }
|
||||
if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected
|
||||
// What the pane showed before this change: the content a page opened over belongs to it.
|
||||
val before = remember { arrayOf(target) }
|
||||
if (page != null && s.pageKey != null) {
|
||||
pages[s.pageKey] = page
|
||||
if (s.pageKey !in origins) PageOrigin.take()?.let { o ->
|
||||
// A hop's page came out of its own rail item, and is that item's content.
|
||||
origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it }
|
||||
}
|
||||
} else if (target != before[0] && target !in origins) {
|
||||
// A section's own page opened from a control in it (an emulator's card, a ROM's) floods
|
||||
// out of that control and belongs to what was showing; a hop's comes out of its rail item.
|
||||
PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } }
|
||||
if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) }
|
||||
}
|
||||
before[0] = target
|
||||
val targetNow by rememberUpdatedState(target)
|
||||
// A page leaving through a hop's link is not coming back to its control.
|
||||
val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary
|
||||
LaunchedEffect(target) { Hops.linking = false }
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() },
|
||||
@@ -160,14 +143,9 @@ internal fun Pane(
|
||||
.togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f))
|
||||
.apply { targetContentZIndex = 1f }
|
||||
// Back out of it: the flood draws back into the control as the pane comes forward again.
|
||||
initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain ->
|
||||
(fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
|
||||
.togetherWith(ExitTransition.None)
|
||||
.apply { targetContentZIndex = -1f }
|
||||
// Away to something else: the control is not coming back, so the page sinks with the pane.
|
||||
initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f))
|
||||
.apply { targetContentZIndex = 1f }
|
||||
initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f))
|
||||
.togetherWith(ExitTransition.None)
|
||||
.apply { targetContentZIndex = -1f }
|
||||
else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 })
|
||||
.togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 })
|
||||
.apply { targetContentZIndex = 1f }
|
||||
@@ -175,51 +153,26 @@ internal fun Pane(
|
||||
},
|
||||
label = "pane",
|
||||
) { key ->
|
||||
val drawn = (if (page != null && key == s.pageKey) page else pages[key])
|
||||
?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) }
|
||||
// A hop leaving this content: it sinks back as the dot leaves it (and comes forward
|
||||
// again, freshly drawn, when the hop is undone).
|
||||
val hopSink by androidx.compose.animation.core.animateFloatAsState(
|
||||
if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink",
|
||||
)
|
||||
val shown: @Composable () -> Unit = {
|
||||
Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() }
|
||||
}
|
||||
run {
|
||||
DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } }
|
||||
val shown = if (page != null && key == s.pageKey) page else pages[key]
|
||||
if (shown != null) {
|
||||
DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } }
|
||||
val from = origins[key]
|
||||
if (from == null) shown()
|
||||
else {
|
||||
// The control's own picture (a card's art, an emulator's icon) on the tile as it grows.
|
||||
val art = when (val m = from.art) {
|
||||
null -> null
|
||||
is Int -> androidx.compose.ui.res.painterResource(m)
|
||||
else -> coil.compose.rememberAsyncImagePainter(m)
|
||||
}
|
||||
val leaving = transition.targetState == EnterExitState.PostExit
|
||||
// Settled as the page starts to leave, from what is coming in, not from where it came in.
|
||||
val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain }
|
||||
// Keeps a leaving page on screen while its flood draws back into the control.
|
||||
transition.animateFloat(
|
||||
transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() },
|
||||
transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() },
|
||||
label = "pageReturn",
|
||||
) { if (it == EnterExitState.PostExit) 1f else 0f }
|
||||
PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() }
|
||||
PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() }
|
||||
}
|
||||
}
|
||||
else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's
|
||||
* height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a
|
||||
* bar that raced across from the far edge.
|
||||
*/
|
||||
internal fun inPane(o: Origin): Origin =
|
||||
if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f))
|
||||
|
||||
@Composable
|
||||
private fun Backdrop(art: File?) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -254,7 +207,6 @@ private fun Content(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp
|
||||
val narrow = LocalNarrowPane.current
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current.density
|
||||
val padH = if (narrow) 16.dp else 22.dp
|
||||
val padV = if (narrow) 12.dp else 18.dp
|
||||
// Setup scrolls inside itself, under its tabs.
|
||||
@@ -274,12 +226,12 @@ private fun Content(
|
||||
return
|
||||
}
|
||||
// The Store scrolls and navigates inside itself (its tabs and an app's page).
|
||||
if (selected == "store") {
|
||||
if (selected == "store" && s.storeEnabled) {
|
||||
StorePage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
// Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it.
|
||||
if (selected == "stores") {
|
||||
if (selected == "stores" && s.gameStoresEnabled) {
|
||||
StoresPage(s, a, modifier)
|
||||
return
|
||||
}
|
||||
@@ -288,11 +240,6 @@ private fun Content(
|
||||
GamesPage(s, a, selected, onSelect, modifier)
|
||||
return
|
||||
}
|
||||
// Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it.
|
||||
if (selected == "desktop") {
|
||||
DesktopPage(s, a, modifier, onSelect)
|
||||
return
|
||||
}
|
||||
// Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid
|
||||
// instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view.
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) {
|
||||
@@ -313,6 +260,37 @@ private fun Content(
|
||||
})
|
||||
}
|
||||
}
|
||||
selected == "desktop" -> {
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0) {
|
||||
PageHeader(stringResource(R.string.drawer_desktop)) {
|
||||
if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false)
|
||||
}
|
||||
}
|
||||
Rise(2) { DesktopCard(s, a) }
|
||||
// Add closes the last grid: the installable emulators, or the installed ones once none are left.
|
||||
val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) }
|
||||
val addAfterReady = available.isEmpty()
|
||||
if (ready.isNotEmpty() || addAfterReady) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) }
|
||||
Rise(4) {
|
||||
LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
|
||||
}
|
||||
}
|
||||
if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() }
|
||||
if (!addAfterReady) {
|
||||
Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) }
|
||||
Rise(6) {
|
||||
LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true })
|
||||
}
|
||||
}
|
||||
if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) }
|
||||
}
|
||||
selected.startsWith("user:") -> {
|
||||
val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected }
|
||||
if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect)
|
||||
@@ -322,26 +300,21 @@ private fun Content(
|
||||
if (e == null) Note(stringResource(R.string.content_not_installed)) else {
|
||||
val pkgId = Library.packageId(e.id)
|
||||
val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } }
|
||||
// Out of its card on Desktop, with the card's icon; B draws it back in.
|
||||
Rise(0) {
|
||||
BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") }
|
||||
}
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp))
|
||||
Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp))
|
||||
Column {
|
||||
Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground)
|
||||
Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(
|
||||
systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) },
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (e.installed) Text(
|
||||
listOfNotNull(
|
||||
pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() },
|
||||
s.romsDir,
|
||||
).joinToString(" · "),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,24 +339,7 @@ private fun Content(
|
||||
)
|
||||
}
|
||||
else Rise(5, Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
// RetroArch's games come from many systems: chips filter them, one
|
||||
// selection gliding between them.
|
||||
val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() }
|
||||
var system by rememberSaveable(e.id) { mutableStateOf<String?>(null) }
|
||||
if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) {
|
||||
val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems
|
||||
SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] }
|
||||
}
|
||||
val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system }
|
||||
ArtGrid(shown.map { (index, g) ->
|
||||
val key = "rom:${e.id}:$index"
|
||||
Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) {
|
||||
TileBounds.mark(key, g.art ?: e.iconRes, 12f * density)
|
||||
onSelect(key)
|
||||
}
|
||||
}, wide = e.games.none { it.art != null })
|
||||
}
|
||||
ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null })
|
||||
}
|
||||
} else {
|
||||
if (pkg != null) Rise(2) {
|
||||
@@ -425,7 +381,12 @@ private fun Content(
|
||||
val pair = romFor(s, selected)
|
||||
if (pair == null) Note(stringResource(R.string.content_rom_gone)) else {
|
||||
val (e, g) = pair
|
||||
Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) }
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
BackLink(e.name) { onSelect("emu:${e.id}") }
|
||||
Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e)))
|
||||
}
|
||||
}
|
||||
Rise(1) { Title(g.name) }
|
||||
Rise(2) {
|
||||
Row {
|
||||
@@ -447,10 +408,7 @@ private fun Content(
|
||||
Rise(4, Modifier.fillMaxWidth()) {
|
||||
ArtGrid(others.map { x ->
|
||||
val index = e.games.indexOf(x)
|
||||
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) {
|
||||
TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density)
|
||||
onSelect("rom:${e.id}:$index")
|
||||
}
|
||||
Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") }
|
||||
}, wide = others.none { it.art != null })
|
||||
}
|
||||
}
|
||||
@@ -461,6 +419,122 @@ private fun Content(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Linux desktop itself, above the emulators that run in it. */
|
||||
@Composable
|
||||
private fun DesktopCard(s: FrontEndState, a: FrontEndActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var confirmRemove by rememberSaveable { mutableStateOf(false) }
|
||||
if (confirmRemove) ConfirmDialog(
|
||||
stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text),
|
||||
stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false },
|
||||
)
|
||||
val actions: @Composable () -> Unit = {
|
||||
Actions {
|
||||
// Enabled without a runtime or the desktop: the session's loading screen installs them first.
|
||||
PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop)
|
||||
Cog(onClick = a.onDesktopSettings)
|
||||
if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall),
|
||||
enabled = !s.runtimeActionsBlocked) { confirmRemove = true }
|
||||
BusyChip(s)
|
||||
}
|
||||
}
|
||||
// The button goes under the title unless the card is wide enough for both side by side: in some
|
||||
// languages its label alone takes half a small phone's pane.
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14),
|
||||
) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) }
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!narrow) actions()
|
||||
}
|
||||
if (narrow) actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide the desktop card must be to keep its button beside the title. */
|
||||
private val DesktopCardInlineWidth = 640.dp
|
||||
|
||||
/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */
|
||||
private sealed class GridItem(val key: String) {
|
||||
class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}")
|
||||
class User(val app: UserApps.App) : GridItem("user:${app.key}")
|
||||
data object Add : GridItem("add")
|
||||
}
|
||||
|
||||
/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun LauncherGrid(items: List<GridItem>, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
// A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the
|
||||
// name no room at all.
|
||||
val columns = when {
|
||||
LocalNarrowPane.current -> 1
|
||||
maxWidth < 720.dp -> 2
|
||||
else -> 3
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
items.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, item ->
|
||||
key(item.key) {
|
||||
val m = Modifier.weight(1f).fillMaxHeight()
|
||||
val isFirst = first && r == 0 && i == 0
|
||||
when (item) {
|
||||
is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) }
|
||||
is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) }
|
||||
GridItem.Add -> AddTile(m, isFirst, onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A grid tile's frame: the emulator tiles' look, focus glide and press. */
|
||||
@Composable
|
||||
private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileText(title: String, detail: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun UserApps.Kind.label() = when (this) {
|
||||
UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script
|
||||
UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage
|
||||
@@ -482,6 +556,26 @@ private fun UserAppIcon(app: UserApps.App, size: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) =
|
||||
TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) {
|
||||
UserAppIcon(app, 44)
|
||||
TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f))
|
||||
}
|
||||
|
||||
/** The grid's last tile: opens the Add dialog. */
|
||||
@Composable
|
||||
private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12),
|
||||
) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) }
|
||||
TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/** The add or remove under way, or why the last one failed. */
|
||||
@Composable
|
||||
private fun UserAppsProgress() {
|
||||
@@ -501,17 +595,20 @@ private fun UserAppsProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */
|
||||
/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */
|
||||
@Composable
|
||||
private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ctx = LocalContext.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
var confirm by remember(app.key) { mutableStateOf(false) }
|
||||
var editing by rememberSaveable(app.key) { mutableStateOf(false) }
|
||||
val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF
|
||||
val fexReady by produceState<Boolean?>(null, app.key, x86, s.sessionRunning) {
|
||||
value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true
|
||||
}
|
||||
LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } }
|
||||
Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } }
|
||||
Rise(1) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -534,8 +631,12 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
|
||||
BusyChip(s)
|
||||
SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true }
|
||||
if (app.repo != null) UpdateButton(app, s)
|
||||
RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) {
|
||||
UserAppsState.remove(ctx, app); onSelect("desktop")
|
||||
SecondaryButton(
|
||||
stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove),
|
||||
enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null,
|
||||
) {
|
||||
if (!confirm) confirm = true
|
||||
else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") }
|
||||
}
|
||||
if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false)
|
||||
}
|
||||
@@ -556,31 +657,6 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions,
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */
|
||||
@Composable
|
||||
private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.user_apps_remove)
|
||||
val title = stringResource(R.string.flathub_remove_title, app.name)
|
||||
val keep = stringResource(R.string.flathub_keep)
|
||||
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2,
|
||||
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) {
|
||||
StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Looks up a newer release of an app added from GitHub, then installs it. */
|
||||
@Composable
|
||||
private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
@@ -612,5 +688,21 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) {
|
||||
private fun systemLabel(e: Library.Emulator): String =
|
||||
if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() }
|
||||
|
||||
/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */
|
||||
private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty()
|
||||
@Composable
|
||||
private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val system = systemLabel(e)
|
||||
val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system
|
||||
TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) {
|
||||
Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (!e.installed) Text(
|
||||
stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -77,9 +77,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
|
||||
// The front end's side rail: the page list and the resume entry under it.
|
||||
@@ -106,53 +103,27 @@ internal fun SideRail(
|
||||
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
|
||||
) {
|
||||
// Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
|
||||
val count = 7 + (if (s.isHomeApp) 1 else 0)
|
||||
val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
|
||||
// A hop in flight moves the selection ahead of the page it is bringing.
|
||||
val shown = Hops.railPreview ?: selected
|
||||
val keys = buildList {
|
||||
add("steam"); add("games")
|
||||
add("stores")
|
||||
add("desktop")
|
||||
add("components")
|
||||
if (s.isHomeApp) add("android-apps")
|
||||
add("setup"); add("updates")
|
||||
}
|
||||
// Where each item sits in the column, for the selection that glides between them.
|
||||
val bounds = remember { androidx.compose.runtime.mutableStateMapOf<String, androidx.compose.ui.geometry.Rect>() }
|
||||
val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal)
|
||||
var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) {
|
||||
// One selection for the whole rail, under the items: it glides from item to item as the
|
||||
// chips' does, its leading edge first, and a jump of more than one item goes as a drop.
|
||||
if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
val place: (String) -> Modifier = { key ->
|
||||
// In root px: an item that comes and goes sits in its own wrapper, not the column.
|
||||
Modifier.onGloballyPositioned {
|
||||
bounds[key] = it.boundsInRoot()
|
||||
Hops.railBounds[key] = it.boundsInRoot()
|
||||
}
|
||||
}
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
|
||||
// Always there, so the items below it never move; an empty library says how to fill it.
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
|
||||
// The game stores, right under Games: what they install lands there. Its badge counts
|
||||
// the downloads in flight, so a download started here is never out of sight.
|
||||
// A section switched on in Setup comes in as the rail's own entrance; off, it folds away.
|
||||
RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") }
|
||||
RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
|
||||
if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
|
||||
RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
|
||||
// Home mode's extra section goes last, so it shifts nothing above it.
|
||||
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
}
|
||||
if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
|
||||
RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
|
||||
RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
|
||||
}
|
||||
}
|
||||
var lastRunning by remember { mutableStateOf("") }
|
||||
@@ -171,8 +142,6 @@ private fun RailItem(
|
||||
badge: Boolean = false,
|
||||
/** A number on the item (downloads in flight); 0 shows nothing. */
|
||||
count: Int = 0,
|
||||
/** Reports where the item sits, for the rail's gliding selection and a hop's dot. */
|
||||
place: (String) -> Modifier = { Modifier },
|
||||
onFocus: () -> Unit = {}, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
@@ -193,9 +162,8 @@ private fun RailItem(
|
||||
if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant,
|
||||
Motion.tw(220), label = "railFg",
|
||||
)
|
||||
// The selected look is the rail's glide under the item, so the item draws only hover here.
|
||||
val fill by animateColorAsState(
|
||||
if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
|
||||
if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent,
|
||||
Motion.tw(220), label = "railFill",
|
||||
)
|
||||
val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale")
|
||||
@@ -204,9 +172,6 @@ private fun RailItem(
|
||||
modifier = Modifier
|
||||
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
|
||||
.testTag("rail-$key")
|
||||
.then(place(key))
|
||||
// Pops as a hop's dot lands on it.
|
||||
.landingPop(Hops.railLanded[key] ?: 0)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.size(width = if (iconOnly) 52.dp else 80.dp, height = height)
|
||||
.clip(Shape14)
|
||||
|
||||
@@ -70,8 +70,6 @@ import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
@@ -134,6 +132,10 @@ class FrontEndState(
|
||||
val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
|
||||
val launcherFullscreen: Boolean = true,
|
||||
val animationsEnabled: Boolean = true,
|
||||
/** The Flathub Store, a beta the user turns on in Setup. */
|
||||
val storeEnabled: Boolean = false,
|
||||
/** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */
|
||||
val gameStoresEnabled: Boolean = false,
|
||||
/** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */
|
||||
val gameStoresSpeedTier: String = "fast",
|
||||
/** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */
|
||||
@@ -176,12 +178,8 @@ class FrontEndActions(
|
||||
val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {},
|
||||
val onRom: (Library.Rom) -> Unit,
|
||||
val onResume: () -> Unit,
|
||||
/** Reads Steam's session settings for its lifted sheet (under Play). */
|
||||
val onSteamSettings: () -> Unit,
|
||||
/** Reads the desktop's session settings for its lifted sheet (under Open desktop). */
|
||||
val onDesktopSettings: () -> Unit,
|
||||
/** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */
|
||||
val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> },
|
||||
val onRemoveDesktop: () -> Unit = {},
|
||||
val onInstallPackage: (String) -> Unit,
|
||||
val onRemovePackage: (String) -> Unit,
|
||||
@@ -191,8 +189,6 @@ class FrontEndActions(
|
||||
val onProtons: () -> Unit,
|
||||
/** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */
|
||||
val onComponents: (focusContent: Boolean) -> Unit,
|
||||
/** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */
|
||||
val onComponentsTab: (String) -> Unit = {},
|
||||
/** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */
|
||||
val onSaveImport: (Library.SteamGame) -> Unit = {},
|
||||
val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> },
|
||||
@@ -212,6 +208,8 @@ class FrontEndActions(
|
||||
val onOrientation: (Int) -> Unit = {},
|
||||
val onLauncherFullscreen: (Boolean) -> Unit = {},
|
||||
val onAnimationsEnabled: (Boolean) -> Unit = {},
|
||||
val onStoreEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresEnabled: (Boolean) -> Unit = {},
|
||||
val onGameStoresSpeedTier: (String) -> Unit = {},
|
||||
val onStoresOpenTab: (String) -> Unit = {},
|
||||
val onNavigated: () -> Unit = {},
|
||||
@@ -384,18 +382,6 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks [target] for focus on the next frames until a request lands (a control can take focus only
|
||||
* once it is laid out), for at most half a second; then leaves focus alone, so a press right after
|
||||
* is never pulled back.
|
||||
*/
|
||||
internal suspend fun focusSoon(target: () -> FocusRequester) {
|
||||
repeat(30) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
if (runCatching { target().requestFocus() }.isSuccess) return
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
|
||||
val frontFocus = remember { FrontFocus() }
|
||||
@@ -437,27 +423,19 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
}
|
||||
// A page reached by a hop goes back the way it came; any other page just closes.
|
||||
val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() }
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() }
|
||||
// Left any other way, the hop's page has no way back to keep.
|
||||
LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) }
|
||||
// A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came.
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) {
|
||||
val rail = Hops.origin?.rail ?: return@BackHandler
|
||||
Hops.back { selected = rail }
|
||||
}
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current.density
|
||||
remember(density) { Hops.density = density; true }
|
||||
// Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead
|
||||
// of leaving the app: a ROM -> its emulator, the others -> Desktop.
|
||||
BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() }
|
||||
// Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link
|
||||
// does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop.
|
||||
BackHandler(
|
||||
enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"),
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")),
|
||||
) {
|
||||
selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop"
|
||||
}
|
||||
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
|
||||
// The Store turned off in Setup takes its page with it; so does Stores.
|
||||
LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" }
|
||||
LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" }
|
||||
// The last game uninstalled leaves the Games tab on its empty state.
|
||||
LaunchedEffect(s.steamGames.isEmpty()) {
|
||||
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
|
||||
@@ -465,14 +443,11 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
remember { Motion.refresh(ctx); true }
|
||||
|
||||
val railSelection = when {
|
||||
// A hop's page belongs to the rail item it flew to.
|
||||
Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest
|
||||
// Performance opens from Play's sheet, and is Steam's.
|
||||
s.pageKey == "performance" -> "steam"
|
||||
s.pageKey == "controller-mapping" -> "setup"
|
||||
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
|
||||
s.pageKey?.startsWith("settings:steam") == true -> "steam"
|
||||
s.pageKey?.startsWith("settings:") == true -> "desktop"
|
||||
selected.startsWith("app:") -> "games"
|
||||
// Flathub is a page of Desktop's, as an emulator's is.
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop"
|
||||
selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop"
|
||||
else -> s.pageKey ?: selected
|
||||
}
|
||||
// Bumped each time a rail item is picked, so a controller moves on into the new page.
|
||||
@@ -483,10 +458,6 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
if (key == "components") a.onComponents(focusContent)
|
||||
else {
|
||||
if (s.pageKey != null) a.onPageBack()
|
||||
// Another section: no way back to keep, and no sheet to put back.
|
||||
Hops.origin = null
|
||||
Hops.restore = null
|
||||
Hops.sinking = null
|
||||
selected = key
|
||||
}
|
||||
}
|
||||
@@ -548,22 +519,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
val railFocus = Modifier
|
||||
.focusProperties { enter = { frontFocus.railFor(railSelection) } }
|
||||
.focusGroup()
|
||||
// Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's
|
||||
// Flathub card): focus
|
||||
// goes back to the control that asked, once the page has gone.
|
||||
LaunchedEffect(Hops.restore) {
|
||||
val r = Hops.restore ?: return@LaunchedEffect
|
||||
if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect
|
||||
Hops.restore = null
|
||||
if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail
|
||||
kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong())
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect
|
||||
frontFocus.last = r.control
|
||||
focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() }
|
||||
}
|
||||
var screenAt by remember { mutableStateOf(Offset.Zero) }
|
||||
CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) {
|
||||
Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight())
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
@@ -579,12 +535,6 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// Dots on their way somewhere: a hop to another section and back, a swap flying home.
|
||||
FlightsLayer(screenAt)
|
||||
// A confirm stepping out of the control that asked for it.
|
||||
StepsLayer(screenAt)
|
||||
|
||||
appToChooseDisplay?.let { app ->
|
||||
val secondaryDisplay = s.secondScreenDisplays.firstOrNull()
|
||||
ChooseAppDisplayDialog(
|
||||
@@ -647,10 +597,8 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
}
|
||||
}
|
||||
val hasBackTarget = (s.pageKey != null && page != null) ||
|
||||
// A section reached by a hop goes back to where it came from, not to the rail.
|
||||
(s.pageKey == null && Hops.origin?.page == selected) ||
|
||||
((s.pageKey == null || page == null) &&
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"))
|
||||
(selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")))
|
||||
// At the top of a section, Back goes to the rail - the launcher itself is never backed out of.
|
||||
BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) {
|
||||
if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard)
|
||||
|
||||
@@ -71,8 +71,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.AppLanguage
|
||||
import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
@@ -168,26 +166,21 @@ internal fun SetupPanel(
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
var editSgdbKey by remember { mutableStateOf(false) }
|
||||
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
|
||||
// Four tabs by scope, a chip row with one gliding selection; LB and RB turn them from
|
||||
// anywhere on the page. Build and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_diagnostics))
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
val chipFocus = remember { List(4) { FocusRequester() } }
|
||||
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
val inputModeManager = LocalInputModeManager.current
|
||||
// The control a controller was on went with the old tab: it lands on the new tab's chip.
|
||||
// The control a controller was on went with the old tab: it lands on the new tab itself.
|
||||
LaunchedEffect(tab) {
|
||||
if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching { chipFocus[tab].requestFocus() }
|
||||
runCatching { tabFocus[tab].requestFocus() }
|
||||
}
|
||||
}
|
||||
val wide = !LocalNarrowPane.current
|
||||
val runtimeOk = s.ready && !s.busy && !s.removalPending
|
||||
val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn)
|
||||
val passing = checks.count { it }
|
||||
var showChecks by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0, Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().bumpers(
|
||||
@@ -196,12 +189,15 @@ internal fun SetupPanel(
|
||||
),
|
||||
) {
|
||||
PageHeader(stringResource(R.string.setup_title)) {
|
||||
Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) }
|
||||
TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
|
||||
when (tab) {
|
||||
0 -> {
|
||||
val gpuCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
SectionTitle(stringResource(R.string.setup_system_check), null)
|
||||
// What Steam needs, one row each: green when done, one button when not. The
|
||||
// process-limit controls only open under their row.
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
CheckRow(
|
||||
if (gpuOk) CheckState.OK else CheckState.WARN,
|
||||
when (gpu.support) {
|
||||
@@ -214,10 +210,7 @@ internal fun SetupPanel(
|
||||
com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() })
|
||||
else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName)
|
||||
},
|
||||
divider = divider,
|
||||
)
|
||||
}
|
||||
val runtimeCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK },
|
||||
stringResource(R.string.setup_runtime),
|
||||
@@ -228,7 +221,7 @@ internal fun SetupPanel(
|
||||
s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed))
|
||||
else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed))
|
||||
},
|
||||
divider = divider && !s.busy,
|
||||
divider = !s.busy,
|
||||
) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) }
|
||||
if (s.busy) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
@@ -239,10 +232,8 @@ internal fun SetupPanel(
|
||||
if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
val limitCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
if (limitBlocks) CheckState.WARN else CheckState.OK,
|
||||
stringResource(R.string.setup_limit),
|
||||
@@ -252,7 +243,6 @@ internal fun SetupPanel(
|
||||
PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown)
|
||||
else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus)
|
||||
},
|
||||
divider = divider && !showLimitDetails,
|
||||
) {
|
||||
if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails }
|
||||
else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) {
|
||||
@@ -281,9 +271,6 @@ internal fun SetupPanel(
|
||||
processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) }
|
||||
}
|
||||
}
|
||||
if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
val accountCheck: @Composable (Boolean) -> Unit = { divider ->
|
||||
CheckRow(
|
||||
if (signedIn) CheckState.OK else CheckState.WARN,
|
||||
stringResource(R.string.setup_account),
|
||||
@@ -294,42 +281,11 @@ internal fun SetupPanel(
|
||||
else -> stringResource(R.string.setup_signed_in, it)
|
||||
}
|
||||
} ?: stringResource(R.string.setup_sign_in),
|
||||
divider = divider,
|
||||
divider = false,
|
||||
)
|
||||
}
|
||||
val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck)
|
||||
// Everything passes: one line says so, and Details opens the checks in place.
|
||||
ReadinessCard(
|
||||
passing, checks.size,
|
||||
listOfNotNull(
|
||||
gpu.displayName(ctx),
|
||||
s.installed?.let { stringResource(R.string.setup_ready_runtime, it) },
|
||||
stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks },
|
||||
s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) },
|
||||
).joinToString(" · "),
|
||||
open = showChecks, onDetails = { showChecks = !showChecks },
|
||||
) {
|
||||
val shownChecks = all.filterIndexed { i, _ -> checks[i] }
|
||||
shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) }
|
||||
}
|
||||
// Each check that warns is its own card, its Fix it as it always was.
|
||||
if (passing < checks.size) {
|
||||
SectionTitle(stringResource(R.string.setup_needs_you), null)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
all.forEachIndexed { i, check ->
|
||||
if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_account)) {
|
||||
accountCheck(true)
|
||||
ToggleRow(
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) }
|
||||
SectionTitle(stringResource(R.string.setup_tools), null)
|
||||
ToolGrid(s, a)
|
||||
}
|
||||
1 -> {
|
||||
val controller = s.controller
|
||||
@@ -339,79 +295,110 @@ internal fun SetupPanel(
|
||||
if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable))
|
||||
}
|
||||
2 -> {
|
||||
val look: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_look)) {
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_session)) {
|
||||
ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(s, a, host)
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
}
|
||||
}
|
||||
val device: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_device)) {
|
||||
ToggleRow(
|
||||
host, "home-screen", stringResource(R.string.setup_home),
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
}
|
||||
}
|
||||
val art: @Composable () -> Unit = {
|
||||
SettingsGroup(stringResource(R.string.setup_group_art)) {
|
||||
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
|
||||
ActionRow(
|
||||
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
|
||||
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Two columns on a wide page: how it looks; this device and the library's art.
|
||||
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
|
||||
Column(Modifier.weight(1f)) { look() }
|
||||
Column(Modifier.weight(1f)) { device(); art() }
|
||||
} else { look(); device(); art() }
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
|
||||
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
|
||||
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
|
||||
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
|
||||
ToggleRow(
|
||||
host, "offline", stringResource(R.string.setup_offline),
|
||||
s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first),
|
||||
s.offline, enabled = s.offlineAccount != null,
|
||||
) { a.onOffline() }
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_launcher)) {
|
||||
LanguageRow(host, s.language, a.onLanguage)
|
||||
ChoiceRow(
|
||||
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
|
||||
com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent ->
|
||||
percent to stringResource(
|
||||
if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default
|
||||
else R.string.ctrl_percent, percent,
|
||||
)
|
||||
}, s.appScale, onPick = a.onAppScale,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) {
|
||||
Box {
|
||||
ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" }
|
||||
AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus ->
|
||||
Themes.all.forEachIndexed { index, theme ->
|
||||
MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) {
|
||||
a.onTheme(theme.id)
|
||||
host.open = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToggleRow(
|
||||
host, "home-screen", stringResource(R.string.setup_home),
|
||||
if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off),
|
||||
s.homeScreenEnabled,
|
||||
) { a.onHomeScreen(it) }
|
||||
ChoiceRow(
|
||||
host, "orientation", stringResource(R.string.setup_orientation), null,
|
||||
SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) },
|
||||
s.orientation, onPick = a.onOrientation,
|
||||
)
|
||||
ToggleRow(
|
||||
host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen),
|
||||
if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off),
|
||||
s.launcherFullscreen,
|
||||
) { a.onLauncherFullscreen(it) }
|
||||
ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null,
|
||||
s.animationsEnabled, onChange = a.onAnimationsEnabled)
|
||||
if (s.homeScreenEnabled) {
|
||||
ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_linux_apps)) {
|
||||
ToggleRow(
|
||||
host, "store-enabled", stringResource(R.string.setup_store),
|
||||
if (s.storeEnabled) stringResource(R.string.setup_store_on)
|
||||
else stringResource(R.string.setup_store_off),
|
||||
s.storeEnabled,
|
||||
) { a.onStoreEnabled(it) }
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.content_games)) {
|
||||
ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) }
|
||||
ActionRow(
|
||||
stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null,
|
||||
stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true },
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.setup_stores)) {
|
||||
ToggleRow(
|
||||
host, "stores-enabled", stringResource(R.string.setup_stores_show),
|
||||
null, s.gameStoresEnabled,
|
||||
) { a.onGameStoresEnabled(it) }
|
||||
// The same rows as the Stores page's own cog, for whoever looks here first.
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
s.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
}
|
||||
ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) }
|
||||
SettingsRow(stringResource(R.string.setup_stores_speed), null) {
|
||||
SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) }
|
||||
}
|
||||
StoresEngineRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,43 +407,50 @@ internal fun SetupPanel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one
|
||||
* line of what that means, and a Details chip that opens the checks themselves in place ([checks]).
|
||||
*/
|
||||
/** The launcher's own tools as cards: four across, two by two on a narrow page. */
|
||||
@Composable
|
||||
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
|
||||
private fun ToolGrid(s: FrontEndState, a: FrontEndActions) {
|
||||
val columns = if (LocalNarrowPane.current) 2 else 4
|
||||
val tools = listOf(
|
||||
ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles),
|
||||
ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons),
|
||||
ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance),
|
||||
ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
for (row in tools.chunked(columns)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight())
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit)
|
||||
|
||||
@Composable
|
||||
private fun ToolCard(t: ToolSpec, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val ready = passing == total
|
||||
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
val tint = if (ready) pal.good else AttentionAmber
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp,
|
||||
color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp),
|
||||
)
|
||||
Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
|
||||
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
)
|
||||
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails)
|
||||
}
|
||||
AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
checks()
|
||||
}
|
||||
}
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale")
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier.paneItem("tool:${t.title}")
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick)
|
||||
.controllerConfirm(onClick = t.onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
) {
|
||||
Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,19 +472,14 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
|
||||
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
|
||||
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
|
||||
val wincompKey = game?.let {
|
||||
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
|
||||
else it.appId.takeIf { id -> id > 0 }?.toString()
|
||||
}
|
||||
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
|
||||
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
|
||||
val cards = buildList<@Composable (Modifier) -> Unit> {
|
||||
add { m ->
|
||||
val back = stringResource(R.string.hop_from_games)
|
||||
// Components is its own section: the card hops there, and B on it comes back here.
|
||||
SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) {
|
||||
val at = Hops.sources["card:components"]
|
||||
if (at == null) a.onComponents(true)
|
||||
else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) }
|
||||
}
|
||||
}
|
||||
add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } }
|
||||
add { m ->
|
||||
Box(m) {
|
||||
SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) {
|
||||
|
||||
@@ -52,11 +52,7 @@ import kotlinx.coroutines.withContext
|
||||
// dialog, with each variable's actions in a menu off its value as everywhere else.
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(
|
||||
modifier: Modifier = Modifier,
|
||||
showHint: Boolean = true,
|
||||
initialScope: String = "",
|
||||
) {
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
|
||||
// would otherwise give the pad nothing to move from.
|
||||
@@ -68,7 +64,7 @@ fun GameEnvironmentRow(
|
||||
open = true
|
||||
}
|
||||
}
|
||||
if (open) GameEnvironmentEditor(byPad, initialScope) { open = false }
|
||||
if (open) GameEnvironmentEditor(byPad) { open = false }
|
||||
}
|
||||
|
||||
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
|
||||
@@ -85,19 +81,19 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", onClose: () -> Unit) {
|
||||
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf(initialScope) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var otherId by remember { mutableStateOf<String?>(null) }
|
||||
var menu by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<EnvironmentVariableEdit?>(null) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
val forceSsbs = SessionPrefs.forceSsbs(context)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
@@ -172,7 +168,7 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
|
||||
VariableRow(
|
||||
name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy,
|
||||
onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null },
|
||||
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, name, value ?: "") },
|
||||
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") },
|
||||
// A value set here or inherited can be switched off for this profile; one set here can go back to inheriting.
|
||||
onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null,
|
||||
onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null,
|
||||
@@ -189,9 +185,9 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
|
||||
SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" }
|
||||
AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first ->
|
||||
for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) {
|
||||
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, option.name, option.value)
|
||||
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value
|
||||
}
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, "", "") }
|
||||
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" }
|
||||
}
|
||||
}
|
||||
if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) {
|
||||
@@ -201,21 +197,19 @@ internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", on
|
||||
PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close)
|
||||
}
|
||||
}
|
||||
editing?.let { edit ->
|
||||
VariableDialog(edit.name, edit.value, edit.profileName, editByPad, onDismiss = { editing = null }) { key, content ->
|
||||
editing?.let { (name, value) ->
|
||||
VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content ->
|
||||
config?.let {
|
||||
val entries = it.entries(edit.scope).toMutableMap()
|
||||
if (edit.name.isNotEmpty() && edit.name != key) entries[edit.name] = null
|
||||
val entries = it.entries(scope).toMutableMap()
|
||||
if (name.isNotEmpty() && name != key) entries[name] = null
|
||||
entries[key] = content
|
||||
save(it.withEntries(edit.scope, entries))
|
||||
save(it.withEntries(scope, entries))
|
||||
}
|
||||
editing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class EnvironmentVariableEdit(val scope: String, val profileName: String, val name: String, val value: String)
|
||||
|
||||
/** The panel the profile and the variables sit in, as a settings group does. */
|
||||
@Composable
|
||||
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
|
||||
|
||||
@@ -57,10 +57,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
/** One matched driver pair in the list (DriverPairs), and what this device makes of it. */
|
||||
class PairRow(
|
||||
@@ -123,38 +119,155 @@ class GpuDriversActions(
|
||||
)
|
||||
|
||||
/**
|
||||
* Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the
|
||||
* drivers in use - gathered once, off the main thread, with a button that copies it for a bug
|
||||
* report. Stepped out of the Components page's device chip; [first] is where a pad starts.
|
||||
* The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the
|
||||
* games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps
|
||||
* the pair recommended for the GPU installed and current; Manual picks a pair from the list; the
|
||||
* two lists each on their own, and imports, are under Advanced.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DeviceDetails(first: FocusRequester) {
|
||||
internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
DeviceCard(s)
|
||||
if (!s.unsupported) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) }
|
||||
ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) }
|
||||
}
|
||||
if (s.auto) AutoCard(s)
|
||||
else {
|
||||
val shown = s.pairs.filter { it.suits || showAll }
|
||||
if (shown.isEmpty()) Text(
|
||||
if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) }
|
||||
if (s.pairs.any { !it.suits }) FocusText(
|
||||
if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal,
|
||||
) { showAll = !showAll }
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.gpu_advanced)) {
|
||||
SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) {
|
||||
DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) {
|
||||
DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One driver list as a drop-down: the installed drivers to pick from, and the full page for
|
||||
* downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both.
|
||||
*/
|
||||
@Composable
|
||||
private fun DriverDropdown(rows: List<DriverRow>, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true }
|
||||
DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) {
|
||||
for (row in rows) DropdownMenuItem(
|
||||
leadingIcon = { Radio(row.id == selected) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
},
|
||||
onClick = { open = false; if (row.id != selected) onSelect(row.id) },
|
||||
)
|
||||
HorizontalDivider(color = pal.line)
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip -
|
||||
* SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once,
|
||||
* off the main thread, with a button that copies it for a bug report.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val tint = when {
|
||||
s.unsupported -> pal.error
|
||||
s.supported -> pal.good
|
||||
else -> AttentionAmber
|
||||
}
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
var details by remember { mutableStateOf<List<DeviceInfo.Section>?>(null) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } }
|
||||
LaunchedEffect(open) {
|
||||
if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) }
|
||||
}
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val toggle = { open = !open; copied = false }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()),
|
||||
Modifier.fillMaxWidth().clip(Shape14).background(colors.surface)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line),
|
||||
) {
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("gpu-device")
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle)
|
||||
.controllerConfirm(onClick = toggle)
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) {
|
||||
Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
}
|
||||
// The verdict shares the width with the GPU's name and wraps, rather than taking all it
|
||||
// needs: a long one (in some languages) would squeeze the name to a letter a line.
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
|
||||
Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Icon(
|
||||
if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show),
|
||||
tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(open) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) {
|
||||
HorizontalDivider(color = pal.line)
|
||||
val sections = details
|
||||
if (sections == null) {
|
||||
Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
} else {
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f),
|
||||
)
|
||||
SecondaryButton(stringResource(R.string.device_info_copy), compact = true) {
|
||||
clipboard.setText(AnnotatedString(DeviceInfo.text(sections)))
|
||||
copied = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (section in sections) DeviceInfoSection(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,9 +288,115 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) {
|
||||
Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
for ((label, value) in section.rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp))
|
||||
Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp))
|
||||
Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What Auto has set and what it is doing. */
|
||||
@Composable
|
||||
private fun AutoCard(s: GpuDriversState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val active = s.activePair
|
||||
val recommended = s.pairs.firstOrNull { it.recommended }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Text(
|
||||
active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin),
|
||||
fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
)
|
||||
Text(
|
||||
if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair)
|
||||
else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
if (s.busy != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (s.percent.coerceAtLeast(0)) / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
|
||||
)
|
||||
}
|
||||
if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
// Selection is the radio; the ring is only ever focus.
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp)
|
||||
.clip(Shape14).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Radio(selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val usable = enabled && p.complete && !p.active
|
||||
val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp)
|
||||
.clip(Shape12).background(colors.surface).background(fill)
|
||||
.glideBorder(hot, Shape12, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick)
|
||||
.controllerConfirm(enabled = usable, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Radio(p.active)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (p.recommended) Text(
|
||||
stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good,
|
||||
modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
when {
|
||||
busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) {
|
||||
Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant)
|
||||
LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp))
|
||||
}
|
||||
p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
!p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Radio(selected: Boolean) {
|
||||
val pal = LocalPalette.current
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
|
||||
if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal))
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
// A link from a control in one section to a page in another (the Play sheet's Graphics drivers,
|
||||
// a game's Components card): the control squashes into a dot that flies to the destination's rail
|
||||
// item, the rail's selection glides there as it passes the top of its arc, and the page floods
|
||||
// out of the rail item once it lands. B on that page flies the dot back and puts the source
|
||||
// surface back as it was, focus on the control that asked.
|
||||
|
||||
/**
|
||||
* Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the
|
||||
* Games page) and its tab, the control's key and its root bounds, and the words for the way back.
|
||||
* [page] is the page key the hop opened, so leaving it any other way forgets the way back.
|
||||
*/
|
||||
internal class HopOrigin(
|
||||
val rail: String,
|
||||
val surface: String,
|
||||
val tab: String?,
|
||||
val control: String,
|
||||
val bounds: Rect,
|
||||
val back: String,
|
||||
val dest: String,
|
||||
val page: String,
|
||||
)
|
||||
|
||||
/** The surfaces a hop can start from and come back to. */
|
||||
internal const val HOP_SURFACE_GAMES = "games"
|
||||
internal const val HOP_SURFACE_DESKTOP = "desktop"
|
||||
internal const val HOP_SURFACE_SHEET = "sheet"
|
||||
|
||||
internal object Hops {
|
||||
/** The hop the page on screen was reached by, while B can still undo it. */
|
||||
var origin by mutableStateOf<HopOrigin?>(null)
|
||||
|
||||
/** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */
|
||||
var sinking by mutableStateOf<String?>(null)
|
||||
|
||||
/** Set while a hop's dot is flying: the rail item its selection has already glided to. */
|
||||
var railPreview by mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* Set as a page leaves through a hop (either way), so the pane sinks it rather than draining
|
||||
* it; the pane clears it once the change has started.
|
||||
*/
|
||||
var linking by mutableStateOf(false)
|
||||
|
||||
/** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */
|
||||
var restore by mutableStateOf<HopOrigin?>(null)
|
||||
|
||||
/** Per rail item, bumped when a dot lands on it: the item pops. */
|
||||
val railLanded = mutableStateMapOf<String, Int>()
|
||||
|
||||
/** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */
|
||||
var arrived by mutableIntStateOf(0)
|
||||
|
||||
/** Where each rail item sits, root px. */
|
||||
val railBounds = HashMap<String, Rect>()
|
||||
|
||||
/** Where each control that can start a hop sits, root px. */
|
||||
val sources = HashMap<String, Rect>()
|
||||
|
||||
/**
|
||||
* Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands.
|
||||
* With animations off there is no dot and no flood, only the page.
|
||||
*/
|
||||
fun go(origin: HopOrigin, go: () -> Unit) {
|
||||
val railAt = railBounds[origin.dest]
|
||||
val finish: () -> Unit = {
|
||||
railPreview = null
|
||||
this.origin = origin
|
||||
if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest))
|
||||
linking = true
|
||||
go()
|
||||
railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1
|
||||
arrived++
|
||||
}
|
||||
if (railAt == null || Motion.scale == 0f) { finish(); return }
|
||||
// The surface the control is on sinks as its dot leaves, and stays down as it goes.
|
||||
sinking = origin.rail
|
||||
Flights.fly(Flight(
|
||||
origin.bounds, to = { railAt.center },
|
||||
onHalf = { railPreview = origin.dest },
|
||||
onLand = finish,
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* B on a page reached by a hop: the page sinks, the source surface comes back as it was, and
|
||||
* the dot flies home from the rail item to the control. False when there is no hop to undo.
|
||||
*/
|
||||
fun back(close: () -> Unit): Boolean {
|
||||
val o = origin ?: return false
|
||||
origin = null
|
||||
sinking = null
|
||||
val railAt = railBounds[o.dest]
|
||||
railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null
|
||||
restore = o
|
||||
linking = true
|
||||
close()
|
||||
if (railAt == null || Motion.scale == 0f) { railPreview = null; return true }
|
||||
Flights.fly(Flight(
|
||||
railAt, to = { (sources[o.control] ?: o.bounds).center },
|
||||
onHalf = { railPreview = null },
|
||||
))
|
||||
return true
|
||||
}
|
||||
|
||||
/** The page the hop opened has gone some other way: there is no way back to keep. */
|
||||
fun forget(pageKey: String?) {
|
||||
val o = origin ?: return
|
||||
if (pageKey != o.page) { origin = null; sinking = null }
|
||||
}
|
||||
|
||||
/** Screen density, for the rail item's corner; set by the front end. */
|
||||
var density = 1f
|
||||
}
|
||||
|
||||
/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */
|
||||
internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} }
|
||||
|
||||
/** Remembers where this control sits, so a hop from it can fly from it and back to it. */
|
||||
internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() }
|
||||
|
||||
/**
|
||||
* "B · back to <source>" at the right of a page's header while it was reached by a hop; a tap is B.
|
||||
* Nothing when it was not.
|
||||
*/
|
||||
@Composable
|
||||
internal fun HopBackChip(onBack: () -> Unit) {
|
||||
val o = Hops.origin ?: return
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
Text(
|
||||
stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
|
||||
.background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f))
|
||||
.glideBorder(hot, shape, pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack)
|
||||
.controllerConfirm(onClick = onBack)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -1,805 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
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.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.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a
|
||||
// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip)
|
||||
// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding
|
||||
// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a
|
||||
// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill
|
||||
// before the launch flood. Open desktop does the same with the desktop's settings.
|
||||
|
||||
/** The sheets' tabs (their chips), by id. */
|
||||
internal object SheetTab {
|
||||
const val DISPLAY = "display"
|
||||
const val CONTROLS = "controls"
|
||||
const val AUDIO = "audio"
|
||||
const val SESSION = "session"
|
||||
const val LIBRARY = "library"
|
||||
const val STEAM = "steam"
|
||||
const val FILES = "files"
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall
|
||||
* riding it: 82dp down a 360dp pane, the same share of any other, and never so high that the
|
||||
* button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the
|
||||
* button's centre sits.
|
||||
*/
|
||||
internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float =
|
||||
maxOf(paneH * 82f / 360f, minAbove + buttonH / 2f - ride)
|
||||
|
||||
/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */
|
||||
private class SheetDrag {
|
||||
var from = 0f
|
||||
var progress = 0f
|
||||
var engaged = false
|
||||
}
|
||||
|
||||
/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */
|
||||
internal class SheetBeat(private val pieces: List<Animatable<Float, AnimationVector1D>>) {
|
||||
private var next = 1
|
||||
fun take(): Int = next++
|
||||
fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value
|
||||
}
|
||||
|
||||
/** The sheet whose rows are being laid out, so each settings group can take its beat. */
|
||||
internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
|
||||
|
||||
/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */
|
||||
@Composable
|
||||
internal fun Modifier.sheetPiece(): Modifier {
|
||||
val beat = LocalSheetBeat.current ?: return this
|
||||
val i = remember { beat.take() }
|
||||
return graphicsLayer {
|
||||
val v = beat.value(i)
|
||||
alpha = v
|
||||
translationY = (1f - v) * 6.dp.toPx()
|
||||
}
|
||||
}
|
||||
|
||||
private val SheetInset = 12.dp
|
||||
private val SheetCorner = 18.dp
|
||||
private val ChipsTop = 14.dp
|
||||
private val RowsTop = 54.dp
|
||||
private const val PIECES = 12
|
||||
|
||||
/**
|
||||
* A hero with one big button and the sheet that lifts out from under it. [key] names the sheet
|
||||
* (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and
|
||||
* [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the
|
||||
* button itself with the click it should make; [onPrimary] is what that click does once the
|
||||
* sheet is out of the way. [onOpen] reads the settings the rows show.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
internal fun LiftHero(
|
||||
key: String,
|
||||
tabs: List<Pair<String, String>>,
|
||||
onOpen: () -> Unit,
|
||||
onPrimary: () -> Unit,
|
||||
background: @Composable () -> Unit,
|
||||
/**
|
||||
* Drawn over [background] at the pane's own size: the wall's fades, which must not sink with
|
||||
* it. Given how far the sheet is up (0 to 1), for anything there that should give way to it.
|
||||
*/
|
||||
scrim: @Composable (lifted: () -> Float) -> Unit = {},
|
||||
words: @Composable () -> Unit,
|
||||
/**
|
||||
* Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as
|
||||
* the sheet lifts: only the big button rides it.
|
||||
*/
|
||||
beside: @Composable () -> Unit = {},
|
||||
button: @Composable (onClick: () -> Unit) -> Unit,
|
||||
rows: @Composable (tab: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down
|
||||
* from the lip goes on past it to what is under the hero (Desktop's shelves).
|
||||
*/
|
||||
downOpens: Boolean = true,
|
||||
/** Told when the sheet starts to lift (true) and once it has gone (false). */
|
||||
onOpenChange: (Boolean) -> Unit = {},
|
||||
start: Dp = 44.dp,
|
||||
bottom: Dp = 44.dp,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val inputMode = LocalInputModeManager.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val ff = LocalFrontFocus.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
// Held as states too, so the sheet's focus rule reads what they are now, not at the last composition.
|
||||
val openState = remember { mutableStateOf(false) }
|
||||
var open by openState
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
val launchingState = remember { mutableStateOf(false) }
|
||||
var launching by launchingState
|
||||
var tab by remember { mutableStateOf(0) }
|
||||
var travel by remember { mutableStateOf(1) }
|
||||
// The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip.
|
||||
var focusOnLift by remember { mutableStateOf<String?>(null) }
|
||||
// Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone.
|
||||
var lipBack by remember { mutableStateOf(false) }
|
||||
// Set as a finger's drag lets go: the sheet is already where it is going.
|
||||
var dragSettled by remember { mutableStateOf(false) }
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
val onOpenNow = rememberUpdatedState(onOpen)
|
||||
val pull = remember { Animatable(0f) }
|
||||
val sink = remember { Animatable(0f) }
|
||||
val lift = remember { Animatable(0f) }
|
||||
val ride = remember { Animatable(0f) }
|
||||
val hot = remember { Animatable(1f) }
|
||||
val lipShow = remember { Animatable(0f) }
|
||||
val lipGone = remember { Animatable(0f) }
|
||||
val fold = remember { Animatable(0f) }
|
||||
val foldTop = remember { Animatable(0f) }
|
||||
val pieces = remember { List(PIECES) { Animatable(0f) } }
|
||||
val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } }
|
||||
val playSrc = remember { MutableInteractionSource() }
|
||||
val playHovered by playSrc.collectIsHoveredAsState()
|
||||
var playFocused by remember { mutableStateOf(false) }
|
||||
var heroAt by remember { mutableStateOf(Offset.Zero) }
|
||||
var rest by remember { mutableStateOf<Rect?>(null) }
|
||||
fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } }
|
||||
fun close() {
|
||||
if (!open) return
|
||||
// Focus leaves the rows before they go; the sheet lets it go once it is closing.
|
||||
open = false
|
||||
focusPlay()
|
||||
}
|
||||
// Back from a hop the sheet started (or a page it opened): put it back as it was.
|
||||
LaunchedEffect(Unit) {
|
||||
val r = Hops.restore ?: return@LaunchedEffect
|
||||
if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect
|
||||
Hops.restore = null
|
||||
tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0)
|
||||
focusOnLift = r.control
|
||||
open = true
|
||||
}
|
||||
// Launched: the session covers the screen, and the sheet is put away under it.
|
||||
val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = androidx.lifecycle.LifecycleEventObserver { _, e ->
|
||||
if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) {
|
||||
open = false; shown = false; launching = false
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
LaunchedEffect(open, launching) {
|
||||
if (dragSettled) {
|
||||
// A finger brought it here and has already animated it: only what opening needs.
|
||||
dragSettled = false
|
||||
if (open) {
|
||||
if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } }
|
||||
} else focusPlay()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (open && !launching) {
|
||||
lipBack = false
|
||||
shown = true
|
||||
onOpen()
|
||||
fold.snapTo(0f); foldTop.snapTo(0f)
|
||||
coroutineScope {
|
||||
// Pull: the lip stretches down as the wall sinks back and dims.
|
||||
launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) }
|
||||
launch { sink.animateTo(1f, Motion.tw(260)) }
|
||||
// Lift: the sheet rises with Play riding its top edge; the lip lets go.
|
||||
launch {
|
||||
delay(Motion.ms(120).toLong())
|
||||
launch { lipGone.animateTo(1f, Motion.tw(140)) }
|
||||
launch { pull.animateTo(0f, Motion.sp(1f, 500f)) }
|
||||
launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) }
|
||||
launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) }
|
||||
launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) }
|
||||
}
|
||||
// Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts.
|
||||
pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } }
|
||||
if (inputMode.inputMode == InputMode.Keyboard) launch {
|
||||
val control = focusOnLift
|
||||
focusOnLift = null
|
||||
delay(Motion.ms(if (control != null) 420 else 300).toLong())
|
||||
withFrameNanos { }
|
||||
val target = control?.let { ff?.items?.get(it) } ?: chipFocus[tab]
|
||||
runCatching { target.requestFocus() }
|
||||
}
|
||||
}
|
||||
} else if (launching) {
|
||||
// Play from the sheet: the rows go, the sheet folds into the pill, then the launch.
|
||||
coroutineScope {
|
||||
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
|
||||
launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) }
|
||||
launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) }
|
||||
}
|
||||
shown = false
|
||||
onPrimary()
|
||||
// The session covers the screen and the sheet is put away under it (ON_STOP). If
|
||||
// nothing started, put it away anyway.
|
||||
delay(3_000)
|
||||
launching = false
|
||||
} else if (shown) {
|
||||
coroutineScope {
|
||||
pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } }
|
||||
launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) }
|
||||
launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) }
|
||||
launch { sink.animateTo(0f, Motion.tw(300)) }
|
||||
// The lip tucks back under Play as it lands, not once every spring has settled.
|
||||
launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true }
|
||||
}
|
||||
shown = false
|
||||
lipBack = false
|
||||
lipGone.snapTo(0f)
|
||||
hot.snapTo(1f)
|
||||
} else {
|
||||
// Snapped shut under a session: everything back at rest, no animation.
|
||||
listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) }
|
||||
pieces.forEach { it.snapTo(0f) }
|
||||
hot.snapTo(1f)
|
||||
}
|
||||
}
|
||||
BackHandler(enabled = open && !launching) { close() }
|
||||
val openNow = open || shown
|
||||
LaunchedEffect(openNow) { onOpenChange(openNow) }
|
||||
|
||||
// The lip: there while Play has a pad or a pointer on it, or always with touch alone.
|
||||
val touchOnly = inputMode.inputMode == InputMode.Touch
|
||||
val lipFocus = remember { FocusRequester() }
|
||||
val settingsLabel = stringResource(R.string.drawer_page_settings)
|
||||
var lipFocused by remember { mutableStateOf(false) }
|
||||
val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused)
|
||||
LaunchedEffect(lipWanted) {
|
||||
if (lipWanted) {
|
||||
// Back from the sheet it comes straight away; otherwise a beat after Play has focus.
|
||||
if (!lipBack) delay(Motion.ms(160).toLong())
|
||||
lipShow.animateTo(1f, Motion.tw(220))
|
||||
} else {
|
||||
delay(Motion.ms(140).toLong())
|
||||
lipShow.animateTo(0f, Motion.tw(220))
|
||||
}
|
||||
}
|
||||
|
||||
// How far the sheet travels, for a finger's drag to follow: set once the hero is measured.
|
||||
val travelPx = remember { floatArrayOf(1f) }
|
||||
val drag = remember { SheetDrag() }
|
||||
/** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */
|
||||
fun follow(p: Float) {
|
||||
drag.progress = p
|
||||
scope.launch {
|
||||
lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f)
|
||||
lipGone.snapTo(1f)
|
||||
pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) }
|
||||
}
|
||||
}
|
||||
/** A finger takes the sheet where it is: up from rest, or down from open. */
|
||||
fun beginDrag() {
|
||||
drag.engaged = true
|
||||
drag.from = if (open) 1f else 0f
|
||||
drag.progress = drag.from
|
||||
if (!open) {
|
||||
shown = true
|
||||
scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) }
|
||||
onOpenNow.value()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow
|
||||
* drag stays once it is a third of the way from where it started.
|
||||
*/
|
||||
fun settleDrag(v: Float) {
|
||||
drag.engaged = false
|
||||
val flick = with(density) { 600.dp.toPx() }
|
||||
val opens = when {
|
||||
v < -flick -> true
|
||||
v > flick -> false
|
||||
drag.from > 0.5f -> drag.progress > 0.7f
|
||||
else -> drag.progress > 0.3f
|
||||
}
|
||||
dragSettled = true
|
||||
scope.launch {
|
||||
val to = if (opens) 1f else 0f
|
||||
coroutineScope {
|
||||
launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) }
|
||||
launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) }
|
||||
launch { sink.animateTo(to, Motion.tw(220)) }
|
||||
pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } }
|
||||
}
|
||||
if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false }
|
||||
}
|
||||
if (open != opens) open = opens else dragSettled = false
|
||||
}
|
||||
// Pulled down past the top of the rows, the sheet itself comes down with the finger; up again
|
||||
// before letting go, it goes back up before the rows scroll.
|
||||
val rowsPull = remember {
|
||||
object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
|
||||
if (!drag.engaged || available.y >= 0f) return Offset.Zero
|
||||
val before = drag.progress
|
||||
follow((before - available.y / travelPx[0]).coerceAtMost(1f))
|
||||
return Offset(0f, (drag.progress - before) * -travelPx[0])
|
||||
}
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset {
|
||||
if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero
|
||||
if (!drag.engaged) beginDrag()
|
||||
follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f))
|
||||
return available
|
||||
}
|
||||
override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity {
|
||||
if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero
|
||||
settleDrag(available.y)
|
||||
return available
|
||||
}
|
||||
}
|
||||
}
|
||||
BoxWithConstraints(
|
||||
modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft }
|
||||
// A swipe anywhere on the hero moves the sheet under the finger - up from rest, down
|
||||
// when open - and lets go open or shut by how far it went and how fast. A swipe that
|
||||
// starts the wrong way (down at rest) is left alone, for the page under it.
|
||||
.pointerInput(Unit) {
|
||||
val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
|
||||
var decided = false
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false },
|
||||
onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) },
|
||||
onDragCancel = { if (drag.engaged) settleDrag(0f) },
|
||||
) { change, amount ->
|
||||
if (launching) return@detectVerticalDragGestures
|
||||
if (!decided) {
|
||||
decided = true
|
||||
// Up at rest opens; down when open closes. Anything else is not for the sheet.
|
||||
if (if (open) amount > 0f else amount < 0f) beginDrag()
|
||||
}
|
||||
if (!drag.engaged) return@detectVerticalDragGestures
|
||||
change.consume()
|
||||
tracker.addPosition(change.uptimeMillis, change.position)
|
||||
follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f))
|
||||
}
|
||||
},
|
||||
) {
|
||||
val w = constraints.maxWidth.toFloat()
|
||||
val h = constraints.maxHeight.toFloat()
|
||||
val inset = with(density) { SheetInset.toPx() }
|
||||
val restRect = rest?.translate(-heroAt)
|
||||
val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() }
|
||||
val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() })
|
||||
// Play's raised place: its centre 3dp under the sheet's edge, same left inset.
|
||||
val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f
|
||||
travelPx[0] = (h - openTop).coerceAtLeast(1f)
|
||||
|
||||
// The wall behind sinks back about the pane's centre.
|
||||
val foot = h
|
||||
Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() }
|
||||
// The fades stay put: scaled with the wall they stopped short of the pane's edges and left
|
||||
// a bright band of unfaded wall down the side and along the top.
|
||||
scrim { sink.value }
|
||||
// The dim; a tap on it (above the sheet) folds the sheet away.
|
||||
if (shown || open) Box(
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f))
|
||||
.focusProperties { canFocus = false }
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() },
|
||||
)
|
||||
|
||||
// The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot.
|
||||
if (shown) {
|
||||
val top = lerp(foot, openTop, lift.value)
|
||||
val sheetLeft = inset
|
||||
val sheetRight = w - inset
|
||||
val path = remember { Path() }
|
||||
val rimPath = remember { Path() }
|
||||
val corner = with(density) { SheetCorner.toPx() }
|
||||
val rim = with(density) { 1.4.dp.toPx() }
|
||||
val pill = restRect?.translate(0f, raise)
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val f = fold.value
|
||||
val ft = foldTop.value
|
||||
val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft
|
||||
val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight
|
||||
val t = if (pill != null) lerp(top, pill.top, ft) else top
|
||||
val b = if (pill != null) lerp(foot + corner, pill.bottom, ft) else foot + corner
|
||||
val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner
|
||||
val bc = if (ft > 0f) c else 0f
|
||||
path.reset()
|
||||
path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc)))
|
||||
val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value)
|
||||
drawPath(path, fill)
|
||||
val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value))
|
||||
if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim))
|
||||
else {
|
||||
// Sides and top only: the sheet runs on under the pane's foot.
|
||||
rimPath.reset()
|
||||
rimPath.moveTo(l, b)
|
||||
rimPath.lineTo(l, t + c)
|
||||
rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false)
|
||||
rimPath.lineTo(r - c, t)
|
||||
rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false)
|
||||
rimPath.lineTo(r, b)
|
||||
drawPath(rimPath, rimColor, style = Stroke(rim))
|
||||
}
|
||||
}
|
||||
// What the sheet holds: the chips to the right of Play, the rows under them.
|
||||
val chipsX = with(density) {
|
||||
if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft
|
||||
}
|
||||
val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() }
|
||||
// The rows start under the chips, which are 44dp to touch: from 54dp they ran under the
|
||||
// chips' feet and, scrolled, covered them.
|
||||
val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) }
|
||||
val sheetW = sheetRight - sheetLeft
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) }
|
||||
.size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() })
|
||||
// Touches on the sheet stay on it; none reach the dim behind.
|
||||
.pointerInput(Unit) { detectTapGestures { } }
|
||||
.controllerBack { close() }
|
||||
.bumpers(
|
||||
// LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with
|
||||
// its tab) is never left with nothing.
|
||||
onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } },
|
||||
onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } },
|
||||
)
|
||||
// A pad stays in the sheet until it folds; Left from it reaches Play, the handle.
|
||||
.focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) ff.primary else FocusRequester.Cancel } }
|
||||
.focusGroup(),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) }
|
||||
// Over the rows, whatever they scroll under them.
|
||||
.zIndex(1f)
|
||||
.width(with(density) { (sheetW - chipsX - 16.dp.toPx()).coerceAtLeast(0f).toDp() })
|
||||
.graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() }
|
||||
// Up from the chips folds the sheet (a finger drags it down, see the hero's drag).
|
||||
.onPreviewKeyEvent { e ->
|
||||
val k = e.nativeKeyEvent
|
||||
if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false
|
||||
},
|
||||
) {
|
||||
SheetChips(tabs.map { it.second }, tab, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i }
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset(0, rowsY.roundToInt()) }
|
||||
.nestedScroll(rowsPull)
|
||||
.fillMaxWidth()
|
||||
.height(with(density) { (foot - openTop - rowsY).coerceAtLeast(0f).toDp() }),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = tab,
|
||||
transitionSpec = {
|
||||
val d = travel
|
||||
val shift = with(density) { 24.dp.roundToPx() }
|
||||
(slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240)))
|
||||
.togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240)))
|
||||
},
|
||||
label = "sheetTab",
|
||||
) { t ->
|
||||
val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit
|
||||
val leavingNow = rememberUpdatedState(leaving)
|
||||
CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) {
|
||||
Column(
|
||||
Modifier.fillMaxSize()
|
||||
// Focus never lands in rows on their way out. Same group always, only
|
||||
// its rule changes (canFocus here made the scroll's own group a stop).
|
||||
.focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } }
|
||||
.focusGroup()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||
) { rows(tabs[t].first) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The words and the button, where they always were; the button rides the sheet's edge.
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) {
|
||||
Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() }
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.onGloballyPositioned { rest = it.boundsInRoot() }
|
||||
.graphicsLayer { translationY = raise * ride.value }
|
||||
.onFocusChanged { playFocused = it.hasFocus }
|
||||
.hoverable(playSrc)
|
||||
.onPreviewKeyEvent { e ->
|
||||
val k = e.nativeKeyEvent
|
||||
if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false
|
||||
else {
|
||||
if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) {
|
||||
if (open) runCatching { chipFocus[tab].requestFocus() }
|
||||
else if (downOpens) open = true
|
||||
else runCatching { lipFocus.requestFocus() }
|
||||
}
|
||||
true
|
||||
}
|
||||
},
|
||||
) {
|
||||
button {
|
||||
if (launching) return@button
|
||||
if (open || shown) { launching = true; open = false } else onPrimary()
|
||||
}
|
||||
}
|
||||
// Gone with the words while the sheet is up; nothing a pad or a finger can reach then.
|
||||
val besideShown = !(open || shown)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.graphicsLayer { alpha = 1f - sink.value }
|
||||
.focusProperties { canFocus = besideShown },
|
||||
) { if (besideShown || sink.value < 0.99f) beside() }
|
||||
}
|
||||
}
|
||||
|
||||
// The lip, under Play: stretched by the pull, gone as the sheet lifts.
|
||||
val r0 = restRect
|
||||
// A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on.
|
||||
if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) {
|
||||
var handleW by remember { mutableStateOf(0f) }
|
||||
val lipH = 22.dp + pull.value.dp
|
||||
val lift = { if (!open) open = true }
|
||||
Box(
|
||||
Modifier
|
||||
.offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) }
|
||||
.onSizeChanged { handleW = it.width.toFloat() }
|
||||
.graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) }
|
||||
// 44dp to touch; the tab itself is the top 22.
|
||||
.heightIn(min = 44.dp)
|
||||
.semantics { contentDescription = settingsLabel }
|
||||
.then(
|
||||
if (downOpens) Modifier.focusProperties { canFocus = false }
|
||||
else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift),
|
||||
)
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift),
|
||||
) {
|
||||
Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) {
|
||||
Handle(lipH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often,
|
||||
* saying there is a drawer to pull up. Still with animations off.
|
||||
*/
|
||||
@Composable
|
||||
private fun Handle(height: Dp) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
|
||||
val still = Motion.scale == 0f
|
||||
val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle")
|
||||
// A quick lift and settle, then a rest: a nudge, not a pulse.
|
||||
val lift by bob.animateFloat(
|
||||
0f, 0f,
|
||||
androidx.compose.animation.core.infiniteRepeatable(
|
||||
androidx.compose.animation.core.keyframes {
|
||||
durationMillis = 2400
|
||||
0f at 0
|
||||
1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing
|
||||
-0.25f at 420
|
||||
0f at 620
|
||||
},
|
||||
),
|
||||
label = "handleBob",
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.height(height).clip(shape).background(colors.surface)
|
||||
.drawBorderSidesAndFoot(pal.line, 12.dp)
|
||||
.padding(start = 14.dp, end = 10.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.drawer_page_settings), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1)
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */
|
||||
private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier =
|
||||
this.drawWithContent {
|
||||
drawContent()
|
||||
val c = corner.toPx()
|
||||
val sw = 1.dp.toPx()
|
||||
val p = Path().apply {
|
||||
moveTo(sw / 2, 0f)
|
||||
lineTo(sw / 2, size.height - c)
|
||||
arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false)
|
||||
lineTo(size.width - c, size.height - sw / 2)
|
||||
arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false)
|
||||
lineTo(size.width - sw / 2, 0f)
|
||||
}
|
||||
drawPath(p, color, style = Stroke(sw))
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row),
|
||||
* LB and RB a chip at a time, a tap or A on a far one a hop as a drop. [first] takes focus as the
|
||||
* sheet opens. Too narrow for equal chips, the row scrolls.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetChips(labels: List<String>, selected: Int, focus: List<FocusRequester>? = null, onPick: (Int) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val tint = rememberUpdatedState(pal.signal)
|
||||
// Each chip its own requester: one moved from chip to chip under a focused chip lost the ring.
|
||||
val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } }
|
||||
var within by remember { mutableStateOf(false) }
|
||||
// LB / RB with a pad on the chips: focus goes with the selection.
|
||||
LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } }
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) {
|
||||
val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp
|
||||
val scroll = rememberScrollState()
|
||||
// Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut.
|
||||
Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) {
|
||||
ChipGlide(selected, bounds[selected], tint)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = if (equal) Modifier.fillMaxWidth() else Modifier) {
|
||||
labels.forEachIndexed { i, label ->
|
||||
val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp))
|
||||
.onGloballyPositioned { bounds[i] = it.boundsInParent() }
|
||||
.focusRequester(chips[i])
|
||||
SheetChip(label, i == selected, m) { onPick(i) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(shape)
|
||||
.background(if (on) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f))
|
||||
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the
|
||||
* signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent)
|
||||
.glideBorder(hot, shape, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 11.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -154,169 +152,293 @@ class ModeSettingsActions(
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and
|
||||
* Session (its Files chip is the Desktop page's own).
|
||||
*/
|
||||
internal fun modeSheetTabs(steam: Boolean): List<String> =
|
||||
if (steam) listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY, SheetTab.STEAM)
|
||||
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
|
||||
|
||||
/** A sheet tab's chip label. */
|
||||
@Composable
|
||||
internal fun sheetTabLabel(tab: String): String = when (tab) {
|
||||
SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display)
|
||||
SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls)
|
||||
SheetTab.AUDIO -> stringResource(R.string.mode_audio)
|
||||
SheetTab.SESSION -> stringResource(R.string.mode_session)
|
||||
SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library)
|
||||
SheetTab.STEAM -> stringResource(R.string.mode_tab_steam)
|
||||
else -> stringResource(R.string.setup_tool_files)
|
||||
private enum class ModeSettingsTab(val label: Int) {
|
||||
DISPLAY(R.string.drawer_page_display),
|
||||
CONTROLS(R.string.drawer_page_controls),
|
||||
GAMES(R.string.game_settings_title),
|
||||
AUDIO(R.string.mode_audio),
|
||||
SESSION(R.string.mode_session),
|
||||
STEAM(R.string.mode_tab_steam),
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and
|
||||
* the Graphics drivers link to Components (Display, given the drivers' summary).
|
||||
*/
|
||||
class SheetExtras(
|
||||
val frameGen: @Composable () -> Unit = {},
|
||||
val performance: @Composable () -> Unit = {},
|
||||
val drivers: @Composable (summary: String?) -> Unit = {},
|
||||
)
|
||||
|
||||
/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */
|
||||
@Composable
|
||||
fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) {
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
var explainWifiDiscovery by remember { mutableStateOf(false) }
|
||||
if (tab == SheetTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution)
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
|
||||
val tabs = if (steam) ModeSettingsTab.entries else listOf(
|
||||
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
|
||||
)
|
||||
val tab = tabs[tabIndex]
|
||||
val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() }
|
||||
val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } }
|
||||
val firstChip = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||
fun selectTab(index: Int) { host.open = null; tabIndex = index }
|
||||
androidx.compose.runtime.LaunchedEffect(steam, tabIndex) {
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
runCatching {
|
||||
if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus()
|
||||
else tabFocus[tabIndex].requestFocus()
|
||||
}
|
||||
}
|
||||
SettingsPage(
|
||||
host,
|
||||
title = null,
|
||||
onBack = a.onDismiss,
|
||||
scroll = scrolls[tabIndex],
|
||||
modifier = Modifier.bumpers(
|
||||
onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) },
|
||||
onNext = { selectTab((tabIndex + 1) % tabs.size) },
|
||||
),
|
||||
header = {
|
||||
TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab,
|
||||
modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus)
|
||||
},
|
||||
) {
|
||||
if (tab == ModeSettingsTab.DISPLAY) {
|
||||
val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution)
|
||||
SettingsGroup(stringResource(R.string.display_session)) {
|
||||
ResolutionRow(host, s.resolution, s.panelSize, a.onResolution,
|
||||
chipModifier = Modifier.focusRequester(firstChip))
|
||||
if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) ||
|
||||
com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) {
|
||||
val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize)
|
||||
ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit),
|
||||
stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable),
|
||||
listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)),
|
||||
s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
)
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) {
|
||||
ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_image_scaling)) {
|
||||
ChoiceRow(
|
||||
host, "upscaler", stringResource(R.string.display_filter), null,
|
||||
SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler,
|
||||
note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler,
|
||||
)
|
||||
if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow(
|
||||
stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5,
|
||||
format = { "$it%" }, onChange = a.onUpscaleSharpness,
|
||||
)
|
||||
if (tab == ModeSettingsTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.display_frame_rate)) {
|
||||
ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session),
|
||||
SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit)
|
||||
if (tab == ModeSettingsTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_hdr)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
|
||||
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
|
||||
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
|
||||
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
|
||||
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> stringResource(R.string.common_working)
|
||||
s.deckyChecking -> stringResource(R.string.common_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
|
||||
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.common_check)
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
action,
|
||||
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
|
||||
) {
|
||||
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) SecondaryButton(
|
||||
stringResource(R.string.common_uninstall),
|
||||
enabled = s.deckyStage == null && !s.deckySessionRunning,
|
||||
) { confirmDeckyRemoval = true }
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) ToggleRow(
|
||||
host, "decky-enabled", stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
else stringResource(R.string.mode_decky_off),
|
||||
s.deckyEnabled,
|
||||
enabled = !s.deckySessionRunning && s.deckyStage == null,
|
||||
onChange = a.onDeckyEnabled,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_decky_plugins),
|
||||
stringResource(R.string.mode_decky_plugins_hint),
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
|
||||
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
|
||||
onClick = a.onPickDeckyPluginZip,
|
||||
)
|
||||
}
|
||||
if (s.deckyStage != null && s.deckyPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { s.deckyPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "hdr", stringResource(R.string.mode_hdr10),
|
||||
s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) }
|
||||
?: stringResource(R.string.mode_hdr_restart),
|
||||
checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr,
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
extras.frameGen()
|
||||
extras.drivers(s.gpuDrivers)
|
||||
}
|
||||
if (tab == SheetTab.CONTROLS) {
|
||||
SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) {
|
||||
if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
else -> stringResource(R.string.mode_wifi_discovery_hint)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
|
||||
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
|
||||
}
|
||||
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
|
||||
host.open = null
|
||||
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
if (s.syncBackend != null) SettingsRow(
|
||||
stringResource(R.string.sync_backend_title),
|
||||
stringResource(R.string.sync_backend_hint),
|
||||
) {
|
||||
SegmentedTabs(
|
||||
listOf(
|
||||
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
|
||||
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
s.syncBackend,
|
||||
) { id -> host.open = null; a.onSyncBackend(id) }
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "touch", stringResource(R.string.mode_touch), null,
|
||||
listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode,
|
||||
note = stringResource(R.string.mode_touch_note),
|
||||
onPick = a.onTouch,
|
||||
)
|
||||
if (steam && s.oscMode != null) ChoiceRow(
|
||||
host, "osc", stringResource(R.string.mode_osc), null,
|
||||
listOf(
|
||||
SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always),
|
||||
SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch),
|
||||
SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam),
|
||||
SessionPrefs.OSC_NEVER to stringResource(R.string.common_never),
|
||||
), s.oscMode,
|
||||
note = stringResource(R.string.mode_osc_note), onPick = a.onOsc,
|
||||
)
|
||||
if (steam && s.steamController != null) ChoiceRow(
|
||||
host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint),
|
||||
listOf(
|
||||
SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck),
|
||||
SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam),
|
||||
SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4),
|
||||
SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense),
|
||||
SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360),
|
||||
), s.steamController,
|
||||
note = stringResource(R.string.mode_controller_note),
|
||||
onPick = a.onSteamController,
|
||||
)
|
||||
if (steam && s.unfoldedControls != null) ToggleRow(
|
||||
host, "unfolded-controls", stringResource(R.string.unfolded_controls),
|
||||
stringResource(R.string.unfolded_controls_hint),
|
||||
s.unfoldedControls, onChange = a.onUnfoldedControls,
|
||||
)
|
||||
if (steam) ChoiceRow(
|
||||
host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)),
|
||||
listOf(
|
||||
false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM),
|
||||
true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU),
|
||||
), s.backActionsInverted, onPick = a.onBackActionsInverted,
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
|
||||
}
|
||||
}
|
||||
if (tab == SheetTab.AUDIO && steam) {
|
||||
if (s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) {
|
||||
ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio)
|
||||
ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic)
|
||||
}
|
||||
}
|
||||
if (tab == SheetTab.SESSION) {
|
||||
SettingsGroup(stringResource(R.string.mode_session)) {
|
||||
ChoiceRow(
|
||||
host, "suspend", stringResource(R.string.mode_suspend),
|
||||
stringResource(R.string.mode_suspend_hint),
|
||||
listOf(
|
||||
SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto),
|
||||
SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual),
|
||||
SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never),
|
||||
) + if (steam) listOf(
|
||||
SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native),
|
||||
) else emptyList(),
|
||||
s.suspendPolicy,
|
||||
note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note),
|
||||
onPick = a.onSuspendPolicy,
|
||||
)
|
||||
if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow(
|
||||
host, "background-downloads", stringResource(R.string.mode_background_downloads),
|
||||
stringResource(R.string.mode_background_downloads_hint),
|
||||
s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground,
|
||||
)
|
||||
}
|
||||
if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) {
|
||||
ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null,
|
||||
s.pipAutoEnter, onChange = a.onPipAutoEnter)
|
||||
}
|
||||
if (steam) SettingsGroup(stringResource(R.string.mode_startup)) {
|
||||
ToggleRow(
|
||||
host, "steam-startup", stringResource(R.string.mode_steam_startup),
|
||||
stringResource(R.string.mode_steam_startup_hint),
|
||||
s.runSteamAtStartup, onChange = a.onRunSteamAtStartup,
|
||||
)
|
||||
}
|
||||
extras.performance()
|
||||
}
|
||||
if (tab == SheetTab.LIBRARY && steam) {
|
||||
if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) {
|
||||
val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage }
|
||||
// Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with.
|
||||
class StorageChoice(val path: String, val label: String, val chip: String, val saved: String)
|
||||
@@ -359,96 +481,6 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
)
|
||||
}
|
||||
}
|
||||
if (tab == SheetTab.STEAM && steam) {
|
||||
SettingsGroup(stringResource(R.string.mode_decky)) {
|
||||
val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null &&
|
||||
s.deckyInstalled != s.deckyLatestRelease.tag
|
||||
val status = when {
|
||||
s.deckyStage != null -> s.deckyStage
|
||||
s.deckyChecking -> stringResource(R.string.mode_decky_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag)
|
||||
s.deckyInstalled == null -> stringResource(R.string.mode_decky_none)
|
||||
s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled)
|
||||
updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag)
|
||||
else -> stringResource(R.string.mode_decky_current, s.deckyInstalled)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_decky_loader), status) {
|
||||
val action = when {
|
||||
s.deckyStage != null -> stringResource(R.string.common_working)
|
||||
s.deckyChecking -> stringResource(R.string.common_checking)
|
||||
s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest)
|
||||
s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update)
|
||||
else -> stringResource(R.string.common_check)
|
||||
}
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton(
|
||||
action,
|
||||
enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning,
|
||||
) {
|
||||
if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck()
|
||||
else s.deckyLatestRelease?.let(a.onDeckyInstall)
|
||||
}
|
||||
if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall)
|
||||
}
|
||||
}
|
||||
if (s.deckyInstalled != null) ToggleRow(
|
||||
host, "decky-enabled", stringResource(R.string.mode_decky),
|
||||
if (s.deckyEnabled) stringResource(R.string.mode_decky_on)
|
||||
else stringResource(R.string.mode_decky_off),
|
||||
s.deckyEnabled,
|
||||
enabled = !s.deckySessionRunning && s.deckyStage == null,
|
||||
onChange = a.onDeckyEnabled,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_decky_plugins),
|
||||
stringResource(R.string.mode_decky_plugins_hint),
|
||||
) {
|
||||
SecondaryButton(
|
||||
if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip),
|
||||
enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning,
|
||||
onClick = a.onPickDeckyPluginZip,
|
||||
)
|
||||
}
|
||||
if (s.deckyStage != null && s.deckyPercent >= 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = { s.deckyPercent / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.mode_client)) {
|
||||
ToggleRow(
|
||||
host, "mangoapp", stringResource(R.string.mode_mangoapp),
|
||||
stringResource(R.string.mode_mangoapp_hint),
|
||||
s.mangoapp, onChange = a.onMangoapp,
|
||||
)
|
||||
SettingsRow(
|
||||
stringResource(R.string.mode_steam_repair),
|
||||
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
|
||||
) {
|
||||
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
|
||||
}
|
||||
}
|
||||
if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
|
||||
val hint = when {
|
||||
s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off)
|
||||
!s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied)
|
||||
else -> stringResource(R.string.mode_wifi_discovery_hint)
|
||||
}
|
||||
SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
|
||||
if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) {
|
||||
SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings)
|
||||
}
|
||||
ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on ->
|
||||
host.open = null
|
||||
if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (explainWifiDiscovery) AlertDialog(
|
||||
onDismissRequest = { explainWifiDiscovery = false },
|
||||
title = { Text(stringResource(R.string.mode_wifi_explain_title)) },
|
||||
@@ -462,66 +494,15 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
|
||||
TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games'
|
||||
* environment - for the Components page's Proton layer.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ProtonOptions(
|
||||
syncBackend: String, fexPreset: String, forceSsbs: Boolean,
|
||||
onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint)) {
|
||||
SegmentedTabs(
|
||||
listOf(
|
||||
SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync),
|
||||
SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync),
|
||||
SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync),
|
||||
SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver),
|
||||
),
|
||||
syncBackend,
|
||||
) { id -> host.open = null; onSyncBackend(id) }
|
||||
}
|
||||
ChoiceRow(
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, fexPreset,
|
||||
note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset,
|
||||
if (confirmDeckyRemoval) AlertDialog(
|
||||
onDismissRequest = { confirmDeckyRemoval = false },
|
||||
title = { Text(stringResource(R.string.mode_decky_remove_title)) },
|
||||
text = { Text(stringResource(R.string.mode_decky_remove_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } },
|
||||
)
|
||||
ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs)
|
||||
GameEnvironmentRow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A
|
||||
* costs nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.common_uninstall)
|
||||
val title = stringResource(R.string.mode_decky_remove_title)
|
||||
val note = stringResource(R.string.mode_decky_remove_text)
|
||||
val cancel = stringResource(R.string.common_cancel)
|
||||
var at by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(title)
|
||||
Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
|
||||
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) {
|
||||
StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
|
||||
@@ -44,38 +44,9 @@ import kotlin.math.sin
|
||||
// Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges
|
||||
// first, and the page rises into it once it is covered. Back draws it down into the control again.
|
||||
|
||||
/**
|
||||
* Where a page opened from: the control's bounds and corner radius, in px, and the rail item that
|
||||
* was showing it ([owner]), so the page only draws back into it when that content returns. [art]
|
||||
* is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one.
|
||||
*/
|
||||
internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) {
|
||||
fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art)
|
||||
fun ownedBy(key: String) = Origin(bounds, corner, key, art)
|
||||
}
|
||||
|
||||
/** How a page that opened from a control leaves. */
|
||||
internal enum class PageExit {
|
||||
/** Back into the control it came from, which is on screen again. */
|
||||
Drain,
|
||||
/** Out through a link into another section, which takes the page there itself. */
|
||||
Hop,
|
||||
/** Away with the pane, as any page goes: the control it came from is not coming back. */
|
||||
Sink,
|
||||
}
|
||||
|
||||
/** Why a page is leaving: an ordinary change of page, or a link into another section. */
|
||||
internal enum class PageLeave { Ordinary, Link }
|
||||
|
||||
/**
|
||||
* The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only
|
||||
* draws back into the control when the content that control belongs to is what comes back; a page
|
||||
* left for another rail item or another page drained into a control that was no longer there.
|
||||
*/
|
||||
internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when {
|
||||
owner != null && incoming == owner -> PageExit.Drain
|
||||
leave == PageLeave.Link -> PageExit.Hop
|
||||
else -> PageExit.Sink
|
||||
/** Where a page opened from: the control's bounds and corner radius, in px. */
|
||||
internal class Origin(val bounds: Rect, val corner: Float) {
|
||||
fun translate(by: Offset) = Origin(bounds.translate(by), corner)
|
||||
}
|
||||
|
||||
internal object PageOrigin {
|
||||
@@ -99,7 +70,6 @@ internal const val PAGE_RETURN_MS = 900
|
||||
* [content], a page that opened from the control at [from] (in this element's coordinates): the
|
||||
* control's tile stretching over it until covered, then the page rising in as the tile fades.
|
||||
* Once [leaving], the reverse: the tile comes back over the page and draws down into the control.
|
||||
* A page that [sinks] instead goes with the pane and draws no tile at all.
|
||||
* The focus ring stays out of it until it is done.
|
||||
*/
|
||||
@Composable
|
||||
@@ -108,7 +78,6 @@ internal fun PageFlood(
|
||||
leaving: Boolean,
|
||||
/** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */
|
||||
art: Painter? = null,
|
||||
sinks: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val pal = LocalPalette.current
|
||||
@@ -118,14 +87,7 @@ internal fun PageFlood(
|
||||
val shown = remember { Animatable(0f) }
|
||||
val tile = remember { Animatable(1f) }
|
||||
var size by remember { mutableStateOf(Size.Zero) }
|
||||
LaunchedEffect(size != Size.Zero, leaving, sinks) {
|
||||
if (sinks) {
|
||||
// Whatever the flood in was doing, the page is shown whole and leaves with the pane.
|
||||
tile.snapTo(0f)
|
||||
shown.snapTo(1f)
|
||||
glide?.hidden = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
LaunchedEffect(size != Size.Zero, leaving) {
|
||||
if (size == Size.Zero) return@LaunchedEffect
|
||||
glide?.hidden = true
|
||||
val b = from.bounds
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean)
|
||||
|
||||
@Composable
|
||||
fun ProtonPage(
|
||||
rows: List<ProtonRow>,
|
||||
busyId: String?,
|
||||
stage: String?,
|
||||
percent: Int,
|
||||
runtimeReady: Boolean,
|
||||
sessionRunning: Boolean,
|
||||
onInstall: (String) -> Unit,
|
||||
onCancel: (String) -> Unit,
|
||||
onRemove: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val colors = MaterialTheme.colorScheme
|
||||
SettingsPage(
|
||||
host,
|
||||
title = stringResource(R.string.setup_tool_protons),
|
||||
eyebrow = stringResource(R.string.setup_title),
|
||||
lede = stringResource(R.string.proton_lede),
|
||||
onBack = onBack,
|
||||
) {
|
||||
if (!runtimeReady) Text(
|
||||
stringResource(R.string.content_needs_runtime),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
if (sessionRunning) Text(
|
||||
stringResource(R.string.proton_stop_first),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
val compatible = stringResource(R.string.steam_compat_label)
|
||||
Text(
|
||||
stringResource(R.string.proton_compatible_note, compatible),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp),
|
||||
)
|
||||
SettingsGroup(stringResource(R.string.proton_available)) {
|
||||
for (row in rows) {
|
||||
SettingsRow(
|
||||
row.name,
|
||||
when {
|
||||
row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible)
|
||||
row.queued -> stringResource(R.string.proton_queued)
|
||||
else -> stringResource(R.string.proton_not_installed)
|
||||
},
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
when {
|
||||
row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) }
|
||||
busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {}
|
||||
else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) }
|
||||
}
|
||||
if (row.queued && row.installed == null && busyId == null) {
|
||||
SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) {
|
||||
Text(
|
||||
if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting),
|
||||
fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp),
|
||||
)
|
||||
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.roundToInt
|
||||
@@ -89,9 +88,6 @@ import com.droiddeck.launcher.HomeApp
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.session.ProfileValue
|
||||
import com.droiddeck.launcher.session.SelectedGameProfile
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
@@ -209,8 +205,8 @@ class DrawerActions(
|
||||
/** The compositor's post chain (gpu/ScreenEffects), changed live. */
|
||||
val effects: ScreenEffects = ScreenEffects.OFF,
|
||||
/** Texture filtering for DirectX 9-11 games (core/TextureFiltering); lands on their next launch. */
|
||||
val textureAnisotropy: Int? = 0,
|
||||
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
|
||||
val textureAnisotropy: Int = 0,
|
||||
val textureLodBias: String = TextureFiltering.LOD_BIAS_OFF,
|
||||
val secondScreenMode: SecondScreenMode,
|
||||
val secondScreenDisplays: List<SecondScreenDisplay>,
|
||||
val selectedSecondScreenDisplay: Int,
|
||||
@@ -239,8 +235,8 @@ class DrawerActions(
|
||||
val onUpscaler: (Int) -> Unit = {},
|
||||
val onUpscaleSharpness: (Int) -> Unit = {},
|
||||
val onEffects: (ScreenEffects) -> Unit = {},
|
||||
val onTextureAnisotropy: (Int?) -> Unit = {},
|
||||
val onTextureLodBias: (String?) -> Unit = {},
|
||||
val onTextureAnisotropy: (Int) -> Unit = {},
|
||||
val onTextureLodBias: (String) -> Unit = {},
|
||||
val onSecondScreenMode: (SecondScreenMode) -> Unit,
|
||||
val onSecondScreenDisplay: (Int) -> Unit,
|
||||
val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit,
|
||||
@@ -261,22 +257,8 @@ class DrawerActions(
|
||||
val components: ComponentsManager.Snapshot? = null,
|
||||
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
|
||||
val onComponentsRefresh: () -> Unit = {},
|
||||
/** Installed games available for profile inspection in the session drawer. */
|
||||
val gameProfiles: List<Pair<Long, String>> = emptyList(),
|
||||
/** The selected game profile, or null for the Default profile. */
|
||||
val selectedGameProfileId: Long? = null,
|
||||
val selectedGameProfile: SelectedGameProfile? = null,
|
||||
val gameProfilesLoaded: Boolean = false,
|
||||
val gameProfileRefreshFailed: Boolean = false,
|
||||
val onSelectedGameProfile: (Long?) -> Unit = {},
|
||||
val onGameProfilesPageOpened: () -> Unit = {},
|
||||
val onSelectedGameProfileRefresh: () -> Unit = {},
|
||||
val gameFexPresetSaving: Boolean = false,
|
||||
val onGameFexPreset: (Long, String?) -> Unit = { _, _ -> },
|
||||
/** Swaps [value] ("orig:<build>" or a stored package file) into a Proton's component. */
|
||||
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
|
||||
/** Saves a stored package for a game, or null to inherit that component from the Default profile. */
|
||||
val onGameComponent: (appId: Long, comp: String, file: String?) -> Unit = { _, _, _ -> },
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@@ -304,19 +286,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
BackHandler(enabled = open && confirmStop) { confirmStop = false }
|
||||
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
|
||||
LaunchedEffect(a.selectedGameProfileId) {
|
||||
if (host.open == "fex") host.open = null
|
||||
}
|
||||
LaunchedEffect(open, page) {
|
||||
if (open && page == SessionDrawerPage.GAMES) {
|
||||
a.onGameProfilesPageOpened()
|
||||
a.onComponentsRefresh()
|
||||
while (true) {
|
||||
a.onSelectedGameProfileRefresh()
|
||||
delay(1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
|
||||
LaunchedEffect(open, controllerActive) {
|
||||
if (open && !controllerActive) focusManager.clearFocus(force = true)
|
||||
}
|
||||
@@ -509,17 +479,14 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
SessionDrawerPage.GAMES -> {
|
||||
if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) }
|
||||
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
|
||||
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
DrawerFexPresetRow(host, a, focus.track(page, "fex"))
|
||||
GameEnvironmentRow(
|
||||
modifier = focus.track(page, "game-env"),
|
||||
showHint = false,
|
||||
initialScope = a.selectedGameProfileId?.toString().orEmpty(),
|
||||
)
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
|
||||
}
|
||||
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
|
||||
}
|
||||
@@ -907,187 +874,20 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerFexPresetRow(host: MenuHost, a: DrawerActions, modifier: Modifier) {
|
||||
val appId = a.selectedGameProfileId
|
||||
val profile = a.selectedGameProfile?.takeIf { it.appId == appId }
|
||||
val inherited = profile?.fexPreset?.inherited == true
|
||||
val hint = if (profile == null) null else stringResource(
|
||||
if (inherited) R.string.comp_game_fex_inherited else R.string.comp_game_fex_override,
|
||||
)
|
||||
SettingsRow(stringResource(R.string.fex_preset_title), hint, highlighted = host.open == "fex") {
|
||||
if (appId != null && profile == null) {
|
||||
ValueChip("-", false, enabled = false, modifier = modifier) {}
|
||||
} else {
|
||||
FexPresetChip(
|
||||
preset = profile?.fexPreset?.value ?: a.fexPreset,
|
||||
inherited = inherited,
|
||||
open = host.open == "fex",
|
||||
enabled = !a.gameFexPresetSaving,
|
||||
onToggle = { host.open = if (host.open == "fex") null else "fex" },
|
||||
onDismiss = { if (host.open == "fex") host.open = null },
|
||||
onPick = { preset ->
|
||||
if (appId == null) {
|
||||
preset?.let(a.onFexPreset)
|
||||
} else {
|
||||
a.onGameFexPreset(appId, preset)
|
||||
}
|
||||
},
|
||||
allowInheritance = appId != null,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
|
||||
@Composable
|
||||
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
val inherited = a.selectedGameProfileId != null
|
||||
val anisotropyChoices: List<Pair<Int?, String>> =
|
||||
(if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) +
|
||||
SessionPrefs.textureAnisotropyChoices(LocalContext.current).map { (value, label) -> value as Int? to label }
|
||||
val lodBiasChoices: List<Pair<String?, String>> =
|
||||
(if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) +
|
||||
SessionPrefs.textureLodBiasChoices(LocalContext.current).map { (value, label) -> value as String? to label }
|
||||
SettingsGroup(stringResource(R.string.drawer_texture)) {
|
||||
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
|
||||
anisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
|
||||
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
|
||||
lodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
|
||||
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
|
||||
}
|
||||
}
|
||||
|
||||
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
|
||||
|
||||
@Composable
|
||||
private fun SelectedGameProfileGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val options: List<Pair<Long?, String>> = listOf(null to stringResource(R.string.comp_scope_general)) +
|
||||
a.gameProfiles.map { (id, name) -> Pair(id, name) }
|
||||
val profile = a.selectedGameProfile
|
||||
SettingsGroup(stringResource(R.string.drawer_selected_profile)) {
|
||||
DrawerStackedChoice(
|
||||
host = host,
|
||||
key = "game-profile",
|
||||
label = stringResource(R.string.comp_profile),
|
||||
hint = if (a.selectedGameProfileId == null) stringResource(R.string.comp_scope_general_detail)
|
||||
else stringResource(R.string.drawer_profile_inspect_hint),
|
||||
options = options,
|
||||
selected = a.selectedGameProfileId,
|
||||
chipModifier = track("game-profile"),
|
||||
onPick = a.onSelectedGameProfile,
|
||||
)
|
||||
if (a.gameProfileRefreshFailed) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (a.gameProfilesLoaded) {
|
||||
R.string.drawer_profile_refresh_failed_stale
|
||||
} else {
|
||||
R.string.drawer_profile_refresh_failed_initial
|
||||
},
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
color = colors.error,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
if (a.selectedGameProfileId == null) return@SettingsGroup
|
||||
if (profile == null) {
|
||||
if (!a.gameProfileRefreshFailed) Text(
|
||||
stringResource(R.string.drawer_selected_profile_none),
|
||||
fontSize = 13.sp,
|
||||
color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
return@SettingsGroup
|
||||
}
|
||||
val hasOverrides = profile.hasOverrides ||
|
||||
a.textureAnisotropy != null ||
|
||||
a.textureLodBias != null
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (hasOverrides) R.string.drawer_profile_custom
|
||||
else R.string.drawer_profile_default,
|
||||
),
|
||||
fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = if (hasOverrides) 5.dp else 0.dp),
|
||||
)
|
||||
if (!profile.fexPreset.inherited) {
|
||||
ProfileSummaryRow(stringResource(R.string.comp_game_fex), fexProfileLabel(profile.fexPreset))
|
||||
}
|
||||
if (!profile.proton.inherited) {
|
||||
ProfileSummaryRow(stringResource(R.string.comp_proton), profileValueLabel(profile.proton))
|
||||
}
|
||||
for (component in ComponentsManager.COMPONENTS) {
|
||||
val value = profile.components.getValue(component)
|
||||
if (!value.inherited) {
|
||||
ProfileSummaryRow(ComponentsManager.LABEL.getValue(component), profileValueLabel(value))
|
||||
}
|
||||
}
|
||||
if (profile.environmentOverrides > 0) {
|
||||
ProfileSummaryRow(
|
||||
stringResource(R.string.game_env_title),
|
||||
pluralStringResource(
|
||||
R.plurals.drawer_profile_environment_overrides,
|
||||
profile.environmentOverrides,
|
||||
profile.environmentOverrides,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (profile.windowsComponentNames.isNotEmpty()) {
|
||||
ProfileSummaryRow(
|
||||
stringResource(R.string.wincomp_title),
|
||||
profile.windowsComponentNames.joinToString(", "),
|
||||
)
|
||||
}
|
||||
a.textureAnisotropy?.let { value ->
|
||||
ProfileSummaryRow(
|
||||
stringResource(R.string.drawer_anisotropy),
|
||||
SessionPrefs.textureAnisotropyChoices(LocalContext.current)
|
||||
.firstOrNull { it.first == value }?.second.orEmpty(),
|
||||
)
|
||||
}
|
||||
a.textureLodBias?.let { value ->
|
||||
ProfileSummaryRow(
|
||||
stringResource(R.string.drawer_texture_sharpness),
|
||||
SessionPrefs.textureLodBiasChoices(LocalContext.current)
|
||||
.firstOrNull { it.first == value }?.second.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fexProfileLabel(value: ProfileValue): String {
|
||||
return when (value.value) {
|
||||
GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
|
||||
else -> stringResource(FexPreset.byId(value.value).label)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun profileValueLabel(value: ProfileValue) =
|
||||
value.value.ifEmpty { stringResource(R.string.comp_no_proton_short) }
|
||||
|
||||
@Composable
|
||||
private fun ProfileSummaryRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) {
|
||||
Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(96.dp))
|
||||
Text(
|
||||
value,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
|
||||
* into the Proton a running game uses waits until that game closes; downloading, importing and
|
||||
@@ -1103,54 +903,6 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
|
||||
}
|
||||
return
|
||||
}
|
||||
val gameProfile = a.selectedGameProfileId?.let { appId ->
|
||||
a.selectedGameProfile?.takeIf { it.appId == appId }
|
||||
}
|
||||
if (a.selectedGameProfileId != null) {
|
||||
if (gameProfile == null) {
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
Text(
|
||||
stringResource(R.string.comp_reading),
|
||||
fontSize = 13.sp,
|
||||
color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.drawer_page_components)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
|
||||
ProfileSummaryRow(stringResource(R.string.comp_proton), gameProfile.proton.value)
|
||||
}
|
||||
for (comp in ComponentsManager.COMPONENTS) {
|
||||
val value = gameProfile.components.getValue(comp)
|
||||
val options: List<Pair<String?, String>> =
|
||||
listOf(null to stringResource(R.string.comp_game_use_general)) +
|
||||
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
|
||||
DrawerStackedChoice(
|
||||
host = host,
|
||||
key = "game-cmp-$comp",
|
||||
label = ComponentsManager.LABEL.getValue(comp),
|
||||
hint = if (value.inherited) {
|
||||
stringResource(R.string.drawer_component_inherited, value.value)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
options = options,
|
||||
selected = gameProfile.componentFiles[comp],
|
||||
chipModifier = track("cmp-$comp"),
|
||||
onPick = { file -> a.onGameComponent(gameProfile.appId, comp, file) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.drawer_game_components_note, gameProfile.name),
|
||||
fontSize = 12.sp,
|
||||
color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
val running = snap.protons.filter { it.inUseByGame }
|
||||
var pick by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
|
||||
|
||||
@@ -423,8 +423,6 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo
|
||||
fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable ColumnScope.() -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
// In a lifted sheet each group fades and rises in on its own beat.
|
||||
Column(Modifier.fillMaxWidth().sheetPiece()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp),
|
||||
@@ -434,7 +432,6 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable
|
||||
Box(modifier = Modifier.weight(1f).height(1.dp).background(pal.line))
|
||||
}
|
||||
Column(modifier = Modifier.fillMaxWidth().clip(GroupShape).background(colors.surface).border(1.dp, pal.line, GroupShape)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.sin
|
||||
|
||||
// The moves the stores made first, for any section: one selection shape that glides along a row
|
||||
// (or down a column) of choices, and a pressed control that squashes into a dot and flies home.
|
||||
|
||||
/** Which way a [ChipGlide]'s choices run. */
|
||||
internal enum class GlideAxis { Horizontal, Vertical }
|
||||
|
||||
/**
|
||||
* The selection under a row of chips: [at] (in the row) of chip [index], in [tint]. A move to the
|
||||
* next chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a
|
||||
* beat later on its trail spring - and a hop past a chip pinches it to a drop, carries it and
|
||||
* opens it. [axis] runs it down a column instead (the rail, a stack of layers). [corner] is the
|
||||
* chips' own corner; null draws a capsule. [fill] and [outline] are the resting alphas.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BoxScope.ChipGlide(
|
||||
index: Int,
|
||||
at: Rect?,
|
||||
tint: State<Color>,
|
||||
axis: GlideAxis = GlideAxis.Horizontal,
|
||||
corner: Dp? = null,
|
||||
fill: Float = 0.16f,
|
||||
outline: Float = 0.7f,
|
||||
) {
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
// The two edges along the axis (left and right, or top and bottom), and the two across it.
|
||||
val start = remember { Animatable(Float.NaN) }
|
||||
val end = remember { Animatable(0f) }
|
||||
val pinch = remember { Animatable(0f) }
|
||||
val crossStart = remember { mutableStateOf(0f) }
|
||||
val crossEnd = remember { mutableStateOf(0f) }
|
||||
val was = remember { intArrayOf(-1) }
|
||||
val vertical = axis == GlideAxis.Vertical
|
||||
LaunchedEffect(index, at) {
|
||||
val b = at ?: return@LaunchedEffect
|
||||
val s = if (vertical) b.top else b.left
|
||||
val e = if (vertical) b.bottom else b.right
|
||||
crossStart.value = if (vertical) b.left else b.top
|
||||
crossEnd.value = if (vertical) b.right else b.bottom
|
||||
val from = was[0]
|
||||
was[0] = index
|
||||
if (start.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
|
||||
start.snapTo(s); end.snapTo(e); pinch.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
coroutineScope {
|
||||
if (abs(index - from) > 1) {
|
||||
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
|
||||
pinch.animateTo(1f, Motion.tw(90))
|
||||
val l = launch { start.animateTo(s, Motion.sp(0.7f, 380f)) }
|
||||
val r = launch { end.animateTo(e, Motion.sp(0.7f, 380f)) }
|
||||
l.join(); r.join()
|
||||
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
|
||||
} else {
|
||||
val forward = index > from
|
||||
val (lead, trail) = if (forward) end to start else start to end
|
||||
val leadTo = if (forward) e else s
|
||||
val trailTo = if (forward) s else e
|
||||
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
|
||||
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
if (start.value.isNaN() || crossEnd.value <= crossStart.value) return@Canvas
|
||||
val p = pinch.value
|
||||
val shape = glideShape(start.value, end.value, crossStart.value, crossEnd.value, p, with(density) { 6.dp.toPx() }, vertical)
|
||||
val at0 = shape.topLeft
|
||||
val size = shape.size
|
||||
val round = minOf(size.width, size.height) / 2f
|
||||
val r = CornerRadius(corner?.let { lerp(minOf(with(density) { it.toPx() }, round), round, p) } ?: round)
|
||||
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
|
||||
drawRoundRect(tint.value.copy(alpha = lerp(fill, 0.9f, p)), at0, size, r)
|
||||
if (outline > 0f) {
|
||||
// Inside the chip's edge: a stroke on it would lose its outer half to a scrolling row's clip.
|
||||
val w = with(density) { 1.dp.toPx() }
|
||||
val inset = w / 2f
|
||||
drawRoundRect(
|
||||
tint.value.copy(alpha = outline * (1f - p)), at0 + Offset(inset, inset), Size(size.width - w, size.height - w),
|
||||
CornerRadius((r.x - inset).coerceAtLeast(0f)), style = Stroke(w),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a glide's shape is: its edges along the axis at [start] and [end] (either order), across
|
||||
* it from [crossStart] to [crossEnd], pinched [pinch] of the way (0 to 1) into a drop [drop] each
|
||||
* side of its centre. [vertical] runs the axis down instead of across.
|
||||
*/
|
||||
internal fun glideShape(start: Float, end: Float, crossStart: Float, crossEnd: Float, pinch: Float, drop: Float, vertical: Boolean): Rect {
|
||||
val a0 = minOf(start, end)
|
||||
val a1 = maxOf(start, end)
|
||||
val ca = (a0 + a1) / 2f
|
||||
val cc = (crossStart + crossEnd) / 2f
|
||||
val s0 = lerp(a0, ca - drop, pinch)
|
||||
val s1 = lerp(a1, ca + drop, pinch)
|
||||
val c0 = lerp(crossStart, cc - drop, pinch)
|
||||
val c1 = lerp(crossEnd, cc + drop, pinch)
|
||||
return if (vertical) Rect(c0, s0, c1, s1) else Rect(s0, c0, s1, c1)
|
||||
}
|
||||
|
||||
/**
|
||||
* A dot on its way somewhere: from the control at [from] (root px) to the point [to] gives (root
|
||||
* px, asked each frame, so a target that moves is followed). [color] null is the palette's signal
|
||||
* blue. [onHalf] runs as it passes the top of its arc, [onLand] once it lands.
|
||||
*/
|
||||
internal class Flight(
|
||||
val from: Rect,
|
||||
val to: Density.() -> Offset?,
|
||||
val color: Color? = null,
|
||||
val onHalf: () -> Unit = {},
|
||||
val onLand: () -> Unit = {},
|
||||
) {
|
||||
val id = SystemClock.uptimeMillis() * 1000 + (next++ % 1000)
|
||||
|
||||
private companion object { var next = 0L }
|
||||
}
|
||||
|
||||
/** Dots flying anywhere outside the Stores page (which keeps its own): a hop, a swap, a toggle. */
|
||||
internal object Flights {
|
||||
val all = mutableStateListOf<Flight>()
|
||||
|
||||
/** Sends [f], or with animations off just runs its ends. */
|
||||
fun fly(f: Flight) {
|
||||
if (Motion.scale == 0f) { f.onHalf(); f.onLand(); return }
|
||||
all += f
|
||||
}
|
||||
}
|
||||
|
||||
/** [Flights] drawn over everything, in the space of an element at [origin] (root px). */
|
||||
@Composable
|
||||
internal fun FlightsLayer(origin: Offset) {
|
||||
for (f in Flights.all.toList()) key(f.id) { FlightDot(f, origin) { Flights.all.remove(f) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* One dot: the control squashes into a 14dp dot (width first, a little bloop), which then flies
|
||||
* on an arc to its target, stretched along its path like the focus ring's drop.
|
||||
*/
|
||||
@Composable
|
||||
internal fun FlightDot(f: Flight, origin: Offset, onDone: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
val color = f.color ?: pal.signal
|
||||
val t = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
|
||||
coroutineScope {
|
||||
launch { delay(Motion.ms(150).toLong()); f.onHalf() }
|
||||
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
|
||||
}
|
||||
f.onLand()
|
||||
onDone()
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val r = 7.dp.toPx()
|
||||
val from = f.from.translate(-origin)
|
||||
val a = from.center
|
||||
val b = (f.to(this) ?: return@Canvas) - origin
|
||||
val v = t.value
|
||||
if (v < 1f) {
|
||||
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
|
||||
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
|
||||
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
|
||||
val k = 1f - 0.15f * sin(PI * v).toFloat()
|
||||
drawRoundRect(color, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
|
||||
} else {
|
||||
val p = v - 1f
|
||||
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
|
||||
val q = 1f - p
|
||||
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
|
||||
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
|
||||
val stretch = sin(PI * p).toFloat()
|
||||
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
|
||||
rotate(angle, pos) {
|
||||
val w = 2 * r * (1f + 0.5f * stretch)
|
||||
val h = 2 * r * (1f - 0.25f * stretch)
|
||||
drawOval(color, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A row that scrolls sideways fades out at an edge it has more past, instead of slicing a pill or
|
||||
* a card through: the content's own alpha, so whatever is behind the row shows through.
|
||||
*/
|
||||
internal fun Modifier.edgeFade(scroll: androidx.compose.foundation.ScrollState, width: Dp = 28.dp): Modifier =
|
||||
this.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
val w = width.toPx().coerceAtMost(size.width / 3f)
|
||||
if (scroll.value > 0) drawRect(
|
||||
androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Transparent, 1f to Color.Black, startX = 0f, endX = w),
|
||||
size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
|
||||
)
|
||||
if (scroll.value < scroll.maxValue) drawRect(
|
||||
androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Black, 1f to Color.Transparent, startX = size.width - w, endX = size.width),
|
||||
topLeft = Offset(size.width - w, 0f), size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
// The rows the lifted sheets add to what the session settings already had: frame generation,
|
||||
// Performance and the link to the GPU drivers on Components.
|
||||
|
||||
/** Frame generation, moved here from Setup: it is how Play's games are drawn. */
|
||||
@Composable
|
||||
internal fun FrameGenGroup(s: FrontEndState, a: FrontEndActions) {
|
||||
val host = rememberMenuHost()
|
||||
SettingsGroup(stringResource(R.string.frame_gen_title)) {
|
||||
SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint), highlighted = host.open == "fg") {
|
||||
Box {
|
||||
ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" }
|
||||
FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance (CPU core assignment), opened from the sheet's Session chip: its page floods out of
|
||||
* this row, and Back draws it into the row again with the sheet lifted where it was.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PerformanceLink(a: FrontEndActions, rail: String, tab: String) {
|
||||
val id = "sheet:performance"
|
||||
val title = stringResource(R.string.setup_tool_performance)
|
||||
SettingsGroup(title) {
|
||||
LinkRow(title, stringResource(R.string.setup_tool_performance_hint), stringResource(R.string.store_open), id, arrow = "›") {
|
||||
Hops.sources[id]?.let { at ->
|
||||
PageOrigin.mark(Origin(at, 12f * Hops.density))
|
||||
Hops.restore = HopOrigin(rail, HOP_SURFACE_SHEET, tab, id, at, "", dest = rail, page = "performance")
|
||||
}
|
||||
a.onPerformance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPU drivers in use, read only, and a link to where they are set: A hops to Components with
|
||||
* the GPU drivers layer chosen, and B there comes back to this row.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DriversLink(a: FrontEndActions, summary: String?, rail: String, back: String) {
|
||||
val id = "sheet:gpu-drivers"
|
||||
SettingsGroup(stringResource(R.string.mode_drivers)) {
|
||||
LinkRow(
|
||||
stringResource(R.string.sheet_graphics_drivers), summary ?: stringResource(R.string.common_auto),
|
||||
stringResource(R.string.rail_components), id,
|
||||
) {
|
||||
val at = Hops.sources[id]
|
||||
if (at == null) a.onComponentsTab(GPU_TAB)
|
||||
else Hops.go(HopOrigin(rail, HOP_SURFACE_SHEET, SheetTab.DISPLAY, id, at, back, dest = "components", page = "components")) {
|
||||
a.onComponentsTab(GPU_TAB)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.inset
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
@@ -83,52 +82,41 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
// came out differently from one read of the library to the next moved every capsule.
|
||||
val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending<Library.SteamGame> { it.lastPlayed }.thenBy { it.gameId }) }
|
||||
val narrow = LocalNarrowPane.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
// Where the words and buttons sit, for the empty library's wandering mark to keep off.
|
||||
var words by remember { mutableStateOf<Rect?>(null) }
|
||||
val mode = com.droiddeck.launcher.session.SessionService.MODE_STEAM
|
||||
val tabs = modeSheetTabs(steam = true).map { it to sheetTabLabel(it) }
|
||||
val back = stringResource(R.string.hop_from_play)
|
||||
val extras = SheetExtras(
|
||||
frameGen = { FrameGenGroup(s, a) },
|
||||
performance = { PerformanceLink(a, "steam", SheetTab.SESSION) },
|
||||
drivers = { summary -> DriversLink(a, summary, "steam", back) },
|
||||
)
|
||||
LiftHero(
|
||||
key = "steam", tabs = tabs,
|
||||
onOpen = a.onSteamSettings, onPrimary = a.onPlay,
|
||||
modifier = modifier.clipToBounds(),
|
||||
start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp,
|
||||
background = {
|
||||
when {
|
||||
// The mark wanders over this fade, so it sinks with the wall: drawn a little oversized,
|
||||
// it still reaches the pane's edges at the sink's 0.95.
|
||||
games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } }
|
||||
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
|
||||
else -> CapsuleWall(games)
|
||||
Box(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
when {
|
||||
games.isEmpty() -> EmptyLibrary(avoid = words) { WallFade() }
|
||||
games.size < WALL_MIN_GAMES -> CapsuleFan(games)
|
||||
else -> {
|
||||
CapsuleWall(games)
|
||||
WallFade()
|
||||
}
|
||||
},
|
||||
scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() },
|
||||
words = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.onPlaced { words = it.boundsInParent() },
|
||||
) {
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
.padding(start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, end = 20.dp)
|
||||
.onPlaced { words = it.boundsInParent() },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Wordmark(if (narrow) 30.dp else 40.dp)
|
||||
// Said only when something stands between Play and Steam; a ready runtime needs no words.
|
||||
if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s)
|
||||
}
|
||||
},
|
||||
button = { click ->
|
||||
// Enabled without a runtime: the session's loading screen installs it first.
|
||||
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = click)
|
||||
},
|
||||
rows = { tab -> a.modeSheet(mode, tab, extras) },
|
||||
)
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// Enabled without a runtime: the session's loading screen installs it first.
|
||||
PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay)
|
||||
Cog(size = 54.dp, onClick = a.onSteamSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The wall fades out behind the words, and toward the bottom where the buttons sit. */
|
||||
@Composable
|
||||
internal fun WallFade() {
|
||||
private fun WallFade() {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
@@ -159,12 +147,8 @@ internal fun CapsuleWall(
|
||||
TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. A
|
||||
* tile that is an app rather than a cover carries an [icon] instead (a drawable id or an image),
|
||||
* drawn whole at the tile's centre, and a window's [bar] along its foot.
|
||||
*/
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null)
|
||||
/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */
|
||||
internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null)
|
||||
|
||||
/**
|
||||
* [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i
|
||||
@@ -177,28 +161,25 @@ internal fun TiltedWall(
|
||||
count: Int,
|
||||
driftMs: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
/** One tile's size, the gap between tiles and their corner: portrait capsules unless a wall says otherwise. */
|
||||
capW: androidx.compose.ui.unit.Dp = 112.dp,
|
||||
capH: androidx.compose.ui.unit.Dp = 168.dp,
|
||||
gap: androidx.compose.ui.unit.Dp = 14.dp,
|
||||
corner: androidx.compose.ui.unit.Dp = 10.dp,
|
||||
capsule: (index: Int) -> WallCapsule,
|
||||
) {
|
||||
val capW = 112.dp
|
||||
val capH = 168.dp
|
||||
val gap = 14.dp
|
||||
// Animations off in the system settings: a still wall.
|
||||
val still = Motion.scale == 0f
|
||||
// The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as
|
||||
// stepped on every frame, which on a 120 Hz screen drew it twice as often. The phase advances by
|
||||
// the time since the last step over the period now, so a period that changes (Flathub's wall
|
||||
// slowing while it sets up) changes the speed without the wall jumping.
|
||||
// stepped on every frame, which on a 120 Hz screen drew it twice as often.
|
||||
val drift = remember { mutableFloatStateOf(0f) }
|
||||
val period = androidx.compose.runtime.rememberUpdatedState(driftMs)
|
||||
if (!still) LaunchedEffect(Unit) {
|
||||
if (!still) LaunchedEffect(driftMs) {
|
||||
val period = driftMs * 1_000_000.0 * Motion.scale
|
||||
var start = 0L
|
||||
var shown = 0L
|
||||
while (true) withFrameNanos { now ->
|
||||
if (shown == 0L) shown = now
|
||||
else if (now - shown >= DRIFT_STEP_NS) {
|
||||
drift.floatValue = ((drift.floatValue + (now - shown) / (period.value * 1_000_000.0 * Motion.scale)) % 1.0).toFloat()
|
||||
if (start == 0L) start = now
|
||||
if (shown == 0L || now - shown >= DRIFT_STEP_NS) {
|
||||
shown = now
|
||||
drift.floatValue = ((now - start) % period / period).toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,15 +196,14 @@ internal fun TiltedWall(
|
||||
val run = (capH + gap) * perRun
|
||||
val cap = with(density) { Size(capW.toPx(), capH.toPx()) }
|
||||
val step = with(density) { (capH + gap).toPx() }
|
||||
val radius = with(density) { corner.toPx() }
|
||||
val radius = with(density) { 10.dp.toPx() }
|
||||
val line = with(density) { 1.dp.toPx() }
|
||||
val barH = with(density) { 12.dp.toPx() }
|
||||
val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count }
|
||||
val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() }
|
||||
val looks = HashMap<Int, Pair<WallCapsule, Painter?>>(shown.size * 2)
|
||||
for (index in shown) key(index) {
|
||||
val look = capsule(index)
|
||||
looks[index] = look to (look.art?.let { rememberCapsuleArt(it, cap, radius) } ?: look.icon?.let { rememberIcon(it) })
|
||||
looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) }
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(gap),
|
||||
@@ -244,7 +224,7 @@ internal fun TiltedWall(
|
||||
for (i in 0 until perRun * 2) {
|
||||
val (look, art) = looks[indexOf(c, i)] ?: continue
|
||||
val top = i * step
|
||||
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line, barH) }
|
||||
inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) }
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -267,20 +247,9 @@ private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter {
|
||||
)
|
||||
}
|
||||
|
||||
/** An app's icon for a wall tile: a bundled drawable, or an image Coil loads. */
|
||||
@Composable
|
||||
private fun rememberIcon(model: Any): Painter =
|
||||
if (model is Int) androidx.compose.ui.res.painterResource(model) else rememberAsyncImagePainter(model)
|
||||
|
||||
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float, barH: Float = 0f) {
|
||||
private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) {
|
||||
drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius))
|
||||
look.bar?.let { drawRect(it, Offset(0f, size.height - barH), Size(size.width, barH)) }
|
||||
if (art != null && look.icon != null) {
|
||||
// An app's icon whole at the tile's centre (above a window's bar), not cropped over it.
|
||||
val side = minOf(size.width, size.height - (if (look.bar != null) barH else 0f)) * 0.5f
|
||||
val cy = (size.height - (if (look.bar != null) barH else 0f)) / 2f
|
||||
translate((size.width - side) / 2f, cy - side / 2f) { with(art) { draw(Size(side, side)) } }
|
||||
} else if (art != null) with(art) { draw(size) }
|
||||
if (art != null) with(art) { draw(size) }
|
||||
look.edge?.let {
|
||||
drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line))
|
||||
}
|
||||
|
||||
@@ -184,10 +184,6 @@ internal fun StepOut(
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val hostWidth = constraints.maxWidth.toFloat()
|
||||
val hostHeight = constraints.maxHeight.toFloat()
|
||||
// No room under the control (a row near the foot of the screen): it steps out upward
|
||||
// instead, the same outline mirrored about the control.
|
||||
val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false
|
||||
val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left
|
||||
val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width)
|
||||
LaunchedEffect(open, measured) {
|
||||
@@ -250,7 +246,6 @@ internal fun StepOut(
|
||||
if (!grown) return@Canvas
|
||||
if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
|
||||
else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner)
|
||||
if (up) path.transform(Matrix().apply { this[1, 1] = -1f; this[3, 1] = pill.top + pill.bottom })
|
||||
drawPath(path, lerp(hotFill, restFill, tint.value))
|
||||
drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim))
|
||||
}
|
||||
@@ -263,7 +258,7 @@ internal fun StepOut(
|
||||
val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) }
|
||||
.offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) }
|
||||
.widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() })
|
||||
.onSizeChanged { if (measured != it) measured = it }
|
||||
.controllerBack(onDismiss)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
// The front end's confirms and short lists, each stepping out of the control that asked for it
|
||||
// (StepOut): a sheet's Uninstall, a Components swap, a remove. The Stores page keeps its own, in
|
||||
// StoresMotion; everything else asks here and is drawn over the whole screen.
|
||||
|
||||
internal object Steps {
|
||||
/** The confirm or list stepping out of a control now, and whether it is open. */
|
||||
var step by mutableStateOf<StepAsk?>(null)
|
||||
private set
|
||||
var open by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun ask(a: StepAsk) {
|
||||
step?.onDismiss()
|
||||
step = a
|
||||
open = true
|
||||
}
|
||||
|
||||
/** Folds the current one back into its control. */
|
||||
fun fold() { open = false }
|
||||
|
||||
/** Drops it at once: the control it came from has gone. */
|
||||
fun drop() {
|
||||
step?.let { step = null; it.onDismiss() }
|
||||
open = false
|
||||
}
|
||||
|
||||
internal fun closed(a: StepAsk) {
|
||||
if (step === a) step = null
|
||||
a.onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/** [Steps] drawn over an element at [origin] (root px) that fills the screen. */
|
||||
@Composable
|
||||
internal fun StepsLayer(origin: Offset) {
|
||||
val ask = Steps.step ?: return
|
||||
// Back (B, or the system's) folds what is stepped out, before it reaches the page under it.
|
||||
androidx.activity.compose.BackHandler(enabled = Steps.open) { Steps.fold() }
|
||||
key(ask) {
|
||||
StepOut(
|
||||
open = Steps.open, pill = ask.anchor.translate(-origin), side = ask.side, accent = ask.accent,
|
||||
onDismiss = { Steps.fold() }, onClosed = { Steps.closed(ask) },
|
||||
pillCorner = ask.pillCorner, maxWidth = ask.maxWidth, items = ask.items, handle = ask.handle, content = ask.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,6 @@ import com.droiddeck.launcher.stores.Store
|
||||
|
||||
// Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder.
|
||||
|
||||
/** Flathub's own accent, as each store has one, and the ink that reads on it. */
|
||||
internal val FlathubTeal = Color(0xFF3FB8AF)
|
||||
internal val FlathubInk = Color(0xFF04211F)
|
||||
|
||||
/** The Store rail section's source id, for its colours. */
|
||||
internal const val FLATHUB = "flathub"
|
||||
|
||||
/** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */
|
||||
internal class SourceColours(val fill: Color, val ink: Color, val dot: Color)
|
||||
|
||||
@@ -34,7 +27,6 @@ internal fun sourceColours(source: String): SourceColours = when (source) {
|
||||
Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD))
|
||||
Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900))
|
||||
Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF))
|
||||
FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal)
|
||||
else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,632 +1,292 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.Verified
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInParent
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.store.FlathubApi
|
||||
import com.droiddeck.launcher.store.StoreState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// The Store rail section: Flathub's apps for this device's architecture, installed into the
|
||||
// Linux runtime with Flatpak, and the ones already installed - in the stores' style, in Flathub's
|
||||
// teal. Until Flatpak is set up it is the stores' sign-in hero: a wall of real app icons, one word,
|
||||
// one button that becomes its own progress. Then a chip row, shelves of square cards that flood
|
||||
// open into an app's page, and installs that fly home to the Installed chip.
|
||||
// Linux runtime with Flatpak, and the ones already installed.
|
||||
|
||||
private const val MODE_DISCOVER = "discover"
|
||||
private const val MODE_INSTALLED = "installed"
|
||||
private const val MODE_UPDATES = "updates"
|
||||
|
||||
/** One app as a card shows it: Flathub's summary or what Flatpak says is installed. */
|
||||
private class CardApp(val id: String, val name: String, val icon: Any?, val summary: String? = null, val verified: Boolean = false)
|
||||
|
||||
/** Where the Installed chip is, for an install's dot to land in, and how many have landed. */
|
||||
private object FlathubMotion {
|
||||
var installedChip: Rect? = null
|
||||
var landed by mutableIntStateOf(0)
|
||||
}
|
||||
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
|
||||
|
||||
@Composable
|
||||
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
|
||||
LaunchedEffect(Unit) { StoreState.loadSections() }
|
||||
FlathubTheme {
|
||||
// The storefront takes over from the hero once Flatpak is ready: the setup rail rings
|
||||
// once, then the hero sinks away and the storefront rises.
|
||||
var front by remember { mutableStateOf(StoreState.ready && s.ready) }
|
||||
var ring by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(StoreState.ready, s.ready) {
|
||||
val ready = StoreState.ready && s.ready
|
||||
if (ready && !front) {
|
||||
ring++
|
||||
delay(Motion.ms(450).toLong())
|
||||
front = true
|
||||
} else if (!ready) front = false
|
||||
}
|
||||
AnimatedContent(
|
||||
front,
|
||||
modifier = modifier,
|
||||
transitionSpec = {
|
||||
EnterTransition.None.togetherWith(fadeOut(Motion.tw(320)) + scaleOut(Motion.tw(320), targetScale = 0.95f))
|
||||
},
|
||||
label = "flathub",
|
||||
) { ready ->
|
||||
if (ready) Storefront(s, a) else FlathubHero(s, ring)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatpak is not set up yet: the stores' sign-in shape in teal. A tilted wall of the popular apps'
|
||||
* icons (Flathub's public pages need no Flatpak) drifts behind, fading toward the words; one big
|
||||
* button sets it up, and stretches into its own progress while it does.
|
||||
*/
|
||||
@Composable
|
||||
private fun FlathubHero(s: FrontEndState, ring: Int) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val icons = remember(StoreState.sections.values.toList()) {
|
||||
StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty()
|
||||
}
|
||||
val busy = StoreState.busy == "setup"
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) {
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) {
|
||||
// Slower while it sets up, as the empty wall drifts.
|
||||
androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles ->
|
||||
val ground = Brush.linearGradient(listOf(Color(0xFF0B2E2B), Color(0xFF0B2E2B)))
|
||||
val edge = FlathubTeal.copy(alpha = 0.18f)
|
||||
TiltedWall(tiles.size, if (tiles.isEmpty() || busy) 75_000 else 50_000, capW = 68.dp, capH = 68.dp, gap = 14.dp, corner = 17.dp) { i ->
|
||||
WallCapsule(ground, edge = edge, icon = tiles.getOrNull(i))
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f),
|
||||
0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f))))
|
||||
Box(
|
||||
Modifier.fillMaxSize().drawBehind {
|
||||
drawRect(Brush.radialGradient(listOf(FlathubTeal.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f))
|
||||
},
|
||||
)
|
||||
BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp)) {
|
||||
val full = maxWidth
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Rise(1) {
|
||||
Text(stringResource(R.string.flathub_eyebrow), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, color = FlathubTeal)
|
||||
}
|
||||
Rise(2) {
|
||||
Text(
|
||||
stringResource(R.string.flathub_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp,
|
||||
fontWeight = FontWeight.Black, color = colors.onBackground,
|
||||
)
|
||||
}
|
||||
Rise(3) { Text(stringResource(R.string.flathub_line), fontSize = 15.sp, color = colors.onSurfaceVariant) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Rise(4) { SetupButton(s, busy, full, ring) { StoreState.setup(ctx) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Flathub, and its own progress: while it sets up the button springs out to the full width
|
||||
* and fills in teal from the left with how far it is, the stage and the figure inside it. Without
|
||||
* a runtime it says what is missing instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun SetupButton(s: FrontEndState, busy: Boolean, full: Dp, ring: Int, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
val ff = LocalFrontFocus.current
|
||||
val inputMode = LocalInputModeManager.current
|
||||
var restW by remember { mutableStateOf(0.dp) }
|
||||
val grow by animateFloatAsState(if (busy) 1f else 0f, Motion.sp(0.7f, 380f), label = "setupGrow")
|
||||
val fill by animateFloatAsState((StoreState.percent.coerceAtLeast(0) / 100f), Motion.tw(300), label = "setupFill")
|
||||
if (!busy && grow < 0.01f) {
|
||||
Box(Modifier.onSizeChanged { restW = with(density) { it.width.toDp() } }) {
|
||||
PrimaryButton(
|
||||
if (s.ready) stringResource(R.string.flathub_setup) else stringResource(R.string.store_needs_runtime),
|
||||
enabled = s.ready && StoreState.busy == null, main = true, large = true, icon = Icons.Filled.Download, onClick = onClick,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A pad keeps its place on the rail that took the button's.
|
||||
if (ff != null) LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { ff.primary } }
|
||||
val width = androidx.compose.ui.unit.lerp(restW.coerceAtLeast(120.dp), full, grow)
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val shimmer = rememberInfiniteTransition(label = "setupShimmer")
|
||||
val sweep by shimmer.animateFloat(-0.3f, 1.3f, infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), label = "sweep")
|
||||
val stage = StoreState.stage ?: stringResource(R.string.store_starting)
|
||||
val label = if (StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(width).heightIn(min = 56.dp)
|
||||
.then(if (ff != null) Modifier.focusRequester(ff.primary) else Modifier)
|
||||
.pulseRing(ring, FlathubTeal)
|
||||
.clip(shape).background(colors.surfaceVariant).border(1.dp, FlathubTeal.copy(alpha = 0.5f), shape)
|
||||
.drawBehind {
|
||||
if (StoreState.percent >= 0) drawRect(FlathubTeal, size = size.copy(width = size.width * fill))
|
||||
else if (Motion.scale != 0f) {
|
||||
val w = size.width * 0.3f
|
||||
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, FlathubTeal.copy(alpha = 0.55f), Color.Transparent), startX = size.width * sweep - w, endX = size.width * sweep + w))
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.semantics { contentDescription = label }
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = if (StoreState.percent >= 50) FlathubInk else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- storefront ------------------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun Storefront(s: FrontEndState, a: FrontEndActions) {
|
||||
val ctx = LocalContext.current
|
||||
val narrow = LocalNarrowPane.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
val padH = if (narrow) 14.dp else 22.dp
|
||||
var mode by rememberSaveable { mutableStateOf(MODE_DISCOVER) }
|
||||
val padH = if (narrow) 16.dp else 22.dp
|
||||
val padV = if (narrow) 12.dp else 18.dp
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var category by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val updates = StoreState.updates
|
||||
if (mode == MODE_UPDATES && updates.isEmpty()) mode = MODE_INSTALLED
|
||||
// Once a session is plenty: the Updates chip shows itself when there is something to update.
|
||||
LaunchedEffect(StoreState.ready) { if (StoreState.ready && !StoreState.updatesChecked && !StoreState.checkingUpdates) StoreState.checkUpdates(ctx) }
|
||||
// A search runs a beat after typing stops, or as a category is picked.
|
||||
LaunchedEffect(query, category) {
|
||||
if (query.isBlank() && category == null) { StoreState.clearSearch(); return@LaunchedEffect }
|
||||
delay(350)
|
||||
StoreState.search(query.trim(), category)
|
||||
}
|
||||
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
|
||||
LaunchedEffect(Unit) { StoreState.loadSections() }
|
||||
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
|
||||
// a tab's contents - and would be lost with it. Each move says where focus goes next.
|
||||
val ff = LocalFrontFocus.current
|
||||
val inputMode = LocalInputModeManager.current
|
||||
// Cards, where they are, for the page that opens from one.
|
||||
val cards = remember { HashMap<String, Rect>() }
|
||||
var viewportAt by remember { mutableStateOf(Offset.Zero) }
|
||||
var detail by remember { mutableStateOf<String?>(null) }
|
||||
var leaving by remember { mutableStateOf(false) }
|
||||
var detailFrom by remember { mutableStateOf<Origin?>(null) }
|
||||
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
|
||||
val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } }
|
||||
var focusMove by remember { mutableStateOf<Pair<String, Int>?>(null) }
|
||||
var lastApp by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(openApp) {
|
||||
fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 }
|
||||
fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 }
|
||||
BackHandler(enabled = openApp != null) { closeApp() }
|
||||
val scroll = rememberScrollState()
|
||||
LaunchedEffect(openApp, tab) { scroll.scrollTo(0) }
|
||||
LaunchedEffect(focusMove, openApp) {
|
||||
if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect
|
||||
val target = when {
|
||||
openApp != null -> ff?.primary
|
||||
focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab]
|
||||
focusMove?.first == "tab" -> tabFocus[tab]
|
||||
else -> null
|
||||
} ?: return@LaunchedEffect
|
||||
var landed = false
|
||||
focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } }
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV)
|
||||
.bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) },
|
||||
onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }),
|
||||
) {
|
||||
val id = openApp
|
||||
if (id != null) {
|
||||
detailFrom = cards[id]?.let { Origin(it.translate(-viewportAt), 16f * density.density) }
|
||||
leaving = false
|
||||
detail = id
|
||||
if (inputMode.inputMode == InputMode.Keyboard && ff != null) { delay(Motion.ms(300).toLong()); focusSoon { ff.primary } }
|
||||
} else if (detail != null) {
|
||||
lastApp = detail
|
||||
if (detailFrom != null) { leaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) }
|
||||
detail = null; leaving = false; detailFrom = null
|
||||
AppDetail(s, a, id) { closeApp() }
|
||||
return@Column
|
||||
}
|
||||
}
|
||||
// Back on the grid: focus goes to the card the page came from.
|
||||
LaunchedEffect(detail) {
|
||||
if (detail != null || inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect
|
||||
val id = lastApp ?: return@LaunchedEffect
|
||||
ff.items["tile:store:$id"]?.let { req -> focusSoon { req } }
|
||||
}
|
||||
BackHandler(enabled = openApp != null) { openApp = null }
|
||||
// An app's card on Desktop asked for its page.
|
||||
LaunchedEffect(StoreState.openRequest) { StoreState.openRequest?.let { openApp = it; StoreState.openRequest = null } }
|
||||
val covering = detail != null && !leaving
|
||||
val sink by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "flathubSink")
|
||||
val fade by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "flathubFade")
|
||||
val modes = buildList { add(MODE_DISCOVER); add(MODE_INSTALLED); if (updates.isNotEmpty()) add(MODE_UPDATES) }
|
||||
// LB / RB with a pad: focus goes to the new mode's chip; the cards it was on leave with the old mode.
|
||||
val seenMode = remember { arrayOf(mode) }
|
||||
LaunchedEffect(mode) {
|
||||
if (seenMode[0] == mode) return@LaunchedEffect
|
||||
seenMode[0] = mode
|
||||
if (inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect
|
||||
ff.items["flathub:$mode"]?.let { req -> focusSoon { req } }
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp)
|
||||
.bumpers(
|
||||
onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] },
|
||||
onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] },
|
||||
),
|
||||
) {
|
||||
Rise(0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
// A page of Desktop's: the way back to it, for a finger (B does the same).
|
||||
val show = LocalShowSection.current
|
||||
BackLink(stringResource(R.string.rail_desktop), compact = true) { show("desktop") }
|
||||
Box(Modifier.weight(1f)) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } }
|
||||
PageHeader(stringResource(R.string.store_title)) {
|
||||
Chip(stringResource(R.string.store_chip_flathub), ok = false)
|
||||
if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().graphicsLayer { scaleX = sink; scaleY = sink; alpha = fade }.verticalScroll(rememberScrollState()).padding(bottom = 16.dp),
|
||||
) {
|
||||
val open: (String) -> Unit = { openApp = it }
|
||||
when (mode) {
|
||||
MODE_DISCOVER -> Discover(query, category, { query = it }, { category = if (category == it) null else it }, cards, open)
|
||||
MODE_INSTALLED -> InstalledMode(cards, open)
|
||||
else -> UpdatesMode(cards, open)
|
||||
}
|
||||
}
|
||||
detail?.let { id ->
|
||||
val from = detailFrom
|
||||
val icon = StoreState.details[id]?.icon ?: StoreState.sections.values.flatten().firstOrNull { it.id == id }?.icon ?: FlathubApi.iconUrl(id)
|
||||
val body: @Composable () -> Unit = {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }
|
||||
.verticalScroll(rememberScrollState()).padding(bottom = 16.dp).focusGroup(),
|
||||
) { AppDetail(s, a, id) { openApp = null } }
|
||||
}
|
||||
if (from == null) body()
|
||||
else {
|
||||
// The card's own icon, cropped over the tile as it grows: the page arrives in the app's colour.
|
||||
val art = coil.compose.rememberAsyncImagePainter(
|
||||
remember(icon) { coil.request.ImageRequest.Builder(ctx).data(icon).size(256, 256).build() },
|
||||
)
|
||||
PageFlood(from, leaving, art) { body() }
|
||||
}
|
||||
}
|
||||
if (!s.ready) {
|
||||
Rise(1) { Note(stringResource(R.string.store_needs_runtime)) }
|
||||
return@Column
|
||||
}
|
||||
if (!StoreState.ready) Rise(1) { SetupCard() }
|
||||
Rise(2) {
|
||||
TabStrip(
|
||||
TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } },
|
||||
tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus,
|
||||
)
|
||||
}
|
||||
BusyBar()
|
||||
when (tab) {
|
||||
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
|
||||
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
|
||||
else -> Installed(a, onOpen = { openApp = it })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover, Installed (with its count) and Updates (when there are any): one selection glides under
|
||||
* them in teal; the Installed count pops as an install's dot lands in it.
|
||||
*/
|
||||
/** Flatpak is not in the runtime yet: one button puts it there. */
|
||||
@Composable
|
||||
private fun FlathubChips(mode: String, modes: List<String>, installed: Int, updates: Int, onPick: (String) -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
private fun SetupCard() {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val bounds = remember { mutableStateMapOf<String, Rect>() }
|
||||
val tint = rememberUpdatedState(pal.signal)
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
ChipGlide(modes.indexOf(mode), bounds[mode], tint)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
for (m in modes) {
|
||||
key(m) {
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
val on = m == mode
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.weight(1f).paneItem("flathub:$m")
|
||||
.onGloballyPositioned {
|
||||
bounds[m] = it.boundsInParent()
|
||||
if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot()
|
||||
}
|
||||
.heightIn(min = 44.dp).clip(shape)
|
||||
.background(if (on) Color.Transparent else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) }
|
||||
.controllerConfirm { onPick(m) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(when (m) { MODE_DISCOVER -> R.string.store_tab_discover; MODE_INSTALLED -> R.string.store_tab_installed; else -> R.string.store_updates }),
|
||||
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1,
|
||||
)
|
||||
val count = when (m) { MODE_INSTALLED -> installed; MODE_UPDATES -> updates; else -> -1 }
|
||||
if (count >= 0) Text(
|
||||
count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1,
|
||||
modifier = Modifier.then(if (m == MODE_INSTALLED) Modifier.landingPop(FlathubMotion.landed) else Modifier)
|
||||
.clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val pal = LocalPalette.current
|
||||
val busy = StoreState.busy == "setup"
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.store_setup_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
Text(
|
||||
stringResource(R.string.store_setup_text),
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
Actions {
|
||||
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The card width for a width: room for a name on two lines and a two-line summary under the art,
|
||||
* so a long name wraps instead of being cut to a few letters.
|
||||
*/
|
||||
private fun cardSize(width: Dp): Dp = when {
|
||||
width >= 700.dp -> 164.dp
|
||||
width >= 480.dp -> 148.dp
|
||||
else -> 132.dp
|
||||
/** The running install, update or setup, whichever page it was started from. */
|
||||
@Composable
|
||||
private fun BusyBar() {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val busy = StoreState.busy ?: return
|
||||
val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name
|
||||
?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_updates); else -> busy.substringAfterLast('.') }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) {
|
||||
val stage = StoreState.stage
|
||||
Text(
|
||||
if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)),
|
||||
fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Discover(
|
||||
query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String) -> Unit,
|
||||
cards: HashMap<String, Rect>, onOpen: (String) -> Unit,
|
||||
) {
|
||||
Rise(1) {
|
||||
ModeHeader(
|
||||
stringResource(R.string.store_tab_discover), query, onQuery, stringResource(R.string.store_search_placeholder),
|
||||
) {
|
||||
// The categories as small pills, at the right of the header.
|
||||
val pills = rememberScrollState()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.widthIn(max = 420.dp).edgeFade(pills).horizontalScroll(pills),
|
||||
) {
|
||||
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id) } }
|
||||
}
|
||||
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
|
||||
Rise(3) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
|
||||
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
|
||||
}
|
||||
}
|
||||
val results = StoreState.searchResults
|
||||
if (query.isNotBlank() || category != null) {
|
||||
when {
|
||||
StoreState.searching && results == null -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_searching)) } }
|
||||
results.isNullOrEmpty() -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_results)) } }
|
||||
else -> Rise(2) { CardGrid(results.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, cards, onOpen) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
|
||||
Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_unreachable)) } }
|
||||
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
|
||||
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
|
||||
return
|
||||
}
|
||||
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
|
||||
val apps = StoreState.sections[key]
|
||||
Rise(2 + i) { Shelf(stringResource(title), apps?.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, first = i == 0, cards, onOpen) }
|
||||
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
|
||||
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
|
||||
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
val run = {
|
||||
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
|
||||
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
|
||||
?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
StoreState.search(query.trim(), category)
|
||||
}
|
||||
Rise(3) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
AdbTextField(
|
||||
value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run,
|
||||
)
|
||||
PrimaryButton(stringResource(R.string.store_search), main = true, onClick = run)
|
||||
}
|
||||
}
|
||||
Rise(4) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
|
||||
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
|
||||
FlathubApi.categories.forEach { c ->
|
||||
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val results = StoreState.searchResults
|
||||
when {
|
||||
StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) }
|
||||
results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) }
|
||||
results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) }
|
||||
else -> {
|
||||
Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) }
|
||||
Rise(6) { AppGrid(results, first = true, onOpen = onOpen) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val apps = StoreState.installed
|
||||
Rise(1) {
|
||||
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_tab_installed), apps.size), "", {}, "") {
|
||||
SmallLink(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = !StoreState.checkingUpdates && StoreState.busy == null) {
|
||||
StoreState.checkUpdates(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (apps.isEmpty()) { Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_apps)) } }; return }
|
||||
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdatesMode(cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
val apps = StoreState.installed.filter { it.id in StoreState.updates }
|
||||
Rise(1) {
|
||||
ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_updates), apps.size), "", {}, "") {
|
||||
PrimaryButton(stringResource(R.string.store_update_all, apps.size), compact = true, enabled = StoreState.busy == null) {
|
||||
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
|
||||
}
|
||||
}
|
||||
}
|
||||
Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) }
|
||||
}
|
||||
|
||||
/** A horizontal row of cards under a title, the stores' shelf; nothing while it is empty. */
|
||||
@Composable
|
||||
private fun Shelf(title: String, apps: List<CardApp>?, first: Boolean, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp))
|
||||
if (apps == null) Text(stringResource(R.string.store_section_loading), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
else if (apps.isNotEmpty()) BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val size = cardSize(maxWidth)
|
||||
val shelf = rememberScrollState()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shelf).horizontalScroll(shelf).padding(vertical = 2.dp)) {
|
||||
apps.take(18).forEachIndexed { i, app -> key(app.id) { AppCard(app, size, first && i == 0, cards) { onOpen(app.id) } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Installed and Updates modes, and search: a grid of the same cards. */
|
||||
@Composable
|
||||
private fun CardGrid(apps: List<CardApp>, cards: HashMap<String, Rect>, onOpen: (String) -> Unit) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) {
|
||||
val size = cardSize(maxWidth)
|
||||
val columns = ((maxWidth + 10.dp) / (size + 10.dp)).toInt().coerceAtLeast(1)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
apps.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEachIndexed { i, app -> key(app.id) { AppCard(app, size, r == 0 && i == 0, cards) { onOpen(app.id) } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One app: its icon over a blurred, darkened copy of itself (darkened only, before Android 12),
|
||||
* with one status on the art - Get, ✓, or how far an install is - and under it the name, on two
|
||||
* lines if it needs them, and what the app is.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppCard(app: CardApp, size: Dp, first: Boolean, cards: HashMap<String, Rect>, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale")
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
val installed = StoreState.installed.any { it.id == app.id }
|
||||
val update = app.id in StoreState.updates
|
||||
val busy = StoreState.busy == app.id
|
||||
val artH = size * 0.62f
|
||||
Column(
|
||||
Modifier.width(size).paneItem("tile:store:${app.id}").then(if (first) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.semantics { contentDescription = app.name },
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { cards[app.id] = it.boundsInRoot() }
|
||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(Color(0xFF0B2E2B)),
|
||||
) {
|
||||
if (app.icon != null) {
|
||||
// The icon's own colour, blurred and darkened, as the card's ground.
|
||||
AsyncImage(
|
||||
model = app.icon, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.55f }
|
||||
.then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier),
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)))
|
||||
AsyncImage(model = app.icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.62f))
|
||||
} else Icon(Icons.Outlined.Apps, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.5f))
|
||||
// One status, on the art: what A on the card leads to.
|
||||
Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) {
|
||||
when {
|
||||
busy -> Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) {
|
||||
if (StoreState.percent >= 0) CircularProgressIndicator(progress = { StoreState.percent / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp))
|
||||
else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp))
|
||||
val apps = StoreState.installed
|
||||
Rise(3) {
|
||||
Actions {
|
||||
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
|
||||
StoreState.checkUpdates(ctx)
|
||||
}
|
||||
if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
|
||||
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
|
||||
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true)
|
||||
}
|
||||
}
|
||||
if (apps.isEmpty()) {
|
||||
Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } }
|
||||
return
|
||||
}
|
||||
Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
apps.forEachIndexed { i, app ->
|
||||
key(app.id) {
|
||||
Rise(5 + i.coerceAtMost(6)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
(app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it },
|
||||
fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
|
||||
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
|
||||
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
|
||||
}
|
||||
else -> Text(
|
||||
when {
|
||||
update -> stringResource(R.string.common_update)
|
||||
installed -> "✓"
|
||||
else -> stringResource(R.string.comp_get)
|
||||
},
|
||||
fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1,
|
||||
color = if (installed && !update) FlathubInk else pal.onSignal,
|
||||
modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (installed && !update) pal.good else pal.signal)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
app.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (app.verified) Icon(Icons.Outlined.Verified, stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.padding(top = 2.dp).size(13.dp))
|
||||
}
|
||||
// Two lines kept for the summary even when it is short, so cards in a row line up.
|
||||
Text(
|
||||
app.summary.orEmpty(), fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant,
|
||||
minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's page, flooded out of its card: its icon, name and what it is, one action (Install or
|
||||
* Open, with Update beside it when there is one) and Website; then its screenshots and details.
|
||||
* Install flies a dot to the Installed chip; Remove asks by stepping out of its button, in red.
|
||||
*/
|
||||
private fun s_busyFor(id: String) = StoreState.busy == id
|
||||
|
||||
@Composable
|
||||
private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) {
|
||||
val ctx = LocalContext.current
|
||||
@@ -636,53 +296,44 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
val d = StoreState.details[id]
|
||||
val local = StoreState.installed.firstOrNull { it.id == id }
|
||||
val name = d?.name ?: local?.name ?: id
|
||||
val busyHere = StoreState.busy == id
|
||||
val installed = local != null
|
||||
Rise(0) { BackLink(stringResource(R.string.flathub_name), onClick = onBack) }
|
||||
Rise(0) { BackLink(stringResource(R.string.store_title), onClick = onBack) }
|
||||
Rise(1) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) {
|
||||
AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
val meta = listOfNotNull(
|
||||
d?.developer, d?.version?.let { "v$it" },
|
||||
d?.downloadSize?.takeIf { it > 0 }?.let { stringResource(R.string.store_download_size, FileUtils.sizeToString(it)) },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotEmpty()) Text(meta, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
(d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val busyHere = StoreState.busy == id
|
||||
val installed = local != null
|
||||
// Remove asks twice: one stray press of A should not cost a download. The second press has
|
||||
// to come within a few seconds; the app's own data in ~/.var/app stays either way.
|
||||
var confirmRemove by remember(id) { mutableStateOf(false) }
|
||||
LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } }
|
||||
Rise(2) {
|
||||
Actions {
|
||||
if (installed) {
|
||||
PrimaryButton(if (busyHere) stringResource(R.string.common_working) else stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
|
||||
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
|
||||
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
|
||||
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) {
|
||||
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
|
||||
}
|
||||
} else {
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
PrimaryButton(
|
||||
if (busyHere) (if (StoreState.percent >= 0) "${StoreState.stage ?: stringResource(R.string.store_installing)} · ${StoreState.percent}%" else stringResource(R.string.store_installing))
|
||||
else stringResource(R.string.store_install),
|
||||
main = true,
|
||||
enabled = StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
|
||||
) {
|
||||
StoreState.install(ctx, id, name)
|
||||
// The button squashes into a dot that flies to the Installed chip, whose count pops.
|
||||
val from = at
|
||||
if (from != null) Flights.fly(Flight(from, to = { FlathubMotion.installedChip?.center }, onLand = { FlathubMotion.landed++ }))
|
||||
else FlathubMotion.landed++
|
||||
}
|
||||
}
|
||||
PrimaryButton(
|
||||
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true,
|
||||
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
|
||||
) { StoreState.install(ctx, id, name) }
|
||||
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false)
|
||||
}
|
||||
d?.homepage?.let { url ->
|
||||
SecondaryButton(stringResource(R.string.store_website)) {
|
||||
runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))) }
|
||||
}
|
||||
}
|
||||
if (installed) RemoveButton(id, name, enabled = StoreState.busy == null)
|
||||
d?.version?.let { ActionChip("v$it", ok = false) }
|
||||
if (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false)
|
||||
if (installed) ActionChip(stringResource(R.string.store_installed_chip), ok = true)
|
||||
}
|
||||
}
|
||||
if (busyHere) BusyBar()
|
||||
if (d == null) {
|
||||
Rise(3) {
|
||||
Box(Modifier.padding(top = 14.dp)) {
|
||||
@@ -695,8 +346,7 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
if (d.screenshots.isNotEmpty()) {
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_screenshots), null) }
|
||||
Rise(3) {
|
||||
val shots = rememberScrollState()
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shots).horizontalScroll(shots)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
|
||||
d.screenshots.take(6).forEach { shot ->
|
||||
AsyncImage(
|
||||
model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
@@ -729,45 +379,52 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove, which asks first: are you sure steps out of it in red, Keep first, so a stray A costs nothing. */
|
||||
/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */
|
||||
@Composable
|
||||
private fun RemoveButton(id: String, name: String, enabled: Boolean) {
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val label = stringResource(R.string.store_remove)
|
||||
var at by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
|
||||
SecondaryButton(label, enabled = enabled) {
|
||||
val from = at ?: return@SecondaryButton
|
||||
Steps.ask(StepAsk(
|
||||
anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 3,
|
||||
handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) },
|
||||
) {
|
||||
StepTitle(ctx.getString(R.string.flathub_remove_title, name))
|
||||
Text(ctx.getString(R.string.flathub_remove_note), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(2)) {
|
||||
StepChoice(ctx.getString(R.string.flathub_keep), enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() }
|
||||
StepChoice(label, danger = true, enabled = open) { Steps.fold(); StoreState.uninstall(ctx, id, name) }
|
||||
private fun AppGrid(apps: List<FlathubApi.AppSummary>, first: Boolean, onOpen: (String) -> Unit) {
|
||||
val columns = if (LocalNarrowPane.current) 1 else 3
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) {
|
||||
apps.chunked(columns).forEachIndexed { r, row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
row.forEachIndexed { i, app ->
|
||||
key(app.id) {
|
||||
AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) }
|
||||
}
|
||||
}
|
||||
})
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small text action at the right of a header. */
|
||||
@Composable
|
||||
private fun SmallLink(text: String, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
val hot = rememberHot(src) && enabled
|
||||
Text(
|
||||
text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (!enabled) colors.onSurfaceVariant.copy(alpha = 0.5f) else if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick).padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
)
|
||||
val hot = rememberHot(src)
|
||||
val pressed by src.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale")
|
||||
val installed = StoreState.installed.any { it.id == app.id }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(Shape14)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, Shape14, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
AppIcon(app.icon, app.name, 44)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
Text(if (installed) stringResource(R.string.store_tab_installed) else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */
|
||||
@@ -789,13 +446,13 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val hot = rememberHot(src)
|
||||
val shape = RoundedCornerShape(99.dp)
|
||||
Text(
|
||||
label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1,
|
||||
color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant,
|
||||
modifier = Modifier.paneItem("pill:$label").heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape)
|
||||
modifier = Modifier.paneItem("pill:$label").clip(shape)
|
||||
.background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
)
|
||||
}
|
||||
@@ -24,9 +24,6 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -493,36 +490,31 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip row's cog: the section's own settings stepped out of it - which tab a store opens on,
|
||||
* mature titles, the speed tier and how many at once, and the download engine. Their only home;
|
||||
* Setup no longer repeats them. Every install is added to Steam; that is not a choice. [s] is
|
||||
* read live, so a change shows at once in what is stepped out.
|
||||
* The chip row's cog: the section's own settings in a small card - which tab a store opens on
|
||||
* and the speed tier. Setup › Stores repeats them under its gate. Every install is added to Steam;
|
||||
* that is not a choice.
|
||||
*/
|
||||
internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State<FrontEndState>, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk =
|
||||
StepAsk(
|
||||
anchor = at, side = StepSide.Left, accent = accent, items = 3, maxWidth = 440.dp,
|
||||
handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) },
|
||||
) {
|
||||
StepTitle(stringResource(R.string.stores_settings_eyebrow))
|
||||
Column(
|
||||
Modifier.stepItem(1).fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val st = s.value
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
st.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
@Composable
|
||||
internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) {
|
||||
val shown = rememberShown(onDismiss)
|
||||
val close = { shown.targetState = false }
|
||||
AppDialog(shown, close, "storesSettings", wide = false, maxWidth = 440.dp) {
|
||||
DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title))
|
||||
Rise(1) {
|
||||
Column {
|
||||
SettingsRow(stringResource(R.string.setup_stores_open_on), null) {
|
||||
SegmentedTabs(
|
||||
listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)),
|
||||
s.storesOpenTab,
|
||||
) { a.onStoresOpenTab(it) }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.stores_show_mature), null) {
|
||||
ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
|
||||
}
|
||||
// The queue's two knobs as one compact row, the same control the Downloads page has.
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) }
|
||||
}
|
||||
SettingsRow(stringResource(R.string.stores_show_mature), null) {
|
||||
ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) }
|
||||
}
|
||||
// The queue's two knobs as one compact row, the same control the Downloads page has.
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) }
|
||||
StoresEngineRow()
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().stepItem(2).padding(top = 8.dp)) {
|
||||
StepChoice(stringResource(R.string.common_ok), enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() }
|
||||
}
|
||||
Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } }
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,7 @@ internal object StoresMotion {
|
||||
|
||||
fun fly(from: Rect) {
|
||||
if (Motion.scale == 0f || downloadsChip == null) { landed++; return }
|
||||
// The count sits at the chip's right end.
|
||||
flights += Flight(from, to = { downloadsChip?.let { Offset(it.right - 22.dp.toPx(), it.center.y) } }, onLand = { landed++ })
|
||||
flights += Flight(from, SystemClock.uptimeMillis())
|
||||
}
|
||||
|
||||
/** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */
|
||||
@@ -141,6 +140,8 @@ internal object StoresMotion {
|
||||
/** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */
|
||||
internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?)
|
||||
|
||||
internal class Flight(val from: Rect, val id: Long)
|
||||
|
||||
/**
|
||||
* A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor]
|
||||
* (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however.
|
||||
@@ -152,7 +153,6 @@ internal class StepAsk(
|
||||
val pillCorner: androidx.compose.ui.unit.Dp? = null,
|
||||
val items: Int = 4,
|
||||
val onDismiss: () -> Unit = {},
|
||||
val maxWidth: androidx.compose.ui.unit.Dp = 300.dp,
|
||||
val handle: @Composable () -> Unit,
|
||||
val content: @Composable StepOutScope.() -> Unit,
|
||||
)
|
||||
@@ -167,21 +167,63 @@ internal class SignInFlood(val store: Store, val from: Rect, val start: Color, v
|
||||
/** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */
|
||||
@Composable
|
||||
internal fun FlightLayer(origin: Offset) {
|
||||
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) { StoresMotion.flights.remove(f) } }
|
||||
for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pops (1.3 to 1, with one ring) each time [trigger] changes: by default each time a download's
|
||||
* dot lands, for the count on the chip and the rail; any other landing passes its own count.
|
||||
* One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then
|
||||
* flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop.
|
||||
*/
|
||||
internal fun Modifier.landingPop(trigger: Int = StoresMotion.landed): Modifier = composed {
|
||||
@Composable
|
||||
private fun FlightDot(f: Flight, origin: Offset) {
|
||||
val pal = LocalPalette.current
|
||||
val t = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing))
|
||||
t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing))
|
||||
StoresMotion.landed++
|
||||
StoresMotion.flights.remove(f)
|
||||
}
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val r = 7.dp.toPx()
|
||||
val from = f.from.translate(-origin)
|
||||
val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin)
|
||||
val a = from.center
|
||||
// The count sits at the chip's right end.
|
||||
val b = Offset(chip.right - 22.dp.toPx(), chip.center.y)
|
||||
val v = t.value
|
||||
if (v < 1f) {
|
||||
// Squash: the width goes first, then the height, into the dot; a bloop at the end.
|
||||
val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f))
|
||||
val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f))
|
||||
val k = 1f - 0.15f * sin(PI * v).toFloat()
|
||||
drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2))
|
||||
} else {
|
||||
val p = v - 1f
|
||||
val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx())
|
||||
val q = 1f - p
|
||||
val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y)
|
||||
val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y))
|
||||
val stretch = sin(PI * p).toFloat()
|
||||
val angle = atan2(d.y, d.x) * 180f / PI.toFloat()
|
||||
rotate(angle, pos) {
|
||||
val w = 2 * r * (1f + 0.5f * stretch)
|
||||
val h = 2 * r * (1f - 0.25f * stretch)
|
||||
drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */
|
||||
internal fun Modifier.landingPop(): Modifier = composed {
|
||||
val pal = LocalPalette.current
|
||||
val scale = remember { Animatable(1f) }
|
||||
val ring = remember { Animatable(1f) }
|
||||
val seen = remember { intArrayOf(trigger) }
|
||||
LaunchedEffect(trigger) {
|
||||
if (trigger == seen[0]) return@LaunchedEffect
|
||||
seen[0] = trigger
|
||||
val seen = remember { intArrayOf(StoresMotion.landed) }
|
||||
LaunchedEffect(StoresMotion.landed) {
|
||||
if (StoresMotion.landed == seen[0]) return@LaunchedEffect
|
||||
seen[0] = StoresMotion.landed
|
||||
coroutineScope {
|
||||
launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) }
|
||||
launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) }
|
||||
|
||||
@@ -160,8 +160,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var openGame by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
// The cog's settings read the front end's state live while stepped out.
|
||||
val liveState = androidx.compose.runtime.rememberUpdatedState(s)
|
||||
var settings by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } }
|
||||
val store = Store.byId(chip)
|
||||
// A tab this store does not have (Store or All on Amazon) reads as Library.
|
||||
@@ -338,7 +337,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
StoreTheme(Store.byId(focusedChip ?: chip)) {
|
||||
Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") },
|
||||
onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) }
|
||||
onFocusChip = { focusedChip = it }, onSettings = { settings = true }) }
|
||||
}
|
||||
}
|
||||
val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() }
|
||||
@@ -476,6 +475,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
|
||||
seenActive.clear(); seenActive.addAll(activeKeys)
|
||||
if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) }
|
||||
}
|
||||
if (settings) StoresSettingsDialog(s, a) { settings = false }
|
||||
StoresState.pendingInstall?.let { item -> InstallWhere(item) }
|
||||
}
|
||||
|
||||
@@ -571,7 +571,7 @@ private fun SignInFloodLayer(hostAt: Offset) {
|
||||
*/
|
||||
@Composable
|
||||
private fun StoreChips(
|
||||
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit,
|
||||
selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: () -> Unit,
|
||||
/** The chip a pad's focus is on (null when it leaves the row): the page shows that store. */
|
||||
onFocusChip: (String?) -> Unit,
|
||||
) {
|
||||
@@ -621,16 +621,75 @@ private fun StoreChips(
|
||||
trail = if (active > 0) { { CountPill(active) } } else null,
|
||||
onFocused = onFocusChip,
|
||||
) { onDownloads() }
|
||||
var cogAt by remember { mutableStateOf<Rect?>(null) }
|
||||
Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) {
|
||||
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) }
|
||||
}
|
||||
IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings)
|
||||
}
|
||||
// Over the chips: the fill is translucent, and the other chips' rest fill would hide it in flight.
|
||||
ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection under the chips: [at] (in the row) of chip [index], in [tint]. A move to the next
|
||||
* chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a beat
|
||||
* later on its trail spring - and a hop past a chip pinches it to a drop, carries it and opens it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.ChipGlide(index: Int, at: Rect?, tint: State<Color>) {
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val left = remember { Animatable(Float.NaN) }
|
||||
val right = remember { Animatable(0f) }
|
||||
val pinch = remember { Animatable(0f) }
|
||||
val top = remember { mutableStateOf(0f) }
|
||||
val bottom = remember { mutableStateOf(0f) }
|
||||
val was = remember { intArrayOf(-1) }
|
||||
LaunchedEffect(index, at) {
|
||||
val b = at ?: return@LaunchedEffect
|
||||
top.value = b.top; bottom.value = b.bottom
|
||||
val from = was[0]
|
||||
was[0] = index
|
||||
if (left.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) {
|
||||
left.snapTo(b.left); right.snapTo(b.right); pinch.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
coroutineScope {
|
||||
if (kotlin.math.abs(index - from) > 1) {
|
||||
// Too far to stretch: pinch into a drop where it is, carry it over, open it there.
|
||||
pinch.animateTo(1f, Motion.tw(90))
|
||||
val l = launch { left.animateTo(b.left, Motion.sp(0.7f, 380f)) }
|
||||
val r = launch { right.animateTo(b.right, Motion.sp(0.7f, 380f)) }
|
||||
l.join(); r.join()
|
||||
pinch.animateTo(0f, Motion.sp(0.6f, 500f))
|
||||
} else {
|
||||
val forward = index > from
|
||||
val (lead, trail) = if (forward) right to left else left to right
|
||||
val leadTo = if (forward) b.right else b.left
|
||||
val trailTo = if (forward) b.left else b.right
|
||||
launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) }
|
||||
launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.Canvas(Modifier.matchParentSize()) {
|
||||
if (left.value.isNaN() || bottom.value <= top.value) return@Canvas
|
||||
val l = minOf(left.value, right.value)
|
||||
val r = maxOf(left.value, right.value)
|
||||
val cx = (l + r) / 2f
|
||||
val cy = (top.value + bottom.value) / 2f
|
||||
val drop = with(density) { 6.dp.toPx() }
|
||||
val p = pinch.value
|
||||
val x0 = androidx.compose.ui.util.lerp(l, cx - drop, p)
|
||||
val x1 = androidx.compose.ui.util.lerp(r, cx + drop, p)
|
||||
val y0 = androidx.compose.ui.util.lerp(top.value, cy - drop, p)
|
||||
val y1 = androidx.compose.ui.util.lerp(bottom.value, cy + drop, p)
|
||||
val size = androidx.compose.ui.geometry.Size(x1 - x0, y1 - y0)
|
||||
val corner = androidx.compose.ui.geometry.CornerRadius(minOf(size.width, size.height) / 2f)
|
||||
val at0 = Offset(x0, y0)
|
||||
// A drop in flight is solid; on a chip it is the chip's tinted fill and outline.
|
||||
drawRoundRect(tint.value.copy(alpha = androidx.compose.ui.util.lerp(0.16f, 0.9f, p)), at0, size, corner)
|
||||
drawRoundRect(tint.value.copy(alpha = 0.7f * (1f - p)), at0, size, corner, style = androidx.compose.ui.graphics.drawscope.Stroke(with(density) { 1.dp.toPx() }))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoreChip(
|
||||
label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit,
|
||||
@@ -886,7 +945,7 @@ private fun Storefront(
|
||||
* search field across the row, and [trailing] (the account) at the right, over a rule.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
|
||||
private fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
// Open while it holds a query; an empty field closes again once focus leaves it.
|
||||
@@ -942,22 +1001,6 @@ internal fun storePalette(base: Palette, store: Store?): Palette {
|
||||
)
|
||||
}
|
||||
|
||||
/** The Store section's accent: Flathub's teal, with its dark ink on it. */
|
||||
internal fun flathubPalette(base: Palette): Palette = Palette(
|
||||
base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2,
|
||||
base.onBackground, base.onSurfaceVariant, primary = FlathubTeal, primary2 = Color(0xFF2E958D), onPrimary = FlathubInk, signal = FlathubTeal,
|
||||
good = base.good, error = base.error,
|
||||
)
|
||||
|
||||
/** [content] with the focus ring, buttons, step-outs and the chip glide in Flathub's teal. */
|
||||
@Composable
|
||||
internal fun FlathubTheme(content: @Composable () -> Unit) {
|
||||
val base = LocalPalette.current
|
||||
val p = remember(base) { flathubPalette(base) }
|
||||
val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary)
|
||||
CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) }
|
||||
}
|
||||
|
||||
/** [content] with the focus ring, buttons and step-outs in [store]'s colour. */
|
||||
@Composable
|
||||
internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) {
|
||||
@@ -1017,7 +1060,7 @@ private fun SignOutButton(store: Store, name: String) {
|
||||
|
||||
/** The header's magnifier: a round button the search field opens out of. */
|
||||
@Composable
|
||||
internal fun SearchIcon(description: String, onClick: () -> Unit) {
|
||||
private fun SearchIcon(description: String, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
|
||||
@@ -642,6 +642,9 @@
|
||||
<string name="setup_choose_home">Elige una app de inicio</string>
|
||||
<string name="setup_choose">Elegir</string>
|
||||
<string name="setup_linux_apps">Apps de Linux (beta)</string>
|
||||
<string name="setup_store">Tienda Flathub</string>
|
||||
<string name="setup_store_on">En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Instala apps y juegos de Linux desde Flathub</string>
|
||||
<string name="setup_tool_files">Archivos</string>
|
||||
<string name="setup_tool_files_hint">Explorar y gestionar archivos</string>
|
||||
<string name="setup_tool_protons">Versiones de Proton</string>
|
||||
|
||||
@@ -518,6 +518,9 @@
|
||||
<string name="setup_choose_home">Choisir l\'application d\'écran d\'accueil</string>
|
||||
<string name="setup_choose">Choisir</string>
|
||||
<string name="setup_linux_apps">Applications Linux (bêta)</string>
|
||||
<string name="setup_store">Boutique Flathub</string>
|
||||
<string name="setup_store_on">Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Installer des applications et des jeux Linux depuis Flathub</string>
|
||||
<string name="setup_tool_files">Fichiers</string>
|
||||
<string name="setup_tool_files_hint">Parcourir et gérer les fichiers</string>
|
||||
<string name="setup_tool_protons">Versions de Proton</string>
|
||||
|
||||
@@ -638,6 +638,9 @@
|
||||
<string name="setup_choose_home">ホームアプリを選択</string>
|
||||
<string name="setup_choose">選択</string>
|
||||
<string name="setup_linux_apps">Linuxアプリ(ベータ版)</string>
|
||||
<string name="setup_store">Flathubストア</string>
|
||||
<string name="setup_store_on">メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。</string>
|
||||
<string name="setup_store_off">FlathubからLinuxアプリとゲームをインストール</string>
|
||||
<string name="setup_tool_files">ファイル</string>
|
||||
<string name="setup_tool_files_hint">ファイルの閲覧と管理</string>
|
||||
<string name="setup_tool_protons">Protonバージョン</string>
|
||||
|
||||
@@ -638,6 +638,9 @@
|
||||
<string name="setup_choose_home">홈 앱 선택</string>
|
||||
<string name="setup_choose">선택</string>
|
||||
<string name="setup_linux_apps">Linux 앱(베타)</string>
|
||||
<string name="setup_store">Flathub 스토어</string>
|
||||
<string name="setup_store_on">메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다.</string>
|
||||
<string name="setup_store_off">Flathub에서 Linux 앱 및 게임 설치</string>
|
||||
<string name="setup_tool_files">파일</string>
|
||||
<string name="setup_tool_files_hint">파일 탐색 및 관리</string>
|
||||
<string name="setup_tool_protons">Proton 버전</string>
|
||||
|
||||
@@ -596,6 +596,9 @@
|
||||
<string name="setup_choose_home">Выбрать главный экран</string>
|
||||
<string name="setup_choose">Выбрать</string>
|
||||
<string name="setup_linux_apps">Приложения Linux (бета)</string>
|
||||
<string name="setup_store">Магазин Flathub</string>
|
||||
<string name="setup_store_on">Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Устанавливать приложения и игры Linux из Flathub</string>
|
||||
<string name="setup_tool_files">Файлы</string>
|
||||
<string name="setup_tool_files_hint">Просмотр и управление файлами</string>
|
||||
<string name="setup_tool_protons">Версии Proton</string>
|
||||
|
||||
@@ -638,6 +638,9 @@
|
||||
<string name="setup_choose_home">选择主屏幕应用</string>
|
||||
<string name="setup_choose">选择</string>
|
||||
<string name="setup_linux_apps">Linux 应用(测试版)</string>
|
||||
<string name="setup_store">Flathub 商店</string>
|
||||
<string name="setup_store_on">显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。</string>
|
||||
<string name="setup_store_off">从 Flathub 安装 Linux 应用和游戏</string>
|
||||
<string name="setup_tool_files">文件</string>
|
||||
<string name="setup_tool_files_hint">浏览和管理文件</string>
|
||||
<string name="setup_tool_protons">Proton 版本</string>
|
||||
|
||||
@@ -638,6 +638,9 @@
|
||||
<string name="setup_choose_home">選擇主畫面應用程式</string>
|
||||
<string name="setup_choose">選擇</string>
|
||||
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
|
||||
<string name="setup_store">Flathub 商店</string>
|
||||
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。</string>
|
||||
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
|
||||
<string name="setup_tool_files">檔案</string>
|
||||
<string name="setup_tool_files_hint">瀏覽及管理檔案</string>
|
||||
<string name="setup_tool_protons">Proton 版本</string>
|
||||
|
||||
@@ -638,6 +638,9 @@
|
||||
<string name="setup_choose_home">選擇主螢幕應用程式</string>
|
||||
<string name="setup_choose">選擇</string>
|
||||
<string name="setup_linux_apps">Linux 應用程式(測試版)</string>
|
||||
<string name="setup_store">Flathub 商店</string>
|
||||
<string name="setup_store_on">顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。</string>
|
||||
<string name="setup_store_off">從 Flathub 安裝 Linux 應用程式和遊戲</string>
|
||||
<string name="setup_tool_files">檔案</string>
|
||||
<string name="setup_tool_files_hint">瀏覽和管理檔案</string>
|
||||
<string name="setup_tool_protons">Proton 版本</string>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<string name="game_env_save">Save</string>
|
||||
<string name="game_env_remove">Remove</string>
|
||||
<string name="game_env_restore">Inherit again</string>
|
||||
<string name="game_env_reset">Clear environment overrides</string>
|
||||
<string name="game_env_reset">Reset this profile</string>
|
||||
<string name="game_env_unset">Unset for this profile</string>
|
||||
<string name="game_env_empty">No variables set.</string>
|
||||
<string name="game_env_inherited">Inherited</string>
|
||||
@@ -413,41 +413,6 @@
|
||||
<string name="comp_check_nightlies">Check the Nightlies for new packages</string>
|
||||
<string name="comp_proton">Proton</string>
|
||||
<string name="comp_proton_note">The Proton chosen here runs every game that has not picked its own in Steam, and a change made in Steam shows here too. Steam replaces Valve\'s Proton when it updates; originals are kept per build.</string>
|
||||
<string name="comp_scope">Configuration</string>
|
||||
<string name="comp_profile_configure">Configure profile</string>
|
||||
<string name="comp_profile">Profile</string>
|
||||
<string name="comp_scope_general">Default profile</string>
|
||||
<string name="comp_scope_general_detail">Used automatically by games without overrides.</string>
|
||||
<string name="comp_profile_game_detail">Saved settings used automatically when %1$s launches.</string>
|
||||
<string name="comp_game_hint">Choose only the settings this game needs. Everything else continues to use the Default profile.</string>
|
||||
<string name="comp_game_fex">FEX preset</string>
|
||||
<string name="comp_game_fex_inherited">Using the Default profile preset.</string>
|
||||
<string name="comp_game_fex_override">Custom preset saved for this game.</string>
|
||||
<string name="comp_game_fex_custom">Custom FEX variables</string>
|
||||
<string name="comp_game_use_general">Use Default</string>
|
||||
<string name="comp_game_compatibility">Game compatibility</string>
|
||||
<string name="comp_game_environment_hint">Proton, DXVK, VKD3D and synchronization environment options for this game.</string>
|
||||
<string name="comp_game_proton_inherited">Using the Default profile Proton.</string>
|
||||
<string name="comp_game_proton_override">Custom Proton saved for this game through Steam compatibility mapping.</string>
|
||||
<string name="comp_no_proton_short">No Proton</string>
|
||||
<string name="comp_game_dxvk">DXVK</string>
|
||||
<string name="comp_game_vkd3d">VKD3D-Proton</string>
|
||||
<string name="comp_game_component_runtime">Uses the Default profile package from %1$s unless this game selects another package.</string>
|
||||
<string name="comp_game_component_general">Uses the Default profile component package.</string>
|
||||
<string name="comp_game_component_override">Uses %1$s only for this game.</string>
|
||||
<string name="comp_game_runtime_options">Runtime options</string>
|
||||
<string name="comp_game_environment_other_hint">Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options.</string>
|
||||
<string name="comp_game_saved_next">%1$s\'s profile is saved for the next game launch.</string>
|
||||
<string name="comp_game_proton_live">%1$s\'s Proton choice is saved and available in Steam now.</string>
|
||||
<string name="comp_game_proton_next">%1$s\'s Proton choice is saved. Restart Steam before launching the game.</string>
|
||||
<string name="comp_game_proton_not_runnable">%1$s cannot use the selected Proton on this device.</string>
|
||||
<string name="comp_game_load_failed">Could not load %1$s\'s profile: %2$s</string>
|
||||
<string name="comp_game_save_failed">The game profile could not be saved: %1$s</string>
|
||||
<string name="comp_game_reset">Reset game profile</string>
|
||||
<string name="comp_game_reset_title">Reset %1$s\'s profile?</string>
|
||||
<string name="comp_game_reset_body">Remove this game\'s Proton, FEX, component package, environment, texture filtering, and Windows component overrides? It will use the Default profile.</string>
|
||||
<string name="comp_game_reset_done">%1$s now uses the Default profile.</string>
|
||||
<string name="comp_game_reset_failed">The game profile could not be reset completely: %1$s</string>
|
||||
<string name="comp_default_live">%1$s now runs every game that has not picked its own Proton in Steam.</string>
|
||||
<string name="comp_default_next">%1$s will run every game that has not picked its own Proton, from the next time Steam starts.</string>
|
||||
<string name="comp_default_not_runnable">%1$s cannot start games here, so it cannot be the default.</string>
|
||||
@@ -466,7 +431,7 @@
|
||||
<string name="comp_applies_next_game">Applies the next time a game starts</string>
|
||||
<string name="comp_import_zip">Import .zip</string>
|
||||
<string name="comp_reading">Reading Protons…</string>
|
||||
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton, CachyOS or DroidDeck-Proton from Setup.</string>
|
||||
<string name="comp_no_proton">No Proton installed yet. Start Steam once to download its ARM64 Proton, or add GE-Proton or CachyOS from Setup.</string>
|
||||
<string name="comp_original">Original · %1$s</string>
|
||||
<string name="comp_this_build">%1$s · this build</string>
|
||||
<string name="comp_earlier_build">%1$s · earlier build</string>
|
||||
@@ -611,19 +576,6 @@
|
||||
<string name="drawer_stop_title">Stop session?</string>
|
||||
<string name="drawer_running_now">Running now</string>
|
||||
<string name="drawer_no_game">No game is running on a Proton</string>
|
||||
<string name="drawer_selected_profile">Game profile</string>
|
||||
<string name="drawer_selected_profile_none">Profile details are not available yet.</string>
|
||||
<string name="drawer_profile_refresh_failed_initial">Couldn\'t load game profiles. Retrying automatically.</string>
|
||||
<string name="drawer_profile_refresh_failed_stale">Couldn\'t refresh game profiles. Showing the last available data; retrying automatically.</string>
|
||||
<string name="drawer_profile_inspect_hint">View and edit the saved settings used on the next launch.</string>
|
||||
<string name="drawer_profile_default">Using Default profile</string>
|
||||
<string name="drawer_profile_custom">Custom profile</string>
|
||||
<string name="drawer_component_inherited">Using %1$s from the Default profile.</string>
|
||||
<string name="drawer_game_components_note">Changes are saved to %1$s\'s profile and apply the next time the game launches.</string>
|
||||
<plurals name="drawer_profile_environment_overrides">
|
||||
<item quantity="one">%1$d game override</item>
|
||||
<item quantity="other">%1$d game overrides</item>
|
||||
</plurals>
|
||||
<string name="drawer_no_proton">No Proton is installed yet.</string>
|
||||
<string name="drawer_game_running">game running</string>
|
||||
<string name="drawer_reapplied">re-applied at launch %1$s</string>
|
||||
@@ -706,6 +658,9 @@
|
||||
<string name="setup_choose_home">Choose a Home app</string>
|
||||
<string name="setup_choose">Choose</string>
|
||||
<string name="setup_linux_apps">Linux apps (beta)</string>
|
||||
<string name="setup_store">Flathub Store</string>
|
||||
<string name="setup_store_on">In the menu. Some apps may not start; logs are in Download/DroidDeck.</string>
|
||||
<string name="setup_store_off">Install Linux apps and games from Flathub</string>
|
||||
<string name="setup_tool_files">Files</string>
|
||||
<string name="setup_tool_files_hint">Browse and manage files</string>
|
||||
<string name="setup_tool_protons">Proton versions</string>
|
||||
@@ -1077,13 +1032,6 @@
|
||||
<string name="proton_installed">Installed %1$s</string>
|
||||
<string name="proton_queued">Pending from an earlier request</string>
|
||||
<string name="proton_not_installed">Not installed</string>
|
||||
<!-- %1$s is the newer release's tag -->
|
||||
<string name="proton_update_available">Update available → %1$s</string>
|
||||
<!-- %1$s is the Proton's name (GE-Proton, proton-cachyos, DroidDeck-Proton) -->
|
||||
<string name="proton_remove_older">Remove old %1$s versions</string>
|
||||
<!-- %1$s lists the folders that are removed -->
|
||||
<string name="proton_remove_older_body">Removes %1$s. Games set to these versions must be switched to another Proton.</string>
|
||||
<string name="proton_remove_body">Games set to this version must be switched to another Proton.</string>
|
||||
<string name="proton_install_now">Install now</string>
|
||||
<string name="proton_cancel_queued">Cancel queued</string>
|
||||
<string name="pckb_android">Android keyboard</string>
|
||||
@@ -1305,7 +1253,6 @@
|
||||
<!-- Shown as "Auto (direct)" in the drawer's touch setting -->
|
||||
<string name="session_touch_auto_direct">direct</string>
|
||||
<string name="session_swap_failed">Swap failed: %1$s</string>
|
||||
<string name="session_profile_save_failed">Profile save failed: %1$s</string>
|
||||
<string name="session_desktop_paused">The desktop is paused</string>
|
||||
<!-- %1$s is the program's name -->
|
||||
<string name="session_named_paused">%1$s is paused</string>
|
||||
@@ -1814,7 +1761,7 @@
|
||||
<!-- %1$s is the client's own download progress, as it prints it -->
|
||||
<string name="loadstate_steam_client">Downloading the Steam client · %1$s</string>
|
||||
|
||||
<!-- Installing GE-Proton / proton-cachyos / DroidDeck-Proton (%1$s is the tool's name, %2$s its release tag) -->
|
||||
<!-- Installing GE-Proton / proton-cachyos (%1$s is the tool's name, %2$s its release tag) -->
|
||||
<string name="pextra_busy">A compatibility tool install is already running</string>
|
||||
<string name="pextra_stop_session">Stop the active session before installing compatibility tools</string>
|
||||
<string name="pextra_no_release">Could not find an ARM64 release for %1$s</string>
|
||||
@@ -2046,6 +1993,7 @@
|
||||
<string name="source_steam">Steam</string>
|
||||
<string name="source_added">Custom</string>
|
||||
<string name="setup_stores">Stores</string>
|
||||
<string name="setup_stores_show">Show Stores in the rail</string>
|
||||
<string name="setup_stores_open_on">Open a store on</string>
|
||||
<string name="setup_stores_speed">Download speed tier</string>
|
||||
<string name="setup_stores_engine">Stores engine</string>
|
||||
@@ -2235,50 +2183,4 @@
|
||||
<item quantity="one">%1$d already in Games</item>
|
||||
<item quantity="other">%1$d already in Games</item>
|
||||
</plurals>
|
||||
<!-- A page reached through a link from another section: the way back, named for where it came from. -->
|
||||
<string name="hop_back">B · Back to %1$s</string>
|
||||
<string name="hop_from_games">the game</string>
|
||||
<string name="hop_from_play">Play settings</string>
|
||||
<!-- The lifted settings sheets under Play and Open desktop. -->
|
||||
<string name="sheet_graphics_drivers">Graphics drivers</string>
|
||||
<!-- Components as one stack of layers. -->
|
||||
<string name="comp_this_proton">This Proton</string>
|
||||
<string name="comp_every_proton">Every Proton</string>
|
||||
<string name="comp_for_every_proton">For every Proton</string>
|
||||
<string name="comp_available_count">Available · %1$d</string>
|
||||
<string name="comp_checked">Checked %1$s</string>
|
||||
<string name="comp_check_all">Check for new components and drivers</string>
|
||||
<string name="comp_get">Get</string>
|
||||
<string name="comp_proton_bar">%1$s · DXVK, VKD3D-Proton and FEX below are this Proton\'s</string>
|
||||
<string name="comp_device_tested">%1$s · tested</string>
|
||||
<string name="comp_device_untested">%1$s · untested</string>
|
||||
<string name="comp_device_unsupported">%1$s · unsupported</string>
|
||||
<string name="gpu_auto_keeps">Keeps the recommended pair current</string>
|
||||
<string name="gpu_bar_auto">Auto · %1$s</string>
|
||||
<string name="gpu_separately">Runtime and display drivers separately</string>
|
||||
<string name="gpu_use_title">Use %1$s?</string>
|
||||
<string name="proton_remove_title">Remove %1$s?</string>
|
||||
<!-- Setup sorted by scope. -->
|
||||
<string name="setup_tab_diagnostics">Diagnostics</string>
|
||||
<string name="setup_ready">Ready to play</string>
|
||||
<string name="setup_not_ready">Not ready to play yet</string>
|
||||
<string name="setup_ready_runtime">runtime %1$s</string>
|
||||
<string name="setup_ready_limit_off">process limit off</string>
|
||||
<string name="setup_ready_signed_in">signed in as %1$s</string>
|
||||
<string name="setup_needs_you">When something needs you</string>
|
||||
<string name="setup_group_look">Look</string>
|
||||
<string name="setup_group_device">This device</string>
|
||||
<string name="setup_group_art">Library art</string>
|
||||
<!-- Flathub, in the stores' style. -->
|
||||
<string name="flathub_eyebrow">LINUX APPS</string>
|
||||
<string name="flathub_name">Flathub</string>
|
||||
<string name="flathub_line">Desktop apps for the Linux desktop, opened from Desktop.</string>
|
||||
<string name="flathub_setup">Set up Flathub</string>
|
||||
<string name="flathub_remove_title">Remove %1$s?</string>
|
||||
<string name="flathub_remove_note">Its settings and files are kept.</string>
|
||||
<string name="flathub_keep">Keep</string>
|
||||
<!-- Desktop in the Steam tab's shape. -->
|
||||
<string name="desktop_size">Size on disk</string>
|
||||
<string name="desktop_uninstall_title">Uninstall the desktop?</string>
|
||||
<string name="desktop_all_systems">All</string>
|
||||
</resources>
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Every quirk of the Java reader is kept on purpose (the Java manager stays the reference and
|
||||
//! the two engines must derive the same plan from the same bytes): section skips by declared
|
||||
//! size, `readFString` UTF-16LE/ASCII decoding, duplicate-GUID handling (last value, first
|
||||
//! position), the JSON blob decoding (`blobToNum`) and its fallbacks, and "any exception →
|
||||
//! position), the JSON `Long.parseLong(.., 16)` / `parseInt` fallbacks, and "any exception →
|
||||
//! parse failed".
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -445,25 +445,6 @@ fn json_to_string(v: &serde_json::Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EpicDownloadManager.blobToNum`: a JSON manifest writes every number as a "blob", each byte
|
||||
/// as three decimal digits, least significant first ("013000000000" = 13). Bits past 64 are
|
||||
/// dropped; `None` when a group is not a number (Java: `NumberFormatException`).
|
||||
pub fn blob_to_num(s: &str) -> Option<u64> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut num: u64 = 0;
|
||||
for (i, group) in bytes.chunks(3).enumerate() {
|
||||
if !group.iter().all(u8::is_ascii_digit) {
|
||||
return None;
|
||||
}
|
||||
let byte: u64 = std::str::from_utf8(group).ok()?.parse().ok()?;
|
||||
let shift = i * 8;
|
||||
if shift < 64 {
|
||||
num = num.wrapping_add(byte << shift);
|
||||
}
|
||||
}
|
||||
Some(num)
|
||||
}
|
||||
|
||||
/// `obj.optString(key, fallback)`.
|
||||
fn opt_string(obj: &serde_json::Map<String, serde_json::Value>, key: &str, fallback: &str) -> String {
|
||||
match obj.get(key) {
|
||||
@@ -502,8 +483,9 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result<Manifest, String> {
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON manifest: root is not an object".to_string())?;
|
||||
|
||||
let manifest_version: i32 = blob_to_num(&opt_string(root, "ManifestFileVersion", "0"))
|
||||
.map_or(0, |v| v as i32);
|
||||
let manifest_version: i32 = opt_string(root, "ManifestFileVersion", "0")
|
||||
.parse::<i32>()
|
||||
.unwrap_or(0);
|
||||
let chunk_dir = chunk_dir_for_json_version(manifest_version);
|
||||
|
||||
let chunk_hash_list = root
|
||||
@@ -520,14 +502,19 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result<Manifest, String> {
|
||||
if guid_hex.len() < 32 || !guid_hex.is_char_boundary(32) {
|
||||
continue;
|
||||
}
|
||||
let hash_hex = json_to_string(hash_value);
|
||||
let guid = parse_guid_from_hex(guid_hex)?;
|
||||
let hash = blob_to_num(&json_to_string(hash_value)).unwrap_or(0);
|
||||
let hash = if hash_hex.len() >= 16 && hash_hex.is_char_boundary(16) {
|
||||
u64::from_str_radix(&hash_hex[0..16], 16).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let group_num = match data_group_list {
|
||||
Some(dgl) => blob_to_num(&opt_string(dgl, guid_hex, "0")).map_or(0, |v| v as i32),
|
||||
Some(dgl) => opt_string(dgl, guid_hex, "0").parse::<i32>().unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let file_size = match chunk_filesize_list {
|
||||
Some(cfl) => blob_to_num(&opt_string(cfl, guid_hex, "0")).map_or(0, |v| v as i64),
|
||||
Some(cfl) => i64::from_str_radix(&opt_string(cfl, guid_hex, "0"), 16).unwrap_or(0),
|
||||
None => 0,
|
||||
};
|
||||
let c = ChunkInfo {
|
||||
@@ -583,8 +570,8 @@ pub fn parse_json_manifest(bytes: &[u8]) -> Result<Manifest, String> {
|
||||
if part_guid.len() >= 32 && part_guid.is_char_boundary(32) {
|
||||
part.guid = parse_guid_from_hex(&part_guid)?;
|
||||
}
|
||||
part.offset = blob_to_num(&opt_string(part_obj, "Offset", "0")).map_or(0, |v| v as i32);
|
||||
part.size = blob_to_num(&opt_string(part_obj, "Size", "0")).map_or(0, |v| v as i32);
|
||||
part.offset = opt_string(part_obj, "Offset", "0").parse::<i32>().unwrap_or(0);
|
||||
part.size = opt_string(part_obj, "Size", "0").parse::<i32>().unwrap_or(0);
|
||||
fi.parts.push(part);
|
||||
}
|
||||
}
|
||||
@@ -941,29 +928,17 @@ mod tests {
|
||||
assert_eq!(c.fstring().unwrap(), "a\u{FFFD}b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_numbers_are_decimal_bytes_least_significant_first() {
|
||||
assert_eq!(blob_to_num("001000000000"), Some(1));
|
||||
assert_eq!(blob_to_num("000001000000"), Some(0x100));
|
||||
assert_eq!(blob_to_num("001002003004005006007008"), Some(0x0807_0605_0403_0201));
|
||||
assert_eq!(blob_to_num("255255255255255255255255"), Some(u64::MAX));
|
||||
assert_eq!(blob_to_num("7"), Some(7));
|
||||
assert_eq!(blob_to_num(""), Some(0));
|
||||
assert_eq!(blob_to_num("0x1"), None);
|
||||
assert_eq!(blob_to_num("-05"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_manifest_parses_like_java() {
|
||||
let json = r#"{
|
||||
"ManifestFileVersion": "013",
|
||||
"ChunkHashList": {
|
||||
"0000000100000002000000030000000ff": "001002003004005006007008",
|
||||
"0000000100000002000000030000000ff": "0123456789ABCDEF00",
|
||||
"short": "00",
|
||||
"DEADBEEFFFFFFFFF0000000080000000": "zz"
|
||||
},
|
||||
"DataGroupList": { "0000000100000002000000030000000ff": "7", "DEADBEEFFFFFFFFF0000000080000000": "x" },
|
||||
"ChunkFilesizeList": { "0000000100000002000000030000000ff": "255" },
|
||||
"ChunkFilesizeList": { "0000000100000002000000030000000ff": "FF" },
|
||||
"FileManifestList": [
|
||||
{ "Filename": "a.bin", "InstallTags": ["German", ""],
|
||||
"FileChunkParts": [ { "Guid": "0000000100000002000000030000000ff", "Offset": "12", "Size": "34" },
|
||||
@@ -976,7 +951,7 @@ mod tests {
|
||||
assert_eq!(m.unique_chunks.len(), 2, "keys shorter than 32 chars are skipped");
|
||||
let c0 = &m.unique_chunks[0];
|
||||
assert_eq!(c0.guid, [1, 2, 3, 0x0000_000F]);
|
||||
assert_eq!(c0.hash, 0x0807_0605_0403_0201);
|
||||
assert_eq!(c0.hash, 0x0123_4567_89AB_CDEF);
|
||||
assert_eq!(c0.group_num, 7);
|
||||
assert_eq!(c0.file_size, 0xFF);
|
||||
assert_eq!(c0.window_size, 0);
|
||||
@@ -993,36 +968,11 @@ mod tests {
|
||||
assert_eq!(m.files[0].parts[0].size, 34);
|
||||
assert_eq!(m.files[0].parts[1].guid, [0; 4]);
|
||||
assert_eq!(m.files[0].parts[1].offset, 0);
|
||||
assert_eq!(m.files[0].parts[1].size, 0);
|
||||
assert_eq!(m.files[0].parts[1].size, -5);
|
||||
assert!(m.files[0].sha1.is_none());
|
||||
assert!(m.files[1].parts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_manifest_uses_blobs() {
|
||||
let json = r#"{
|
||||
"ManifestFileVersion": "006000000000",
|
||||
"ChunkHashList": { "0000000100000002000000030000000F": "001002003004005006007008" },
|
||||
"DataGroupList": { "0000000100000002000000030000000F": "007" },
|
||||
"ChunkFilesizeList": { "0000000100000002000000030000000F": "001002000000000000000000" },
|
||||
"FileManifestList": [
|
||||
{ "Filename": "a.bin",
|
||||
"FileChunkParts": [ { "Guid": "0000000100000002000000030000000F", "Offset": "001000000000", "Size": "000000016000" } ] }
|
||||
]
|
||||
}"#;
|
||||
let m = parse_manifest(json.as_bytes()).expect("json parse");
|
||||
assert_eq!(m.chunk_dir, "ChunksV3");
|
||||
let c = &m.unique_chunks[0];
|
||||
assert_eq!(c.file_size, 0x0201);
|
||||
assert_eq!(
|
||||
c.path(&m.chunk_dir),
|
||||
"ChunksV3/07/0807060504030201_0000000100000002000000030000000F.chunk"
|
||||
);
|
||||
let part = &m.files[0].parts[0];
|
||||
assert_eq!(part.offset, 1);
|
||||
assert_eq!(part.size, 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_manifest_failures_match_java_nulls() {
|
||||
assert!(parse_manifest(br#"{"FileManifestList": []}"#).is_err(), "no ChunkHashList");
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AgentGuestSelectionTest {
|
||||
private val installedAppIds = setOf(646570L, 3180310L)
|
||||
|
||||
@Test fun focusedInstalledGameIsSelected() {
|
||||
assertEquals(3180310L, AgentGuest.focusedInstalledSteamAppId(3180310L, installedAppIds))
|
||||
}
|
||||
|
||||
@Test fun focusedInstalledGameIsActive() {
|
||||
assertEquals(
|
||||
3180310L,
|
||||
AgentGuest.activeInstalledSteamAppId(3180310L, listOf(769L, 3180310L), installedAppIds),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun selectedSteamGameWinsOverStaleActiveSteamGame() {
|
||||
assertEquals(
|
||||
646570L,
|
||||
AgentGuest.preferredInstalledSteamAppId(646570L, 3180310L, installedAppIds),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun validGamescopeGameFallsBackWhenSteamSelectionFails() {
|
||||
assertEquals(
|
||||
3180310L,
|
||||
AgentGuest.selectedOrActiveInstalledSteamAppId(
|
||||
Result.failure(IllegalStateException("agent unavailable")),
|
||||
3180310L,
|
||||
installedAppIds,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun absentSelectionAndActiveGameRemainAValidNoSelection() {
|
||||
assertNull(AgentGuest.selectedOrActiveInstalledSteamAppId(Result.success(null), null, installedAppIds))
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun selectionFailureWithoutGamescopeFallbackRemainsAnError() {
|
||||
AgentGuest.selectedOrActiveInstalledSteamAppId(
|
||||
Result.failure(IllegalStateException("agent unavailable")),
|
||||
null,
|
||||
installedAppIds,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun soleFocusableInstalledGameRemainsActiveUnderSteamOverlay() {
|
||||
assertEquals(
|
||||
3180310L,
|
||||
AgentGuest.activeInstalledSteamAppId(769L, listOf(3180310L), installedAppIds),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun ambiguousFocusableGamesAreIgnored() {
|
||||
assertNull(
|
||||
AgentGuest.activeInstalledSteamAppId(769L, listOf(646570L, 3180310L), installedAppIds),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun soleBaselayerGameResolvesAmbiguousSteamOverlayFocus() {
|
||||
assertEquals(
|
||||
646570L,
|
||||
AgentGuest.activeInstalledSteamAppId(
|
||||
focusedAppId = 769L,
|
||||
focusableAppIds = listOf(646570L, 3180310L),
|
||||
installedAppIds = installedAppIds,
|
||||
baselayerAppIds = listOf(769L, 646570L),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun steamClientFocusIsIgnored() {
|
||||
assertNull(AgentGuest.focusedInstalledSteamAppId(769L, installedAppIds))
|
||||
}
|
||||
|
||||
@Test fun unknownGameFocusIsIgnored() {
|
||||
assertNull(AgentGuest.focusedInstalledSteamAppId(42L, installedAppIds))
|
||||
}
|
||||
|
||||
@Test fun missingFocusSnapshotIsIgnored() {
|
||||
assertNull(AgentGuest.focusedInstalledSteamAppId(null, installedAppIds))
|
||||
}
|
||||
}
|
||||
@@ -102,24 +102,4 @@ class GameEnvironmentTest {
|
||||
assertTrue(GameEnvironment.validScope("4294967295"))
|
||||
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
|
||||
}
|
||||
|
||||
@Test fun gameFexPresetOverridesGeneralAndCanInheritAgain() {
|
||||
val performance = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), "42", "PERFORMANCE")
|
||||
assertEquals("PERFORMANCE", GameEnvironment.gameFexPreset(performance, "42"))
|
||||
assertEquals("0", GameEnvironment.effective(performance, "STABILITY", "42")["FEX_TSOENABLED"])
|
||||
assertEquals("1", GameEnvironment.effective(performance, "STABILITY", "43")["FEX_TSOENABLED"])
|
||||
|
||||
val inherited = GameEnvironment.withGameFexPreset(performance, "42", null)
|
||||
assertNull(GameEnvironment.gameFexPreset(inherited, "42"))
|
||||
assertFalse(inherited.games.containsKey("42"))
|
||||
}
|
||||
|
||||
@Test fun gameFexPresetPreservesOtherOverridesAndRecognizesCustomValues() {
|
||||
val original = GameEnvironment.Config(games = mapOf("42" to mapOf("CUSTOM" to "value", "FEX_MULTIBLOCK" to "7")))
|
||||
assertEquals(GameEnvironment.CUSTOM_FEX_PRESET, GameEnvironment.gameFexPreset(original, "42"))
|
||||
|
||||
val compatibility = GameEnvironment.withGameFexPreset(original, "42", "COMPATIBILITY")
|
||||
assertEquals("value", compatibility.games.getValue("42")["CUSTOM"])
|
||||
assertEquals("COMPATIBILITY", GameEnvironment.gameFexPreset(compatibility, "42"))
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ComponentsProcessTest {
|
||||
private val guestPath = "/root/.local/share/Steam/steamapps/common/Proton"
|
||||
private val hostPath = "/data/user/0/com.droiddeck.launcher/files/root/root/.local/share/Steam/steamapps/common/Proton"
|
||||
|
||||
@Test
|
||||
fun `running game is recognized through guest path`() {
|
||||
val command = "python3 $guestPath/proton waitforexitandrun game.exe"
|
||||
|
||||
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `running game is recognized through Android host path`() {
|
||||
val command = "python3 $hostPath/proton waitforexitandrun game.exe"
|
||||
|
||||
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Steam startup Proton process is not reported as a running game`() {
|
||||
val command = "python3 $hostPath/proton run wineboot.exe"
|
||||
|
||||
assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
|
||||
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated Proton process is ignored`() {
|
||||
val command = "python3 /other/Proton/proton waitforexitandrun game.exe"
|
||||
|
||||
assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.core.Hashes
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.github.luben.zstd.ZstdOutputStream
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
@@ -92,283 +89,6 @@ class ForcedPackageTest {
|
||||
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
|
||||
}
|
||||
|
||||
@Test fun gameComponentProfilesReadOnlyKnownComponents() {
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
state.parentFile!!.mkdirs()
|
||||
state.writeText(JSONObject()
|
||||
.put("active", JSONObject())
|
||||
.put("queued", JSONObject())
|
||||
.put("profiles", JSONObject().put("42", JSONObject()
|
||||
.put("fex", "fex.wcp")
|
||||
.put("dxvk", "dxvk.wcp")
|
||||
.put("unknown", "ignored.wcp")))
|
||||
.toString())
|
||||
|
||||
assertEquals(mapOf("fex" to "fex.wcp", "dxvk" to "dxvk.wcp"), ComponentsManager.gameComponents(context, "42"))
|
||||
assertTrue(ComponentsManager.gameComponents(context, "43").isEmpty())
|
||||
}
|
||||
|
||||
@Test fun removingLastGameOverrideKeepsBundledComponentRestoreRow() {
|
||||
val protonName = "proton-exp"
|
||||
proton(protonName)
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
|
||||
assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk"))
|
||||
assertDefaultDxvkRestoreRow(protonName)
|
||||
}
|
||||
|
||||
@Test fun usingDefaultForLastGameOverrideKeepsBundledComponentRestoreRow() {
|
||||
val protonName = "proton-exp"
|
||||
proton(protonName)
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
|
||||
ComponentsManager.setGameComponent(context, "42", "dxvk", null)
|
||||
|
||||
assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk"))
|
||||
assertDefaultDxvkRestoreRow(protonName)
|
||||
}
|
||||
|
||||
@Test fun aNewProtonIsCapturedWhenTheLastOverrideIsReset() {
|
||||
proton("existing")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
ComponentsManager.snapshotAll(context)
|
||||
proton("new-proton")
|
||||
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
|
||||
assertDefaultDxvkRestoreRow("existing")
|
||||
assertDefaultDxvkRestoreRow("new-proton")
|
||||
}
|
||||
|
||||
@Test fun anUpdatedBuildIsCapturedEvenAfterTheLastProfileWasRemoved() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
proton("proton-exp", "experimental-12")
|
||||
|
||||
ComponentsManager.setGameComponent(context, "43", "dxvk", null)
|
||||
|
||||
assertDefaultDxvkRestoreRow("proton-exp", "experimental-12")
|
||||
assertTrue(original("proton-exp", "experimental-11").isFile)
|
||||
assertTrue(original("proton-exp", "experimental-12").isFile)
|
||||
}
|
||||
|
||||
@Test fun aKnownEmptyComponentHasAnExplicitOriginalBundle() {
|
||||
proton("proton-exp", dxvk = false)
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
|
||||
assertTrue(original("proton-exp").isFile)
|
||||
val rows = launchState().readLines().map { it.split('\t') }
|
||||
assertEquals(14, rows.size)
|
||||
assertTrue(rows.all { it[3] == "dxvk" && it[5] == "-" && it[6] == "-" })
|
||||
}
|
||||
|
||||
@Test fun missingOriginalsCannotBeRecapturedFromPreviouslyProfiledFiles() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
val saved = File(context.filesDir, "components/state.json").readText()
|
||||
val prior = "42\tproton-exp\texperimental-11\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t-\t-\n"
|
||||
launchState().apply { parentFile!!.mkdirs(); writeText(prior) }
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
ComponentsManager.setGameComponent(context, "42", "dxvk", null)
|
||||
}
|
||||
|
||||
assertEquals(saved, File(context.filesDir, "components/state.json").readText())
|
||||
assertEquals(prior, launchState().readText())
|
||||
assertFalse(original("proton-exp").isFile)
|
||||
}
|
||||
|
||||
@Test fun aBrokenOriginalDoesNotPublishDeletionRowsOrRemoveThePreviousStore() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val saved = state.readText()
|
||||
val prior = "*\tother\tbuild\tdxvk\tfiles/old.dll\told-hash\t/old.dll\n"
|
||||
launchState().apply { parentFile!!.mkdirs(); writeText(prior) }
|
||||
val oldSource = File(launchState().parentFile, "store/old/files/old.dll")
|
||||
oldSource.apply { parentFile!!.mkdirs(); writeText("old component") }
|
||||
original("proton-exp").apply { parentFile!!.mkdirs(); writeText("not a bundle") }
|
||||
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
}
|
||||
|
||||
assertEquals(saved, state.readText())
|
||||
assertEquals(prior, launchState().readText())
|
||||
assertEquals("old component", oldSource.readText())
|
||||
}
|
||||
|
||||
@Test fun anArchiveWithoutAProfileIsNotAKnownEmptyOriginal() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
val bundle = original("proton-exp")
|
||||
bundle.parentFile!!.mkdirs()
|
||||
TarArchiveOutputStream(ZstdOutputStream(bundle.outputStream())).use { }
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
}
|
||||
|
||||
assertFalse(launchState().exists())
|
||||
assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
}
|
||||
|
||||
@Test fun aFailedStateCommitIsRecoveredBeforeSelectionsCanBeRead() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val saved = state.readText()
|
||||
val blocked = File(context.filesDir, "components/state.json.tmp")
|
||||
blocked.mkdir()
|
||||
val blocker = File(blocked, "blocker").apply { writeText("keep directory nonempty") }
|
||||
val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeText("old component")
|
||||
}
|
||||
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
}
|
||||
|
||||
assertEquals(saved, state.readText())
|
||||
assertTrue(pendingState().isFile)
|
||||
assertTrue(launchState().isFile)
|
||||
assertEquals(Hashes.sha256(launchState()), JSONObject(pendingState().readText()).getString("launchSha256"))
|
||||
assertEquals("old component", oldSource.readText())
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
ComponentsManager.gameComponents(context, "42")
|
||||
}
|
||||
assertTrue(pendingState().isFile)
|
||||
|
||||
assertTrue(blocker.delete())
|
||||
assertTrue(blocked.delete())
|
||||
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
|
||||
assertFalse(pendingState().exists())
|
||||
assertDefaultDxvkRestoreRow("proton-exp")
|
||||
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
|
||||
ComponentsManager.snapshotAll(context)
|
||||
assertFalse(oldSource.exists())
|
||||
assertDefaultDxvkRestoreRow("proton-exp")
|
||||
}
|
||||
|
||||
@Test fun aFailedLaunchPublicationRetainsTheOldSelectionsAndStore() {
|
||||
proton("proton-exp")
|
||||
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val saved = state.readText()
|
||||
launchState().mkdirs()
|
||||
val blocker = File(launchState(), "blocker").apply { writeText("block rename") }
|
||||
val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeText("old component")
|
||||
}
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
}
|
||||
|
||||
assertEquals(saved, state.readText())
|
||||
assertTrue(pendingState().isFile)
|
||||
assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
assertFalse(pendingState().exists())
|
||||
assertEquals("old component", oldSource.readText())
|
||||
assertTrue(blocker.delete())
|
||||
assertTrue(launchState().delete())
|
||||
|
||||
ComponentsManager.clearGameComponents(context, "42")
|
||||
|
||||
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
|
||||
assertFalse(oldSource.exists())
|
||||
assertDefaultDxvkRestoreRow("proton-exp")
|
||||
}
|
||||
|
||||
@Test fun interruptionBeforePublicationDiscardsOnlyThePendingSelections() {
|
||||
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val saved = state.readText()
|
||||
val next = JSONObject(saved).apply {
|
||||
getJSONObject("profiles").getJSONObject("42").put("dxvk", "new-dxvk.wcp")
|
||||
}
|
||||
val staged = File(launchState().parentFile, "desired.tsv.tmp").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeText("new launch table")
|
||||
}
|
||||
launchState().writeText("old launch table")
|
||||
writePendingState(next, staged)
|
||||
|
||||
assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
assertEquals(saved, state.readText())
|
||||
assertEquals("old launch table", launchState().readText())
|
||||
assertFalse(pendingState().exists())
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test fun identicalLaunchTablesDoNotCommitUnpublishedSettings() {
|
||||
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val saved = state.readText()
|
||||
val next = JSONObject(saved).put("queued", JSONObject().put("proton-exp", JSONObject()))
|
||||
val staged = File(launchState().parentFile, "desired.tsv.tmp").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeText("unchanged launch table")
|
||||
}
|
||||
launchState().writeText(staged.readText())
|
||||
writePendingState(next, staged)
|
||||
|
||||
assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
assertEquals(saved, state.readText())
|
||||
assertFalse(pendingState().exists())
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test fun interruptionAfterPublicationRecoversAllSelectionsAndIsIdempotent() {
|
||||
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
|
||||
val next = JSONObject()
|
||||
.put("active", JSONObject().put("proton-exp", JSONObject().put("dxvk", JSONObject().put("file", dxvk2))))
|
||||
.put("queued", JSONObject().put("proton-exp", JSONObject().put("fex", JSONObject().put("file", "fex.wcp"))))
|
||||
.put("profiles", JSONObject()
|
||||
.put("42", JSONObject().put("dxvk", "new-dxvk.wcp"))
|
||||
.put("43", JSONObject().put("fex", "fex.wcp")))
|
||||
.put("profiledComponents", JSONArray(listOf("dxvk", "fex")))
|
||||
launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") }
|
||||
writePendingState(next, launchState())
|
||||
|
||||
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
assertEquals("fex.wcp", ComponentsManager.gameComponents(context, "43")["fex"])
|
||||
assertEquals(next.toString(2), File(context.filesDir, "components/state.json").readText())
|
||||
assertFalse(pendingState().exists())
|
||||
assertEquals("published launch table", launchState().readText())
|
||||
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
}
|
||||
|
||||
@Test fun aPendingTransactionAfterTheStateCommitCanBeRecoveredAgain() {
|
||||
writeGameProfile("42", "dxvk", "new-dxvk.wcp")
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
val next = JSONObject(state.readText())
|
||||
launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") }
|
||||
writePendingState(next, launchState())
|
||||
|
||||
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
|
||||
assertEquals(next.toString(2), state.readText())
|
||||
assertFalse(pendingState().exists())
|
||||
}
|
||||
|
||||
@Test fun anUnreadableRecoveryRecordDoesNotSilentlyReturnStaleSelections() {
|
||||
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
|
||||
pendingState().writeText("incomplete recovery record")
|
||||
|
||||
assertThrows(org.json.JSONException::class.java) {
|
||||
ComponentsManager.gameComponents(context, "42")
|
||||
}
|
||||
|
||||
assertTrue(pendingState().isFile)
|
||||
}
|
||||
|
||||
@Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() {
|
||||
proton("proton-exp")
|
||||
writeState("proton-exp", dxvk2, "experimental-11")
|
||||
@@ -484,57 +204,11 @@ class ForcedPackageTest {
|
||||
|
||||
private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file")
|
||||
|
||||
private fun writeGameProfile(scope: String, comp: String, file: String) {
|
||||
val state = File(context.filesDir, "components/state.json")
|
||||
state.parentFile!!.mkdirs()
|
||||
state.writeText(
|
||||
JSONObject()
|
||||
.put("active", JSONObject())
|
||||
.put("queued", JSONObject())
|
||||
.put("profiles", JSONObject().put(scope, JSONObject().put(comp, file)))
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchState() = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-components/desired.tsv")
|
||||
|
||||
private fun pendingState() = File(context.filesDir, "components/state.pending.json")
|
||||
|
||||
private fun writePendingState(state: JSONObject, launch: File) {
|
||||
pendingState().writeText(JSONObject()
|
||||
.put("state", state)
|
||||
.put("launchSha256", Hashes.sha256(launch))
|
||||
.toString())
|
||||
}
|
||||
|
||||
private fun original(protonName: String, version: String = "experimental-11") =
|
||||
File(context.filesDir, "components/originals/$protonName/$version/dxvk.wcp")
|
||||
|
||||
private fun assertDefaultDxvkRestoreRow(protonName: String, version: String = "experimental-11") {
|
||||
val row = launchState().readLines().map { it.split('\t') }.single {
|
||||
it.size == 7 && it[0] == "*" && it[1] == protonName && it[2] == version &&
|
||||
it[3] == "dxvk" && it[4].endsWith("/aarch64-windows/d3d11.dll")
|
||||
}
|
||||
assertNotEquals("-", row[6])
|
||||
val source = File(LinuxRuntime.rootDir(context), row[6].removePrefix("/"))
|
||||
assertEquals("bundled DXVK $version", source.readText())
|
||||
assertEquals(Hashes.sha256(source), row[5])
|
||||
assertTrue(original(protonName, version).isFile)
|
||||
val state = JSONObject(File(context.filesDir, "components/state.json").readText())
|
||||
assertEquals(listOf("dxvk"), state.getJSONArray("profiledComponents").let { values ->
|
||||
(0 until values.length()).map(values::getString)
|
||||
})
|
||||
}
|
||||
|
||||
private fun proton(name: String, version: String = "experimental-11", dxvk: Boolean = true) {
|
||||
private fun proton(name: String, version: String = "experimental-11") {
|
||||
val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name")
|
||||
File(dir, "files/lib/wine").mkdirs()
|
||||
File(dir, "proton").writeText("#!/bin/sh\n")
|
||||
File(dir, "version").writeText("1 $version\n")
|
||||
if (dxvk) File(dir, "files/lib/wine/dxvk/aarch64-windows/d3d11.dll").apply {
|
||||
parentFile!!.mkdirs()
|
||||
writeText("bundled DXVK $version")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeState(protonId: String, file: String, protonVersion: String) {
|
||||
|
||||
@@ -69,91 +69,6 @@ class GameEnvironmentStoreTest {
|
||||
SessionState.upscaleRatio = 0f
|
||||
}
|
||||
|
||||
@Test fun changingGameFexPresetPreservesDefaultAndOtherGameSettings() {
|
||||
SessionPrefs.setFexPreset(context, "PERFORMANCE_TSO")
|
||||
val original = GameEnvironment.withGameFexPreset(
|
||||
GameEnvironment.Config(
|
||||
shared = mapOf("PROTON_LOG" to "1"),
|
||||
games = mapOf("42" to mapOf("DXVK_HUD" to "fps")),
|
||||
),
|
||||
"43",
|
||||
"COMPATIBILITY",
|
||||
)
|
||||
GameEnvironmentStore.save(context, original)
|
||||
|
||||
GameEnvironmentStore.setGameFexPreset(context, "42", "STABILITY")
|
||||
|
||||
val saved = GameEnvironmentStore.read(context)
|
||||
assertEquals("STABILITY", GameEnvironment.gameFexPreset(saved, "42"))
|
||||
assertEquals("fps", saved.entries("42")["DXVK_HUD"])
|
||||
assertEquals(original.shared, saved.shared)
|
||||
assertEquals(original.entries("43"), saved.entries("43"))
|
||||
assertEquals("PERFORMANCE_TSO", SessionPrefs.fexPreset(context))
|
||||
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
|
||||
assertEquals(saved.games, published.games)
|
||||
assertEquals("0", published.shared["FEX_HALFBARRIERTSOENABLED"])
|
||||
assertEquals("1", published.entries("42")["FEX_HALFBARRIERTSOENABLED"])
|
||||
}
|
||||
|
||||
@Test fun gameFexDefaultsAndInheritanceRemainDistinct() {
|
||||
SessionPrefs.setFexPreset(context, "EXTREME")
|
||||
val original = GameEnvironment.Config(
|
||||
games = mapOf("42" to mapOf("DXVK_HUD" to "fps")),
|
||||
)
|
||||
GameEnvironmentStore.save(context, original)
|
||||
|
||||
GameEnvironmentStore.setGameFexPreset(context, "42", "")
|
||||
|
||||
val explicitDefaults = GameEnvironmentStore.read(context)
|
||||
assertEquals("", GameEnvironment.gameFexPreset(explicitDefaults, "42"))
|
||||
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
|
||||
assertTrue(published.entries("42").containsKey("FEX_SMCCHECKS"))
|
||||
assertNull(published.entries("42")["FEX_SMCCHECKS"])
|
||||
|
||||
GameEnvironmentStore.setGameFexPreset(context, "42", null)
|
||||
|
||||
assertEquals(original, GameEnvironmentStore.read(context))
|
||||
val inherited = GameEnvironmentStore.decode(JSONObject(guest.readText()))
|
||||
assertFalse(inherited.entries("42").containsKey("FEX_SMCCHECKS"))
|
||||
assertEquals("none", inherited.shared["FEX_SMCCHECKS"])
|
||||
assertEquals("EXTREME", SessionPrefs.fexPreset(context))
|
||||
}
|
||||
|
||||
@Test fun invalidGamePresetEditsCannotChangeSavedOrPublishedSettings() {
|
||||
val original = GameEnvironment.Config(shared = mapOf("PROTON_LOG" to "1"))
|
||||
GameEnvironmentStore.save(context, original)
|
||||
val published = guest.readText()
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameEnvironmentStore.setGameFexPreset(context, "", "STABILITY")
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameEnvironmentStore.setGameFexPreset(context, "42", GameEnvironment.CUSTOM_FEX_PRESET)
|
||||
}
|
||||
|
||||
assertEquals(original, GameEnvironmentStore.read(context))
|
||||
assertEquals(published, guest.readText())
|
||||
}
|
||||
|
||||
@Test fun targetedFexUpdateDoesNotRestoreEnvironmentClearedByACompletedReset() {
|
||||
val original = GameEnvironment.withGameFexPreset(
|
||||
GameEnvironment.Config(games = mapOf("42" to mapOf("DXVK_HUD" to "fps"))),
|
||||
"42",
|
||||
"COMPATIBILITY",
|
||||
)
|
||||
GameEnvironmentStore.save(context, original)
|
||||
val staleEditorSnapshot = GameEnvironmentStore.read(context)
|
||||
val resetEnvironment = staleEditorSnapshot.withEntries("42", emptyMap())
|
||||
GameEnvironmentStore.save(context, resetEnvironment)
|
||||
|
||||
GameEnvironmentStore.setGameFexPreset(context, "43", "PERFORMANCE_TSO")
|
||||
|
||||
val saved = GameEnvironmentStore.read(context)
|
||||
assertTrue(saved.entries("42").isEmpty())
|
||||
assertEquals("PERFORMANCE_TSO", GameEnvironment.gameFexPreset(saved, "43"))
|
||||
assertFalse(saved.games.containsKey("42"))
|
||||
}
|
||||
|
||||
@Test fun unchosenFexPresetPublishesPerformanceTso() {
|
||||
GameEnvironmentStore.save(context, GameEnvironment.Config())
|
||||
val shared = JSONObject(guest.readText()).getJSONObject("shared")
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GameProfileFollowStateTest {
|
||||
@Test fun reopeningGamesPageResumesFollowingSteamAfterManualInspection() {
|
||||
val state = GameProfileFollowState()
|
||||
|
||||
state.selectManually()
|
||||
assertFalse(state.followsSteam)
|
||||
|
||||
state.resumeFollowingSteam()
|
||||
assertTrue(state.followsSteam)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class GameProfileInventoryCacheTest {
|
||||
@Test
|
||||
fun loadsOnlyOncePerRevision() {
|
||||
val cache = GameProfileInventoryCache<String>()
|
||||
var loads = 0
|
||||
|
||||
assertEquals(listOf("one"), cache.get(0) { loads++; listOf("one") })
|
||||
assertEquals(listOf("one"), cache.get(0) { loads++; listOf("two") })
|
||||
assertEquals(1, loads)
|
||||
|
||||
assertEquals(listOf("two"), cache.get(1) { loads++; listOf("two") })
|
||||
assertEquals(2, loads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedLoadIsRetriedInsteadOfCached() {
|
||||
val cache = GameProfileInventoryCache<String>()
|
||||
var loads = 0
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
cache.get(0) { loads++; error("inventory unavailable") }
|
||||
}
|
||||
|
||||
assertEquals(listOf("recovered"), cache.get(0) { loads++; listOf("recovered") })
|
||||
assertEquals(2, loads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedNewRevisionDoesNotReuseThePreviousInventory() {
|
||||
val cache = GameProfileInventoryCache<String>()
|
||||
assertEquals(listOf("old"), cache.get(0) { listOf("old") })
|
||||
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
cache.get(1) { error("inventory unavailable") }
|
||||
}
|
||||
|
||||
assertEquals(listOf("new"), cache.get(1) { listOf("new") })
|
||||
assertEquals(listOf("new"), cache.get(1) { error("should use cached inventory") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyInventoryIsCachedUntilTheRevisionChanges() {
|
||||
val cache = GameProfileInventoryCache<String>()
|
||||
|
||||
assertEquals(emptyList<String>(), cache.get(0) { emptyList() })
|
||||
assertEquals(emptyList<String>(), cache.get(0) { error("should use cached inventory") })
|
||||
assertEquals(listOf("installed"), cache.get(1) { listOf("installed") })
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000)
|
||||
fun reopeningDuringAScanQueuesFreshInventoryWithoutWaitingAndRejectsOldResults() {
|
||||
data class Refresh(val appId: Long, val inventoryRevision: Long)
|
||||
|
||||
val cache = GameProfileInventoryCache<String>()
|
||||
val coordinator = LatestRequestCoordinator<Refresh>()
|
||||
val scanStarted = CountDownLatch(1)
|
||||
val finishScan = CountDownLatch(1)
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
assertTrue(coordinator.submit(Refresh(42, 0)))
|
||||
val first = coordinator.takeLatest()!!
|
||||
val oldInventory = worker.submit<List<String>> {
|
||||
cache.get(first.value.inventoryRevision) {
|
||||
scanStarted.countDown()
|
||||
check(finishScan.await(5, TimeUnit.SECONDS))
|
||||
listOf("old")
|
||||
}
|
||||
}
|
||||
assertTrue(scanStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
assertFalse(coordinator.submit(Refresh(42, 1)))
|
||||
assertFalse(oldInventory.isDone)
|
||||
assertFalse(coordinator.isLatest(first))
|
||||
|
||||
finishScan.countDown()
|
||||
assertEquals(listOf("old"), oldInventory.get(5, TimeUnit.SECONDS))
|
||||
assertFalse(coordinator.isLatest(first))
|
||||
val reopened = coordinator.takeLatest()!!
|
||||
val newInventory = worker.submit<List<String>> {
|
||||
cache.get(reopened.value.inventoryRevision) { listOf("new") }
|
||||
}
|
||||
assertEquals(listOf("new"), newInventory.get(5, TimeUnit.SECONDS))
|
||||
assertTrue(coordinator.isLatest(reopened))
|
||||
assertNull(coordinator.takeLatest())
|
||||
} finally {
|
||||
finishScan.countDown()
|
||||
worker.shutdownNow()
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class GameProfileManagerTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
File(context.filesDir, "game-environment.json").delete()
|
||||
File(context.filesDir, "wincomponents.json").delete()
|
||||
File(context.filesDir, "components").deleteRecursively()
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetClearsOnlyTheSelectedGameProfile() {
|
||||
val game = "42"
|
||||
val other = "43"
|
||||
GameEnvironmentStore.save(
|
||||
context,
|
||||
GameEnvironment.Config(
|
||||
games = mapOf(
|
||||
game to mapOf("DXVK_HUD" to "fps"),
|
||||
other to mapOf("DXVK_HUD" to "compiler"),
|
||||
),
|
||||
),
|
||||
)
|
||||
ProtonDefault.requestGame(context, game, proton("game"))
|
||||
ProtonDefault.requestGame(context, other, proton("other"))
|
||||
seedComponentProfiles(game, other)
|
||||
SessionPrefs.setGameTextureAnisotropy(context, game, 4)
|
||||
SessionPrefs.setGameTextureAnisotropy(context, other, 8)
|
||||
WinComponents.setPicks(context, game, listOf("vcrun2022"))
|
||||
WinComponents.setPicks(context, other, listOf("dotnet48"))
|
||||
|
||||
GameProfileManager.reset(context, game)
|
||||
|
||||
val environment = GameEnvironmentStore.read(context)
|
||||
assertFalse(environment.games.containsKey(game))
|
||||
assertEquals("compiler", environment.entries(other)["DXVK_HUD"])
|
||||
assertNull(ProtonDefault.gameChoice(context, game))
|
||||
assertEquals("other", ProtonDefault.gameChoice(context, other)?.dir)
|
||||
assertEquals(emptyMap<String, String>(), ComponentsManager.gameComponents(context, game))
|
||||
assertEquals("other.wcp", ComponentsManager.gameComponents(context, other)["dxvk"])
|
||||
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, game))
|
||||
assertEquals(8, SessionPrefs.gameTextureFiltering(context, other).anisotropy)
|
||||
assertEquals(emptyList<String>(), WinComponents.picks(context, game))
|
||||
assertEquals(listOf("dotnet48"), WinComponents.picks(context, other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetRejectsEmptyScopeWithoutChangingSharedSettings() {
|
||||
GameEnvironmentStore.save(
|
||||
context,
|
||||
GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps")),
|
||||
)
|
||||
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
GameProfileManager.reset(context, "")
|
||||
}
|
||||
assertEquals("fps", GameEnvironmentStore.read(context).shared["DXVK_HUD"])
|
||||
}
|
||||
|
||||
private fun proton(name: String) = ComponentsManager.Proton(
|
||||
id = name,
|
||||
name = name,
|
||||
dir = File(context.filesDir, name),
|
||||
guestPath = "/proton/$name",
|
||||
version = "1",
|
||||
valve = false,
|
||||
)
|
||||
|
||||
private fun seedComponentProfiles(game: String, other: String) {
|
||||
val components = File(context.filesDir, "components").apply { mkdirs() }
|
||||
File(components, "state.json").writeText(
|
||||
JSONObject().put(
|
||||
"profiles",
|
||||
JSONObject()
|
||||
.put(game, JSONObject().put("dxvk", "game.wcp"))
|
||||
.put(other, JSONObject().put("dxvk", "other.wcp")),
|
||||
).toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GameProfileRefreshStateTest {
|
||||
@Test
|
||||
fun firstFailureKeepsInitialStateWithoutCachedData() {
|
||||
val state = GameProfileRefreshState<String>().failed()
|
||||
|
||||
assertTrue(state.failed)
|
||||
assertFalse(state.loaded)
|
||||
assertNull(state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failureAfterSuccessPreservesCachedData() {
|
||||
val state = GameProfileRefreshState<String>().succeeded("cached profiles").failed()
|
||||
|
||||
assertTrue(state.failed)
|
||||
assertTrue(state.loaded)
|
||||
assertEquals("cached profiles", state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successAfterFailureReplacesCachedDataAndClearsWarning() {
|
||||
val state = GameProfileRefreshState<String>()
|
||||
.succeeded("old profiles")
|
||||
.failed()
|
||||
.succeeded("new profiles")
|
||||
|
||||
assertFalse(state.failed)
|
||||
assertTrue(state.loaded)
|
||||
assertEquals("new profiles", state.data)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryingClearsWarningWithoutDiscardingCachedData() {
|
||||
val state = GameProfileRefreshState<String>().succeeded("cached profiles").failed().retrying()
|
||||
|
||||
assertFalse(state.failed)
|
||||
assertTrue(state.loaded)
|
||||
assertEquals("cached profiles", state.data)
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LatestRequestCoordinatorTest {
|
||||
@Test fun coalescesPendingRequestsToTheLatestValue() {
|
||||
val coordinator = LatestRequestCoordinator<String>()
|
||||
|
||||
assertTrue(coordinator.submit("first"))
|
||||
val first = coordinator.takeLatest()!!
|
||||
assertFalse(coordinator.submit("second"))
|
||||
assertFalse(coordinator.submit("third"))
|
||||
|
||||
assertFalse(coordinator.isLatest(first))
|
||||
val latest = coordinator.takeLatest()!!
|
||||
assertEquals("third", latest.value)
|
||||
assertTrue(coordinator.isLatest(latest))
|
||||
assertNull(coordinator.takeLatest())
|
||||
}
|
||||
|
||||
@Test fun requestAfterWorkerBecomesIdleStartsAnotherWorker() {
|
||||
val coordinator = LatestRequestCoordinator<String>()
|
||||
|
||||
assertTrue(coordinator.submit("first"))
|
||||
coordinator.takeLatest()
|
||||
assertNull(coordinator.takeLatest())
|
||||
|
||||
assertTrue(coordinator.submit("second"))
|
||||
assertEquals("second", coordinator.takeLatest()!!.value)
|
||||
}
|
||||
|
||||
@Test fun newerRequestInvalidatesCompletedResultBeforeItIsApplied() {
|
||||
val coordinator = LatestRequestCoordinator<String>()
|
||||
|
||||
assertTrue(coordinator.submit("first"))
|
||||
val completed = coordinator.takeLatest()!!
|
||||
assertFalse(coordinator.submit("second"))
|
||||
|
||||
assertFalse(coordinator.isLatest(completed))
|
||||
assertEquals("second", coordinator.takeLatest()!!.value)
|
||||
}
|
||||
|
||||
@Test fun profileSwitchInvalidatesAnOlderFailedRefresh() {
|
||||
val coordinator = LatestRequestCoordinator<Long?>()
|
||||
|
||||
assertTrue(coordinator.submit(42L))
|
||||
val failedRefresh = coordinator.takeLatest()!!
|
||||
assertFalse(coordinator.submit(43L))
|
||||
|
||||
assertFalse(coordinator.isLatest(failedRefresh))
|
||||
assertEquals(43L, coordinator.takeLatest()!!.value)
|
||||
}
|
||||
|
||||
@Test fun repeatedPollDoesNotInvalidateEquivalentInFlightResult() {
|
||||
val coordinator = LatestRequestCoordinator<String>()
|
||||
|
||||
assertTrue(coordinator.submit("same"))
|
||||
val inFlight = coordinator.takeLatest()!!
|
||||
assertFalse(coordinator.submit("same"))
|
||||
|
||||
assertTrue(coordinator.isLatest(inFlight))
|
||||
assertEquals("same", coordinator.takeLatest()!!.value)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -8,7 +7,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@@ -61,46 +59,4 @@ class ProtonDefaultTest {
|
||||
assertNull(ProtonDefault.chosen(ProtonDefault.parseState("{"), ProtonDefault.parseRequest("[]")))
|
||||
assertNull(ProtonDefault.parseRequest("""{"seq": 0, "dir": "x"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perGameChoicesPreserveCustomizeAndInheritSemantics() {
|
||||
val games = ProtonDefault.parseGames(
|
||||
"""{"version":1,"games":{"42":{"valve":false,"dir":"GE-Proton11-7"},"43":null,"0":null,"bad":{},"44":{"valve":"false","dir":"GE-Proton9-1"}}}""",
|
||||
)
|
||||
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), games["42"])
|
||||
assertTrue(games.containsKey("43"))
|
||||
assertNull(games["43"])
|
||||
assertFalse(games.containsKey("0"))
|
||||
assertFalse(games.containsKey("bad"))
|
||||
assertFalse(games.containsKey("44"))
|
||||
assertTrue(ProtonDefault.parseGames("""{"version":2,"games":{"42":null}}""").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun perGameRequestsPersistCustomizeThenInherit() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json")
|
||||
file.parentFile?.deleteRecursively()
|
||||
val selected = proton("GE-Proton11-7", false)
|
||||
|
||||
ProtonDefault.requestGame(context, "42", selected)
|
||||
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), ProtonDefault.parseGames(file.readText())["42"])
|
||||
|
||||
ProtonDefault.requestGame(context, "42", null)
|
||||
val inherited = ProtonDefault.parseGames(file.readText())
|
||||
assertTrue(inherited.containsKey("42"))
|
||||
assertNull(inherited["42"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPerGameProtonIdentityRemainsStoredButUnresolved() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json")
|
||||
file.parentFile?.deleteRecursively()
|
||||
ProtonDefault.requestGame(context, "42", proton("GE-Proton9-1", false))
|
||||
|
||||
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.gameChoice(context, "42"))
|
||||
assertNull(ProtonDefault.gameSelectedId(context, "42", listOf(proton("GE-Proton11-7", false))))
|
||||
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.parseGames(file.readText())["42"])
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class ProtonExtrasTest {
|
||||
@get:Rule val temp = TemporaryFolder()
|
||||
|
||||
private fun tool(id: String) = ProtonExtras.tools.single { it.id == id }
|
||||
|
||||
private fun asset(name: String, digest: String? = null) = JSONObject()
|
||||
.put("name", name)
|
||||
.put("browser_download_url", "https://github.com/o/r/releases/download/t/$name")
|
||||
.put("size", 625_775_670L)
|
||||
.apply { if (digest != null) put("digest", digest) }
|
||||
|
||||
private fun release(tag: String, vararg assets: JSONObject, prerelease: Boolean = false, draft: Boolean = false) = JSONObject()
|
||||
.put("tag_name", tag).put("prerelease", prerelease).put("draft", draft)
|
||||
.put("assets", JSONArray().apply { assets.forEach { put(it) } })
|
||||
|
||||
private fun droiddeckRelease(ver: String, prerelease: Boolean = false) = release(
|
||||
"DroidDeck-Proton-$ver",
|
||||
asset("DroidDeck-Proton-$ver-aarch64.build-info.json"),
|
||||
asset("DroidDeck-Proton-$ver-aarch64.sha512sum"),
|
||||
asset("DroidDeck-Proton-$ver-aarch64.tar.gz"),
|
||||
prerelease = prerelease,
|
||||
)
|
||||
|
||||
private fun installTool(parent: File, name: String) = File(parent, name).apply {
|
||||
mkdirs()
|
||||
File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theThreeSourcesInOrder() {
|
||||
assertEquals(listOf("ge", "cachyos", "droiddeck"), ProtonExtras.tools.map { it.id })
|
||||
val droiddeck = tool("droiddeck")
|
||||
assertEquals("DroidDeck-Proton", droiddeck.name)
|
||||
assertEquals("DroidDeck-Proton", droiddeck.prefix)
|
||||
assertEquals("The412Banner/DroidDeck-Proton", droiddeck.repo)
|
||||
assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.tar.gz"))
|
||||
assertTrue(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-8-aarch64.tar.xz"))
|
||||
assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.sha512sum"))
|
||||
assertFalse(droiddeck.assetPattern.containsMatchIn("DroidDeck-Proton-11-7.5-aarch64.build-info.json"))
|
||||
assertFalse(tool("cachyos").assetPattern.containsMatchIn("proton-cachyos-11.0-20261005-slr-x86_64.tar.xz"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTarballAndItsSha512FileArePicked() {
|
||||
val picked = ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-7.5")))!!
|
||||
assertEquals("DroidDeck-Proton-11-7.5", picked.tag)
|
||||
assertEquals("DroidDeck-Proton-11-7.5-aarch64.tar.gz", picked.name)
|
||||
assertEquals("https://github.com/o/r/releases/download/t/DroidDeck-Proton-11-7.5-aarch64.sha512sum", picked.sha512)
|
||||
assertEquals(625_775_670L, picked.size)
|
||||
assertNull(picked.sha256)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun githubsDigestIsKeptBesideTheSha512File() {
|
||||
val sum = "ab".repeat(32)
|
||||
val release = release("GE-Proton11-7", asset("GE-Proton11-7-x86_64.tar.gz"), asset("GE-Proton11-7-aarch64.sha512sum"),
|
||||
asset("GE-Proton11-7-aarch64.tar.gz", "sha256:$sum"))
|
||||
val picked = ProtonExtras.pickAsset(tool("ge"), JSONArray().put(release))!!
|
||||
assertEquals("GE-Proton11-7-aarch64.tar.gz", picked.name)
|
||||
assertEquals(sum, picked.sha256)
|
||||
assertTrue(picked.sha512!!.endsWith("GE-Proton11-7-aarch64.sha512sum"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prereleasesDraftsAndReleasesWithoutAnArm64BuildAreSkipped() {
|
||||
val releases = JSONArray()
|
||||
.put(droiddeckRelease("11-8", prerelease = true))
|
||||
.put(release("DroidDeck-Proton-11-7.9", asset("DroidDeck-Proton-11-7.9-aarch64.tar.gz"), draft = true))
|
||||
.put(release("DroidDeck-Proton-11-7.8", asset("DroidDeck-Proton-11-7.8-x86_64.tar.gz")))
|
||||
.put(droiddeckRelease("11-7.5"))
|
||||
assertEquals("DroidDeck-Proton-11-7.5", ProtonExtras.pickAsset(tool("droiddeck"), releases)?.tag)
|
||||
assertNull(ProtonExtras.pickAsset(tool("droiddeck"), JSONArray().put(droiddeckRelease("11-8", prerelease = true))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionsCompareByTheirNumbers() {
|
||||
val sorted = listOf("GE-Proton11-10", "GE-Proton10-25", "GE-Proton11-9", "GE-Proton11-1")
|
||||
.sortedWith { a, b -> ProtonExtras.compareVersions(a, b) }
|
||||
assertEquals(listOf("GE-Proton10-25", "GE-Proton11-1", "GE-Proton11-9", "GE-Proton11-10"), sorted)
|
||||
assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10") < 0)
|
||||
assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-8") < 0)
|
||||
assertTrue(ProtonExtras.compareVersions("DroidDeck-Proton 11-7", "DroidDeck-Proton 11-7.1") < 0)
|
||||
assertTrue(ProtonExtras.compareVersions("cachyos-11.0-20260703-slr", "cachyos-11.0-20261005-slr") < 0)
|
||||
assertTrue(ProtonExtras.compareVersions("cachyos-10.0-sunset-slr", "cachyos-11.0-20260601-slr") < 0)
|
||||
// The architecture in a folder name is not part of its version.
|
||||
assertEquals(listOf(11L, 7L), ProtonExtras.versionKey("GE-Proton11-7-aarch64"))
|
||||
assertEquals(listOf(11L, 0L, 20261005L), ProtonExtras.versionKey("proton-cachyos-11.0-20261005-slr-arm64"))
|
||||
assertEquals(listOf(11L, 7L, 5L), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5"))
|
||||
assertEquals(ProtonExtras.versionKey("DroidDeck-Proton-11-7.5"), ProtonExtras.versionKey("DroidDeck-Proton 11-7.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun installedBuildsAreFoundByPrefixNewestLast() {
|
||||
val tools = temp.newFolder("compatibilitytools.d")
|
||||
for (name in listOf("GE-Proton11-10", "GE-Proton11-9", "DroidDeck-Proton 11-7.10", "DroidDeck-Proton 11-7.5",
|
||||
"droiddeck-proton-arm64", "droiddeck-proton-11-arm64", "proton-cachyos-11.0-20261005-slr-arm64")) installTool(tools, name)
|
||||
File(tools, "DroidDeck-Proton 11-6").mkdirs() // no manifest: half unpacked, not installed
|
||||
assertEquals(listOf("GE-Proton11-9", "GE-Proton11-10"), ProtonExtras.installedIn(tools, tool("ge")))
|
||||
// Ours, not DroidDeck's own droiddeck-proton-* tools: the prefix is matched with its case.
|
||||
assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedIn(tools, tool("droiddeck")))
|
||||
assertEquals(listOf("proton-cachyos-11.0-20261005-slr-arm64"), ProtonExtras.installedIn(tools, tool("cachyos")))
|
||||
assertEquals(emptyList<String>(), ProtonExtras.installedIn(File(tools, "missing"), tool("ge")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUpdateIsANewerReleaseThanTheNewestInstalledBuild() {
|
||||
assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.6"))
|
||||
assertEquals("DroidDeck-Proton-11-7.10", ProtonExtras.updateFor(listOf("DroidDeck-Proton-11-7.9"), "DroidDeck-Proton-11-7.10"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.5"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6"), "DroidDeck-Proton-11-7.6"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-10"), "GE-Proton11-9"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64"), "GE-Proton11-7"))
|
||||
assertEquals("GE-Proton11-10", ProtonExtras.updateFor(listOf("GE-Proton11-7-aarch64", "GE-Proton11-9"), "GE-Proton11-10"))
|
||||
assertEquals("cachyos-11.0-20261101-slr", ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-11.0-20261101-slr"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("proton-cachyos-11.0-20261005-slr-arm64"), "cachyos-10.0-sunset-slr"))
|
||||
assertNull(ProtonExtras.updateFor(emptyList(), "GE-Proton11-10"))
|
||||
assertNull(ProtonExtras.updateFor(listOf("GE-Proton11-7"), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRecentCheckIsReusedAndComparedWithTheInstalledBuild() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d")
|
||||
installTool(tools, "DroidDeck-Proton 11-7.5")
|
||||
installTool(tools, "GE-Proton11-7")
|
||||
val now = 1_800_000_000_000L
|
||||
val latest = JSONObject()
|
||||
.put("ge", JSONObject().put("tag", "GE-Proton11-7").put("at", now - 60_000))
|
||||
.put("cachyos", JSONObject().put("tag", "cachyos-11.0-20261005-slr").put("at", now - 60_000))
|
||||
.put("droiddeck", JSONObject().put("tag", "DroidDeck-Proton-11-7.6").put("at", now - ProtonExtras.CHECK_EVERY_MS + 60_000))
|
||||
File(context.filesDir, "proton-latest.json").writeText(latest.toString())
|
||||
// Every source was looked up within the last six hours: nothing goes to GitHub.
|
||||
assertFalse(ProtonExtras.checkLatest(context, now = now))
|
||||
assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.update(context, tool("droiddeck")))
|
||||
assertNull(ProtonExtras.update(context, tool("ge")))
|
||||
assertNull(ProtonExtras.update(context, tool("cachyos")))
|
||||
assertEquals(listOf("DroidDeck-Proton 11-7.5"), ProtonExtras.installedTags(context, tool("droiddeck")))
|
||||
// The tag recorded at install stands for the folder.
|
||||
File(context.filesDir, "proton-extras.json").writeText(JSONObject().put("DroidDeck-Proton 11-7.5", "DroidDeck-Proton-11-7.6").toString())
|
||||
assertEquals(listOf("DroidDeck-Proton-11-7.6"), ProtonExtras.installedTags(context, tool("droiddeck")))
|
||||
assertNull(ProtonExtras.update(context, tool("droiddeck")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingDeletesOneBuildOrEveryOlderOne() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val tools = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/compatibilitytools.d")
|
||||
for (name in listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.6", "DroidDeck-Proton 11-7.10", "GE-Proton11-7")) installTool(tools, name)
|
||||
ProtonExtras.remove(context, tool("droiddeck"), "DroidDeck-Proton 11-7.6")
|
||||
assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck")))
|
||||
// A folder of another source is never removed through this one.
|
||||
ProtonExtras.remove(context, tool("droiddeck"), "GE-Proton11-7")
|
||||
assertTrue(File(tools, "GE-Proton11-7").isDirectory)
|
||||
assertEquals(listOf("DroidDeck-Proton 11-7.5", "DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck")))
|
||||
ProtonExtras.removeOlder(context, tool("droiddeck"))
|
||||
assertEquals(listOf("DroidDeck-Proton 11-7.10"), ProtonExtras.installedAll(context, tool("droiddeck")))
|
||||
assertEquals("DroidDeck-Proton 11-7.10", ProtonExtras.installed(context, tool("droiddeck")))
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadows.ShadowBuild
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class QuestExperimentalTest {
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun reset() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
ShadowBuild.setModel("Phone")
|
||||
ShadowBuild.setDevice("phone")
|
||||
}
|
||||
|
||||
@Test fun otherDevicesAndQuest2KeepTheirSettings() {
|
||||
for (model in listOf("Phone", "Quest 2")) {
|
||||
ShadowBuild.setModel(model)
|
||||
QuestExperimental.initialize(context)
|
||||
assertEquals(SessionPrefs.GPU_DRIVERS_AUTO, SessionPrefs.gpuDriverMode(context))
|
||||
assertEquals("", SessionPrefs.androidDriver(context))
|
||||
assertFalse(SessionPrefs.hdr(context, "steam"))
|
||||
}
|
||||
assertFalse(QuestExperimental.supported("Quest 2", "hollywood", "Oculus"))
|
||||
}
|
||||
|
||||
@Test fun questDefaultsSelectBothCompatibilityPathsAndAreAppliedOnlyOnce() {
|
||||
ShadowBuild.setModel("Quest 3")
|
||||
QuestExperimental.initialize(context)
|
||||
assertEquals(SessionPrefs.GPU_DRIVERS_MANUAL, SessionPrefs.gpuDriverMode(context))
|
||||
assertEquals(QuestExperimental.DRIVER, SessionPrefs.androidDriver(context))
|
||||
assertEquals("", SessionPrefs.linuxDriver(context))
|
||||
assertTrue(SessionPrefs.hdr(context, "steam"))
|
||||
assertTrue(context.filesDir.resolve("quest-startup.txt").readText().contains("model=Quest 3"))
|
||||
SessionPrefs.setAndroidDriver(context, "chosen-driver")
|
||||
SessionPrefs.setHdr(context, "steam", false)
|
||||
QuestExperimental.initialize(context)
|
||||
assertEquals("chosen-driver", SessionPrefs.androidDriver(context))
|
||||
assertFalse(SessionPrefs.hdr(context, "steam"))
|
||||
}
|
||||
|
||||
@Test fun quest3sIsRecognizedByModelOrDevice() {
|
||||
assertTrue(QuestExperimental.supported("Quest 3S", "unknown", "Oculus"))
|
||||
assertTrue(QuestExperimental.supported("unknown", "panther", "Oculus"))
|
||||
assertTrue(QuestExperimental.supported("unknown", "eureka", "Meta"))
|
||||
assertFalse(QuestExperimental.supported("Pixel 7", "panther", "Google"))
|
||||
}
|
||||
|
||||
@Test fun installedPackagesUseSeparateSandboxSockets() {
|
||||
val quest = object : ContextWrapper(context) {
|
||||
override fun getPackageName() = "com.droiddeck.quest"
|
||||
}
|
||||
assertEquals("com.droiddeck.quest.bwrap", BwrapSpawner.socket(quest))
|
||||
assertNotEquals(BwrapSpawner.socket(context), BwrapSpawner.socket(quest))
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class SelectedGameProfileTest {
|
||||
private val proton = ComponentsManager.Proton("p1", "Proton 10", File("p1"), "/p1", "10", true)
|
||||
private val snapshot = ComponentsManager.Snapshot(
|
||||
protons = listOf(
|
||||
ComponentsManager.ProtonView(
|
||||
proton = proton,
|
||||
components = mapOf(
|
||||
"fex" to component("FEX General"),
|
||||
"dxvk" to component("DXVK General"),
|
||||
"vkd3d" to component("VKD3D General"),
|
||||
),
|
||||
originals = emptyList(),
|
||||
inUseByGame = false,
|
||||
),
|
||||
),
|
||||
packages = listOf(ComponentsManager.Package("dxvk.wcp", "dxvk", "DXVK Game", "", 1)),
|
||||
)
|
||||
private val game = Library.SteamGame(3180310, "Billies Wheelie", null, "internal")
|
||||
|
||||
@Test fun inheritedValuesUseGeneralProfile() {
|
||||
val profile = resolve()
|
||||
|
||||
assertEquals("Billies Wheelie", profile!!.name)
|
||||
assertEquals("PERFORMANCE_TSO", profile.fexPreset.value)
|
||||
assertTrue(profile.fexPreset.inherited)
|
||||
assertEquals("Proton 10", profile.proton.value)
|
||||
assertTrue(profile.proton.inherited)
|
||||
assertEquals("p1", profile.protonId)
|
||||
assertEquals("DXVK General", profile.components.getValue("dxvk").value)
|
||||
assertTrue(profile.components.getValue("dxvk").inherited)
|
||||
assertEquals(emptyMap<String, String>(), profile.componentFiles)
|
||||
assertEquals(0, profile.environmentOverrides)
|
||||
assertEquals(emptyList<String>(), profile.windowsComponentNames)
|
||||
assertFalse(profile.hasOverrides)
|
||||
}
|
||||
|
||||
@Test fun explicitValuesOverrideGeneralProfile() {
|
||||
val preset = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), game.profileKey, "STABILITY")
|
||||
val environment = preset.withEntries(game.profileKey, preset.entries(game.profileKey) + ("PROTON_LOG" to "1"))
|
||||
val profile = resolve(
|
||||
environment = environment,
|
||||
gameChoice = ProtonDefault.GameChoice(true, "p1"),
|
||||
gameComponents = mapOf("dxvk" to "dxvk.wcp"),
|
||||
)
|
||||
|
||||
assertEquals("STABILITY", profile!!.fexPreset.value)
|
||||
assertFalse(profile.fexPreset.inherited)
|
||||
assertFalse(profile.proton.inherited)
|
||||
assertEquals("DXVK Game", profile.components.getValue("dxvk").value)
|
||||
assertFalse(profile.components.getValue("dxvk").inherited)
|
||||
assertEquals("dxvk.wcp", profile.componentFiles.getValue("dxvk"))
|
||||
assertEquals(1, profile.environmentOverrides)
|
||||
assertTrue(profile.hasOverrides)
|
||||
}
|
||||
|
||||
@Test fun windowsComponentsMakeProfileCustom() {
|
||||
val profile = resolve(gameWindowsComponents = listOf("oalinst_dll", "vcredist2010_dll"))
|
||||
|
||||
assertEquals(listOf("OpenAL", "Visual C++ 2010"), profile!!.windowsComponentNames)
|
||||
assertTrue(profile.hasOverrides)
|
||||
}
|
||||
|
||||
@Test fun unknownSelectedAppIsIgnored() {
|
||||
assertNull(resolve(selectedAppId = 42))
|
||||
}
|
||||
|
||||
@Test fun switchingProfilesResolvesEachGamesFexPresetWithoutChangingSettings() {
|
||||
val other = Library.SteamGame(42, "Other game", null, "internal")
|
||||
val games = listOf(game, other)
|
||||
val environment = GameEnvironment.withGameFexPreset(
|
||||
GameEnvironment.Config(), game.profileKey, "STABILITY",
|
||||
)
|
||||
|
||||
assertEquals(ProfileValue("STABILITY", false),
|
||||
resolve(games = games, environment = environment)!!.fexPreset)
|
||||
assertEquals(ProfileValue("PERFORMANCE_TSO", true),
|
||||
resolve(selectedAppId = 42, games = games, environment = environment)!!.fexPreset)
|
||||
assertEquals(ProfileValue("STABILITY", false),
|
||||
resolve(games = games, environment = environment)!!.fexPreset)
|
||||
assertNull(GameEnvironment.gameFexPreset(environment, other.profileKey))
|
||||
}
|
||||
|
||||
@Test fun customAndExplicitDefaultFexPresetsAreNotShownAsInherited() {
|
||||
val custom = GameEnvironment.Config(
|
||||
games = mapOf(game.profileKey to mapOf("FEX_TSOENABLED" to "0")),
|
||||
)
|
||||
assertEquals(ProfileValue(GameEnvironment.CUSTOM_FEX_PRESET, false),
|
||||
resolve(environment = custom)!!.fexPreset)
|
||||
val defaults = GameEnvironment.withGameFexPreset(custom, game.profileKey, "")
|
||||
assertEquals(ProfileValue("", false), resolve(environment = defaults)!!.fexPreset)
|
||||
val inherited = GameEnvironment.withGameFexPreset(defaults, game.profileKey, null)
|
||||
assertEquals(ProfileValue("PERFORMANCE_TSO", true), resolve(environment = inherited)!!.fexPreset)
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
selectedAppId: Long = 3180310,
|
||||
games: List<Library.SteamGame> = listOf(game),
|
||||
environment: GameEnvironment.Config = GameEnvironment.Config(),
|
||||
gameChoice: ProtonDefault.GameChoice? = null,
|
||||
gameComponents: Map<String, String> = emptyMap(),
|
||||
gameWindowsComponents: List<String> = emptyList(),
|
||||
) = SelectedGameProfile.resolve(
|
||||
selectedAppId = selectedAppId,
|
||||
games = games,
|
||||
generalFexPreset = "PERFORMANCE_TSO",
|
||||
environment = environment,
|
||||
snapshot = snapshot,
|
||||
generalProtonId = "p1",
|
||||
gameProtonChoice = gameChoice,
|
||||
gameProtonId = "p1",
|
||||
gameComponents = gameComponents,
|
||||
gameWindowsComponents = gameWindowsComponents,
|
||||
)
|
||||
|
||||
private fun component(label: String) =
|
||||
ComponentsManager.Component(label, "", "Changed outside Components", label, null, null)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class TextureFilteringPrefsTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
File(context.filesDir, "game-environment.json").delete()
|
||||
File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").delete()
|
||||
}
|
||||
|
||||
@Test fun gameChoicesRemainSeparateFromDefaultAndCanInheritAgain() {
|
||||
SessionPrefs.setTextureAnisotropy(context, 16)
|
||||
SessionPrefs.setTextureLodBias(context, TextureFiltering.LOD_BIAS_AUTO)
|
||||
SessionPrefs.setGameTextureAnisotropy(context, "42", 4)
|
||||
SessionPrefs.setGameTextureLodBias(context, "42", "-0.5")
|
||||
|
||||
assertEquals(16, SessionPrefs.textureAnisotropy(context))
|
||||
assertEquals(TextureFiltering.LOD_BIAS_AUTO, SessionPrefs.textureLodBias(context))
|
||||
assertEquals(SessionPrefs.GameTextureFiltering(4, "-0.5"), SessionPrefs.gameTextureFiltering(context, "42"))
|
||||
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "43"))
|
||||
|
||||
SessionPrefs.setGameTextureAnisotropy(context, "42", null)
|
||||
SessionPrefs.setGameTextureLodBias(context, "42", null)
|
||||
|
||||
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42"))
|
||||
assertFalse(SessionPrefs.gameTextureFiltering(context).containsKey("42"))
|
||||
}
|
||||
|
||||
@Test fun publishedDxvkOptionsUseGameOverridesWithoutChangingDefault() {
|
||||
SessionPrefs.setTextureAnisotropy(context, 16)
|
||||
SessionPrefs.setGameTextureAnisotropy(context, "42", 0)
|
||||
SessionPrefs.setGameTextureLodBias(context, "42", TextureFiltering.LOD_BIAS_OFF)
|
||||
|
||||
val published = JSONObject(
|
||||
File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").readText(),
|
||||
)
|
||||
assertTrue(published.getString(GameEnvironmentStore.DXVK_CONFIG).contains("samplerAnisotropy = 16"))
|
||||
assertEquals("", published.getJSONObject(GameEnvironmentStore.DXVK_CONFIG_GAMES).getString("42"))
|
||||
assertNull(SessionPrefs.gameTextureFiltering(context, "43").anisotropy)
|
||||
}
|
||||
|
||||
@Test fun clearingGameChoicesPreservesOtherProfiles() {
|
||||
SessionPrefs.setGameTextureAnisotropy(context, "42", 4)
|
||||
SessionPrefs.setGameTextureLodBias(context, "42", "-0.5")
|
||||
SessionPrefs.setGameTextureAnisotropy(context, "43", 8)
|
||||
|
||||
SessionPrefs.clearGameTextureFiltering(context, "42")
|
||||
|
||||
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42"))
|
||||
assertEquals(8, SessionPrefs.gameTextureFiltering(context, "43").anisotropy)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.droiddeck.launcher.stores.epic
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** The JSON manifest's numbers are blobs; the same values as the Rust engine's manifest tests. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class EpicJsonManifestTest {
|
||||
@Test
|
||||
fun blobNumbersAreDecimalBytesLeastSignificantFirst() {
|
||||
assertEquals(1L, EpicDownloadManager.blobToNum("001000000000"))
|
||||
assertEquals(0x100L, EpicDownloadManager.blobToNum("000001000000"))
|
||||
assertEquals(0x0807_0605_0403_0201L, EpicDownloadManager.blobToNum("001002003004005006007008"))
|
||||
assertEquals(-1L, EpicDownloadManager.blobToNum("255255255255255255255255"))
|
||||
assertEquals(7L, EpicDownloadManager.blobToNum("7"))
|
||||
assertEquals(0L, EpicDownloadManager.blobToNum(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlobIsDigitsOnly() {
|
||||
assertThrows(NumberFormatException::class.java) { EpicDownloadManager.blobToNum("0x1") }
|
||||
assertThrows(NumberFormatException::class.java) { EpicDownloadManager.blobToNum("-05") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonManifestUsesBlobs() {
|
||||
val json = """{
|
||||
"ManifestFileVersion": "006000000000",
|
||||
"LaunchExeString": "Game\\Binaries\\Game.exe",
|
||||
"ChunkHashList": { "0000000100000002000000030000000F": "001002003004005006007008" },
|
||||
"DataGroupList": { "0000000100000002000000030000000F": "007" },
|
||||
"ChunkFilesizeList": { "0000000100000002000000030000000F": "001002000000000000000000" },
|
||||
"FileManifestList": [
|
||||
{ "Filename": "a.bin",
|
||||
"FileChunkParts": [ { "Guid": "0000000100000002000000030000000F", "Offset": "001000000000", "Size": "000000016000" } ] }
|
||||
]
|
||||
}"""
|
||||
val m = EpicDownloadManager.parseManifest(json.toByteArray())
|
||||
assertNotNull(m)
|
||||
assertEquals("ChunksV3", m.chunkDir)
|
||||
assertEquals("Game/Binaries/Game.exe", m.launchExe)
|
||||
val c = m.uniqueChunks.single()
|
||||
assertEquals(0x0201L, c.fileSize)
|
||||
assertEquals("ChunksV3/07/0807060504030201_0000000100000002000000030000000F.chunk", c.getPath(m.chunkDir))
|
||||
val part = m.files.single().parts.single()
|
||||
assertEquals(1, part.offset)
|
||||
assertEquals(1_048_576, part.size)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class ComponentsPageProfileTest {
|
||||
@get:Rule val compose = createRobolectricComposeRule()
|
||||
|
||||
@Test fun gameProfilesCoexistWithTheDefaultComponentStack() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
compose.setContent {
|
||||
DroidDeckTheme {
|
||||
ComponentsPage(
|
||||
snapshot = null,
|
||||
catalog = emptyList(),
|
||||
catalogAt = 0,
|
||||
protonId = null,
|
||||
comp = PROTON_TAB,
|
||||
checking = false,
|
||||
busy = null,
|
||||
downloads = emptyMap(),
|
||||
onProton = {},
|
||||
onComp = {},
|
||||
onSwap = {},
|
||||
onRestore = {},
|
||||
onCancelQueued = {},
|
||||
onDeletePackage = {},
|
||||
onDeleteOriginal = {},
|
||||
onDownload = {},
|
||||
onRefresh = {},
|
||||
onImport = {},
|
||||
onBack = {},
|
||||
games = listOf(Library.SteamGame(42, "Game A", null, "internal")),
|
||||
requestInitialFocus = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
val stackHeading = context.getString(R.string.comp_this_proton).uppercase()
|
||||
compose.onNodeWithText(stackHeading).assertExists()
|
||||
compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick()
|
||||
compose.onNodeWithText("Game A").performClick()
|
||||
compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists()
|
||||
compose.onNodeWithText(stackHeading).assertDoesNotExist()
|
||||
|
||||
compose.onNodeWithText("Game A").performClick()
|
||||
compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick()
|
||||
compose.onNodeWithText(stackHeading).assertExists()
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onLast
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class GameEnvironmentEditorTest {
|
||||
@get:Rule val compose = createRobolectricComposeRule()
|
||||
|
||||
@Test fun backgroundSelectionCannotRetargetAnOpenVariableEdit() {
|
||||
val context: Context = RuntimeEnvironment.getApplication()
|
||||
val variable = "MESA_SHADER_CACHE_MAX_SIZE"
|
||||
GameEnvironmentStore.save(context, GameEnvironment.Config(games = mapOf(
|
||||
"42" to mapOf(variable to "512M"),
|
||||
"43" to mapOf(variable to "2G"),
|
||||
)))
|
||||
val selectedScope = mutableStateOf("42")
|
||||
compose.setContent {
|
||||
DroidDeckTheme { GameEnvironmentRow(initialScope = selectedScope.value) }
|
||||
}
|
||||
compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick()
|
||||
compose.waitUntil(5_000) {
|
||||
compose.onAllNodes(hasText("512M")).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithText("512M").performScrollTo().performClick()
|
||||
compose.onNodeWithText(context.getString(R.string.game_env_change)).performClick()
|
||||
compose.onAllNodesWithText("512M").onLast().performClick()
|
||||
compose.onNodeWithText("4G").performClick()
|
||||
|
||||
compose.runOnIdle { selectedScope.value = "43" }
|
||||
compose.onNodeWithText(context.getString(R.string.game_env_save)).performClick()
|
||||
compose.waitUntil(5_000) {
|
||||
GameEnvironmentStore.read(context).entries("42")[variable] == "4G"
|
||||
}
|
||||
assertEquals("2G", GameEnvironmentStore.read(context).entries("43")[variable])
|
||||
compose.onNodeWithText("4G").assertExists()
|
||||
|
||||
compose.onNodeWithText(context.getString(R.string.game_env_done)).performScrollTo().performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick()
|
||||
compose.waitUntil(5_000) {
|
||||
compose.onAllNodes(hasText("2G")).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithText("2G").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GlideShapeTest {
|
||||
@Test fun restingAcrossCoversTheChip() {
|
||||
val r = glideShape(10f, 90f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
|
||||
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
|
||||
assertEquals(0f, r.top, 0.01f); assertEquals(40f, r.bottom, 0.01f)
|
||||
}
|
||||
|
||||
@Test fun edgesInEitherOrder() {
|
||||
val r = glideShape(90f, 10f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false)
|
||||
assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f)
|
||||
}
|
||||
|
||||
@Test fun pinchedIsATwelveDpDropAtTheCentre() {
|
||||
val r = glideShape(10f, 90f, 0f, 40f, pinch = 1f, drop = 6f, vertical = false)
|
||||
assertEquals(12f, r.width, 0.01f); assertEquals(12f, r.height, 0.01f)
|
||||
assertEquals(50f, r.center.x, 0.01f); assertEquals(20f, r.center.y, 0.01f)
|
||||
}
|
||||
|
||||
@Test fun verticalRunsTheAxisDown() {
|
||||
val r = glideShape(100f, 160f, 4f, 84f, pinch = 0f, drop = 6f, vertical = true)
|
||||
assertEquals(4f, r.left, 0.01f); assertEquals(84f, r.right, 0.01f)
|
||||
assertEquals(100f, r.top, 0.01f); assertEquals(160f, r.bottom, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class InPaneTest {
|
||||
@Test fun aRailItemLeftOfThePaneStartsHalfInside() {
|
||||
val o = inPane(Origin(Rect(-200f, 300f, -20f, 420f), 14f))
|
||||
assertEquals(-90f, o.bounds.left, 0.01f)
|
||||
assertEquals(90f, o.bounds.right, 0.01f)
|
||||
assertEquals(300f, o.bounds.top, 0.01f)
|
||||
}
|
||||
|
||||
@Test fun aControlInThePaneStaysPut() {
|
||||
val origin = Origin(Rect(40f, 300f, 200f, 360f), 12f)
|
||||
assertSame(origin, inPane(origin))
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LayerStepTest {
|
||||
@Test fun theStackRunsFromTheProtonDownToTheGpuDrivers() {
|
||||
assertEquals(listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB), LAYERS)
|
||||
}
|
||||
|
||||
@Test fun rbStepsDownAndWraps() {
|
||||
assertEquals("dxvk", layerStep(PROTON_TAB, 1))
|
||||
assertEquals(PROTON_TAB, layerStep(GPU_TAB, 1))
|
||||
}
|
||||
|
||||
@Test fun lbStepsUpAndWraps() {
|
||||
assertEquals("fex", layerStep(GPU_TAB, -1))
|
||||
assertEquals(GPU_TAB, layerStep(PROTON_TAB, -1))
|
||||
}
|
||||
|
||||
@Test fun anUnknownLayerStartsFromTheTop() {
|
||||
assertEquals("dxvk", layerStep("tab-from-an-older-build", 1))
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LiftSheetTest {
|
||||
@Test fun theSheetTopIs82dpDownA360dpPane() {
|
||||
assertEquals(82f, sheetTop(360f, 56f, 16f, 3f), 0.01f)
|
||||
assertEquals(164f, sheetTop(720f, 56f, 16f, 3f), 0.01f)
|
||||
}
|
||||
|
||||
@Test fun theButtonKeeps16dpAboveIt() {
|
||||
// A short pane: the edge comes down so the button riding it still clears the top by 16.
|
||||
val top = sheetTop(150f, 56f, 16f, 3f)
|
||||
assertEquals(41f, top, 0.01f)
|
||||
assertEquals(16f, top + 3f - 56f / 2f, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class PageExitTest {
|
||||
@Test fun backToTheSameRailItemDrainsIntoTheControl() {
|
||||
assertEquals(PageExit.Drain, pageExit("steam", "steam", PageLeave.Ordinary))
|
||||
}
|
||||
|
||||
@Test fun anotherRailItemSinks() {
|
||||
assertEquals(PageExit.Sink, pageExit("steam", "games", PageLeave.Ordinary))
|
||||
}
|
||||
|
||||
@Test fun anotherPageSinks() {
|
||||
// The GPU drivers row in Steam's settings opens Components over the same rail item.
|
||||
assertEquals(PageExit.Sink, pageExit("steam", "components", PageLeave.Ordinary))
|
||||
}
|
||||
|
||||
@Test fun aLinkIntoAnotherSectionHops() {
|
||||
assertEquals(PageExit.Hop, pageExit("steam", "components", PageLeave.Link))
|
||||
}
|
||||
|
||||
@Test fun drainingWinsWhenTheOwnerComesBackAnyway() {
|
||||
assertEquals(PageExit.Drain, pageExit("desktop", "desktop", PageLeave.Link))
|
||||
}
|
||||
|
||||
@Test fun aPageWithNoOwnerNeverDrains() {
|
||||
assertEquals(PageExit.Sink, pageExit(null, "steam", PageLeave.Ordinary))
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
|
||||
fun createRobolectricComposeRule() = createComposeRule().also {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
// Register only in Robolectric, without adding a host activity to either APK variant.
|
||||
shadowOf(context.packageManager).addActivityIfNotPresent(
|
||||
ComponentName(context, ComponentActivity::class.java),
|
||||
)
|
||||
}
|
||||
@@ -3,52 +3,16 @@
|
||||
Open **Games → Game environment**, directly below **FEX preset**, in Steam
|
||||
settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a
|
||||
non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit).
|
||||
The drawer opens the editor on its selected game profile. That editing scope stays
|
||||
fixed if Steam's selected game changes in the background; use the editor's own
|
||||
profile picker to switch deliberately.
|
||||
Changes apply on the next game launch, including when Steam is already running.
|
||||
A running game must be restarted. Native Linux games and Steam itself are outside
|
||||
this editor's scope.
|
||||
|
||||
The Components page also has a **Configuration** dropdown. **Default profile** owns the
|
||||
default Proton and FEX preset. An installed game can inherit those defaults or
|
||||
choose its own Proton and FEX preset. Proton choices are applied through Steam's
|
||||
per-app compatibility mapping, including while Steam is running when its live
|
||||
compatibility helper is attached. A newly installed Proton that the running
|
||||
client has not discovered yet applies when Steam next starts.
|
||||
|
||||
Each game can also override the stored FEX, DXVK and VKD3D-Proton package used
|
||||
by its selected Proton. The launch wrapper applies that game’s component files
|
||||
immediately before Proton starts, removes managed files that are absent from the
|
||||
selection, and restores Default component choices for the next game. **Use
|
||||
Default** removes the game override. Component packages must first be downloaded
|
||||
or imported on the Default Components page.
|
||||
|
||||
Before publishing game component choices, DroidDeck preserves each Proton build's
|
||||
original components, including an explicitly empty bundle when a component is not
|
||||
shipped. Those restore choices remain after the last override is cleared. A missing
|
||||
or unreadable original is an error, not permission to delete the component's files.
|
||||
If an original is missing after a build may already have used a game override,
|
||||
DroidDeck refuses to capture those potentially modified files as originals.
|
||||
|
||||
Component selections and the launch table are saved with a recovery record.
|
||||
Publishing the launch table commits the selection: if the app stops before its
|
||||
settings file is updated, the next component read recovers the matching settings.
|
||||
An interruption before publication leaves the previous selections in place.
|
||||
Unused staged packages are removed only after both files have been saved.
|
||||
|
||||
**Reset game profile** removes all overrides owned by the selected game: Proton,
|
||||
FEX preset, component packages, environment variables, texture filtering and
|
||||
Windows components. The game then inherits the Default profile again.
|
||||
|
||||
The order is inherited process environment, selected FEX preset and built-in
|
||||
settings, shared edits, then game-specific edits. Editor entries therefore win
|
||||
over matching Steam launch-option variables. **Remove** explicitly unsets a
|
||||
variable. **Restore inherited settings** removes the override. The environment
|
||||
editor's **Clear environment overrides** action clears only the selected
|
||||
environment entries and is intentionally narrower than **Reset game profile**.
|
||||
Values are literal strings, with no shell expansion; quotes are only needed when
|
||||
the consuming program expects them.
|
||||
variable. **Restore inherited settings** removes the override. **Reset this
|
||||
profile** clears only that profile's overrides. Values are literal strings, with
|
||||
no shell expansion; quotes are only needed when the consuming program expects them.
|
||||
|
||||
## Defaults and available suggestions
|
||||
|
||||
@@ -107,18 +71,14 @@ wrapper and adopted third-party Proton wrappers execute `droiddeck-game-env`,
|
||||
which reads that snapshot for each real game launch and uses `execvpe` to start
|
||||
Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed
|
||||
configuration falls back to the inherited environment without evaluating its
|
||||
contents. Both component and environment selection normalize signed non-Steam
|
||||
prefix IDs to unsigned IDs.
|
||||
contents. Signed non-Steam prefix IDs are normalized to unsigned IDs.
|
||||
|
||||
The published file also carries generated `dxvkConfig` and `dxvkConfigGames`
|
||||
values the app's own file never has. The session menu's texture filtering
|
||||
(`core/TextureFiltering`) becomes `d3d9/d3d11.samplerAnisotropy` and
|
||||
`samplerLodBias` options. Default choices apply to games without overrides; each
|
||||
game can save either choice independently or return it to **Use Default**. The
|
||||
launcher appends the selected options to `DXVK_CONFIG` after the environment
|
||||
profile, so a user's own `DXVK_CONFIG` entry keeps its options. "Auto" texture
|
||||
sharpness is `-log2(panel / session)`, derived when the session is sized, and
|
||||
applies from the next launch.
|
||||
The published file also carries a `dxvkConfig` string the app's own file never
|
||||
has: the session menu's texture filtering (Effects page; `core/TextureFiltering`)
|
||||
as `d3d9/d3d11.samplerAnisotropy` and `samplerLodBias` options. The launcher
|
||||
appends it to `DXVK_CONFIG` after the profiles, so a user's own `DXVK_CONFIG`
|
||||
entry keeps its options. "Auto" texture sharpness is `-log2(panel / session)`,
|
||||
derived when the session is sized, and applies from the next launch.
|
||||
|
||||
Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c),
|
||||
[VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables),
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Experimental Quest 3/3S support
|
||||
|
||||
Quest 3/3S runs DroidDeck as a 2D panel. This does not add immersive VR or
|
||||
OpenXR support. Quest 2 is outside the bundled Adreno 7xx driver path.
|
||||
|
||||
The first launch on Quest 3/3S selects manual drivers, the bundled
|
||||
`turnip25.1.0-quest1` Android driver and the default Linux driver. It enables
|
||||
`hdr.steam` to use the existing SurfaceControl presentation workaround from
|
||||
[the Quest report](https://github.com/Droid-Deck/DroidDeck/issues/245).
|
||||
These defaults apply once; subsequent user changes are retained. Colour
|
||||
accuracy and headset performance still need testing.
|
||||
|
||||
Both the Android driver wrapper and the Linux session preload use
|
||||
`tools/quest/kgsl_compat.c`. Successful legacy allocation/free calls are
|
||||
unchanged. Only `EACCES`/`EPERM` failures retry through `GPUOBJ_ALLOC`,
|
||||
`GPUOBJ_INFO` and `GPUOBJ_FREE`. The Android wrapper depends on the original
|
||||
bundled Turnip binary. Unhandled controller ioctls forward to the next
|
||||
preload hook rather than skipping it with a raw syscall.
|
||||
|
||||
`tools/build_local.sh` and CI build the wrapper from source using the pinned
|
||||
Android NDK; the normal preload builds include the shared Linux adapter.
|
||||
Runtime input verification checks the resulting driver archive and invalidates
|
||||
stale prepared assets after compatibility sources change. To rebuild only the
|
||||
Android driver archive:
|
||||
|
||||
```sh
|
||||
python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/27.3.13750724"
|
||||
```
|
||||
|
||||
The release variant `quest` produces a separate `com.droiddeck.quest` APK
|
||||
with its own app data. All installed packages use their own bwrap socket.
|
||||
The standard APK also applies Quest defaults when running on Quest 3/3S.
|
||||
|
||||
On a headset, check the Steam login panel, sign-in, game installation and
|
||||
then an owned game's rendering/input. Capture the device/OS, session logs,
|
||||
`files/quest-startup.txt` and any `files/quest-last-crash.txt` failure.
|
||||
Host allocation and preload-chain tests do not establish headset operation.
|
||||
@@ -316,7 +316,6 @@ else
|
||||
fi
|
||||
|
||||
cd "${repo_root}"
|
||||
python3 tools/quest/build-driver.py "${NDK}"
|
||||
python3 tools/release/runtime_inputs.py record
|
||||
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}" ${gradle_rust_arg}
|
||||
python3 tools/release/check_session_assets.py "app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
|
||||
|
||||
@@ -453,34 +453,6 @@ def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15)
|
||||
ws.close()
|
||||
|
||||
|
||||
def steam_selection(agent_dir, timeout=5):
|
||||
result = cdp_eval(
|
||||
agent_dir,
|
||||
"""(() => {
|
||||
let node = document.activeElement;
|
||||
while (node) {
|
||||
for (const name of ["data-id", "data-appid"]) {
|
||||
const value = node.getAttribute?.(name);
|
||||
if (/^[1-9][0-9]{0,9}$/.test(value || "")) {
|
||||
const appId = Number(value);
|
||||
if (Number.isSafeInteger(appId) && appId <= 4294967295) return value;
|
||||
}
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
})()""",
|
||||
target="Steam Big Picture Mode",
|
||||
timeout=timeout,
|
||||
)
|
||||
value = result.get("value")
|
||||
if value is None:
|
||||
return {"appId": None}
|
||||
if not isinstance(value, str) or not value.isdigit() or not 0 < int(value) <= 4294967295:
|
||||
raise AgentError("INVALID_SELECTION", "Steam returned an invalid selected app id")
|
||||
return {"appId": int(value)}
|
||||
|
||||
|
||||
# --- Game processes ------------------------------------------------------------------------------
|
||||
|
||||
def game_pids(app_id):
|
||||
@@ -652,8 +624,6 @@ def handle(agent_dir, req):
|
||||
raise AgentError("INVALID_REQUEST", "cdp eval needs an expression")
|
||||
return cdp_eval(agent_dir, expression, req.get("target"), bool(req.get("awaitPromise", True)),
|
||||
request_timeout(req, 15))
|
||||
if kind == "steam-selection":
|
||||
return steam_selection(agent_dir, request_timeout(req, 5))
|
||||
if kind == "quit":
|
||||
return quit_game(agent_dir, req.get("appId"), request_timeout(req, 15))
|
||||
raise AgentError("UNKNOWN_REQUEST", "Unknown agent request '%s'" % kind)
|
||||
|
||||
@@ -20,7 +20,7 @@ import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
SOCKET = "\0" + os.environ.get("DROIDDECK_BWRAP_SOCKET", "com.droiddeck.launcher.bwrap")
|
||||
SOCKET = "\0com.droiddeck.launcher.bwrap"
|
||||
VERSION = "bubblewrap 0.11.0 (droiddeck)"
|
||||
|
||||
# Options and how many arguments each takes; bwrap(1).
|
||||
|
||||
@@ -168,10 +168,7 @@ def apply_config(env, config, app_id, fixes=None):
|
||||
raise ValueError("invalid environment value")
|
||||
# The app's texture filtering (Display page), as DXVK options appended after the profiles so
|
||||
# a DXVK_CONFIG entry of the user's keeps its own.
|
||||
game_dxvk = config.get("dxvkConfigGames", {})
|
||||
if not isinstance(game_dxvk, dict):
|
||||
raise ValueError("invalid game dxvk configuration")
|
||||
dxvk = game_dxvk.get(app_id, config.get("dxvkConfig"))
|
||||
dxvk = config.get("dxvkConfig")
|
||||
if dxvk is not None:
|
||||
if not isinstance(dxvk, str) or "\0" in dxvk or len(dxvk) > 8192:
|
||||
raise ValueError("invalid dxvk configuration")
|
||||
@@ -258,9 +255,8 @@ EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGN
|
||||
|
||||
def mark_install_scripts(command, env):
|
||||
"""The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process
|
||||
in them but a Vulkan runtime installer as already run first, so no redistributable installer
|
||||
starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++
|
||||
ones stop with an error."""
|
||||
in them as already run first, so no redistributable installer starts - on first launch the
|
||||
shared ones may still be downloading, and on ARM64 Proton the VC++ ones stop with an error."""
|
||||
if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command):
|
||||
return
|
||||
root = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user