android: 竖屏旋转热像图填满屏宽/横屏原生方向+控制栏横屏移至右侧+大号圆圈标记与色标+fullSensor方向跟随
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -16,6 +16,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:screenOrientation="fullSensor"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
android:resizeableActivity="true">
|
android:resizeableActivity="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
package com.mag160c.thermal.ui
|
package com.mag160c.thermal.ui
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationRail
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.NavigationRailItem
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -37,56 +38,81 @@ private val TABS = listOf(
|
|||||||
Tab("设置", R.drawable.ic_settings),
|
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
|
@Composable
|
||||||
fun AppRoot() {
|
fun AppRoot() {
|
||||||
var tab by rememberSaveable { mutableStateOf(0) }
|
var tab by rememberSaveable { mutableStateOf(0) }
|
||||||
val isLandscape =
|
val isLandscape =
|
||||||
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
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) {
|
when (tab) {
|
||||||
0 -> LiveScreen()
|
0 -> LiveScreen()
|
||||||
1 -> GalleryScreen()
|
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
|
||||||
2 -> GalleryScreen()
|
GalleryScreen()
|
||||||
else -> SettingsScreen()
|
}
|
||||||
|
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
|
||||||
|
GalleryScreen()
|
||||||
|
}
|
||||||
|
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = bottomPad)) {
|
||||||
|
SettingsScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isLandscape) {
|
// ---- overlay navigation ----
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
if (isLandscape) {
|
||||||
NavigationRail {
|
Surface(
|
||||||
TABS.forEachIndexed { i, t ->
|
color = MaterialTheme.colorScheme.surface,
|
||||||
NavigationRailItem(
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
selected = tab == i,
|
) {
|
||||||
onClick = { tab = i },
|
Column(
|
||||||
icon = { Icon(painterResource(t.icon), null) },
|
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
|
||||||
label = { Text(t.label) },
|
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 {
|
||||||
}
|
Surface(
|
||||||
} else {
|
color = MaterialTheme.colorScheme.surface,
|
||||||
Scaffold(bottomBar = {
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
NavigationBar {
|
) {
|
||||||
TABS.forEachIndexed { i, t ->
|
NavigationBar(modifier = Modifier.fillMaxWidth()) {
|
||||||
NavigationBarItem(
|
TABS.forEachIndexed { i, t ->
|
||||||
selected = tab == i,
|
NavigationBarItem(
|
||||||
onClick = { tab = i },
|
selected = tab == i,
|
||||||
icon = { Icon(painterResource(t.icon), null) },
|
onClick = { tab = i },
|
||||||
label = { Text(t.label) },
|
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
|
import android.view.SurfaceView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Software canvas renderer for the live IR stream. Mirrors the official
|
* Software canvas renderer for the live IR stream.
|
||||||
* apps' drawImage: letterbox the 320x240 frame into the 4:3 area, apply
|
*
|
||||||
* digital zoom, and overlay the temperature OSD.
|
* 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(
|
class LiveRenderer(
|
||||||
private val surfaceView: SurfaceView,
|
private val surfaceView: SurfaceView,
|
||||||
@@ -19,15 +23,23 @@ class LiveRenderer(
|
|||||||
) : SurfaceHolder.Callback, Runnable {
|
) : SurfaceHolder.Callback, Runnable {
|
||||||
private var thread: Thread? = null
|
private var thread: Thread? = null
|
||||||
private val running = java.util.concurrent.atomic.AtomicBoolean(false)
|
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 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 paint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = Color.WHITE
|
color = Color.WHITE
|
||||||
typeface = Typeface.SANS_SERIF
|
typeface = Typeface.SANS_SERIF
|
||||||
textSize = 14f
|
textSize = 15f * density
|
||||||
setShadowLayer(2f, 0f, 0f, Color.BLACK)
|
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 val viewport = android.graphics.RectF()
|
||||||
|
private var isPortraitView = true
|
||||||
|
|
||||||
fun attach() {
|
fun attach() {
|
||||||
surfaceView.holder.addCallback(this)
|
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) {
|
private fun drawFrame(canvas: Canvas) {
|
||||||
val w = canvas.width.toFloat()
|
val w = canvas.width.toFloat()
|
||||||
val h = canvas.height.toFloat()
|
val h = canvas.height.toFloat()
|
||||||
canvas.drawColor(Color.BLACK)
|
canvas.drawColor(Color.BLACK)
|
||||||
|
isPortraitView = h > w
|
||||||
|
|
||||||
val frame = vm.latestFrame
|
val frame = vm.latestFrame
|
||||||
if (frame == null) {
|
if (frame == null) return
|
||||||
drawIdle(canvas, w, h)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
|
||||||
|
|
||||||
// letterbox 4:3
|
// build displayed bitmap (rotated in portrait)
|
||||||
val ar = 4f / 3f
|
if (rotationNeeded) {
|
||||||
val viewRatio = w / h
|
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
||||||
val dstW: Float
|
bitmap.getPixels(px, 0, 320, 0, 0, 320, 240)
|
||||||
val dstH: Float
|
val rpx = IntArray(240 * 320)
|
||||||
if (viewRatio > ar) {
|
for (y in 0 until 240) {
|
||||||
dstH = h
|
for (x in 0 until 320) {
|
||||||
dstW = h * ar
|
// 90 deg CW: dst(x',y') = src(y, 239-x) -> with sizes: dst(240x320)
|
||||||
} else {
|
// dst index: x' = 239 - src_y, y' = src_x
|
||||||
dstW = w
|
val srcIdx = y * 320 + x
|
||||||
dstH = w / ar
|
val dstX = 239 - y
|
||||||
}
|
val dstY = x
|
||||||
val left = (w - dstW) / 2f
|
rpx[dstY * 240 + dstX] = px[srcIdx]
|
||||||
val top = (h - dstH) / 2f
|
}
|
||||||
viewport.set(left, top, left + dstW, top + dstH)
|
}
|
||||||
|
rotated.setPixels(rpx, 0, 240, 0, 0, 240, 320)
|
||||||
val zoom = vm.state.value.zoom
|
// fit 3:4 (240x320) into the portrait view
|
||||||
val srcRect = if (zoom > 1) {
|
val dstW = w
|
||||||
val cw = 320 / zoom
|
val dstH = w * 320f / 240f
|
||||||
val ch = 240 / zoom
|
val left = 0f
|
||||||
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
|
val top = (h - dstH) / 2f
|
||||||
} else null
|
viewport.set(left, top, left + dstW, top + dstH)
|
||||||
|
canvas.drawBitmap(rotated, null, viewport, paint)
|
||||||
if (srcRect != null) {
|
|
||||||
canvas.drawBitmap(bitmap, srcRect, viewport, paint)
|
|
||||||
} else {
|
} 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)
|
canvas.drawBitmap(bitmap, null, viewport, paint)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawOsd(canvas, vm.state.value)
|
drawOsd(canvas, vm.state.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drawIdle(canvas: Canvas, w: Float, h: Float) {
|
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
|
||||||
// status text is shown by the Compose overlay; keep the canvas black
|
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) {
|
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
|
||||||
if (state.maxTempC == null || state.minTempC == null) return
|
if (state.maxTempC == null || state.minTempC == null) return
|
||||||
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
|
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
|
||||||
val barW = 14f
|
val barW = 20f * density
|
||||||
val barH = viewport.height() * 0.66f
|
val barH = viewport.height() * 0.8f
|
||||||
val x = viewport.right - barW - 8f
|
val x = viewport.right - barW - 12f * density
|
||||||
val y0 = viewport.top + (viewport.height() - barH) / 2f
|
val y0 = viewport.top + (viewport.height() - barH) / 2f
|
||||||
val seg = Paint()
|
val seg = Paint()
|
||||||
val n = 64
|
val n = 96
|
||||||
for (i in 0 until n) {
|
for (i in 0 until n) {
|
||||||
// top = hot (palette 255), bottom = cold (palette 0)
|
|
||||||
val c = pal[255 - i * 255 / (n - 1)]
|
val c = pal[255 - i * 255 / (n - 1)]
|
||||||
val sy = y0 + barH * i / n
|
val sy = y0 + barH * i / n
|
||||||
val ey = y0 + barH * (i + 1) / n
|
val ey = y0 + barH * (i + 1) / n
|
||||||
@@ -152,30 +195,24 @@ class LiveRenderer(
|
|||||||
textPaint.color = Color.WHITE
|
textPaint.color = Color.WHITE
|
||||||
val maxT = "%.1f".format(state.maxTempC)
|
val maxT = "%.1f".format(state.maxTempC)
|
||||||
val minT = "%.1f".format(state.minTempC)
|
val minT = "%.1f".format(state.minTempC)
|
||||||
canvas.drawText(maxT, x - textPaint.measureText(maxT) / 2, y0 - 4f, textPaint)
|
canvas.drawText(maxT, x + barW / 2f - textPaint.measureText(maxT) / 2, y0 - 8f * density, textPaint)
|
||||||
canvas.drawText(minT, x - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
|
canvas.drawText(minT, x + barW / 2f - textPaint.measureText(minT) / 2, y0 + barH + textPaint.textSize, textPaint)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sensorToScreen(pos: Int): FloatArray {
|
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
|
||||||
val x = pos % 160
|
textPaint.color = Color.WHITE
|
||||||
val y = pos / 160
|
val ox = viewport.left + 12f * density
|
||||||
val sx = viewport.left + (x / 160f) * viewport.width()
|
val oy = viewport.top + textPaint.textSize + 10f * density
|
||||||
val sy = viewport.top + (y / 120f) * viewport.height()
|
state.centerTempC?.let {
|
||||||
return floatArrayOf(sx, sy)
|
canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
|
||||||
}
|
}
|
||||||
|
if (state.maxTraceOn) {
|
||||||
private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) {
|
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高")
|
||||||
if (pos < 0 || tempC == null) return
|
}
|
||||||
val p = sensorToScreen(pos)
|
drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
|
||||||
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
|
for (p in state.probes) {
|
||||||
val tw = textPaint.measureText(text)
|
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
|
||||||
val cx = p[0]
|
}
|
||||||
val cy = p[1]
|
drawColorBar(canvas, state)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,16 @@ import com.mag160c.thermal.R
|
|||||||
import com.mag160c.thermal.core.Palettes
|
import com.mag160c.thermal.core.Palettes
|
||||||
import android.view.SurfaceView
|
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
|
@Composable
|
||||||
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||||
val state by vm.state.collectAsState()
|
val state by vm.state.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showPalette by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
vm.connect()
|
vm.connect()
|
||||||
@@ -50,134 +57,162 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
.weight(1f)
|
AndroidSurface(vm)
|
||||||
.fillMaxSize(),
|
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)
|
Column(
|
||||||
// tap overlay: add / remove probe points
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
|
||||||
Box(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
.fillMaxSize()
|
) {
|
||||||
.pointerInput(Unit) {
|
IconButton(onClick = { vm.triggerFfc() }) {
|
||||||
detectTapGestures { offset ->
|
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
||||||
vm.tapImage(
|
}
|
||||||
offset.x, offset.y,
|
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
|
||||||
size.width.toFloat(), size.height.toFloat(),
|
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
||||||
)
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!state.connected) {
|
|
||||||
Text(
|
Text(
|
||||||
text = when (state.status) {
|
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
||||||
"no_device" -> "未检测到热像仪,请插入MAG160C"
|
style = MaterialTheme.typography.labelMedium,
|
||||||
"no_permission" -> "USB权限未授予"
|
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
|
||||||
"ddt_fail" -> "标定文件加载失败"
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
else -> "连接中…"
|
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(8.dp),
|
||||||
},
|
)
|
||||||
modifier = Modifier.align(Alignment.Center).padding(16.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)
|
} else {
|
||||||
}
|
Surface(
|
||||||
}
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
}
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
|
) {
|
||||||
@Composable
|
Row(
|
||||||
private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
|
modifier = Modifier
|
||||||
var showPalette by remember { mutableStateOf(false) }
|
.fillMaxWidth()
|
||||||
val context = LocalContext.current
|
.padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
) {
|
||||||
Row(
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier
|
IconButton(onClick = { vm.triggerFfc() }) {
|
||||||
.fillMaxWidth()
|
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
}
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
}
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
) {
|
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
|
||||||
IconButton(onClick = { vm.triggerFfc() }) {
|
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
||||||
Icon(painterResource(R.drawable.ic_ffc), contentDescription = "FFC 快门校正")
|
}
|
||||||
|
}
|
||||||
|
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) {
|
if (showPalette) {
|
||||||
AlertDialog(
|
PaletteDialog(
|
||||||
onDismissRequest = { showPalette = false },
|
current = state.paletteIndex,
|
||||||
title = { Text("调色板") },
|
onSelect = { vm.setPalette(it); showPalette = false },
|
||||||
text = {
|
onDismiss = { showPalette = false },
|
||||||
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 = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Compose host for the SurfaceView renderer. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AndroidSurface(vm: LiveViewModel) {
|
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
|
* 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
|
* tapping near it. Rotation-aware: portrait draws the frame rotated 90
|
||||||
* letterbox + zoom math the renderer uses.
|
* deg CW (same mapping as LiveRenderer).
|
||||||
*/
|
*/
|
||||||
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
|
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (!s.streaming) return
|
if (!s.streaming) return
|
||||||
val ar = 4f / 3f
|
val isPortrait = viewH > viewW
|
||||||
val viewRatio = viewW / viewH
|
|
||||||
val dstW: Float
|
val dstW: Float
|
||||||
val dstH: Float
|
val dstH: Float
|
||||||
if (viewRatio > ar) {
|
val left: Float
|
||||||
dstH = viewH
|
val top: Float
|
||||||
dstW = viewH * ar
|
if (isPortrait) {
|
||||||
} else {
|
|
||||||
dstW = viewW
|
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
|
if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return
|
||||||
val srcW = 160f / s.zoom
|
val fx = (screenX - left) / dstW
|
||||||
val srcH = 120f / s.zoom
|
val fy = (screenY - top) / dstH
|
||||||
val sx = (screenX - left) / dstW * srcW + (160 - srcW) / 2f
|
val sx: Float
|
||||||
val sy = (screenY - top) / dstH * srcH + (120 - srcH) / 2f
|
val sy: Float
|
||||||
val sensorX = sx.toInt().coerceIn(0, 159)
|
if (isPortrait) {
|
||||||
val sensorY = sy.toInt().coerceIn(0, 119)
|
// fx = 1 - sy/120, fy = sx/160 (inverse of the rotated draw mapping)
|
||||||
// near an existing probe? delete it instead of adding
|
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 thr = dstW * 0.06f
|
||||||
val existing = s.probes.firstOrNull { p ->
|
val existing = s.probes.firstOrNull { p ->
|
||||||
kotlin.math.abs(p.x + 0.5f - sx) * (dstW / srcW) < thr &&
|
val pxs: Float
|
||||||
kotlin.math.abs(p.y + 0.5f - sy) * (dstH / srcH) < thr
|
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) {
|
if (existing != null) {
|
||||||
_state.value = _state.value.copy(probes = _state.value.probes - existing)
|
_state.value = _state.value.copy(probes = _state.value.probes - existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val label = "Pt${s.probes.size + 1}"
|
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)
|
_state.value = _state.value.copy(probes = _state.value.probes + p)
|
||||||
refreshTemps()
|
refreshTemps()
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user