The cockpit turn card stays clear of the car's own dials and bars

This commit is contained in:
Seb3thehacker committed 2026-11-05 14:09:42 -08:00
1 parent 31d8232978
commit 99407dfcb2
2 files changed
+92 -40

No files matched your search

@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import app.seb3thehacker.gearslip.car.ClusterScreen import app.seb3thehacker.gearslip.car.ClusterScreen
import kotlinx.coroutines.flow.MutableStateFlow
/** /**
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that * Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
@@ -50,6 +51,8 @@ internal class ClusterVideo(
@Volatile private var maxUnacked = 1 @Volatile private var maxUnacked = 1
@Volatile private var lastAckAt = 0L @Volatile private var lastAckAt = 0L
private var frames = 0 private var frames = 0
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
private val clearArea = MutableStateFlow<Insets?>(null)
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
@@ -92,6 +95,13 @@ internal class ClusterVideo(
} }
} }
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
clearArea.value = insetsFrom(body)
}
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)") else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
} }
} }
@@ -158,7 +168,7 @@ internal class ClusterVideo(
projector = ScreenProjector(context).also { projector = ScreenProjector(context).also {
it.start(surface, width, height, density, name = "gearslip-cluster") { it.start(surface, width, height, density, name = "gearslip-cluster") {
// The margins the car crops off are kept clear, as on the main screen. // The margins the car crops off are kept clear, as on the main screen.
ClusterScreen(config.widthMargin, config.heightMargin) ClusterScreen(config.widthMargin, config.heightMargin, clearArea)
} }
} }
} }
@@ -185,6 +195,15 @@ internal class ClusterVideo(
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)") if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
} }
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
private fun insetsFrom(body: ByteArray): Insets? {
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
.also { log.i("the car keeps clear: $it") }
}
fun stop() { fun stop() {
running = false running = false
main.removeCallbacks(watchRoute) main.removeCallbacks(watchRoute)
@@ -16,6 +16,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -24,11 +25,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.Insets
import app.seb3thehacker.gearslip.host.clockTime import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime import app.seb3thehacker.gearslip.host.remainingTime
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
/** /**
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel. * The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
@@ -36,10 +40,13 @@ import kotlinx.coroutines.delay
* along the bottom. With no route it shows what's playing, quietly, or nothing. * along the bottom. With no route it shows what's playing, quietly, or nothing.
* *
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off * Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
* the frame, kept clear. * the frame, kept clear. Many cars also draw their own dials and bars over what's left, so the
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
*/ */
@Composable @Composable
internal fun ClusterScreen(marginX: Int, marginY: Int) { internal fun ClusterScreen(marginX: Int, marginY: Int, clearArea: StateFlow<Insets?>) {
val carInsets by clearArea.collectAsState()
// The map app's turn changes a few times a minute: read it once a second. // The map app's turn changes a few times a minute: read it once a second.
val turn by produceState<ClusterTurn?>(null) { val turn by produceState<ClusterTurn?>(null) {
while (true) { while (true) {
@@ -52,52 +59,78 @@ internal fun ClusterScreen(marginX: Int, marginY: Int) {
CompositionLocalProvider(LocalContentColor provides Color.White) { CompositionLocalProvider(LocalContentColor provides Color.White) {
BoxWithConstraints( BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black).padding( Modifier.fillMaxSize().background(Color.Black).padding(
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp, horizontal = with(density) { (marginX / 2).toDp() },
vertical = with(density) { (marginY / 2).toDp() } + 16.dp, vertical = with(density) { (marginY / 2).toDp() },
), ),
contentAlignment = Alignment.CenterStart,
) { ) {
// Sized off the height: clusters run from short strips to near squares. val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
val unit = maxHeight / 12 BoxWithConstraints(
val t = turn Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
if (t == null) { contentAlignment = Alignment.Center,
NowPlayingLine(Modifier.align(Alignment.Center)) ) {
return@BoxWithConstraints // The card is about 8.5 units tall with two lines of directions, and 14 wide: fit both ways.
} val unit = minOf(maxHeight / 8.5f, maxWidth / 14)
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) { val t = turn
Row(verticalAlignment = Alignment.CenterVertically) { if (t == null) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f)) NowPlayingLine(Modifier.align(Alignment.Center))
Spacer(Modifier.width(unit)) return@BoxWithConstraints
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
} }
TurnCard(t, unit)
} }
} }
} }
} }
} }
/**
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
* along the bottom, keeps to the upper middle; anything squarer just keeps off the edges.
*/
private fun clearPadding(width: Dp, height: Dp, car: List<Dp>?): List<Dp> {
if (car != null && width - car[0] - car[2] >= width * 0.3f && height - car[1] - car[3] >= height * 0.3f) return car
if (width >= height * 2) return listOf(width * 0.22f, height * 0.08f, width * 0.22f, height * 0.33f)
return listOf(24.dp, 16.dp, 24.dp, 16.dp)
}
/** Insets in frame pixels as start, top, end and bottom padding. */
private fun androidx.compose.ui.unit.Density.toDp(insets: Insets): List<Dp> =
listOf(insets.left.toDp(), insets.top.toDp(), insets.right.toDp(), insets.bottom.toDp())
@Composable
private fun TurnCard(t: ClusterTurn, unit: Dp) {
val density = LocalDensity.current
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
}
}
}
@Composable @Composable
private fun NowPlayingLine(modifier: Modifier) { private fun NowPlayingLine(modifier: Modifier) {
val now = CarServices.mediaOrNull()?.now?.value val now = CarServices.mediaOrNull()?.now?.value