Track glasses mode, refresh-aware glasses window, align Play SteamVR with Play Steam

This commit is contained in:
maxjivi05 committed 2026-10-13 18:07:41 -04:00
1 parent b8fe2fbd88
commit 697565f84b
8 files changed
+105 -29

No files matched your search

@@ -1580,7 +1580,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* active; it is the rate gamescope gives Steam, and the QAM's Frame Limit divides it.
*/
private fun refreshHz(): Float {
if (glassesScreen != null) glasses.hz.takeIf { it > 1f }?.let { return it }
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
@@ -2846,7 +2846,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val want = started && !isInPictureInPictureMode && !runtimeRemovalBlocked && g.usable && glassesSettings.gameOnGlasses
val target = if (want) Glasses.glassesDisplay()?.takeIf { it.isValid } else null
val current = glassesScreen
if (current != null && current.display.displayId == target?.displayId) return
if (current != null && current.display.displayId == target?.displayId) {
current.prefer(Glasses.outputHz())
return
}
if (current != null) {
glassesScreen = null
glassesOutputActive = false
@@ -2857,7 +2860,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder)
}
if (target != null) {
val screen = GlassesScreen(this, target, this)
val screen = GlassesScreen(this, target, Glasses.outputHz(), this)
try {
screen.show()
} catch (e: Exception) {
@@ -114,6 +114,8 @@ object Glasses {
private var asked = false
private var lastModeSentAt = 0L
private var sbsAskedAt = 0L
private var glassesMode = 0
private var autoModeSent = false
@Volatile private var sink: PoseSink? = null
@Volatile private var probe: ((ByteArray) -> Unit)? = null
@@ -204,6 +206,8 @@ object Glasses {
fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } }
fun setVrFovDeg(deg: Int) = post { update { it.copy(vrFovDeg = deg.coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX)) } }
fun outputHz(): Int = _settings.value.let { if (_state.value.sbs) it.sbsHz else it.refreshHz }
fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) }
fun setPoseSink(s: PoseSink) {
@@ -395,6 +399,8 @@ object Glasses {
}
link = l
linkDevice = device
glassesMode = 0
autoModeSent = false
Log.i(TAG, "opened ${model?.name} pid=0x%04X imu=%s".format(device.productId, l.hasImu))
publish { it.copy(connected = true) }
listOf(VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_DISPLAY).forEachIndexed { i, msg ->
@@ -412,6 +418,7 @@ object Glasses {
val l = link ?: return
link = null
linkDevice = null
glassesMode = 0
l.close()
publish { it.copy(connected = false, tracking = false) }
Log.i(TAG, "link closed")
@@ -425,10 +432,15 @@ object Glasses {
private fun applyMode(force: Boolean) {
val s = _state.value
val m = model ?: return
if (!s.canControl || !s.usable || s.sbs) return
val want = _settings.value.refreshHz
if (!force && abs(s.hz - want) < 1.5f) return
if (!force && SystemClock.uptimeMillis() - lastModeSentAt < 8_000) return
val hz = _settings.value.refreshHz
if (glassesMode == m.mode(hz, false)) return
if (!force) {
if (autoModeSent || SystemClock.uptimeMillis() - lastModeSentAt < 8_000) return
if (glassesMode == 0 && abs(s.hz - hz) < 1.5f) return
autoModeSent = true
}
sendMode(false)
}
@@ -496,8 +508,13 @@ object Glasses {
when (frame.msgId) {
VitureProtocol.EVENT_DISPLAY -> {
Log.i(TAG, "glasses switched to mode 0x%02X".format(event))
glassesMode = event
publish { it.copy(sbs = VitureProtocol.modeIsSbs(event)) }
}
VitureProtocol.EVENT_DISPLAY_STEP -> model?.modeForStep(event)?.let { mode ->
Log.i(TAG, "glasses report mode 0x%02X".format(mode))
glassesMode = mode
}
VitureProtocol.EVENT_BRIGHTNESS -> publish { it.copy(brightness = event) }
VitureProtocol.EVENT_VOLUME -> publish { it.copy(volume = event) }
VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_BRIGHTNESS -> answer?.let { v -> publish { it.copy(brightness = v) } }
@@ -505,8 +522,11 @@ object Glasses {
VitureProtocol.MSG_DISPLAY -> if (event != 0) {
Log.w(TAG, "glasses refused display mode 0x%02X".format(p.getOrNull(1)?.toInt()?.and(0xFF) ?: 0))
sbsAskedAt = 0L
} else answer?.let { glassesMode = it }
VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v ->
glassesMode = v
if (VitureProtocol.modeIsSbs(v) != _state.value.sbs && !_state.value.hasDisplay) publish { it.copy(sbs = VitureProtocol.modeIsSbs(v)) }
}
VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v -> if (VitureProtocol.modeIsSbs(v) != _state.value.sbs && !_state.value.hasDisplay) publish { it.copy(sbs = VitureProtocol.modeIsSbs(v)) } }
}
}
@@ -10,24 +10,38 @@ import android.view.SurfaceView
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import kotlin.math.abs
class GlassesScreen(context: Context, display: Display, private val callback: SurfaceHolder.Callback) : Presentation(context, display) {
class GlassesScreen(context: Context, display: Display, refreshHz: Int, private val callback: SurfaceHolder.Callback) : Presentation(context, display) {
val surfaceView = SurfaceView(getContext())
val holder: SurfaceHolder get() = surfaceView.holder
private var wantHz = refreshHz
val hz: Float get() {
val id = window?.attributes?.preferredDisplayModeId ?: 0
return display.supportedModes.firstOrNull { id != 0 && it.modeId == id }?.refreshRate ?: display.refreshRate
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = FrameLayout(context).apply { setBackgroundColor(Color.BLACK) }
root.addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
setContentView(root)
window?.apply {
addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
attributes = attributes.apply {
display.supportedModes.maxByOrNull { it.refreshRate * 10_000 + it.physicalWidth }?.let { best ->
if (display.mode.physicalWidth == best.physicalWidth) preferredDisplayModeId = best.modeId
}
}
}
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
applyMode()
surfaceView.holder.addCallback(callback)
}
fun prefer(hz: Int) {
if (hz == wantHz) return
wantHz = hz
applyMode()
}
private fun applyMode() {
val w = window ?: return
val width = display.mode.physicalWidth
val best = display.supportedModes.filter { it.physicalWidth == width }.minByOrNull { abs(it.refreshRate - wantHz) } ?: return
if (w.attributes.preferredDisplayModeId != best.modeId) w.attributes = w.attributes.apply { preferredDisplayModeId = best.modeId }
}
}
@@ -27,6 +27,7 @@ object VitureProtocol {
const val EVENT_BRIGHTNESS = 0x0301
const val EVENT_DISPLAY = 0x0302
const val EVENT_VOLUME = 0x0304
const val EVENT_DISPLAY_STEP = 0x030D
const val IMU_RATE_240 = 3
@@ -51,6 +52,14 @@ object VitureProtocol {
sbs -> if (hz >= 90 && sbs90) 0x35 else 0x32
else -> when { hz >= 120 -> 0x34; hz >= 90 -> 0x33; else -> 0x31 }
}
fun modeForStep(step: Int): Int? = if (tall) null else when (step) {
1 -> 0x31
2 -> 0x33
3 -> 0x34
4 -> 0x32
else -> null
}
}
fun model(productId: Int, productName: String?): Model? {
@@ -440,10 +440,15 @@ internal fun BoxWithConstraintsScope.DropSheet(
val sideInset = if (narrow) 20.dp else 44.dp
val besideShown = enabled && !state.open && !state.shown
val r1 = rest
if (r1 != null && (besideShown || state.sink.value < 0.99f)) Box(
val play = LocalPlayRect.current
if (r1 != null && play != null && (besideShown || state.sink.value < 0.99f)) Box(
Modifier
.align(Alignment.TopEnd)
.padding(top = sideInset, end = sideInset + with(density) { r1.width.toDp() } + 12.dp)
.offset {
val gap = 12.dp.roundToPx()
val below = play.right + gap > r1.left
IntOffset(play.left.roundToInt(), if (below) r1.bottom.roundToInt() + gap else sideInset.roundToPx())
}
.width(with(density) { play.width.toDp() })
.graphicsLayer { alpha = (1f - state.sink.value) * (1f - covered()) }
.zIndex(2f)
.focusProperties { canFocus = besideShown },
@@ -22,6 +22,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -57,6 +59,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -153,6 +157,7 @@ internal fun PrimaryButton(
/** The Steam tab's own Play: bigger than any other button on a page. */
large: Boolean = false,
icon: ImageVector? = null,
fill: Boolean = false,
onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
@@ -172,7 +177,8 @@ internal fun PrimaryButton(
// Where it sits, so a session it starts can grow out of it (LaunchFlood).
val placed = remember { arrayOfNulls<androidx.compose.ui.layout.LayoutCoordinates>(1) }
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (fill) Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) else Arrangement.spacedBy(8.dp),
modifier = modifier.then(track)
.onGloballyPositioned { placed[0] = it }
// 48dp to touch on a handheld; compact rows keep 44.
@@ -192,18 +198,34 @@ internal fun PrimaryButton(
onClick()
}
.padding(
start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp,
end = if (large) 26.dp else if (compact) 10.dp else 18.dp,
start = if (fill) 12.dp else if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp,
end = if (fill) 12.dp else if (large) 26.dp else if (compact) 10.dp else 18.dp,
top = if (large) 16.dp else if (compact) 7.dp else 11.dp,
bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp,
),
) {
val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant
if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp))
Text(
text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp,
fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1,
)
val style = LocalTextStyle.current.merge(TextStyle(
fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp,
fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg,
))
if (fill) FittedLabel(text, style, Modifier.weight(1f, fill = false))
else Text(text, style = style, maxLines = 1)
}
}
@Composable
private fun FittedLabel(text: String, style: TextStyle, modifier: Modifier = Modifier) {
BoxWithConstraints(modifier) {
val measurer = rememberTextMeasurer()
val max = constraints.maxWidth
val size = remember(text, style, max) {
var size = style.fontSize
while (size.value > 10f && measurer.measure(text, style.copy(fontSize = size), maxLines = 1, softWrap = false).size.width > max) size = (size.value - 0.5f).sp
size
}
Text(text, style = style.copy(fontSize = size), maxLines = 1, softWrap = false)
}
}
@@ -161,6 +161,8 @@ internal val LocalSheetBeat = staticCompositionLocalOf<SheetBeat?> { null }
internal val LocalSheetChipWidth = compositionLocalOf { Dp.Unspecified }
internal val LocalPlayRect = compositionLocalOf<Rect?> { null }
private val ChipGap = 6.dp
internal fun sheetChipWidth(row: Dp, count: Int): Dp =
@@ -727,7 +729,7 @@ internal fun LiftHero(
}
}
}
CompositionLocalProvider(LocalSheetChipWidth provides chipWidth) { overlay { sink.value } }
CompositionLocalProvider(LocalSheetChipWidth provides chipWidth, LocalPlayRect provides restRect) { overlay { sink.value } }
}
}
@@ -1,6 +1,7 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.layout.fillMaxWidth
import com.droiddeck.launcher.R
import androidx.compose.ui.res.stringResource
import androidx.compose.animation.core.FastOutSlowInEasing
@@ -113,8 +114,8 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
if (glasses.present) DropSheet(
drop, glasses.model, GlassesIcon, glassesTabs(glasses), covered = lifted,
beside = { enabled ->
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy, large = true, icon = Icons.Filled.PlayArrow,
modifier = Modifier.testTag("play-steamvr")) { if (enabled) a.onPlaySteamVr() }
PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy, large = true, icon = Icons.Filled.PlayArrow, fill = true,
modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) a.onPlaySteamVr() }
},
) { tab ->
GlassesGroups(rememberMenuHost(), glasses, glassesSettings, inSession = false, tab = tab)