Compare commits

...
4 changed files with 196 additions and 28 deletions

No files matched your search

@@ -89,6 +89,9 @@ class MainActivity : ComponentActivity() {
private var flood by mutableStateOf<PendingFlood?>(null)
private var floodProgress by androidx.compose.runtime.mutableFloatStateOf(0f)
private class PendingFlood(val from: androidx.compose.ui.geometry.Rect, val intent: Intent)
/** A session stopped behind a flood: its blue, drawn back into the button it started from (FloodReturn). */
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
@@ -481,6 +484,13 @@ class MainActivity : ComponentActivity() {
)
if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
}
}
}
}
@@ -557,6 +567,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
@@ -592,6 +606,7 @@ class MainActivity : ComponentActivity() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
returning = null
floodProgress = 0f
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
super.onStop()
@@ -29,6 +29,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
@@ -144,6 +146,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
/** A stop growing out of the dialog's Stop (overlay px); the session ends once it covers the screen. */
private var quitFrom by mutableStateOf<androidx.compose.ui.geometry.Rect?>(null)
/** Left behind that flood: the front end opens on its blue, so no sink of our own. */
private var quitFlooded = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
@@ -394,6 +400,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onBackground = { drawerOpen = false; moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
drawerOpen = false
if (onScreen == null || com.droiddeck.launcher.ui.Motion.scale == 0f || quitFrom != null) {
if (quitFrom == null) { SessionService.stop(this@SessionActivity); finish() }
} else {
val at = IntArray(2).also { sessionOverlay.getLocationOnScreen(it) }
quitFrom = onScreen.translate(-at[0].toFloat(), -at[1].toFloat())
}
},
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
@@ -403,6 +418,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
)
quitFrom?.let { from ->
val error = androidx.compose.material3.MaterialTheme.colorScheme.error
com.droiddeck.launcher.ui.LaunchFlood(
from, onProgress = {},
fromColors = error to androidx.compose.ui.graphics.lerp(error, androidx.compose.ui.graphics.Color.Black, 0.12f),
cornerAtRest = 20.dp,
) { finishFlooded() }
}
}
}
}
@@ -1554,10 +1577,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onDestroy()
}
/** The screen is all blue: the session stops and the front end opens on the same blue (FloodReturn). */
private fun finishFlooded() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
SessionService.stop(this)
finish()
}
/** Leaving the session sinks its surface back down onto the front end. */
override fun finish() {
super.finish()
overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
companion object {
@@ -36,9 +36,18 @@ import kotlinx.coroutines.coroutineScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.runtime.snapshotFlow
import androidx.compose.animation.core.spring
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.unit.Dp
import java.lang.ref.WeakReference
import kotlin.math.PI
import kotlin.math.sin
// Starting a session from a blue button: the button grows until the page is its blue, and the
// loading screen opens on that blue and gathers it into the ball of the throbber.
// Stopping one from the drawer runs it the other way: Stop grows until the page is blue, and the
// front end opens on that blue and draws it back down into the button the session came from.
/** Carries the flood's colour to the session, which opens on it. */
const val EXTRA_FLOOD = "com.droiddeck.launcher.FLOOD"
@@ -48,6 +57,8 @@ internal object LaunchOrigin {
private var bounds: Rect? = null
private var button: Any? = null
private var at = 0L
/** The last button a session was started from, for a stop to return into. */
private var last: WeakReference<Any>? = null
/** The button a flood is growing out of: it steps aside so only the flood shows. */
var flooding by mutableStateOf<Any?>(null)
@@ -55,9 +66,22 @@ internal object LaunchOrigin {
fun mark(r: Rect, key: Any) {
bounds = r
button = key
last = WeakReference(key)
at = SystemClock.uptimeMillis()
}
/**
* Where the button the running session was started from sits now, if it is still on screen;
* it hides for the return, as for the flood.
*/
fun takeReturn(): Rect? {
val key = last?.get() as? Array<*> ?: return null
val c = key.getOrNull(0) as? LayoutCoordinates ?: return null
if (!c.isAttached) return null
flooding = key
return c.boundsInRoot()
}
/** The button behind a launch starting now, if one was pressed just before; it hides for the flood. */
fun take(): Rect? {
val b = bounds?.takeIf { SystemClock.uptimeMillis() - at < 1_500 }
@@ -68,6 +92,23 @@ internal object LaunchOrigin {
}
}
/** A session stopped behind a flood: the blue the front end opens on, for a few seconds. */
internal object QuitFlood {
private var color: Int? = null
private var at = 0L
fun mark(argb: Int) {
color = argb
at = SystemClock.uptimeMillis()
}
fun take(): Int? {
val c = color?.takeIf { SystemClock.uptimeMillis() - at < 3_000 }
color = null
return c
}
}
/** The page a flood grows over, sinking back a touch as it is covered; [progress] is the flood's. */
@Composable
internal fun FloodBehind(progress: () -> Float, content: @Composable () -> Unit) {
@@ -87,9 +128,17 @@ private val Gather = CubicBezierEasing(0.6f, 0f, 0.15f, 1f)
* Swallows touches while it runs.
*/
@Composable
internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: () -> Unit) {
internal fun LaunchFlood(
from: Rect,
onProgress: (Float) -> Unit,
/** The button's own colours, where it is not the page's blue one (a red Stop). */
fromColors: Pair<Color, Color>? = null,
cornerAtRest: Dp = 12.dp,
onCovered: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val start = fromColors ?: (colors.primary to pal.primary2)
// Left, top, right, bottom: 0 on the button, 1 on the page's edge. Springs overshoot past the
// edge, off the page, so the wobble is felt in the pull and never seen as a shrink.
val edges = remember { List(4) { Animatable(0f) } }
@@ -117,30 +166,83 @@ internal fun LaunchFlood(from: Rect, onProgress: (Float) -> Unit, onCovered: ()
}
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
val (l, t, r, b) = edges.map { it.value }
val mean = edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = kotlin.math.sin(Math.PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(12.dp.toPx() * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
drawRoundRect(
Brush.linearGradient(
listOf(lerp(colors.primary, pal.signal, mix), lerp(pal.primary2, pal.signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
),
topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner),
)
drawFlood(from, edges.map { it.value }, start, pal.signal, cornerAtRest.toPx())
}
}
/**
* The flood from a button at [from] with its edges [v] of the way to the page's (left, top, right,
* bottom): the button sinking with the page behind, blobby in flight, its colours [start] giving
* way to the flat [signal] in the first half.
*/
private fun DrawScope.drawFlood(from: Rect, v: List<Float>, start: Pair<Color, Color>, signal: Color, cornerAtRest: Float) {
val (l, t, r, b) = v
val mean = v.sumOf { it.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f
// The button sinks with the page behind (FloodBehind), so the flood leaves from where it is now.
val k = sinkScale(mean)
val cx = size.width / 2f
val cy = size.height / 2f
val left = lerp(cx + (from.left - cx) * k, 0f, l)
val top = lerp(cy + (from.top - cy) * k, 0f, t)
val right = lerp(cx + (from.right - cx) * k, size.width, r)
val bottom = lerp(cy + (from.bottom - cy) * k, size.height, b)
val w = (right - left).coerceAtLeast(0f)
val h = (bottom - top).coerceAtLeast(0f)
// A button's corners at rest, a blob in flight, square once it fills the page.
val blob = sin(PI * mean).toFloat().coerceAtLeast(0f)
val corner = lerp(cornerAtRest * (1f - mean), minOf(w, h) * 0.42f, blob)
// The button's gradient gives way to the ball's flat blue in the first half.
val mix = (mean * 2f).coerceAtMost(1f)
val brush = Brush.linearGradient(
listOf(lerp(start.first, signal, mix), lerp(start.second, signal, mix)),
start = Offset(left, top), end = Offset(right, bottom),
)
drawRoundRect(brush, topLeft = Offset(left, top), size = Size(w, h), cornerRadius = CornerRadius(corner))
}
/**
* The front end's first frames after a stop: the page in [flood] after a beat, drawn back down
* into the button at [to] (root px, measured before the page sank) on critically damped springs,
* the far edges first, so it sets down on the button without a bounce. [onProgress] (1 to 0) lets
* the page behind rise back. No button to return to: the blue fades. Calls [onLanded] at the end.
*/
@Composable
internal fun FloodReturn(flood: Color, to: Rect?, onProgress: (Float) -> Unit, onLanded: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val edges = remember { List(4) { Animatable(1f) } }
val fade = remember { Animatable(1f) }
val landed by rememberUpdatedState(onLanded)
val progress by rememberUpdatedState(onProgress)
var page by remember { mutableStateOf(Size.Zero) }
LaunchedEffect(page != Size.Zero) {
if (page == Size.Zero) return@LaunchedEffect
progress(1f)
// A beat of plain blue first, so the change of screen does not read as a cut.
delay(Motion.ms(220).toLong())
if (to == null || Motion.scale == 0f) {
fade.animateTo(0f, tween(Motion.ms(240).coerceAtLeast(1)))
} else {
val travel = listOf(to.left, to.top, page.width - to.right, page.height - to.bottom).map { it.coerceAtLeast(0f) }
val far = travel.max().coerceAtLeast(1f)
coroutineScope {
edges.mapIndexed { i, edge ->
val lead = travel[i] / far
launch {
delay(Motion.ms((180 * (1f - lead)).toInt()).toLong())
edge.animateTo(0f, spring(dampingRatio = 1f, stiffness = lerp(110f, 190f, lead))) {
progress(edges.sumOf { it.value.coerceIn(0f, 1f).toDouble() }.toFloat() / 4f)
}
}
}.forEach { it.join() }
}
}
progress(0f)
landed()
}
Canvas(Modifier.fillMaxSize().onSizeChanged { page = Size(it.width.toFloat(), it.height.toFloat()) }.pointerInput(Unit) { awaitEachGesture { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) {
if (to == null) drawRect(flood, alpha = fade.value)
else drawFlood(to, edges.map { it.value }, colors.primary to pal.primary2, flood, 12.dp.toPx())
}
}
@@ -85,6 +85,9 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -172,6 +175,8 @@ class DrawerActions(
val onBackground: () -> Unit,
val onShareLogs: () -> Unit,
val onStop: () -> Unit,
/** Stop with the dialog's button's bounds on screen, for the flood to grow out of; null falls back to [onStop]. */
val onStopFrom: ((androidx.compose.ui.geometry.Rect?) -> Unit)? = null,
val onClose: () -> Unit,
/** Components tab: every Proton with what it uses; null until first read. */
val components: ComponentsManager.Snapshot? = null,
@@ -453,7 +458,14 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
val stop = { confirmStop = false; a.onStop() }
// Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken
// against the screen and the session's overlay converts them back.
var stopOnScreen by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
val stop = {
confirmStop = false
val from = a.onStopFrom
if (from != null) from(stopOnScreen) else a.onStop()
}
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
@@ -465,9 +477,13 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
val dialogView = LocalView.current
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop),
modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c ->
val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) }
stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat())
},
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},