mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
The cockpit turn card stays clear of the car's own dials and bars
This commit is contained in:
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.Looper
|
||||
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
|
||||
@@ -50,6 +51,8 @@ internal class ClusterVideo(
|
||||
@Volatile private var maxUnacked = 1
|
||||
@Volatile private var lastAckAt = 0L
|
||||
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) {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -158,7 +168,7 @@ internal class ClusterVideo(
|
||||
projector = ScreenProjector(context).also {
|
||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||
// 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)")
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
running = false
|
||||
main.removeCallbacks(watchRoute)
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
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.
|
||||
@@ -36,10 +40,13 @@ import kotlinx.coroutines.delay
|
||||
* 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
|
||||
* 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
|
||||
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.
|
||||
val turn by produceState<ClusterTurn?>(null) {
|
||||
while (true) {
|
||||
@@ -52,52 +59,78 @@ internal fun ClusterScreen(marginX: Int, marginY: Int) {
|
||||
CompositionLocalProvider(LocalContentColor provides Color.White) {
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black).padding(
|
||||
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
|
||||
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
|
||||
horizontal = with(density) { (marginX / 2).toDp() },
|
||||
vertical = with(density) { (marginY / 2).toDp() },
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
// Sized off the height: clusters run from short strips to near squares.
|
||||
val unit = maxHeight / 12
|
||||
val t = turn
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
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,
|
||||
)
|
||||
val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// 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)
|
||||
val t = turn
|
||||
if (t == null) {
|
||||
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
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
|
||||
private fun NowPlayingLine(modifier: Modifier) {
|
||||
val now = CarServices.mediaOrNull()?.now?.value
|
||||
|
||||
Reference in new issue
Block a user