diff --git a/app/build.gradle b/app/build.gradle index 3a6bd5a2..2b1647fe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index f8ac2bda..37e20a9e 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -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(null) + private var ready by mutableStateOf(false) + private var available by mutableStateOf(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(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 { diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index 387117fa..50e0ea9f 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -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 { // 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) diff --git a/app/src/main/java/com/steamdeck/launcher/session/LoadingPanel.kt b/app/src/main/java/com/steamdeck/launcher/session/LoadingPanel.kt deleted file mode 100644 index ca7c4cd4..00000000 --- a/app/src/main/java/com/steamdeck/launcher/session/LoadingPanel.kt +++ /dev/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(R.id.loading_step) - private val progress = root.findViewById(R.id.loading_progress) - private val elapsed = root.findViewById(R.id.loading_elapsed) - private val hint = root.findViewById(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+)\)""") - } -} diff --git a/app/src/main/java/com/steamdeck/launcher/session/LoadingState.kt b/app/src/main/java/com/steamdeck/launcher/session/LoadingState.kt new file mode 100644 index 00000000..d78fb299 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/session/LoadingState.kt @@ -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? { + 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+)\)""") + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/PerfHud.kt b/app/src/main/java/com/steamdeck/launcher/session/PerfHud.kt index 356a16a1..864da235 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/PerfHud.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/PerfHud.kt @@ -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 { diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionDrawer.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionDrawer.kt deleted file mode 100644 index 242e9e29..00000000 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionDrawer.kt +++ /dev/null @@ -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(R.id.drawer_hud) - private val frameGen = root.findViewById