android: 竖屏旋转热像图填满屏宽/横屏原生方向+控制栏横屏移至右侧+大号圆圈标记与色标+fullSensor方向跟随
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user