mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge remote-tracking branch 'origin/main' into fix/termux-system-runtime
# Conflicts: # app/src/main/java/com/steamdeck/launcher/SessionActivity.kt # app/src/main/java/com/steamdeck/launcher/ui/SessionOverlay.kt
This commit is contained in:
commit
964c876a3e
13 files changed
+475
-38
No files matched your search
@@ -54,6 +54,23 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
|
||||
Home launch either back into a running Steam session or to the regular front end. -->
|
||||
<activity
|
||||
android:name=".HomeActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:taskAffinity="${applicationId}.home"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
A session keeps a Surface, a compositor thread and a proot tree alive; a configuration
|
||||
change must not tear that down and rebuild it, so the activity handles them itself.
|
||||
@@ -79,6 +96,7 @@
|
||||
<activity
|
||||
android:name=".SessionActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/SessionTheme"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.steamdeck.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.steamdeck.launcher.session.SessionService
|
||||
|
||||
/** Receives the system Home launch and routes it without consuming KEYCODE_HOME. */
|
||||
class HomeActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
routeHome()
|
||||
}
|
||||
|
||||
private fun routeHome() {
|
||||
val destination = if (HomeApp.isActiveSteamSession()) {
|
||||
Intent(this, SessionActivity::class.java)
|
||||
.setAction(SessionService.ACTION_HOME_GUIDE)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
} else {
|
||||
Intent(this, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
startActivity(destination)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.steamdeck.launcher
|
||||
|
||||
import android.app.role.RoleManager
|
||||
import android.graphics.Bitmap
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.steamdeck.launcher.session.SessionService
|
||||
import com.steamdeck.launcher.session.SessionState
|
||||
|
||||
/** Android Home role and the launchable Android apps shown by the in-session app drawer. */
|
||||
object HomeApp {
|
||||
data class LaunchableApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val className: String,
|
||||
val icon: Bitmap?,
|
||||
)
|
||||
|
||||
fun isDefault(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val roles = context.getSystemService(RoleManager::class.java)
|
||||
if (roles?.isRoleAvailable(RoleManager.ROLE_HOME) == true) {
|
||||
return roles.isRoleHeld(RoleManager.ROLE_HOME)
|
||||
}
|
||||
}
|
||||
val home = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_HOME)
|
||||
.addCategory(Intent.CATEGORY_DEFAULT)
|
||||
return context.packageManager.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo?.packageName == context.packageName
|
||||
}
|
||||
|
||||
fun defaultLabel(context: Context): String? {
|
||||
val pm = context.packageManager
|
||||
val home = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_HOME)
|
||||
.addCategory(Intent.CATEGORY_DEFAULT)
|
||||
val resolved = pm.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
|
||||
val activity = resolved.activityInfo ?: return null
|
||||
val label = runCatching { pm.getApplicationLabel(activity.applicationInfo).toString() }.getOrNull()
|
||||
return label?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: resolved.loadLabel(pm)?.toString()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun roleRequestIntent(context: Context): Intent? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
|
||||
val roles = context.getSystemService(RoleManager::class.java) ?: return null
|
||||
if (!roles.isRoleAvailable(RoleManager.ROLE_HOME) || roles.isRoleHeld(RoleManager.ROLE_HOME)) return null
|
||||
return roles.createRequestRoleIntent(RoleManager.ROLE_HOME)
|
||||
}
|
||||
|
||||
fun settingsIntent(): Intent = Intent(Settings.ACTION_HOME_SETTINGS)
|
||||
|
||||
fun launchableApps(context: Context): List<LaunchableApp> {
|
||||
val pm = context.packageManager
|
||||
val launch = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(launch, 0)
|
||||
.asSequence()
|
||||
.mapNotNull { result ->
|
||||
val activity = result.activityInfo ?: return@mapNotNull null
|
||||
if (!activity.exported || activity.packageName == context.packageName) return@mapNotNull null
|
||||
val label = result.loadLabel(pm)?.toString()?.trim().orEmpty().ifEmpty { activity.packageName }
|
||||
val icon = runCatching { result.loadIcon(pm).toBitmap(48, 48) }.getOrNull()
|
||||
LaunchableApp(label, activity.packageName, activity.name, icon)
|
||||
}
|
||||
.distinctBy { it.packageName }
|
||||
.toList()
|
||||
return apps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
|
||||
}
|
||||
|
||||
fun launch(context: Context, app: LaunchableApp) {
|
||||
val intent = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(app.packageName, app.className))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun isActiveSteamSession(): Boolean =
|
||||
SessionState.running && SessionState.mode == SessionService.MODE_STEAM
|
||||
|
||||
fun openSystemHomeSettings(context: Context) {
|
||||
try {
|
||||
context.startActivity(settingsIntent())
|
||||
} catch (_: Exception) {
|
||||
// A role request remains available on Android 10+ if the settings page is missing.
|
||||
val request = roleRequestIntent(context)
|
||||
if (request != null) context.startActivity(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ class MainActivity : ComponentActivity() {
|
||||
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
|
||||
private var settingsMode by mutableStateOf<String?>(null)
|
||||
private var resolutionCap by mutableStateOf(1080)
|
||||
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var steamChannel by mutableStateOf("publicbeta")
|
||||
private var theme by mutableStateOf("paper")
|
||||
@@ -172,6 +173,13 @@ class MainActivity : ComponentActivity() {
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var showRoms by mutableStateOf(false)
|
||||
private var homeAppSelected by mutableStateOf(false)
|
||||
private var defaultHomeLabel by mutableStateOf<String?>(null)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
|
||||
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
refreshHomeAppState()
|
||||
}
|
||||
|
||||
/** The session surface rises over the front end instead of cutting to it. */
|
||||
override fun startActivity(intent: Intent?) {
|
||||
@@ -203,6 +211,9 @@ class MainActivity : ComponentActivity() {
|
||||
lsfgReady = LsfgNative.isInstalled(this),
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showApps) "apps" else null,
|
||||
theme = theme,
|
||||
isHomeApp = homeAppSelected,
|
||||
defaultHomeLabel = defaultHomeLabel,
|
||||
androidApps = androidApps,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSession(Intent(this, SessionActivity::class.java)) },
|
||||
@@ -261,6 +272,8 @@ class MainActivity : ComponentActivity() {
|
||||
onCredits = { showCredits = true },
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showApps = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onAndroidApp = { app -> launchAndroidApp(app) },
|
||||
),
|
||||
page = page,
|
||||
)
|
||||
@@ -322,6 +335,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshHomeAppState()
|
||||
refresh()
|
||||
// Added games' art (a store lookup for what the folders lack) starts here, not only when
|
||||
// the cog opens.
|
||||
@@ -329,6 +343,29 @@ class MainActivity : ComponentActivity() {
|
||||
if (!busy) Thread({ checkCatalog() }, "catalog").start()
|
||||
}
|
||||
|
||||
private fun refreshHomeAppState() {
|
||||
homeAppSelected = HomeApp.isDefault(this)
|
||||
defaultHomeLabel = HomeApp.defaultLabel(this)
|
||||
androidApps = if (homeAppSelected) HomeApp.launchableApps(this) else emptyList()
|
||||
}
|
||||
|
||||
private fun launchAndroidApp(app: HomeApp.LaunchableApp) {
|
||||
try {
|
||||
HomeApp.launch(this, app)
|
||||
} catch (_: Exception) {
|
||||
android.widget.Toast.makeText(this, "Could not open ${app.label}", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun manageHomeApp() {
|
||||
val request = HomeApp.roleRequestIntent(this)
|
||||
if (request != null) {
|
||||
homeRoleRequest.launch(request)
|
||||
} else {
|
||||
HomeApp.openSystemHomeSettings(this)
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop & apps, in the front end's pane. */
|
||||
@Composable
|
||||
private fun AppsHost() {
|
||||
@@ -414,7 +451,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun ModeSettingsHost(mode: String) {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, shapeMode = shapeMode,
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
hdr = hdrOn, hdrReason = hdrReason,
|
||||
linuxRows = linuxRows,
|
||||
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
|
||||
@@ -436,6 +473,7 @@ class MainActivity : ComponentActivity() {
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
|
||||
@@ -530,6 +568,7 @@ class MainActivity : ComponentActivity() {
|
||||
showApps = false
|
||||
refreshDrivers()
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.steamdeck.launcher
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
@@ -19,6 +20,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -108,6 +110,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
private var defaultHomeLabel by mutableStateOf<String?>(null)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
private val homeRoleRequest = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
refreshHomeApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the on-screen pad when nothing is plugged in and takes it away the moment something
|
||||
@@ -133,11 +141,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshSecondScreenDisplays()
|
||||
|
||||
if (!LinuxRuntime.isInstalled(this)) {
|
||||
Log.e(TAG, "the Linux runtime is not installed")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// No runtime is not a reason to leave: the loading screen installs it (installThenStart,
|
||||
// below) and the session starts when it is in.
|
||||
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
|
||||
|
||||
val root = FrameLayout(this)
|
||||
surfaceView = SurfaceView(this)
|
||||
@@ -184,6 +190,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
|
||||
SessionDrawer(drawerOpen, DrawerActions(
|
||||
steam = SessionState.mode == SessionService.MODE_STEAM,
|
||||
isHomeApp = isHomeApp,
|
||||
defaultHomeLabel = defaultHomeLabel,
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
|
||||
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
|
||||
@@ -200,16 +209,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
applyFrameGen()
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; keyboard?.toggle() },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
// The Guide button, the way the on-screen ◉ sends it: a device with no
|
||||
// Xbox button, or a pad the client hides the controls for, has no other
|
||||
// way to open the client's menu in a game.
|
||||
drawerOpen = false
|
||||
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
padBridge?.applyTouch { st -> st.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}) else null,
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({
|
||||
drawerOpen = false
|
||||
padBridge?.triggerQam()
|
||||
@@ -221,6 +221,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
|
||||
onSecondScreenMode = ::selectSecondScreenMode,
|
||||
onSecondScreenDisplay = ::selectSecondScreenDisplay,
|
||||
onHomeApp = ::manageHomeApp,
|
||||
onLaunchAndroidApp = { app ->
|
||||
drawerOpen = false
|
||||
try {
|
||||
HomeApp.launch(this@SessionActivity, app)
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(this@SessionActivity, "Could not open ${app.label}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onBackground = { drawerOpen = false; moveTaskToBack(true) },
|
||||
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
|
||||
onClose = { drawerOpen = false },
|
||||
@@ -239,6 +248,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
})
|
||||
setContentView(root)
|
||||
handleHomeGuideIntent(intent)
|
||||
|
||||
updateOnScreenControls()
|
||||
WaylandCompositor.setFirstFrameListener {
|
||||
@@ -269,6 +279,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHomeApp() {
|
||||
isHomeApp = HomeApp.isDefault(this)
|
||||
defaultHomeLabel = HomeApp.defaultLabel(this)
|
||||
androidApps = if (isHomeApp) HomeApp.launchableApps(this) else emptyList()
|
||||
}
|
||||
|
||||
private fun manageHomeApp() {
|
||||
val request = HomeApp.roleRequestIntent(this)
|
||||
if (request != null) homeRoleRequest.launch(request)
|
||||
else HomeApp.openSystemHomeSettings(this)
|
||||
}
|
||||
|
||||
private fun sendSteamGuide() {
|
||||
drawerOpen = false
|
||||
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, true) }
|
||||
uiHandler.postDelayed({
|
||||
padBridge?.applyTouch { state -> state.press(com.steamdeck.launcher.input.PadState.GUIDE, false) }
|
||||
}, 90)
|
||||
}
|
||||
|
||||
private fun handleHomeGuideIntent(incoming: Intent?) {
|
||||
if (incoming?.action != SessionService.ACTION_HOME_GUIDE) return
|
||||
incoming.action = null
|
||||
setIntent(incoming)
|
||||
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM) sendSteamGuide()
|
||||
}
|
||||
|
||||
private fun readPrefs() {
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
@@ -466,13 +503,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
|
||||
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
|
||||
// compositor letterboxes onto a squarer panel.
|
||||
val aspect = if (SessionPrefs.shapeMode(this) == SessionPrefs.SHAPE_WIDE) 16f / 9f
|
||||
else maxOf(panelW / panelH, 16f / 9f)
|
||||
// "Exactly this panel" drops that floor, for a 4:3 or 3:2 handheld whose games should
|
||||
// fill it.
|
||||
val aspect = when (SessionPrefs.shapeMode(this)) {
|
||||
SessionPrefs.SHAPE_WIDE -> 16f / 9f
|
||||
SessionPrefs.SHAPE_EXACT -> panelW / panelH
|
||||
else -> maxOf(panelW / panelH, 16f / 9f)
|
||||
}
|
||||
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
|
||||
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
|
||||
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
|
||||
// the cap or lift it to the panel.
|
||||
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
|
||||
// A custom resolution is taken as given; the compositor fits it to the panel.
|
||||
SessionPrefs.customResolution(this, mode)?.let { return it }
|
||||
val cap = SessionPrefs.resolutionCap(this, mode)
|
||||
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
|
||||
val width = (height * aspect).toInt()
|
||||
@@ -890,7 +934,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
if (intent.action == SessionService.ACTION_RESUME) {
|
||||
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
|
||||
handleHomeGuideIntent(intent)
|
||||
} else if (intent.action == SessionService.ACTION_RESUME) {
|
||||
intent.action = null
|
||||
SessionService.resume(this)
|
||||
}
|
||||
@@ -898,6 +944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshHomeApp()
|
||||
if (intent?.action == SessionService.ACTION_RESUME) {
|
||||
intent.action = null
|
||||
SessionService.resume(this)
|
||||
|
||||
@@ -109,6 +109,8 @@ object DeviceReport {
|
||||
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
|
||||
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
|
||||
k("Shape setting", SessionPrefs.shapeMode(context))
|
||||
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
|
||||
@@ -35,10 +35,18 @@ object SessionPrefs {
|
||||
|
||||
const val SHAPE_AUTO = "auto"
|
||||
const val SHAPE_WIDE = "16:9"
|
||||
const val SHAPE_EXACT = "exact"
|
||||
|
||||
/** The choices the settings offer, in order. */
|
||||
val shapeChoices = listOf(
|
||||
SHAPE_AUTO to "The panel's shape (16:9 or wider)",
|
||||
SHAPE_EXACT to "Exactly this panel (4:3, 3:2…)",
|
||||
SHAPE_WIDE to "16:9 with bars",
|
||||
)
|
||||
|
||||
/**
|
||||
* The shape of the display the session presents: the panel's own (never narrower than 16:9)
|
||||
* or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
|
||||
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
|
||||
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
|
||||
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
|
||||
* display cannot change size once the session is up.
|
||||
*/
|
||||
@@ -300,6 +308,27 @@ object SessionPrefs {
|
||||
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed size for the session's display, per mode, or null. When set it replaces both the
|
||||
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
|
||||
*/
|
||||
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
|
||||
parseResolution(prefs(context).getString("customRes.$mode", null))
|
||||
|
||||
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
|
||||
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
|
||||
}
|
||||
|
||||
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
|
||||
fun parseResolution(text: String?): Pair<Int, Int>? {
|
||||
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
|
||||
if (parts.size != 2) return null
|
||||
val w = parts[0].toIntOrNull() ?: return null
|
||||
val h = parts[1].toIntOrNull() ?: return null
|
||||
if (w !in 320..3840 || h !in 240..2160) return null
|
||||
return Pair(w and 1.inv(), h and 1.inv())
|
||||
}
|
||||
|
||||
/**
|
||||
* What the desktop shell composites with: pixman (software, the default - the Adreno stand-in
|
||||
* is not a DRM render node, so labwc's gbm allocator cannot use it), or gles2 / vulkan for a
|
||||
|
||||
@@ -961,6 +961,7 @@ class SessionService : Service() {
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
const val ACTION_STOP = "com.steamdeck.launcher.STOP_SESSION"
|
||||
const val ACTION_RESUME = "com.steamdeck.launcher.RESUME_SESSION"
|
||||
const val ACTION_HOME_GUIDE = "com.steamdeck.launcher.HOME_GUIDE"
|
||||
private const val ACTION_ACTIVITY_VISIBLE = "com.steamdeck.launcher.ACTIVITY_VISIBLE"
|
||||
private const val ACTION_ACTIVITY_HIDDEN = "com.steamdeck.launcher.ACTIVITY_HIDDEN"
|
||||
private const val ACTION_TRACK_AUXILIARY = "com.steamdeck.launcher.TRACK_AUXILIARY"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.steamdeck.launcher.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -88,6 +89,7 @@ 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.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
@@ -130,6 +132,9 @@ class FrontEndState(
|
||||
val lsfgReady: Boolean = false,
|
||||
val pageKey: String? = null,
|
||||
val theme: String = Themes.PAPER,
|
||||
val isHomeApp: Boolean = false,
|
||||
val defaultHomeLabel: String? = null,
|
||||
val androidApps: List<com.steamdeck.launcher.HomeApp.LaunchableApp> = emptyList(),
|
||||
)
|
||||
|
||||
class FrontEndActions(
|
||||
@@ -156,6 +161,8 @@ class FrontEndActions(
|
||||
val onCredits: () -> Unit,
|
||||
val onPageBack: () -> Unit = {},
|
||||
val onTheme: (String) -> Unit = {},
|
||||
val onHomeApp: () -> Unit = {},
|
||||
val onAndroidApp: (com.steamdeck.launcher.HomeApp.LaunchableApp) -> Unit = {},
|
||||
)
|
||||
|
||||
|
||||
@@ -224,6 +231,7 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ctx = LocalContext.current
|
||||
BackHandler(enabled = s.pageKey != null && page != null) { a.onPageBack() }
|
||||
LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" }
|
||||
remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true }
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.background).systemBarsPadding()) {
|
||||
@@ -312,6 +320,10 @@ private fun Rail(
|
||||
}
|
||||
val unregister: (String) -> Unit = { positions.remove(it) }
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (s.isHomeApp) {
|
||||
NavItem("Android apps", "android-apps", selected == "android-apps", count = s.androidApps.size,
|
||||
register = register, unregister = unregister) { onSelect("android-apps") }
|
||||
}
|
||||
NavItem("Steam", "steam", selected == "steam", caret = openSteam, count = s.steamGames.size, register = register, unregister = unregister) { onSelect("steam") }
|
||||
Sub(openSteam) {
|
||||
for ((i, g) in s.steamGames.withIndex()) NavItem(g.name, "app:${g.appId}", selected == "app:${g.appId}", small = true, i = i, register = register, unregister = unregister) { onSelect("app:${g.appId}") }
|
||||
@@ -334,12 +346,12 @@ private fun Rail(
|
||||
if (s.emulators.none { it.installed }) NavItem("Install emulators", "apps", selected == "apps", small = true, muted = true, register = register, unregister = unregister) { a.onApps() }
|
||||
NavItem("Settings", "settings:lxqt", selected == "settings:lxqt", small = true, tiny = true, muted = true, i = s.emulators.count { it.installed }, register = register, unregister = unregister) { a.onDesktopSettings() }
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val menus = remember { MenuHost() }
|
||||
val item: @Composable (String, String?, Int, String?, () -> Unit) -> Unit = { label, value, i, key, act ->
|
||||
NavItem(label, key ?: "x", key != null && selected == key, small = true, tiny = true, muted = true, value = value, i = i, register = register, unregister = unregister) { act() }
|
||||
}
|
||||
NavItem("Setup", "x", false, caret = openSetup, count = 10, onClick = onToggleSetup)
|
||||
NavItem("Setup", "x", false, caret = openSetup, count = 11, onClick = onToggleSetup)
|
||||
Sub(openSetup) {
|
||||
item("Files", null, 0, null, a.onFiles)
|
||||
item("Desktop & apps", null, 1, "apps", a.onApps)
|
||||
@@ -388,6 +400,7 @@ private fun Rail(
|
||||
for (t in Themes.all) MenuItem(t.label, checked = s.theme == t.id) { a.onTheme(t.id); menus.open = null }
|
||||
}
|
||||
}
|
||||
item("Default Home app", s.defaultHomeLabel ?: "Choose a Home app", 10, null, a.onHomeApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,6 +516,7 @@ private fun Pane(s: FrontEndState, selected: String, a: FrontEndActions, page: (
|
||||
selected.startsWith("app:") -> s.steamGames.firstOrNull { "app:${it.appId}" == selected }.let { it?.art to hueOf(it?.name ?: "") }
|
||||
selected.startsWith("emu:") -> null to hueOf(selected)
|
||||
selected.startsWith("rom:") -> romFor(s, selected).let { it?.second?.art to hueOf(it?.second?.name ?: "") }
|
||||
selected == "android-apps" -> null to hueOf("android-apps")
|
||||
else -> null to 268f
|
||||
}
|
||||
Backdrop(wash)
|
||||
@@ -547,6 +561,24 @@ private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modi
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier.padding(horizontal = 22.dp, vertical = 18.dp)) {
|
||||
when {
|
||||
selected == "android-apps" && s.isHomeApp -> {
|
||||
Rise(0) { Eyebrow("Android apps") }
|
||||
Rise(1) { Title("Installed apps") }
|
||||
Rise(2) { SectionTitle("Apps", s.androidApps.size.toString()) }
|
||||
if (s.androidApps.isEmpty()) Rise(3) { Note("No launchable Android apps found.") }
|
||||
else Rise(3, Modifier.weight(1f).fillMaxWidth()) {
|
||||
ArtGrid(s.androidApps.map { app ->
|
||||
Tile(
|
||||
app.label,
|
||||
null,
|
||||
null,
|
||||
"android:${app.packageName}",
|
||||
onClick = { a.onAndroidApp(app) },
|
||||
iconBitmap = app.icon,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
selected == "steam" -> {
|
||||
Rise(0) { Eyebrow("Steam") }
|
||||
Rise(3) {
|
||||
@@ -662,7 +694,11 @@ private fun Content(s: FrontEndState, selected: String, a: FrontEndActions, modi
|
||||
}
|
||||
}
|
||||
|
||||
private class Tile(val title: String, val sub: String?, val art: File?, val key: String, val iconRes: Int? = null, val dim: Boolean = false, val onClick: () -> Unit)
|
||||
private 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,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -777,7 +813,7 @@ private fun Poster(art: File?, name: String, modifier: Modifier) {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ArtGrid(tiles: List<Tile>, wide: Boolean = false) {
|
||||
val square = tiles.isNotEmpty() && tiles.all { it.art == null && it.iconRes != null }
|
||||
val square = tiles.isNotEmpty() && tiles.all { it.art == null && (it.iconRes != null || it.iconBitmap != null) }
|
||||
// Thumbnails to recognise a game by, not posters; icon tiles are squares.
|
||||
val minSize = if (square) 64.dp else if (wide) 92.dp else 70.dp
|
||||
val gap = 8.dp
|
||||
@@ -821,7 +857,7 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
|
||||
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = t.onClick),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shine(hot)) {
|
||||
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide)
|
||||
Art(t.art, t.iconRes, t.title, Modifier.fillMaxWidth(), wide, t.iconBitmap)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = hot, modifier = Modifier.align(Alignment.Center),
|
||||
enter = scaleIn(Motion.sp(0.5f), initialScale = 0.5f) + fadeIn(Motion.tw(200)),
|
||||
@@ -841,13 +877,14 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false) {
|
||||
private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val ratio = if (art == null && iconRes != null) 1f else if (wide) 16f / 9f else 2f / 3f
|
||||
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
|
||||
val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f
|
||||
Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) {
|
||||
when {
|
||||
art != null -> AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp))
|
||||
iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp))
|
||||
else -> {
|
||||
Spacer(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.55f))))
|
||||
Text(label, fontSize = 9.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.92f), modifier = Modifier.align(Alignment.BottomStart).padding(5.dp), maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -6,6 +6,10 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -18,6 +22,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
|
||||
class ModeSettings(
|
||||
val mode: String,
|
||||
val resolutionCap: Int,
|
||||
/** A fixed session size, or null for the cap and shape. */
|
||||
val customResolution: Pair<Int, Int>? = null,
|
||||
val shapeMode: String,
|
||||
val hdr: Boolean,
|
||||
val hdrReason: String?,
|
||||
@@ -50,6 +56,8 @@ class AddedGameRow(val folderPath: String, val folderName: String, val exePath:
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (Int) -> Unit,
|
||||
/** Null clears it. */
|
||||
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
|
||||
val onShape: (String) -> Unit,
|
||||
val onHdr: (Boolean) -> Unit,
|
||||
val onSelectLinux: (String) -> Unit,
|
||||
@@ -88,16 +96,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
) {
|
||||
SettingsGroup("Display") {
|
||||
val default = SessionPrefs.defaultResolutionCap(s.mode)
|
||||
var editCustom by remember { mutableStateOf(false) }
|
||||
val custom = s.customResolution
|
||||
ChoiceRow(
|
||||
host, "res", "Resolution", "Applies next session.",
|
||||
listOf(720 to "Up to 720p", 900 to "Up to 900p", 1080 to "Up to 1080p", 0 to "The panel's own")
|
||||
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) },
|
||||
s.resolutionCap, note = "720p can improve menu responsiveness.",
|
||||
onPick = a.onResolution,
|
||||
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) } +
|
||||
(CUSTOM to (custom?.let { "Custom · ${it.first}×${it.second}" } ?: "Custom…")),
|
||||
if (custom != null) CUSTOM else s.resolutionCap, note = "720p can improve menu responsiveness.",
|
||||
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
|
||||
)
|
||||
ChoiceRow(
|
||||
host, "shape", "Shape", "Adds bars to preserve 16:9.",
|
||||
listOf("auto" to "The panel's shape", "16:9" to "16:9 with bars"), s.shapeMode, onPick = a.onShape,
|
||||
host, "shape", "Shape",
|
||||
if (custom != null) "Set by the custom resolution." else "The panel's shape stays at 16:9 or wider; pick Exactly this panel for a 4:3 or 3:2 screen.",
|
||||
com.steamdeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
|
||||
)
|
||||
if (editCustom) CustomResolutionDialog(
|
||||
initial = custom,
|
||||
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
|
||||
onDismiss = { editCustom = false },
|
||||
)
|
||||
}
|
||||
SettingsGroup("HDR") {
|
||||
@@ -267,3 +284,59 @@ private fun DriverRowMenu(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The Resolution menu's "Custom…" entry. */
|
||||
private const val CUSTOM = -1
|
||||
|
||||
/** Width × height for the session, with the common handheld shapes one tap away. */
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
|
||||
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
|
||||
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
|
||||
val parsed = SessionPrefs.parseResolution("${w}x$h")
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Custom resolution") },
|
||||
text = {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
Text(
|
||||
"The session's display size. It replaces the cap and the shape; a size that does not match the panel's shape gets bars.",
|
||||
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) {
|
||||
val numbers = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number)
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
w, { v -> w = v.filter(Char::isDigit).take(4) }, label = { Text("Width") },
|
||||
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("×", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 10.dp))
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
h, { v -> h = v.filter(Char::isDigit).take(4) }, label = { Text("Height") },
|
||||
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.FlowRow(
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
) {
|
||||
for ((pw, ph, tag) in listOf(Triple(960, 720, "4:3"), Triple(1024, 768, "4:3"), Triple(1280, 960, "4:3"), Triple(1280, 800, "16:10"), Triple(1152, 648, "16:9"), Triple(1280, 720, "16:9"))) {
|
||||
androidx.compose.material3.AssistChip(
|
||||
onClick = { w = pw.toString(); h = ph.toString() },
|
||||
label = { Text("$pw×$ph · $tag", fontSize = 12.sp) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (parsed == null && (w.isNotEmpty() || h.isNotEmpty())) Text(
|
||||
"Between 320×240 and 3840×2160.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }) { Text("Use") } },
|
||||
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
@@ -29,12 +29,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.steamdeck.launcher.R
|
||||
import com.steamdeck.launcher.HomeApp
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -54,6 +56,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -136,6 +139,9 @@ fun SessionPausedOverlay(onResume: () -> Unit) {
|
||||
/** Everything the drawer shows and does. */
|
||||
class DrawerActions(
|
||||
val steam: Boolean,
|
||||
val isHomeApp: Boolean,
|
||||
val defaultHomeLabel: String?,
|
||||
val androidApps: List<HomeApp.LaunchableApp>,
|
||||
val hudOn: Boolean,
|
||||
val frameGenEngine: String,
|
||||
val frameGenMultiplier: Int,
|
||||
@@ -160,6 +166,8 @@ class DrawerActions(
|
||||
val onFexPreset: (String) -> Unit,
|
||||
val onSecondScreenMode: (SecondScreenMode) -> Unit,
|
||||
val onSecondScreenDisplay: (Int) -> Unit,
|
||||
val onHomeApp: () -> Unit,
|
||||
val onLaunchAndroidApp: (HomeApp.LaunchableApp) -> Unit,
|
||||
val onBackground: () -> Unit,
|
||||
val onStop: () -> Unit,
|
||||
val onClose: () -> Unit,
|
||||
@@ -170,6 +178,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val host = rememberMenuHost()
|
||||
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
|
||||
if (open || veil > 0.01f) Box(
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = veil }.background(Color(0x8A000000))
|
||||
@@ -233,6 +242,40 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
|
||||
}
|
||||
}
|
||||
|
||||
if (a.isHomeApp) SettingsGroup("Android apps") {
|
||||
ActionRow("Launch an app", null, if (androidAppsExpanded) "Hide" else "Show") {
|
||||
androidAppsExpanded = !androidAppsExpanded
|
||||
}
|
||||
if (androidAppsExpanded) {
|
||||
if (a.androidApps.isEmpty()) {
|
||||
Text("No launchable apps found", fontSize = 12.sp, color = colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
for (app in a.androidApps) {
|
||||
MenuItem(
|
||||
app.label,
|
||||
checked = false,
|
||||
leading = {
|
||||
app.icon?.let { icon ->
|
||||
Image(
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(26.dp).clip(RoundedCornerShape(6.dp)),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { a.onLaunchAndroidApp(app) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (a.isHomeApp) {
|
||||
SettingsGroup("Home screen") {
|
||||
ActionRow("Default Home app", a.defaultHomeLabel?.let { "Currently using $it" }, "Change") { a.onHomeApp() }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsGroup("Now") {
|
||||
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn, onChange = a.onHud)
|
||||
val fgOpen = host.open == "fg"
|
||||
@@ -282,7 +325,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
|
||||
SettingsGroup("Next session") {
|
||||
ChoiceRow(
|
||||
host, "shape", "Display shape", null,
|
||||
listOf(SessionPrefs.SHAPE_AUTO to "The panel's shape", SessionPrefs.SHAPE_WIDE to "16:9 with bars"), a.shapeMode, onPick = a.onShape,
|
||||
SessionPrefs.shapeChoices, a.shapeMode, onPick = a.onShape,
|
||||
)
|
||||
if (a.steam) ChoiceRow(
|
||||
host, "fex", "FEX preset", null,
|
||||
@@ -292,8 +335,11 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
SecondaryButton("Send to background") { host.open = null; a.onBackground() }
|
||||
Row(
|
||||
horizontalArrangement = if (a.isHomeApp) Arrangement.End else Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (!a.isHomeApp) SecondaryButton("Send to background") { host.open = null; a.onBackground() }
|
||||
DangerButton("Stop session") { host.open = null; a.onStop() }
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
@@ -129,7 +129,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
|
||||
@Composable
|
||||
fun MenuItem(
|
||||
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
|
||||
trailing: (@Composable () -> Unit)? = null, onClick: () -> Unit,
|
||||
trailing: (@Composable () -> Unit)? = null, leading: (@Composable () -> Unit)? = null, onClick: () -> Unit,
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -147,6 +147,10 @@ fun MenuItem(
|
||||
.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
) {
|
||||
Text("✓", fontSize = 12.sp, color = pal.signal, modifier = Modifier.width(18.dp).alpha(if (checked) 1f else 0f))
|
||||
if (leading != null) {
|
||||
leading()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(label, fontSize = 13.5.sp, color = if (checked) pal.signal else colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (detail != null) Text(detail, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -6,6 +6,25 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-23 (afternoon) - main `90c7574`: first-run onboarding, Desktop & apps page, 4:3 display
|
||||
|
||||
State: **main = `90c7574`** (PR #14 merge), main build run 35895701784. No tag, no release; next version is still open. **Nothing below is device-tested** except the audio default (Kurt, AYN Thor).
|
||||
|
||||
- **PR #11 → `f79dc2d`** (`fix/directaudio-default`): the Steam client's audio is back on the 0.1.5 classic sink by default (`module-aaudio-classic-sink.so`, byte-identical to 0.1.5's arm64 module); DirectAudio is a choice in the Steam cog ("Steam client audio", pref `clientDirectAudio`, default off). Games keep DirectAudio on by default; mic on by default, RECORD_AUDIO asked once. Old armhf sink out of the bundle; the bundle re-unpacks when its contents change (stamp = BUNDLE_STAMP + CRC32 of pulseaudio.tzst). Relay logs a heartbeat line; Setup › Session logs › Share latest logs. Kurt on the Thor: classic "works perfectly".
|
||||
- **PR #12 → `0ddb0be`** (`feat/play-installs-runtime`):
|
||||
- `586c9c2` Play and Desktop UI are enabled with no runtime; the session's loading screen downloads, checks and unpacks it ("downloading the Linux runtime · 332 of 791 MB"), then starts the session. A failure ends on the loading screen with the reason.
|
||||
- `f437957` Desktop does the same: the runtime if missing, then the `desktop` package from `desktop.json` (426 MB), then LXQt. Play, Desktop UI and Desktop all give the non-Adreno warning before any download (`MainActivity.startSession`).
|
||||
- `cbd3bc9` Desktop & apps is a page in the pane (pageKey `apps`), not a dialog: a rail-style dropdown per tier with an installed/total pill (Native ARM64 open), per-package Install/Remove, and the installing package's step line and bar in its own row. Leaving the page does not stop an install.
|
||||
- **PR #14 → `90c7574`** (`fix/exact-panel-shape`), from a user report on a 4:3 handheld (RP Nova) - "the panel's shape" still gave 16:9:
|
||||
- Cause: `SessionActivity` sized the display at `maxOf(panel aspect, 16:9)` (the foldable guard), so on a 4:3 panel both choices were 16:9 with bars.
|
||||
- `9c47095` Shape › **Exactly this panel (4:3, 3:2…)** - the panel's own aspect, no floor. The other two unchanged; foldables still default to 16:9. In the cog and the in-session menu (`SessionPrefs.shapeChoices`).
|
||||
- `ad2f9f8` Resolution › **Custom…** per mode (`customRes.<mode>`, 320×240-3840×2160, evened) with 4:3 / 16:10 / 16:9 presets. It replaces the cap and the shape (Shape greys out); picking a cap clears it; the device report lists it. Not in the in-session menu.
|
||||
- Note: the branch was cut from `cbd3bc9`, not `0ddb0be` - same tree.
|
||||
- Staged: `SteamDeck-play-installs-runtime-586c9c2.apk` (`21460acd…`), `…-f437957.apk` (`174e74a6…`), `SteamDeck-apps-page-cbd3bc9.apk` (`c2cfbebe…`), `SteamDeck-custom-res-ad2f9f8.apk` (`d02ee5ce…`, = main's content). HTML preview of the onboarding flow: `/sdcard/Download/SteamDeck-onboarding-preview.html`.
|
||||
- CI note: a branch push does not build (`build.yml` = main + PRs); dispatch it with `gh workflow run build.yml --ref <branch>`. A newer dispatch on the same branch cancels the older run.
|
||||
- Open: Kurt's PR #13 "Add Steam second-screen controls" (`steamdeck-second-screen`) - not reviewed; now needs to merge onto `90c7574`.
|
||||
- **Next:** (1) fresh-install pass: uninstall, install, press Desktop first (runtime + desktop, ~1.2 GB), then Play with no download; (2) the Desktop & apps page on device (dropdowns, in-row bar, Back mid-install); (3) the Nova reporter tests Exactly this panel / Custom 960×720; (4) PR #13.
|
||||
|
||||
## 2026-09-23 - branch `feat/armada-and-lineage`
|
||||
|
||||
- Steam Deck mode: `-steamdeck` only; SteamOS helper stubs staged from the apk under `/usr/bin` and `/usr/bin/steamos-polkit-helpers` (the missing `steamos-update` there was the "Update Error" dialog; `jupiter-dock-updater --check` answers 7 so no dock firmware row); Deck mode defaults the `steamdeck_publicbeta` channel (on `publicbeta` every start reinstalled the client and lost the launch URL); client branch row in the Steam cog.
|
||||
|
||||
Reference in new issue
Block a user