Every menu, dialog and overlay on Jetpack Compose

The main screen, the loading overlay, the HUD line, the drawer and the three
dialogs are composables now, driven by state the activities own. The session's
SurfaceView and the on-screen pad stay Views underneath a single Compose layer,
which is what they are in any Compose app: a compositor surface and a canvas,
not menus. The XML layouts are gone; the activities extend ComponentActivity
and Back goes through its dispatcher.
This commit is contained in:
The412Banner committed 2026-09-19 16:05:54 -04:00
1 parent 18364845ad
commit aa7c71bfb6
15 files changed
+783 -741

No files matched your search

+12 -1
View File
@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
}
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
android {
namespace 'com.steamdeck.launcher'
@@ -82,6 +83,10 @@ android {
}
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
@@ -92,7 +97,13 @@ android {
}
dependencies {
// The runtime tarball is a zstd-compressed tar; nothing else in the app needs a library.
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.20'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.activity:activity-compose:1.8.2'
}
@@ -1,95 +1,117 @@
package com.steamdeck.launcher
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.gpu.LsfgNative
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.runtime.LinuxRuntimeInstaller
import com.steamdeck.launcher.ui.ConfirmDialog
import com.steamdeck.launcher.ui.CreditsDialog
import com.steamdeck.launcher.ui.FrameGenDialog
import com.steamdeck.launcher.ui.MainScreen
import com.steamdeck.launcher.ui.MainUiState
import com.steamdeck.launcher.ui.SteamDeckTheme
/**
* The whole app outside a session: is the runtime installed, is there a newer one, and one button
* that starts Steam. Everything a Steam client can do — the library, the store, downloads,
* settings — is the client's own job once {@link SessionActivity} has it on screen.
* The whole app outside a session: is the runtime installed, is there a newer one, frame
* generation, and one button that starts Steam. Everything a Steam client can do — the library,
* the store, downloads, settings — is the client's own job once [SessionActivity] has it on screen.
*/
class MainActivity : Activity() {
private lateinit var status: TextView
private lateinit var detail: TextView
private lateinit var progress: ProgressBar
private lateinit var playButton: Button
private lateinit var runtimeButton: Button
private lateinit var frameGenButton: Button
class MainActivity : ComponentActivity() {
private val ui = Handler(Looper.getMainLooper())
@Volatile private var busy = false
@Volatile private var available: LinuxRuntimeInstaller.Release? = null
// The screen's state. Compose redraws whatever reads these when they change.
private var installed by mutableStateOf<String?>(null)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
private var percent by mutableIntStateOf(-1)
private var failed by mutableStateOf(false)
private var frameGenLabel by mutableStateOf("Off")
private var showRemove by mutableStateOf(false)
private var showFrameGen by mutableStateOf(false)
private var showCredits by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
status = findViewById(R.id.status)
detail = findViewById(R.id.detail)
progress = findViewById(R.id.progress)
playButton = findViewById(R.id.play)
runtimeButton = findViewById(R.id.runtime)
frameGenButton = findViewById(R.id.frame_gen)
frameGenButton.setOnClickListener { FrameGen.showPicker(this) { render() } }
playButton.setOnClickListener { startActivity(Intent(this, SessionActivity::class.java)) }
runtimeButton.setOnClickListener { onRuntimeButton() }
findViewById<TextView>(R.id.credits).setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.credits_title)
.setMessage(R.string.credits_body)
.setPositiveButton(android.R.string.ok, null)
.show()
setContent {
SteamDeckTheme {
MainScreen(
state = MainUiState(
installed = installed, ready = ready,
available = available?.version,
availableSize = available?.let { FileUtils.sizeToString(it.size) },
busy = busy, stage = stage, percent = percent, failed = failed,
frameGenLabel = frameGenLabel,
),
onPlay = { startActivity(Intent(this, SessionActivity::class.java)) },
onRuntime = { onRuntimeButton() },
onFrameGen = { showFrameGen = true },
onCredits = { showCredits = true },
)
if (showRemove) ConfirmDialog(
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
confirm = "Remove",
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
onDismiss = { showRemove = false },
)
if (showFrameGen) FrameGenDialog(
engine = FrameGen.engine(this), multiplier = FrameGen.multiplier(this),
lsfgReady = LsfgNative.isInstalled(this),
onPick = { engine, multiplier ->
FrameGen.set(this, engine, multiplier)
frameGenLabel = FrameGen.label(this)
showFrameGen = false
},
onDismiss = { showFrameGen = false },
)
if (showCredits) CreditsDialog { showCredits = false }
}
}
// The session's logs land in Downloads so a failed run can be handed over as a folder
// rather than dug out of app-private storage. targetSdk 28 means the old permission still
// grants exactly that.
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
}
}
override fun onResume() {
super.onResume()
render()
refresh()
if (!busy) Thread({ checkCatalog() }, "catalog").start()
}
private fun refresh() {
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
frameGenLabel = FrameGen.label(this)
}
private fun onRuntimeButton() {
if (busy) return
val installed = LinuxRuntimeInstaller.installedVersion(this)
if (installed != null && available?.version == installed) {
val release = available
if (installed != null && release?.version == installed) {
// Nothing to install: offer the one destructive thing this screen can do.
AlertDialog.Builder(this)
.setTitle(R.string.remove_runtime)
.setMessage(R.string.remove_runtime_message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
Thread({
LinuxRuntimeInstaller.uninstall(this)
ui.post { render() }
}, "uninstall").start()
}
.show()
showRemove = true
return
}
val release = available
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -99,65 +121,25 @@ class MainActivity : Activity() {
private fun install(release: LinuxRuntimeInstaller.Release) {
busy = true
render()
failed = false
stage = "Starting…"
percent = -1
Thread({
val ok = LinuxRuntimeInstaller.install(this, release) { stage, percent ->
ui.post {
progress.isIndeterminate = percent < 0
if (percent >= 0) progress.progress = percent
detail.text = if (percent >= 0) "$stage $percent%" else stage
}
val ok = LinuxRuntimeInstaller.install(this, release) { s, p ->
ui.post { stage = s; percent = p }
}
busy = false
ui.post {
if (!ok) detail.text = getString(R.string.install_failed)
render()
busy = false
failed = !ok
refresh()
}
}, "install").start()
}
private fun checkCatalog() {
val release = LinuxRuntimeInstaller.fetchRelease()
if (release != null) available = release
Log.i(TAG, "catalog: " + (release?.version ?: "unreachable"))
ui.post { render() }
}
private fun render() {
frameGenButton.text = getString(R.string.frame_gen, FrameGen.label(this))
val installed = LinuxRuntimeInstaller.installedVersion(this)
val ready = LinuxRuntime.isInstalled(this)
progress.visibility = if (busy) View.VISIBLE else View.GONE
playButton.isEnabled = ready && !busy
runtimeButton.isEnabled = !busy
val offered = available
when {
busy -> {
status.setText(R.string.working)
runtimeButton.setText(R.string.working)
}
!ready -> {
status.setText(R.string.runtime_missing)
runtimeButton.setText(R.string.install_runtime)
detail.text = if (offered != null) {
getString(R.string.runtime_download_size,
offered.version, FileUtils.sizeToString(offered.size))
} else {
getString(R.string.catalog_unreachable)
}
}
offered != null && offered.version != installed -> {
status.setText(R.string.ready)
runtimeButton.setText(R.string.update_runtime)
detail.text = getString(R.string.runtime_update, installed ?: "?", offered.version)
}
else -> {
status.setText(R.string.ready)
runtimeButton.setText(R.string.remove_runtime)
detail.text = getString(R.string.runtime_installed, installed ?: "?")
}
}
ui.post { if (release != null) available = release }
}
companion object {
@@ -1,11 +1,10 @@
package com.steamdeck.launcher
import android.app.Activity
import android.hardware.input.InputManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.hardware.input.InputManager
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
@@ -15,19 +14,32 @@ import android.view.SurfaceView
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import com.steamdeck.launcher.core.FileUtils
import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.gpu.LsfgNative
import com.steamdeck.launcher.gpu.TurnipDriver
import com.steamdeck.launcher.input.EvdevKeys
import com.steamdeck.launcher.input.OnScreenControls
import com.steamdeck.launcher.input.PadBridge
import com.steamdeck.launcher.runtime.LinuxRuntime
import com.steamdeck.launcher.session.LoadingPanel
import com.steamdeck.launcher.session.LoadingState
import com.steamdeck.launcher.session.PerfHud
import com.steamdeck.launcher.session.SessionDrawer
import com.steamdeck.launcher.session.SessionPrefs
import com.steamdeck.launcher.session.SessionService
import com.steamdeck.launcher.session.SessionState
import com.steamdeck.launcher.ui.DrawerActions
import com.steamdeck.launcher.ui.FrameGenDialog
import com.steamdeck.launcher.ui.HudText
import com.steamdeck.launcher.ui.LoadingOverlay
import com.steamdeck.launcher.ui.SessionDrawer
import com.steamdeck.launcher.ui.SteamDeckTheme
import com.steamdeck.launcher.wayland.CompositorHost
import com.steamdeck.launcher.wayland.WaylandCompositor
import java.io.File
@@ -39,18 +51,26 @@ import java.io.File
* user leave Big Picture for another app and come back to it still signed in and still
* downloading.
*
* So there are only three jobs here: hold a Surface under the compositor, forward input, and show
* the session's milestones until its first frame arrives.
* Three layers: the SurfaceView the compositor draws into, the on-screen pad (a canvas View,
* since it is input rather than a menu), and one Compose layer on top for everything else —
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : Activity(), SurfaceHolder.Callback {
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var surfaceView: SurfaceView
private lateinit var loading: LoadingPanel
private lateinit var loading: LoadingState
private lateinit var hud: PerfHud
private lateinit var drawer: SessionDrawer
private var padBridge: PadBridge? = null
private var onScreenControls: OnScreenControls? = null
private var watching = true
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
private var showFrameGen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var frameGenLabel by mutableStateOf("Off")
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
/**
* Shows the on-screen pad when nothing is plugged in and takes it away the moment something
* is — a user with a controller in their hands should not be looking at buttons they cannot
@@ -77,44 +97,94 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
// Until the client's first frame arrives there is nothing on screen for a minute or more
// on a first run, so the session's own progress is shown instead of a black panel.
val panel = layoutInflater.inflate(R.layout.session_loading, root, false)
root.addView(panel)
loading = LoadingPanel(panel)
// The performance line, above everything but the loading panel; it starts with the
// client's first frame, since before that there is nothing to count.
hud = PerfHud(this)
root.addView(hud.view, root.indexOfChild(panel))
// Back opens this; it sits above everything, the loading panel included.
drawer = SessionDrawer(this, root,
onHudChanged = { hud.refresh() },
onOscChanged = { updateOnScreenControls() },
onFrameGenChanged = { applyFrameGen() },
onBackground = { moveTaskToBack(true) },
onStop = { SessionService.stop(this); finish() })
// A session that has already drawn is past its milestones; do not cover its picture.
if (SessionState.running && SessionState.firstFrameSeen) {
loading.hide()
hud.start()
} else {
loading.show()
}
setContentView(root)
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
padBridge = bridge
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it, 1) }
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) }
loading = LoadingState(this)
hud = PerfHud(this)
// A session that has already drawn is past its milestones; do not cover its picture.
if (SessionState.running && SessionState.firstFrameSeen) {
loading.visible = false
hud.start()
}
readPrefs()
// One Compose layer for every menu and overlay. Touches nothing in it consumes fall
// through to the pad and the game underneath.
root.addView(ComposeView(this).apply {
setContent {
SteamDeckTheme {
if (hud.text.isNotEmpty()) HudText(hud.text)
if (loading.visible) LoadingOverlay(loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended)
if (drawerOpen) SessionDrawer(DrawerActions(
hudOn = hudOn, frameGenLabel = frameGenLabel, oscMode = oscMode,
shapeMode = if (shapeMode == SessionPrefs.SHAPE_WIDE) "16:9" else "panel",
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFrameGen = { showFrameGen = true },
onOsc = {
val next = when (SessionPrefs.oscMode(this@SessionActivity)) {
SessionPrefs.OSC_AUTO -> SessionPrefs.OSC_ALWAYS
SessionPrefs.OSC_ALWAYS -> SessionPrefs.OSC_NEVER
else -> SessionPrefs.OSC_AUTO
}
SessionPrefs.setOscMode(this@SessionActivity, next)
readPrefs()
updateOnScreenControls()
},
onShape = {
val next = if (SessionPrefs.shapeMode(this@SessionActivity) == SessionPrefs.SHAPE_WIDE)
SessionPrefs.SHAPE_AUTO else SessionPrefs.SHAPE_WIDE
SessionPrefs.setShapeMode(this@SessionActivity, next)
readPrefs()
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onClose = { drawerOpen = false },
))
if (showFrameGen) FrameGenDialog(
engine = FrameGen.engine(this@SessionActivity),
multiplier = FrameGen.multiplier(this@SessionActivity),
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
onPick = { engine, multiplier ->
FrameGen.set(this@SessionActivity, engine, multiplier)
showFrameGen = false
readPrefs()
applyFrameGen()
},
onDismiss = { showFrameGen = false },
)
}
}
})
setContentView(root)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener {
SessionState.firstFrameSeen = true
runOnUiThread {
loading.hide()
loading.visible = false
hud.start()
}
}
SessionState.endListener = { status -> onSessionEnded(status) }
watchSessionLog()
// Back opens the drawer (and closes it again). Leaving the session running in the
// background and ending it are both actions in there, so neither can happen by accident
// from a button a game might also be reading.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (showFrameGen) showFrameGen = false else drawerOpen = !drawerOpen
}
})
watchSession()
}
private fun readPrefs() {
hudOn = SessionPrefs.hudEnabled(this)
frameGenLabel = FrameGen.label(this)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
}
// ── Compositor ──────────────────────────────────────────────────────────────────────────
@@ -133,8 +203,8 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
val size = outputSize()
SessionState.outputSize = size
SessionState.refreshHz = refreshHz()
// Letterbox, never stretch or crop: the output above can be a different shape from the
// panel now, and a game's picture must keep its proportions with bars, not lose its edges.
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
// and a game's picture must keep its proportions with bars, not lose its edges.
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
CompositorHost.startOrAttach(
holder.surface, runtimeDir.path,
@@ -164,8 +234,7 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
*/
private fun outputSize(): Pair<Int, Int> {
// The panel, not the window: resources.displayMetrics is what is left after the system
// bars and the cutout are taken out, which sized the first session 1920x968 on a 1080p
// device and had gamescope patch its EDID to match.
// bars and the cutout are taken out.
val bounds = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -177,10 +246,10 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself
// (FlatOut on a Fold lost the edges of its own menus). 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 and the
// compositor letterboxes the 16:9 picture onto a squarer panel.
// square display draws for the frame it was made for and cuts the sides off itself.
// 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)
// 1080 tall at most: the client's CEF is the heaviest thing in the session, and above
@@ -197,15 +266,31 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
return if (hz > 1f) hz else 60f
}
/**
* The saved frame-generation setting, pushed to the compositor. Off the main thread: LSFG's
* first use translates the shader chain out of Lossless.dll, which takes seconds.
*/
private fun applyFrameGen() {
val hz = refreshHz()
Thread({
val problem = FrameGen.apply(this, hz)
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
}, "frame-gen").start()
}
// ── Session state ───────────────────────────────────────────────────────────────────────
/** Keeps the loading panel current from the session log while it is showing. */
private fun watchSessionLog() {
/** Keeps the loading overlay current from the session log, and its clock moving. */
private fun watchSession() {
val handler = Handler(Looper.getMainLooper())
var ticks = 0
val poll = object : Runnable {
override fun run() {
if (!watching) return
if (loading.isVisible) loading.update(SessionState.logFile)
if (loading.visible && !loading.ended) {
loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
}
handler.postDelayed(this, 500)
}
}
@@ -216,8 +301,7 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
if (status != 0) {
loading.showEnded(getString(R.string.session_ended, status,
SessionState.logFile?.path ?: "-"))
loading.showEnded("The session ended ($status)\n${SessionState.logFile?.path ?: "-"}")
// A moment on screen, so a failure is readable rather than a flash of black.
Handler(Looper.getMainLooper()).postDelayed({ finish() }, 4000)
} else {
@@ -230,14 +314,12 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. This is how an account
// name and password get typed on the client's first run; the client's own on-screen
// keyboard covers a device without one, driven by touch or the pad.
if (CompositorHost.isStarted && event.device != null && !PadBridge.isFromController(event.device)) {
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
// activity, which opens the drawer.
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK
&& event.device != null && !PadBridge.isFromController(event.device)) {
val down = event.action == KeyEvent.ACTION_DOWN
if (down || event.action == KeyEvent.ACTION_UP) {
// An unmapped key may still be a real one on a foreign layout: its scan code is
// the evdev code the kernel gave Android in the first place.
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
if (evdev < 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
@@ -256,8 +338,9 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
/**
* Touch as a mouse: where you touch is where the pointer goes, and a tap is a left click.
* The compositor's pointer space is a fixed 1920x1080 whatever the output size, so the view's
* coordinates are scaled into it.
* The compositor's pointer space is a fixed 1920x1080, and the picture is letterboxed inside
* the view when the panel is a different shape from the output, so a touch is mapped through
* the fitted rectangle.
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
val width = surfaceView.width.takeIf { it > 0 } ?: return false
@@ -268,8 +351,6 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> 2
else -> return false
}
// The picture is letterboxed inside the view when the panel is a different shape from the
// output, so a touch is mapped through the fitted rectangle, not the whole view.
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
@@ -282,18 +363,6 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
return true
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
/**
* Back opens the drawer (and closes it again). Leaving the session running in the background
* and ending it are both actions in there, so neither can happen by accident from a button
* a game might also be reading.
*/
@Suppress("DEPRECATION")
override fun onBackPressed() {
drawer.toggle()
}
/**
* The drawer's mode ("always" / "never") decides outright; on "auto" the controls follow
* what is attached. `steamdeck-osc` in Downloads still overrides, for a device we cannot reach.
@@ -315,26 +384,15 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
Log.i(TAG, "on-screen controls " + (if (show) "shown" else "hidden"))
}
/**
* The saved frame-generation setting, pushed to the compositor. Off the main thread: LSFG's
* first use translates the shader chain out of Lossless.dll, which takes seconds.
*/
private fun applyFrameGen() {
val hz = refreshHz()
Thread({
val problem = FrameGen.apply(this, hz)
if (problem != null) runOnUiThread {
android.widget.Toast.makeText(this, problem, android.widget.Toast.LENGTH_LONG).show()
}
}, "frame-gen").start()
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
override fun onResume() {
super.onResume()
if (CompositorHost.isStarted) applyFrameGen()
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
updateOnScreenControls()
readPrefs()
if (CompositorHost.isStarted) applyFrameGen()
}
override fun onPause() {
@@ -369,7 +427,7 @@ class SessionActivity : Activity(), SurfaceHolder.Callback {
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
watching = false
hud.stop()
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
SessionState.endListener = null
WaylandCompositor.setFirstFrameListener(null)
@@ -1,151 +0,0 @@
package com.steamdeck.launcher.session
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import com.steamdeck.launcher.R
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.StandardCharsets
/**
* The loading panel's brain: what to say, read out of the session log, and a clock so a user can
* see that time is passing even when the log is quiet. The runtime script marks its milestones
* with "== STEP"; the client's own bootstrap does not, but it does print its download progress,
* and lifting that out is the difference between "starting the Steam client" for three minutes
* and a percentage that moves.
*/
class LoadingPanel(private val root: View) {
private val step = root.findViewById<TextView>(R.id.loading_step)
private val progress = root.findViewById<ProgressBar>(R.id.loading_progress)
private val elapsed = root.findViewById<TextView>(R.id.loading_elapsed)
private val hint = root.findViewById<TextView>(R.id.loading_hint)
private val hints = root.resources.getStringArray(R.array.loading_hints)
private val handler = Handler(Looper.getMainLooper())
private val startedAt = SystemClock.elapsedRealtime()
private var ticking = false
private val tick = object : Runnable {
override fun run() {
if (!ticking) return
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
elapsed.text = root.context.getString(R.string.loading_elapsed,
String.format(java.util.Locale.US, "%d:%02d", seconds / 60, seconds % 60))
// A new hint every eight seconds; the first one is the one that matters most.
hint.text = hints[((seconds / 8) % hints.size).toInt()]
handler.postDelayed(this, 1000)
}
}
val isVisible: Boolean get() = root.visibility == View.VISIBLE
fun show() {
root.visibility = View.VISIBLE
if (!ticking) {
ticking = true
handler.post(tick)
}
}
fun hide() {
ticking = false
root.visibility = View.GONE
}
/** The session ended without a picture: say so where the milestones were. */
fun showEnded(message: String) {
show()
ticking = false
step.text = message
progress.visibility = View.INVISIBLE
elapsed.text = ""
hint.text = ""
}
/** Re-reads the end of the log and updates the line and the bar. Call from the UI thread. */
fun update(log: File?) {
val state = read(log) ?: return
if (step.text != state.message) step.text = state.message
if (state.percent < 0) {
if (!progress.isIndeterminate) progress.isIndeterminate = true
} else {
if (progress.isIndeterminate) progress.isIndeterminate = false
progress.progress = state.percent
}
}
private class State(val message: String, val percent: Int)
/**
* Only the tail is read: the client alone writes megabytes an hour, and this runs twice a
* second for as long as the panel is up.
*/
private fun read(log: File?): State? {
if (log == null || !log.isFile) return null
val text = try {
RandomAccessFile(log, "r").use { file ->
val length = file.length()
val want = minOf(length, TAIL_BYTES)
file.seek(length - want)
val bytes = ByteArray(want.toInt())
file.readFully(bytes)
String(bytes, StandardCharsets.UTF_8)
}
} catch (e: Exception) {
return null
}
var stepAt = -1
var stepText: String? = null
var downloadAt = -1
var downloadPercent = -1
var clientDownloadAt = -1
var clientDownload: String? = null
var clientPercent = -1
var offset = 0
for (line in text.split('\n')) {
val at = offset
offset += line.length + 1
when {
line.startsWith("== STEP ") -> {
stepAt = at
stepText = line.substringAfter("== STEP ").substringAfter(' ')
// "downloading Steam: bins_hardware_all (1/17)" — the installer's own count.
val m = INSTALL_COUNT.find(line)
if (m != null) {
clientDownloadAt = at
clientDownload = m.groupValues[1]
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
}
}
else -> {
// "Downloading update (350129 of 666167 KB)..." — the client's bootstrap.
val m = UPDATE_PROGRESS.find(line)
if (m != null) {
downloadAt = at
val done = m.groupValues[1].toLong()
val total = maxOf(1L, m.groupValues[2].toLong())
downloadPercent = (done * 100 / total).toInt()
}
}
}
}
val context = root.context
return when {
downloadAt > stepAt && downloadPercent >= 0 ->
State(context.getString(R.string.loading_download, downloadPercent), downloadPercent)
clientDownloadAt == stepAt && clientDownload != null ->
State(context.getString(R.string.loading_client_download, clientDownload), clientPercent)
stepText != null -> State(stepText, -1)
else -> null
}
}
companion object {
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
}
}
@@ -0,0 +1,111 @@
package com.steamdeck.launcher.session
import android.content.Context
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import java.io.File
import java.io.RandomAccessFile
import java.nio.charset.StandardCharsets
/**
* The loading overlay's state: what to say, read out of the session log, and a clock so a user
* can see that time is passing even when the log is quiet. The runtime script marks its
* milestones with "== STEP"; the client's own bootstrap does not, but it does print its download
* progress, and lifting that out is the difference between "starting the Steam client" for three
* minutes and a percentage that moves.
*/
class LoadingState(context: Context) {
var visible by mutableStateOf(true)
var step by mutableStateOf("Starting the session…")
var percent by mutableIntStateOf(-1)
var elapsed by mutableStateOf("")
var hint by mutableStateOf("")
var ended by mutableStateOf(false)
private val hints = context.resources.getStringArray(com.steamdeck.launcher.R.array.loading_hints)
private val startedAt = SystemClock.elapsedRealtime()
/** Once a second: the clock and the hint. */
fun tick() {
val seconds = (SystemClock.elapsedRealtime() - startedAt) / 1000
elapsed = String.format(java.util.Locale.US, "%d:%02d elapsed · still working", seconds / 60, seconds % 60)
hint = hints[((seconds / 8) % hints.size).toInt()]
}
fun showEnded(message: String) {
visible = true
ended = true
step = message
}
/** Re-reads the end of the log and updates the line and the bar. */
fun update(context: Context, log: File?) {
val state = read(context, log) ?: return
step = state.first
percent = state.second
}
/** Only the tail is read: the client alone writes megabytes an hour. */
private fun read(context: Context, log: File?): Pair<String, Int>? {
if (log == null || !log.isFile) return null
val text = try {
RandomAccessFile(log, "r").use { file ->
val length = file.length()
val want = minOf(length, TAIL_BYTES)
file.seek(length - want)
val bytes = ByteArray(want.toInt())
file.readFully(bytes)
String(bytes, StandardCharsets.UTF_8)
}
} catch (e: Exception) {
return null
}
var stepAt = -1
var stepText: String? = null
var downloadAt = -1
var downloadPercent = -1
var clientDownloadAt = -1
var clientDownload: String? = null
var clientPercent = -1
var offset = 0
for (line in text.split('\n')) {
val at = offset
offset += line.length + 1
if (line.startsWith("== STEP ")) {
stepAt = at
stepText = line.substringAfter("== STEP ").substringAfter(' ')
val m = INSTALL_COUNT.find(line)
if (m != null) {
clientDownloadAt = at
clientDownload = m.groupValues[1]
clientPercent = m.groupValues[2].toInt() * 100 / maxOf(1, m.groupValues[3].toInt())
}
} else {
val m = UPDATE_PROGRESS.find(line)
if (m != null) {
downloadAt = at
val done = m.groupValues[1].toLong()
val total = maxOf(1L, m.groupValues[2].toLong())
downloadPercent = (done * 100 / total).toInt()
}
}
}
return when {
downloadAt > stepAt && downloadPercent >= 0 ->
Pair("Downloading the Steam client update · $downloadPercent%", downloadPercent)
clientDownloadAt == stepAt && clientDownload != null ->
Pair("Downloading the Steam client · $clientDownload", clientPercent)
stepText != null -> Pair(stepText, -1)
else -> null
}
}
companion object {
private const val TAIL_BYTES = 48L * 1024
private val UPDATE_PROGRESS = Regex("""Downloading update \((\d+) of (\d+) KB\)""")
private val INSTALL_COUNT = Regex("""downloading Steam: (\S+) \((\d+)/(\d+)\)""")
}
}
@@ -1,17 +1,13 @@
package com.steamdeck.launcher.session
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.steamdeck.launcher.gpu.FrameGen
import com.steamdeck.launcher.wayland.WaylandCompositor
import java.io.File
@@ -19,36 +15,24 @@ import java.util.Locale
import java.util.concurrent.atomic.AtomicInteger
/**
* A line of numbers in the top-right corner: the game's own frame rate and, when frame generation
* is on, what the screen is actually being shown — "60 → 118 fps" is the proof that the engine is
* doing something, and "FG starting" or a reason is the proof that it is not.
* The numbers behind the top-right line: the game's own frame rate and, when frame generation
* is on, what the screen is actually being shown — "60 → 118 fps" is the proof that the engine
* is doing something, and "FG starting" or a reason is the proof that it is not.
*
* The base rate is counted here from the compositor's per-frame callback, so it is right with or
* without an engine. The presented rate comes from the engine's own telemetry while it generates.
* `steamdeck-no-hud` in Downloads hides it.
*/
class PerfHud(context: Context) {
val view: TextView = TextView(context).apply {
typeface = Typeface.MONOSPACE
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(Color.WHITE)
setBackgroundColor(Color.argb(140, 0, 0, 0))
setPadding(14, 6, 14, 6)
// Numbers only; every touch goes through to the game.
isClickable = false
isFocusable = false
visibility = View.GONE
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.END,
).apply { topMargin = 12; rightMargin = 12 }
}
/** Empty when the HUD is off or not yet started. */
var text by mutableStateOf("")
private set
private val context = context.applicationContext
private val frames = AtomicInteger()
private var lastTick = 0L
private var running = false
private val handler = Handler(Looper.getMainLooper())
/** The session's own switch (drawer), with the Downloads file as a device-side override. */
private val enabled: Boolean
get() = SessionPrefs.hudEnabled(context)
@@ -65,8 +49,7 @@ class PerfHud(context: Context) {
val now = SystemClock.elapsedRealtime()
val dt = (now - lastTick).coerceAtLeast(1L) / 1000f
lastTick = now
val base = frames.getAndSet(0) / dt
view.text = line(base)
text = line(frames.getAndSet(0) / dt)
handler.postDelayed(this, 1000)
}
}
@@ -82,14 +65,13 @@ class PerfHud(context: Context) {
lastTick = SystemClock.elapsedRealtime()
frames.set(0)
WaylandCompositor.setGameListener(listener)
view.visibility = View.VISIBLE
handler.post(tick)
}
fun stop() {
running = false
WaylandCompositor.setGameListener(null)
view.visibility = View.GONE
text = ""
}
private fun line(base: Float): String {
@@ -1,99 +0,0 @@
package com.steamdeck.launcher.session
import android.app.Activity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.Switch
import com.steamdeck.launcher.R
import com.steamdeck.launcher.gpu.FrameGen
/**
* The drawer Back opens over a running session. It owns nothing: every control reads and writes
* a preference or calls back into the activity, so closing it changes nothing by itself.
*/
class SessionDrawer(
private val activity: Activity,
parent: ViewGroup,
private val onHudChanged: (Boolean) -> Unit,
private val onOscChanged: () -> Unit,
private val onFrameGenChanged: () -> Unit,
private val onBackground: () -> Unit,
private val onStop: () -> Unit,
) {
private val root: View = activity.layoutInflater.inflate(R.layout.session_drawer, parent, false)
private val hud = root.findViewById<Switch>(R.id.drawer_hud)
private val frameGen = root.findViewById<Button>(R.id.drawer_frame_gen)
private val osc = root.findViewById<Button>(R.id.drawer_osc)
private val shape = root.findViewById<Button>(R.id.drawer_shape)
init {
root.visibility = View.GONE
parent.addView(root)
// The dimmed area closes; the panel itself swallows its touches.
root.setOnClickListener { close() }
root.findViewById<View>(R.id.drawer_panel).setOnClickListener { }
hud.setOnCheckedChangeListener { _, on ->
SessionPrefs.setHudEnabled(activity, on)
onHudChanged(on)
}
frameGen.setOnClickListener {
FrameGen.showPicker(activity) {
refresh()
onFrameGenChanged()
}
}
osc.setOnClickListener {
val next = when (SessionPrefs.oscMode(activity)) {
SessionPrefs.OSC_AUTO -> SessionPrefs.OSC_ALWAYS
SessionPrefs.OSC_ALWAYS -> SessionPrefs.OSC_NEVER
else -> SessionPrefs.OSC_AUTO
}
SessionPrefs.setOscMode(activity, next)
refresh()
onOscChanged()
}
shape.setOnClickListener {
val next = if (SessionPrefs.shapeMode(activity) == SessionPrefs.SHAPE_WIDE) SessionPrefs.SHAPE_AUTO
else SessionPrefs.SHAPE_WIDE
SessionPrefs.setShapeMode(activity, next)
refresh()
}
root.findViewById<View>(R.id.drawer_background).setOnClickListener {
close()
onBackground()
}
root.findViewById<View>(R.id.drawer_stop).setOnClickListener {
close()
onStop()
}
}
val isOpen: Boolean get() = root.visibility == View.VISIBLE
fun open() {
refresh()
root.visibility = View.VISIBLE
root.bringToFront()
}
fun close() {
root.visibility = View.GONE
}
fun toggle() = if (isOpen) close() else open()
private fun refresh() {
hud.isChecked = SessionPrefs.hudEnabled(activity)
frameGen.text = activity.getString(R.string.frame_gen, FrameGen.label(activity))
val mode = when (SessionPrefs.oscMode(activity)) {
SessionPrefs.OSC_ALWAYS -> activity.getString(R.string.osc_always)
SessionPrefs.OSC_NEVER -> activity.getString(R.string.osc_never)
else -> activity.getString(R.string.osc_auto)
}
osc.text = activity.getString(R.string.drawer_osc, mode)
shape.text = activity.getString(R.string.drawer_shape,
if (SessionPrefs.shapeMode(activity) == SessionPrefs.SHAPE_WIDE) activity.getString(R.string.shape_wide)
else activity.getString(R.string.shape_auto))
}
}
@@ -0,0 +1,67 @@
package com.steamdeck.launcher.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.steamdeck.launcher.gpu.FrameGen
/**
* Off, or an engine at 2x/3x/4x. The LSFG rows are dimmed and unpickable until Lossless Scaling
* is installed in the Steam client — the engine cannot run without its DLL, so offering it would
* only produce a setting that quietly does nothing.
*/
@Composable
fun FrameGenDialog(
engine: String,
multiplier: Int,
lsfgReady: Boolean,
onPick: (engine: String, multiplier: Int) -> Unit,
onDismiss: () -> Unit,
) {
data class Row(val label: String, val engine: String, val multiplier: Int, val enabled: Boolean)
val note = if (lsfgReady) "" else " — install Lossless Scaling in Steam"
val rows = listOf(Row("Off", FrameGen.ENGINE_OFF, 2, true)) +
(2..4).map { Row("Win-FG ${it}×", FrameGen.ENGINE_WINFG, it, true) } +
(2..4).map { Row("LSFG ${it}×$note", FrameGen.ENGINE_LSFG, it, lsfgReady) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Frame generation") },
text = {
Column {
Text(
"Extra frames are generated between the real ones on the way to the screen, so a game running at 30 fps looks like 60. Takes effect at once, mid-game included.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
for (row in rows) {
val selected = row.engine == engine && (row.engine == FrameGen.ENGINE_OFF || row.multiplier == multiplier)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.alpha(if (row.enabled) 1f else 0.38f)
.clickable(enabled = row.enabled) { onPick(row.engine, row.multiplier) }
.padding(vertical = 2.dp),
) {
RadioButton(selected = selected, onClick = null, enabled = row.enabled)
Text(row.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(start = 4.dp))
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
@@ -0,0 +1,181 @@
package com.steamdeck.launcher.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.steamdeck.launcher.R
/** Everything the main screen shows; the activity owns the values and the work behind them. */
class MainUiState(
val installed: String?,
val ready: Boolean,
val available: String?,
val availableSize: String?,
val busy: Boolean,
val stage: String,
val percent: Int,
val failed: Boolean,
val frameGenLabel: String,
)
/**
* The whole app outside a session, in one scrolling column that stays a sensible width on a wide
* panel: is the runtime installed, is there a newer one, frame generation, and Play.
*/
@Composable
fun MainScreen(
state: MainUiState,
onPlay: () -> Unit,
onRuntime: () -> Unit,
onFrameGen: () -> Unit,
onCredits: () -> Unit,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.TopCenter,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
) {
// The column never spans a landscape panel: the buttons stay a readable width.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.widthIn(max = 420.dp).fillMaxWidth(),
) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = null,
modifier = Modifier.size(if (maxWidth > 600.dp) 88.dp else 72.dp),
)
Spacer(Modifier.height(8.dp))
Text("SteamDeck", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onBackground)
Text(
"Valve's native ARM64 Steam client, under gamescope",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(18.dp))
val status = when {
state.busy -> "Working…"
!state.ready -> "Linux runtime not installed"
else -> "Ready"
}
Text(status, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
val detail = when {
state.busy -> if (state.percent >= 0) "${state.stage} ${state.percent}%" else state.stage
state.failed -> "Install failed — nothing was changed"
!state.ready -> state.available?.let { "$it · ${state.availableSize} download, one time" }
?: "Could not reach the runtime catalog"
state.available != null && state.available != state.installed ->
"Runtime ${state.installed ?: "?"} installed · ${state.available} available"
else -> "Runtime ${state.installed ?: "?"} installed"
}
Text(
detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
if (state.busy) {
Spacer(Modifier.height(8.dp))
if (state.percent >= 0) {
LinearProgressIndicator(progress = { state.percent / 100f }, modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
Spacer(Modifier.height(18.dp))
Button(onClick = onPlay, enabled = state.ready && !state.busy, modifier = Modifier.fillMaxWidth()) {
Text("Play")
}
val runtimeLabel = when {
state.busy -> "Working…"
!state.ready -> "Install Linux runtime"
state.available != null && state.available != state.installed -> "Update Linux runtime"
else -> "Remove Linux runtime"
}
OutlinedButton(onClick = onRuntime, enabled = !state.busy, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text(runtimeLabel)
}
OutlinedButton(onClick = onFrameGen, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Frame generation: ${state.frameGenLabel}")
}
Spacer(Modifier.height(14.dp))
Text(
"by The412Banner and maxjivi05 · credits",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(onClick = onCredits).padding(6.dp),
)
}
}
}
}
@Composable
fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = { TextButton(onClick = { onDismiss(); onConfirm() }) { Text(confirm) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Composable
fun CreditsDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Credits") },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("The412Banner — the app, the Wayland compositor, the runtime and the Steam session.", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(10.dp))
Text("maxjivi05 (Max) — the gamescope runtime this is built on: the proot session, the session shim, the fake-evdev interposer and the controller work, from WinNative.", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(10.dp))
Text(
"GPL-3.0. Valve, Steam, Steam Deck and Proton are trademarks of Valve Corporation; this project is not affiliated with Valve. gamescope, Mesa, Turnip, Xwayland, PulseAudio, proot and Arch Linux ARM are their authors' own.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
)
}
@@ -0,0 +1,145 @@
package com.steamdeck.launcher.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.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
/** A line of numbers in the top-right corner. It takes no touches: everything goes to the game. */
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
Text(
text,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color.White,
modifier = Modifier
.padding(12.dp)
.background(Color(0x8C000000))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
/**
* What the user sees until the client draws its first frame: the runtime's milestone, the
* client's download progress lifted out of its log, a clock, and a hint. Opaque, so a stale
* frame from an earlier session never shows through, and it swallows touches.
*/
@Composable
fun LoadingOverlay(step: String, percent: Int, elapsed: String, hint: String, ended: Boolean) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(interactionSource = MutableInteractionSource(), indication = null) {}
.padding(32.dp),
) {
Image(painterResource(R.drawable.logo), contentDescription = null, modifier = Modifier.size(64.dp))
Spacer(Modifier.height(14.dp))
Text(if (ended) "The session ended" else "Steam is loading", color = Color.White, fontSize = 17.sp)
Text(step, color = Color(0xFFB8C4D0), fontSize = 13.sp, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 6.dp))
if (!ended) {
Spacer(Modifier.height(14.dp))
if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.width(220.dp))
else LinearProgressIndicator(modifier = Modifier.width(220.dp))
Text(elapsed, color = Color(0xFF667788), fontSize = 11.sp, modifier = Modifier.padding(top = 8.dp))
Text(
hint, color = Color(0xFF667788), fontSize = 11.sp, textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 18.dp).widthIn(max = 360.dp),
)
}
}
}
/** Everything the drawer shows and does. */
class DrawerActions(
val hudOn: Boolean,
val frameGenLabel: String,
val oscMode: String,
val shapeMode: String,
val onHud: (Boolean) -> Unit,
val onFrameGen: () -> Unit,
val onOsc: () -> Unit,
val onShape: () -> Unit,
val onBackground: () -> Unit,
val onStop: () -> Unit,
val onClose: () -> Unit,
)
/**
* The drawer Back opens over a running session: the switches a player reaches for mid-game, and
* the two things that leave the session. A tap on the dimmed area closes it.
*/
@Composable
fun SessionDrawer(a: DrawerActions) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x66000000))
.clickable(interactionSource = MutableInteractionSource(), indication = null, onClick = a.onClose),
contentAlignment = Alignment.CenterEnd,
) {
Column(
modifier = Modifier
.fillMaxHeight()
.width(264.dp)
.background(Color(0xF2151A22))
// The panel swallows its own touches so they do not close it.
.clickable(interactionSource = MutableInteractionSource(), indication = null) {}
.padding(16.dp),
) {
Text("SteamDeck", color = Color.White, fontSize = 16.sp, modifier = Modifier.padding(bottom = 12.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("Performance HUD", color = Color(0xFFDDDDDD), fontSize = 13.sp, modifier = Modifier.weight(1f))
Switch(checked = a.hudOn, onCheckedChange = a.onHud)
}
OutlinedButton(onClick = a.onFrameGen, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
Text("Frame generation: ${a.frameGenLabel}", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onOsc, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("On-screen controls: ${a.oscMode}", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onShape, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Display shape: ${a.shapeMode} · next session", fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = a.onBackground, modifier = Modifier.fillMaxWidth()) {
Text("Send to background", fontSize = 13.sp)
}
OutlinedButton(onClick = a.onStop, modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
Text("Stop session", fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
}
}
}
}
@@ -0,0 +1,25 @@
package com.steamdeck.launcher.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** One dark scheme, the icon's purple as the accent. The app is never light. */
private val Scheme = darkColorScheme(
primary = Color(0xFFC77DFF),
onPrimary = Color(0xFF1B0730),
secondary = Color(0xFF8899A6),
background = Color(0xFF101418),
onBackground = Color(0xFFDDDDDD),
surface = Color(0xFF151A22),
onSurface = Color(0xFFDDDDDD),
surfaceVariant = Color(0xFF1E2530),
onSurfaceVariant = Color(0xFF8899A6),
error = Color(0xFFFF8A80),
)
@Composable
fun SteamDeckTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = Scheme, content = content)
}
-113
View File
@@ -1,113 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Scrolls, and sizes itself from dimens that follow the window width: the first cut of this
screen put the install button under the navigation bar on a landscape phone with no way to
reach it. Everything here is small on purpose — it is a launcher, not the app.
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#FF101418"
android:fillViewport="true"
android:fitsSystemWindows="true"
android:scrollbars="none">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingStart="@dimen/page_side"
android:paddingEnd="@dimen/page_side"
android:paddingTop="20dp"
android:paddingBottom="20dp">
<ImageView
android:id="@+id/logo"
android:layout_width="@dimen/logo_size"
android:layout_height="@dimen/logo_size"
android:layout_marginBottom="8dp"
android:contentDescription="@string/app_name"
android:src="@drawable/logo" />
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#FFFFFFFF"
android:textSize="22sp" />
<TextView
android:id="@+id/subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/subtitle"
android:textColor="#FF8899A6"
android:textSize="12sp"
android:layout_marginBottom="18dp" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFDDDDDD"
android:textSize="15sp" />
<TextView
android:id="@+id/detail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#FF8899A6"
android:textSize="12sp"
android:layout_marginTop="4dp" />
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
android:visibility="gone" />
<Button
android:id="@+id/play"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:minHeight="40dp"
android:textSize="14sp"
android:text="@string/play" />
<Button
android:id="@+id/runtime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp"
android:text="@string/install_runtime" />
<Button
android:id="@+id/frame_gen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp"
android:text="@string/frame_gen" />
<TextView
android:id="@+id/credits"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:padding="6dp"
android:text="@string/credits_line"
android:textColor="#FF667788"
android:textSize="11sp" />
</LinearLayout>
</ScrollView>
@@ -1,90 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The in-session drawer, opened with Back. A tap on the dimmed area or Back again closes it.
Small on purpose: the switches a player reaches for mid-game, nothing that needs the main
screen.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/drawer_scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#66000000"
android:clickable="true"
android:focusable="true">
<LinearLayout
android:id="@+id/drawer_panel"
android:layout_width="264dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:orientation="vertical"
android:background="#F2151A22"
android:clickable="true"
android:focusable="true"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#FFFFFFFF"
android:textSize="16sp"
android:layout_marginBottom="12dp" />
<Switch
android:id="@+id/drawer_hud"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:text="@string/drawer_hud"
android:textColor="#FFDDDDDD"
android:textSize="13sp" />
<Button
android:id="@+id/drawer_frame_gen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:minHeight="40dp"
android:textSize="13sp" />
<Button
android:id="@+id/drawer_osc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp" />
<Button
android:id="@+id/drawer_shape"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/drawer_background"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:textSize="13sp"
android:text="@string/drawer_background" />
<Button
android:id="@+id/drawer_stop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:minHeight="40dp"
android:textSize="13sp"
android:textColor="#FFFF8A80"
android:text="@string/stop_session" />
</LinearLayout>
</FrameLayout>
@@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
What the user sees until the client draws its first frame, which on a first run is minutes:
the runtime's own milestones, the client's download progress lifted out of its log, a clock
that proves time is passing, and a hint about what all of this is. Sized small and centred so
it reads the same on a phone and a tablet.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="#FF000000"
android:padding="32dp">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginBottom="14dp"
android:contentDescription="@string/app_name"
android:src="@drawable/logo" />
<TextView
android:id="@+id/loading_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/loading_title"
android:textColor="#FFFFFFFF"
android:textSize="17sp" />
<TextView
android:id="@+id/loading_step"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:text="@string/session_starting"
android:textColor="#FFB8C4D0"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/loading_progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="220dp"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:indeterminate="true"
android:max="100" />
<TextView
android:id="@+id/loading_elapsed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#FF667788"
android:textSize="11sp" />
<TextView
android:id="@+id/loading_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:maxWidth="360dp"
android:textColor="#FF667788"
android:textSize="11sp" />
</LinearLayout>
+1
View File
@@ -7,5 +7,6 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:8.8.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
classpath 'org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21'
}
}