diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a6c3274..edd6afc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
index aa56c10..200303a 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
@@ -30,10 +30,10 @@ import com.mag160c.thermal.ui.settings.SettingsScreen
private data class Tab(val label: String, val icon: Int)
private val TABS = listOf(
- Tab("ʵʱ", R.drawable.ic_ffc),
- Tab("", R.drawable.ic_gallery),
- Tab("", R.drawable.ic_analysis),
- Tab("", R.drawable.ic_settings),
+ Tab("实时", R.drawable.ic_ffc),
+ Tab("相册", R.drawable.ic_gallery),
+ Tab("分析", R.drawable.ic_analysis),
+ Tab("设置", R.drawable.ic_settings),
)
@Composable
@@ -45,8 +45,8 @@ fun AppRoot() {
val content: @Composable () -> Unit = {
when (tab) {
0 -> LiveScreen()
- 1 -> Placeholder("ý⣨4ʵ֣")
- 2 -> Placeholder("MDT ߷5ʵ֣")
+ 1 -> Placeholder("媒体库(阶段4实现)")
+ 2 -> Placeholder("MDT 离线分析(阶段5实现)")
else -> SettingsScreen()
}
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt
index 2a45e96..60393eb 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/gallery/GalleryScreen.kt
@@ -41,6 +41,19 @@ fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
val context = LocalContext.current
var showViewer by remember { mutableStateOf(false) }
+ // runtime media permission (API 33+: READ_MEDIA_IMAGES, else READ_EXTERNAL_STORAGE)
+ val perm = if (android.os.Build.VERSION.SDK_INT >= 33)
+ android.Manifest.permission.READ_MEDIA_IMAGES
+ else android.Manifest.permission.READ_EXTERNAL_STORAGE
+ val launcher = androidx.activity.compose.rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
+ ) { granted -> vm.refresh() }
+ LaunchedEffect(Unit) {
+ val granted = androidx.core.content.ContextCompat.checkSelfPermission(context, perm) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED
+ if (granted) vm.refresh() else launcher.launch(perm)
+ }
+
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
index a854d61..32ce4af 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
@@ -116,6 +116,46 @@ class LiveRenderer(
textPaint.color = Color.WHITE
}
+ private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
+ textPaint.color = Color.WHITE
+ state.centerTempC?.let {
+ canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, 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 y0 = viewport.top + (viewport.height() - barH) / 2f
+ val seg = Paint()
+ val n = 64
+ 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
+ seg.color = c
+ canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
+ }
+ 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)
+ }
+
private fun sensorToScreen(pos: Int): FloatArray {
val x = pos % 160
val y = pos / 160
@@ -124,10 +164,10 @@ class LiveRenderer(
return floatArrayOf(sx, sy)
}
- private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?) {
+ private fun drawTempMarker(canvas: Canvas, pos: Int, tempC: Float?, label: String? = null) {
if (pos < 0 || tempC == null) return
val p = sensorToScreen(pos)
- val text = "%.1f℃".format(tempC)
+ val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
val tw = textPaint.measureText(text)
val cx = p[0]
val cy = p[1]
@@ -138,13 +178,4 @@ class LiveRenderer(
canvas.drawCircle(cx, cy, 4f, textPaint)
canvas.drawText(text, tx, ty, textPaint)
}
-
- private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
- textPaint.color = Color.WHITE
- state.centerTempC?.let {
- canvas.drawText("中心 %.1f℃".format(it), 10f, textPaint.textSize + 8f, textPaint)
- }
- drawTempMarker(canvas, state.maxPos, state.maxTempC)
- drawTempMarker(canvas, state.minPos, state.minTempC)
- }
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
index f183662..6587eaf 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
@@ -1,6 +1,7 @@
package com.mag160c.thermal.ui.live
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -27,6 +28,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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -56,6 +58,19 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
.fillMaxSize(),
) {
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) {
Text(
text = when (state.status) {
@@ -92,6 +107,15 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) {
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) } ?: "--",
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
index d22d546..9155ca5 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
@@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.StateFlow
* Live view state machine: USB permission -> link -> stream -> OSD stats.
*/
class LiveViewModel(app: Application) : AndroidViewModel(app) {
+ data class ProbePoint(val x: Int, val y: Int, val label: String, val tempC: Float?)
+
data class LiveState(
val connected: Boolean = false,
val streaming: Boolean = false,
@@ -24,6 +26,8 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val maxPos: Int = -1,
val minPos: Int = -1,
val identity: IrSession.CameraIdentity? = null,
+ val maxTraceOn: Boolean = true,
+ val probes: List = emptyList(),
)
private val _state = MutableStateFlow(LiveState())
@@ -36,6 +40,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private val session = IrSession(app)
+ private var usbReceiver: android.content.BroadcastReceiver? = null
+
+ private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
+
private val sessionListener = object : IrSession.Listener {
override fun onStateChanged(state: IrSession.State, message: String?) {
_state.value = _state.value.copy(
@@ -56,6 +64,18 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
init {
session.setListener(sessionListener)
+ // auto permission + start when the camera is plugged in while running
+ val ctx = getApplication()
+ usbReceiver = object : android.content.BroadcastReceiver() {
+ override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
+ connect()
+ }
+ }
+ ctx.registerReceiver(
+ usbReceiver,
+ android.content.IntentFilter(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED),
+ if (android.os.Build.VERSION.SDK_INT >= 33) android.content.Context.RECEIVER_NOT_EXPORTED else 0,
+ )
// push frames into the MP4 recorder while recording
session.recorderHook = { argb ->
val rec = recorder
@@ -95,6 +115,11 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
}
+ /** Toggle max-temperature trace marker. */
+ fun toggleMaxTrace() {
+ _state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
+ }
+
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
fun capturePhoto(context: android.content.Context) {
val frame = latestFrame ?: return
@@ -116,7 +141,49 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail")
}
- private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
+ /**
+ * 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.
+ */
+ 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 dstW: Float
+ val dstH: Float
+ if (viewRatio > ar) {
+ dstH = viewH
+ dstW = viewH * ar
+ } else {
+ dstW = viewW
+ dstH = viewW / ar
+ }
+ 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 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
+ }
+ 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)
+ _state.value = _state.value.copy(probes = _state.value.probes + p)
+ refreshTemps()
+ }
/** Toggle MP4 recording of the live stream. */
fun toggleRecording(context: android.content.Context) {
@@ -159,12 +226,17 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
mxPos = i
}
}
+ // probe temperatures
+ val probes = _state.value.probes.map { p ->
+ p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
+ }
_state.value = _state.value.copy(
centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f },
maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null,
minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null,
maxPos = mxPos,
minPos = mnPos,
+ probes = probes,
)
}
diff --git a/android/app/src/main/res/drawable/ic_analysis.xml b/android/app/src/main/res/drawable/ic_analysis.xml
index 0b3d02e..ebc0509 100644
--- a/android/app/src/main/res/drawable/ic_analysis.xml
+++ b/android/app/src/main/res/drawable/ic_analysis.xml
@@ -1,3 +1,5 @@
-
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_camera.xml b/android/app/src/main/res/drawable/ic_camera.xml
index a4e09c4..6e5e363 100644
--- a/android/app/src/main/res/drawable/ic_camera.xml
+++ b/android/app/src/main/res/drawable/ic_camera.xml
@@ -1,4 +1,5 @@
-
-
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_ffc.xml b/android/app/src/main/res/drawable/ic_ffc.xml
index 7b74525..a8794b2 100644
--- a/android/app/src/main/res/drawable/ic_ffc.xml
+++ b/android/app/src/main/res/drawable/ic_ffc.xml
@@ -1,3 +1,5 @@
-
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_gallery.xml b/android/app/src/main/res/drawable/ic_gallery.xml
index 43e248c..76661e7 100644
--- a/android/app/src/main/res/drawable/ic_gallery.xml
+++ b/android/app/src/main/res/drawable/ic_gallery.xml
@@ -1,3 +1,5 @@
-
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
index 349308b..2ea4388 100644
--- a/android/app/src/main/res/drawable/ic_launcher.xml
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -1,6 +1,6 @@
-
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_palette.xml b/android/app/src/main/res/drawable/ic_palette.xml
index bcafd88..26c33fb 100644
--- a/android/app/src/main/res/drawable/ic_palette.xml
+++ b/android/app/src/main/res/drawable/ic_palette.xml
@@ -1,5 +1,6 @@
-
-
-
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_record.xml b/android/app/src/main/res/drawable/ic_record.xml
index cf1c302..c589cf0 100644
--- a/android/app/src/main/res/drawable/ic_record.xml
+++ b/android/app/src/main/res/drawable/ic_record.xml
@@ -1 +1,4 @@
-
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml
index ee1a3c2..7e07468 100644
--- a/android/app/src/main/res/drawable/ic_settings.xml
+++ b/android/app/src/main/res/drawable/ic_settings.xml
@@ -1,3 +1,8 @@
-
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_zoom.xml b/android/app/src/main/res/drawable/ic_zoom.xml
index 1200161..d8a6435 100644
--- a/android/app/src/main/res/drawable/ic_zoom.xml
+++ b/android/app/src/main/res/drawable/ic_zoom.xml
@@ -1,4 +1,4 @@
-
-
+
+
diff --git a/android/app/src/main/res/xml/device_filter.xml b/android/app/src/main/res/xml/device_filter.xml
new file mode 100644
index 0000000..f4e19cf
--- /dev/null
+++ b/android/app/src/main/res/xml/device_filter.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index 599e9c6..432d31d 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,4 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
+kotlin.daemon.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official