android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued

This commit is contained in:
ZXCLI
2026-09-07 13:37:34 +08:00
parent d0b133a895
commit b1a3c38a69
12 changed files with 155 additions and 17 deletions
@@ -12,13 +12,17 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.mag160c.thermal.R
@@ -38,12 +42,20 @@ private val TABS = listOf(
/**
* App shell. The activity is portrait-locked, so the bottom navigation bar
* is glued to the phone's portrait bottom edge at all times (its absolute
* position never moves, however the phone is physically held). Tab content
* lives in a stable slot.
* position never moves, however the phone is physically held). Bar content
* (icons/text) is pre-rotated by the physical device orientation so it stays
* readable in any grip. Tab content lives in a stable slot.
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val phi by DeviceOrientation.deg.collectAsState()
val ctx = LocalContext.current
DisposableEffect(Unit) {
DeviceOrientation.start(ctx)
onDispose { DeviceOrientation.stop() }
}
Box(modifier = Modifier.fillMaxSize()) {
when (tab) {
@@ -71,6 +83,8 @@ fun AppRoot() {
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
// pre-rotate so the item is upright in the current grip
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
@@ -0,0 +1,59 @@
package com.mag160c.thermal.ui
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.abs
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Physical orientation of the phone relative to its portrait grip, from the
* accelerometer. [deg] is the CLOCKWISE rotation of the device as seen by
* the user: 0 = upright portrait, 90 = turned clockwise (portrait top edge
* points to the user's right, gravity along +X device), 180 = upside down,
* 270 = turned counter-clockwise.
*
* The activity is portrait-locked (composition glued to the phone frame, so
* the thermal image region always matches the lens direction). UI layers use
* [deg] to pre-rotate their icons/text by -deg so labels stay readable in
* whatever grip the phone is currently held.
*/
object DeviceOrientation : SensorEventListener {
private val _deg = MutableStateFlow(0)
/** Physical CW rotation of the phone relative to the portrait grip: 0/90/180/270. */
val deg: StateFlow<Int> = _deg
private var sm: SensorManager? = null
fun start(context: Context) {
if (sm != null) return
val m = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val sensor = m.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
sm = m
m.registerListener(this, sensor, SensorManager.SENSOR_DELAY_UI)
}
fun stop() {
sm?.unregisterListener(this)
sm = null
}
override fun onSensorChanged(event: SensorEvent) {
val gx = event.values[0]
val gy = event.values[1]
// hysteresis: only switch pose when the dominant axis clearly wins,
// so ~45 deg in-between holds keep the previous reading
val next = when {
abs(gx) > abs(gy) + 2.5f -> if (gx > 0) 90 else 270
abs(gy) > abs(gx) + 2.5f -> if (gy < 0) 0 else 180
else -> _deg.value
}
if (next != _deg.value) _deg.value = next
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
@@ -16,8 +16,10 @@ import android.view.SurfaceView
* CW (3:4 vertical) into a fixed fitted rect inside the available area
* (full screen minus the control top bar and the bottom navigation bar).
* The image region does NOT move or rotate however the phone is physically
* held, so the on-screen area always matches the thermal lens direction;
* OSD text stays screen-horizontal so the labels are always readable.
* held, so the on-screen area always matches the thermal lens direction.
* OSD text (center temp / probe labels / color-bar numbers) is pre-rotated
* by the accelerometer-derived grip angle so labels stay readable in any
* grip; marker dots and the color-bar strip stay glued to the image.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -40,6 +42,10 @@ class LiveRenderer(
}
private val viewport = android.graphics.RectF()
/** OSD text compensation: pre-rotation so labels are upright in the current grip. */
private val textRot: Float
get() = -com.mag160c.thermal.ui.DeviceOrientation.deg.value.toFloat()
fun attach() {
surfaceView.holder.addCallback(this)
}
@@ -147,7 +153,11 @@ class LiveRenderer(
if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
// pivot around the marker anchor: the label stays attached while upright
canvas.save()
canvas.rotate(textRot, cx, cy)
canvas.drawText(text, tx, ty, textPaint)
canvas.restore()
}
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -169,8 +179,16 @@ class LiveRenderer(
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint)
canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
canvas.restore()
canvas.save()
canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
canvas.restore()
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -178,7 +196,10 @@ class LiveRenderer(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.save()
canvas.rotate(textRot, ox, oy)
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
canvas.restore()
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
@@ -31,6 +31,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
@@ -48,12 +49,15 @@ import android.view.SurfaceView
* The activity is portrait-locked, so the whole composition (top bar /
* image region / bottom bar) is glued to the phone's portrait frame and
* never moves or rotates, however the phone is physically held — the on-
* screen area always matches the thermal lens direction.
* screen area always matches the thermal lens direction. The bars' icons
* and text are pre-rotated by the physical grip angle (DeviceOrientation)
* so they stay readable in any grip.
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState()
var showPalette by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -84,7 +88,10 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
if (!state.connected) {
Text(
text = statusText(state),
modifier = Modifier.align(Alignment.Center).padding(16.dp),
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer { rotationZ = -phi.toFloat() }
.padding(16.dp),
)
}
@@ -107,17 +114,17 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.triggerFfc() }) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
@@ -126,22 +133,22 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
modifier = Modifier.clickable { vm.toggleMaxTrace() },
)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelMedium,