android: 竖屏旋转热像图填满屏宽/横屏原生方向+控制栏横屏移至右侧+大号圆圈标记与色标+fullSensor方向跟随

This commit is contained in:
ZXCLI
2026-09-07 00:29:16 +08:00
parent 17fcb7b94c
commit 8d22969f6d
8 changed files with 393 additions and 263 deletions
Binary file not shown.
Binary file not shown.
+1
View File
@@ -16,6 +16,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullSensor"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:resizeableActivity="true">
<intent-filter>
@@ -1,17 +1,18 @@
package com.mag160c.thermal.ui
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -37,56 +38,81 @@ private val TABS = listOf(
Tab("设置", R.drawable.ic_settings),
)
/**
* App shell with an overlay navigation that adapts to orientation without
* recreating the tab content: bottom bar in portrait, left rail in landscape.
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val isLandscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val content: @Composable () -> Unit = {
Box(modifier = Modifier.fillMaxSize()) {
// ---- tab content (always the same slot; nav is an overlay) ----
val bottomPad = if (isLandscape) 0.dp else 84.dp
when (tab) {
0 -> LiveScreen()
1 -> GalleryScreen()
2 -> GalleryScreen()
else -> SettingsScreen()
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
GalleryScreen()
}
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
GalleryScreen()
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
SettingsScreen()
}
}
}
if (isLandscape) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
TABS.forEachIndexed { i, t ->
NavigationRailItem(
selected = tab == i,
onClick = { tab = i },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
// ---- overlay navigation ----
if (isLandscape) {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.align(Alignment.CenterStart),
) {
Column(
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TABS.forEachIndexed { i, t ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable { tab = i }
.padding(vertical = 14.dp, horizontal = 8.dp),
) {
Icon(
painterResource(t.icon),
contentDescription = t.label,
tint = if (tab == i) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
t.label,
style = MaterialTheme.typography.labelSmall,
color = if (tab == i) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
}
} else {
Scaffold(bottomBar = {
NavigationBar {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.align(Alignment.BottomCenter),
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
onClick = { tab = i },
icon = { Icon(painterResource(t.icon), null) },
label = { Text(t.label) },
)
}
}
}
}) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) { content() }
}
}
}
@Composable
private fun Placeholder(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -9,9 +9,13 @@ import android.view.SurfaceHolder
import android.view.SurfaceView
/**
* Software canvas renderer for the live IR stream. Mirrors the official
* apps' drawImage: letterbox the 320x240 frame into the 4:3 area, apply
* digital zoom, and overlay the temperature OSD.
* Software canvas renderer for the live IR stream.
*
* The image always fills the display area as much as possible:
* portrait -> the 320x240 frame is rotated 90 deg (drawn 240x320, fills
* the screen width)
* landscape -> native 4:3, fills the screen height
* OSD icons/text stay upright in the current screen orientation.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -19,15 +23,23 @@ class LiveRenderer(
) : SurfaceHolder.Callback, Runnable {
private var thread: Thread? = null
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
private val density = surfaceView.resources.displayMetrics.density
private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
private val rotated = Bitmap.createBitmap(240, 320, Bitmap.Config.ARGB_8888)
private val px = IntArray(320 * 240)
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
typeface = Typeface.SANS_SERIF
textSize = 14f
setShadowLayer(2f, 0f, 0f, Color.BLACK)
textSize = 15f * density
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val viewport = android.graphics.RectF()
private var isPortraitView = true
fun attach() {
surfaceView.holder.addCallback(this)
@@ -67,82 +79,113 @@ class LiveRenderer(
}
}
/** true when the frame must be rotated 90 (portrait view). */
private val rotationNeeded: Boolean get() = isPortraitView
/** Screen position of a sensor point (x in 0..159, y in 0..119). */
fun sensorToScreen(sx: Int, sy: Int): FloatArray {
val w = 160f
val h = 120f
if (rotationNeeded) {
// dst_x = 1 - sy/120 ; dst_y = sx/160 (normalized on the rotated rect)
val fx = 1f - sy / h
val fy = sx / w
return floatArrayOf(viewport.left + fx * viewport.width(), viewport.top + fy * viewport.height())
}
val fx = sx / w
val fy = sy / h
return floatArrayOf(viewport.left + fx * viewport.width(), viewport.top + fy * viewport.height())
}
private fun drawFrame(canvas: Canvas) {
val w = canvas.width.toFloat()
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
isPortraitView = h > w
val frame = vm.latestFrame
if (frame == null) {
drawIdle(canvas, w, h)
return
}
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
if (frame == null) return
// letterbox 4:3
val ar = 4f / 3f
val viewRatio = w / h
val dstW: Float
val dstH: Float
if (viewRatio > ar) {
dstH = h
dstW = h * ar
} else {
dstW = w
dstH = w / ar
}
val left = (w - dstW) / 2f
val top = (h - dstH) / 2f
viewport.set(left, top, left + dstW, top + dstH)
val zoom = vm.state.value.zoom
val srcRect = if (zoom > 1) {
val cw = 320 / zoom
val ch = 240 / zoom
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
if (srcRect != null) {
canvas.drawBitmap(bitmap, srcRect, viewport, paint)
// build displayed bitmap (rotated in portrait)
if (rotationNeeded) {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
bitmap.getPixels(px, 0, 320, 0, 0, 320, 240)
val rpx = IntArray(240 * 320)
for (y in 0 until 240) {
for (x in 0 until 320) {
// 90 deg CW: dst(x',y') = src(y, 239-x) -> with sizes: dst(240x320)
// dst index: x' = 239 - src_y, y' = src_x
val srcIdx = y * 320 + x
val dstX = 239 - y
val dstY = x
rpx[dstY * 240 + dstX] = px[srcIdx]
}
}
rotated.setPixels(rpx, 0, 240, 0, 0, 240, 320)
// fit 3:4 (240x320) into the portrait view
val dstW = w
val dstH = w * 320f / 240f
val left = 0f
val top = (h - dstH) / 2f
viewport.set(left, top, left + dstW, top + dstH)
canvas.drawBitmap(rotated, null, viewport, paint)
} else {
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val ar = 4f / 3f
val viewRatio = w / h
val dstW: Float
val dstH: Float
if (viewRatio > ar) {
dstH = h
dstW = h * ar
} else {
dstW = w
dstH = w / ar
}
val left = (w - dstW) / 2f
val top = (h - dstH) / 2f
viewport.set(left, top, left + dstW, top + dstH)
canvas.drawBitmap(bitmap, null, viewport, paint)
}
drawOsd(canvas, vm.state.value)
}
private fun drawIdle(canvas: Canvas, w: Float, h: Float) {
// status text is shown by the Compose overlay; keep the canvas black
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = sensorToScreen(sx, sy)
val cx = p[0]
val cy = p[1]
val dotR = 4.5f * density
// bigger ring + dot marker
markerPaint.style = Paint.Style.FILL
canvas.drawCircle(cx, cy, dotR, markerPaint)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
canvas.drawCircle(cx, cy, dotR + 5f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val pad = 6f * density
var tx = cx + 14f * density
var ty = cy + textPaint.textSize
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
canvas.drawText(text, tx, ty, textPaint)
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
textPaint.color = Color.WHITE
val ox = viewport.left + 10f
val oy = viewport.top + textPaint.textSize + 8f
state.centerTempC?.let {
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos, state.maxTempC)
}
drawTempMarker(canvas, state.minPos, state.minTempC)
for (p in state.probes) {
drawTempMarker(canvas, p.x + p.y * 160, p.tempC, p.label)
}
drawColorBar(canvas, state)
}
/** Side color bar: palette gradient + min/max temperature labels. */
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
val barW = 14f
val barH = viewport.height() * 0.66f
val x = viewport.right - barW - 8f
val barW = 20f * density
val barH = viewport.height() * 0.8f
val x = viewport.right - barW - 12f * density
val y0 = viewport.top + (viewport.height() - barH) / 2f
val seg = Paint()
val n = 64
val n = 96
for (i in 0 until n) {
// top = hot (palette 255), bottom = cold (palette 0)
val c = pal[255 - i * 255 / (n - 1)]
val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n
@@ -152,30 +195,24 @@ class LiveRenderer(
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
canvas.drawText(maxT, x - textPaint.measureText(maxT) / 2, y0 - 4f, textPaint)
canvas.drawText(minT, x - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
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)
}
private fun sensorToScreen(pos: Int): FloatArray {
val x = pos % 160
val y = pos / 160
val sx = viewport.left + (x / 160f) * viewport.width()
val sy = viewport.top + (y / 120f) * viewport.height()
return floatArrayOf(sx, sy)
}
private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) {
if (pos < 0 || tempC == null) return
val p = sensorToScreen(pos)
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val cx = p[0]
val cy = p[1]
var tx = cx + 8f
var ty = cy + textPaint.textSize + 6f
if (tx + tw + 4f > canvas.width) tx = cx - 8f - tw
if (ty > canvas.height) ty = cy - 6f
canvas.drawCircle(cx, cy, 4f, textPaint)
canvas.drawText(text, tx, ty, textPaint)
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
textPaint.color = Color.WHITE
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
}
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "")
}
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
}
drawColorBar(canvas, state)
}
}
@@ -38,9 +38,16 @@ import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView
/**
* Stable single-branch layout for the live view: the SurfaceView is created
* once and survives orientation changes. The control bar is an overlay
* (bottom row in portrait, right column in landscape).
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
val state by vm.state.collectAsState()
val context = LocalContext.current
var showPalette by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
vm.connect()
@@ -50,134 +57,162 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
}
}
Surface(color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize(),
val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
Box(modifier = Modifier.fillMaxSize()) {
AndroidSurface(vm)
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
)
}
},
)
if (!state.connected) {
Text(
text = statusText(state),
modifier = Modifier.align(Alignment.Center).padding(16.dp),
)
}
if (isLandscape) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.align(Alignment.CenterEnd),
) {
AndroidSurface(vm)
// tap overlay: add / remove probe points
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures { offset ->
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
)
}
},
)
if (!state.connected) {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(onClick = { vm.triggerFfc() }) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
}
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
}
Text(
text = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
"ddt_fail" -> "标定文件加载失败"
else -> "连接中…"
},
modifier = Modifier.align(Alignment.Center).padding(16.dp),
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(8.dp),
)
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelSmall,
)
}
}
ControlBar(state, vm)
}
}
}
@Composable
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
var showPalette by remember { mutableStateOf(false) }
val context = LocalContext.current
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { vm.triggerFfc() }) {
Icon(painterResource(R.drawable.ic_ffc), contentDescription = "FFC 快门校正")
} else {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.align(Alignment.BottomCenter),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.triggerFfc() }) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
}
}
Box(modifier = Modifier.weight(1f), 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) {
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable { vm.toggleMaxTrace() },
)
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
}
}
}
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
Icon(painterResource(R.drawable.ic_zoom), contentDescription = "数码变倍 x${state.zoom}")
}
Text(
text = if (state.maxTraceOn) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelMedium,
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clickable { vm.toggleMaxTrace() }
.padding(6.dp),
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = state.centerTempC?.let { "%.1f℃".format(it) } ?: "--",
style = MaterialTheme.typography.titleLarge,
)
Text(
text = if (state.maxTempC != null && state.minTempC != null)
"%.1f / %.1f℃".format(state.maxTempC, state.minTempC)
else "-- / --℃",
style = MaterialTheme.typography.bodySmall,
)
}
IconButton(onClick = { showPalette = true }) {
Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板")
}
IconButton(onClick = {
val ctx = context
vm.capturePhoto(ctx)
}) {
Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照")
}
IconButton(onClick = {
val ctx = context
vm.toggleRecording(ctx)
}) {
Icon(painterResource(R.drawable.ic_record), contentDescription = "录像")
}
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelMedium,
)
}
}
if (showPalette) {
AlertDialog(
onDismissRequest = { showPalette = false },
title = { Text("调色板") },
text = {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(320.dp),
) {
items((0..11).toList()) { idx ->
Text(
text = Palettes.NAMES[idx],
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable {
vm.setPalette(idx)
showPalette = false
}
.padding(14.dp),
)
}
}
},
confirmButton = {},
PaletteDialog(
current = state.paletteIndex,
onSelect = { vm.setPalette(it); showPalette = false },
onDismiss = { showPalette = false },
)
}
}
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
"ddt_fail" -> "标定文件加载失败"
else -> "连接中…"
}
@Composable
private fun PaletteDialog(current: Int, onSelect: (Int) -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("调色板") },
text = {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(320.dp),
) {
items((0..11).toList()) { idx ->
Text(
text = Palettes.NAMES[idx],
style = MaterialTheme.typography.bodyLarge,
color = if (idx == current) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clickable { onSelect(idx) }
.padding(14.dp),
)
}
}
},
confirmButton = {},
)
}
/** Compose host for the SurfaceView renderer. */
@Composable
private fun AndroidSurface(vm: LiveViewModel) {
@@ -231,44 +231,75 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Coordinates are view pixels; converted via the same
* letterbox + zoom math the renderer uses.
* tapping near it. Rotation-aware: portrait draws the frame rotated 90
* deg CW (same mapping as LiveRenderer).
*/
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value
if (!s.streaming) return
val ar = 4f / 3f
val viewRatio = viewW / viewH
val isPortrait = viewH > viewW
val dstW: Float
val dstH: Float
if (viewRatio > ar) {
dstH = viewH
dstW = viewH * ar
} else {
val left: Float
val top: Float
if (isPortrait) {
dstW = viewW
dstH = viewW / ar
dstH = viewW * 320f / 240f
left = 0f
top = (viewH - dstH) / 2f
} else {
val ar = 4f / 3f
val viewRatio = viewW / viewH
if (viewRatio > ar) {
dstH = viewH
dstW = viewH * ar
} else {
dstW = viewW
dstH = viewW / ar
}
left = (viewW - dstW) / 2f
top = (viewH - dstH) / 2f
}
val left = (viewW - dstW) / 2f
val top = (viewH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return
val srcW = 160f / s.zoom
val srcH = 120f / s.zoom
val sx = (screenX - left) / dstW * srcW + (160 - srcW) / 2f
val sy = (screenY - top) / dstH * srcH + (120 - srcH) / 2f
val sensorX = sx.toInt().coerceIn(0, 159)
val sensorY = sy.toInt().coerceIn(0, 119)
// near an existing probe? delete it instead of adding
val fx = (screenX - left) / dstW
val fy = (screenY - top) / dstH
val sx: Float
val sy: Float
if (isPortrait) {
// fx = 1 - sy/120, fy = sx/160 (inverse of the rotated draw mapping)
sy = (1f - fx) * 120f
sx = fy * 160f
} else {
sx = fx * 160f
sy = fy * 120f
}
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f
val existing = s.probes.firstOrNull { p ->
kotlin.math.abs(p.x + 0.5f - sx) * (dstW / srcW) < thr &&
kotlin.math.abs(p.y + 0.5f - sy) * (dstH / srcH) < thr
val pxs: Float
val pys: Float
if (isPortrait) {
pxs = left + (1f - p.y / 120f) * dstW
pys = top + (p.x / 160f) * dstH
} else {
pxs = left + (p.x / 160f) * dstW
pys = top + (p.y / 120f) * dstH
}
val dx = screenX - pxs
val dy = screenY - pys
dx * dx + dy * dy < thr * thr
}
if (existing != null) {
_state.value = _state.value.copy(probes = _state.value.probes - existing)
return
}
val label = "Pt${s.probes.size + 1}"
val p = ProbePoint(sensorX, sensorY, label, null)
val p = ProbePoint(
sx.toInt().coerceIn(0, 159),
sy.toInt().coerceIn(0, 119),
label,
null,
)
_state.value = _state.value.copy(probes = _state.value.probes + p)
refreshTemps()
}
Binary file not shown.