mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Track glasses mode, refresh-aware glasses window, align Play SteamVR with Play Steam
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user