android: rotate bar icons/text + OSD labels by physical grip angle (accelerometer), image region stays glued
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user