android: 顶栏控制项+图像世界固定旋转+状态栏沉浸+inset感知渲染+乱码二次修复

This commit is contained in:
ZXCLI
2026-09-07 12:43:35 +08:00
parent 96b92a8d26
commit 798e180683
12 changed files with 256 additions and 189 deletions
@@ -11,6 +11,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// immersive: hide the status bar (swipe to reveal)
window.insetsController?.let { c ->
c.hide(android.view.WindowInsets.Type.statusBars())
c.systemBarsBehavior =
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent {
Mag160cTheme {
AppRoot()
@@ -21,6 +21,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -66,9 +67,13 @@ fun AppRoot() {
// ---- overlay navigation ----
if (isLandscape) {
// rail overlays the left letterbox margin: no bottom inset needed
UiInsets.navPx = 0
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.align(Alignment.CenterStart),
modifier = Modifier
.align(Alignment.CenterStart)
.onSizeChanged { },
) {
Column(
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
@@ -100,7 +105,9 @@ fun AppRoot() {
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.align(Alignment.BottomCenter),
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { UiInsets.navPx = it.height },
) {
NavigationBar(modifier = Modifier.fillMaxWidth()) {
TABS.forEachIndexed { i, t ->
@@ -0,0 +1,8 @@
package com.mag160c.thermal.ui
/** Shared UI inset state (px), written by AppRoot's nav overlay. */
object UiInsets {
/** Bottom navigation overlay height in px (0 in landscape). */
@Volatile
var navPx: Int = 0
}
@@ -11,11 +11,11 @@ import android.view.SurfaceView
/**
* 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.
* The image CONTENT stays fixed in the world while the phone rotates: the
* draw rotation tracks the display rotation (LiveViewModel.drawRotationDeg).
* Available area = full canvas minus the control-bar (top) and nav (bottom)
* insets reported via [LiveViewModel.uiTopPx]/[uiBottomPx]. OSD icons/text
* stay upright in the current screen orientation.
*/
class LiveRenderer(
private val surfaceView: SurfaceView,
@@ -25,8 +25,6 @@ class LiveRenderer(
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
@@ -39,7 +37,6 @@ class LiveRenderer(
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
}
private val viewport = android.graphics.RectF()
private var isPortraitView = true
fun attach() {
surfaceView.holder.addCallback(this)
@@ -79,85 +76,71 @@ 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 ?: return
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val frame = vm.latestFrame
if (frame == null) return
// available area (minus UI overlays)
val top = vm.uiTopPx.toFloat()
val bottom = h - vm.uiBottomPx.toFloat()
val availW = w
val availH = bottom - top
if (availH <= 0) return
// 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]
}
val d = vm.drawRotationDeg()
val swap = d == 90 || d == 270
// fit the DISPLAYED (rotated) image into the available area
var dstW: Float
var dstH: Float
if (swap) {
dstW = availW
dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
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
dstH = availH
dstW = availH * 4f / 3f
if (dstW > availW) {
dstW = availW
dstH = availW * 3f / 4f
}
val left = (w - dstW) / 2f
val top = (h - dstH) / 2f
viewport.set(left, top, left + dstW, top + dstH)
canvas.drawBitmap(bitmap, null, viewport, paint)
}
val left = (availW - dstW) / 2f
val vpTop = top + (availH - dstH) / 2f
viewport.set(left, vpTop, left + dstW, vpTop + dstH)
val cx = viewport.centerX()
val cy = viewport.centerY()
val w0 = if (swap) dstH else dstW // pre-rotation draw rect
val h0 = if (swap) dstW else 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
canvas.save()
canvas.rotate(d.toFloat(), cx, cy)
val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
drawOsd(canvas, vm.state.value)
}
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 p = vm.probeToScreen(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
@@ -2,9 +2,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
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -29,6 +27,7 @@ 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -37,11 +36,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView
import android.view.Surface
/**
* 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).
* Stable single-branch live view: one SurfaceView for the whole screen,
* a control TOP bar (always visible) and overlays anchored by orientation.
* Image content stays world-fixed across rotations (renderer handles the
* draw rotation from the display rotation).
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
@@ -51,14 +52,21 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
while (true) {
kotlinx.coroutines.delay(500)
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.refreshTemps()
}
}
val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE
// keep the renderer in sync with the physical display rotation
val rotation = remember(context) {
context.display?.rotation ?: Surface.ROTATION_0
}
LaunchedEffect(rotation) {
vm.uiRotation = rotation
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidSurface(vm)
@@ -82,91 +90,61 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
)
}
if (isLandscape) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.align(Alignment.CenterEnd),
// ---- control TOP bar (always visible, both orientations) ----
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.onSizeChanged { vm.uiTopPx = it.height },
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {
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() }.padding(8.dp),
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), "录像")
}
}
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
)
}
}
} 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), "录像")
}
}
}
}
}
}
@@ -216,10 +194,18 @@ private fun PaletteDialog(current: Int, onSelect: (Int) -> Unit, onDismiss: () -
/** Compose host for the SurfaceView renderer. */
@Composable
private fun AndroidSurface(vm: LiveViewModel) {
AndroidView(factory = { ctx ->
SurfaceView(ctx).also { sv ->
val renderer = LiveRenderer(sv, vm)
renderer.attach()
}
}, modifier = Modifier.fillMaxSize())
AndroidView(
factory = { ctx ->
SurfaceView(ctx).also { sv ->
val renderer = LiveRenderer(sv, vm)
renderer.attach()
}
},
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
vm.uiViewW = it.width
vm.uiViewH = it.height
},
)
}
@@ -46,6 +46,17 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
/** UI insets (px) reported from Compose, consumed by the renderer. */
@Volatile
var uiTopPx: Int = 0
@Volatile
var uiBottomPx: Int = 0
/** Display rotation (Surface.ROTATION_0/90/180/270), drives image orientation. */
@Volatile
var uiRotation: Int = 0
private val sessionListener = object : IrSession.Listener {
override fun onStateChanged(state: IrSession.State, message: String?) {
_state.value = _state.value.copy(
@@ -231,62 +242,57 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
/**
* Tap on the live image: add a probe point, or delete an existing one when
* tapping near it. Rotation-aware: portrait draws the frame rotated 90
* deg CW (same mapping as LiveRenderer).
* tapping near it. Rotation-aware: the image draw rotation follows the
* display rotation (same mapping as LiveRenderer), insets carve the
* available area.
*/
fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
val s = _state.value
if (!s.streaming) return
val isPortrait = viewH > viewW
val dstW: Float
val dstH: Float
val left: Float
val top: Float
if (isPortrait) {
dstW = viewW
dstH = viewW * 320f / 240f
left = 0f
top = (viewH - dstH) / 2f
val top = uiTopPx.toFloat()
val bottom = viewH - uiBottomPx.toFloat()
if (screenY < top || screenY > bottom) return
val d = drawRotationDeg()
val swap = d == 90 || d == 270
// fit rect of the DISPLAYED (rotated) image inside the avail rect
val availW = viewW
val availH = bottom - top
var dstW: Float
var dstH: Float
if (swap) {
dstW = availW
dstH = availW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
} else {
val ar = 4f / 3f
val viewRatio = viewW / viewH
if (viewRatio > ar) {
dstH = viewH
dstW = viewH * ar
} else {
dstW = viewW
dstH = viewW / ar
dstH = availH
dstW = availH * 4f / 3f
if (dstW > availW) {
dstW = availW
dstH = availW * 3f / 4f
}
left = (viewW - dstW) / 2f
top = (viewH - dstH) / 2f
}
if (screenX < left || screenX > left + dstW || screenY < top || screenY > top + dstH) return
val left = (availW - dstW) / 2f
val top2 = top + (availH - dstH) / 2f
if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
val fx = (screenX - left) / dstW
val fy = (screenY - top) / dstH
val fy = (screenY - top2) / 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
when (d) {
0 -> { sx = fx * 160f; sy = fy * 120f }
90 -> { sy = (1f - fx) * 120f; sx = fy * 160f }
180 -> { sx = (1f - fx) * 160f; sy = (1f - fy) * 120f }
else -> { sx = (1f - fy) * 160f; sy = fx * 120f }
}
// near an existing probe (compare in screen space)? delete it instead
val thr = dstW * 0.06f
val existing = s.probes.firstOrNull { p ->
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
val scr = probeToScreen(p.x, p.y)
val dx = screenX - scr[0]
val dy = screenY - scr[1]
dx * dx + dy * dy < thr * thr
}
if (existing != null) {
@@ -304,6 +310,54 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
refreshTemps()
}
/** Image draw rotation (deg CW) from the current display rotation. */
fun drawRotationDeg(): Int = ((90 - uiRotation * 90) % 360 + 360) % 360
/** Screen coords of a sensor point under the current rotation + insets. */
fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = uiViewW.toFloat().coerceAtLeast(1f)
val viewH = uiViewH.toFloat().coerceAtLeast(1f)
val top = uiTopPx.toFloat()
val availH = viewH - uiTopPx - uiBottomPx
val d = drawRotationDeg()
val swap = d == 90 || d == 270
var dstW: Float
var dstH: Float
if (swap) {
dstW = viewW
dstH = viewW * 4f / 3f
if (dstH > availH) {
dstH = availH
dstW = availH * 3f / 4f
}
} else {
dstH = availH
dstW = availH * 4f / 3f
if (dstW > viewW) {
dstW = viewW
dstH = viewW * 3f / 4f
}
}
val left = (viewW - dstW) / 2f
val top2 = uiTopPx + (availH - dstH) / 2f
val fx: Float
val fy: Float
when (d) {
0 -> { fx = sx / 160f; fy = sy / 120f }
90 -> { fx = 1f - sy / 120f; fy = sx / 160f }
180 -> { fx = 1f - sx / 160f; fy = 1f - sy / 120f }
else -> { fx = sy / 120f; fy = 1f - sx / 160f }
}
return floatArrayOf(left + fx * dstW, top2 + fy * dstH)
}
/** Last known surface size, for probe screen mapping. */
@Volatile
var uiViewW: Int = 1080
@Volatile
var uiViewH: Int = 2280
/** Toggle MP4 recording of the live stream. */
fun toggleRecording(context: android.content.Context) {
val rec = recorder