android: 顶栏控制项+图像世界固定旋转+状态栏沉浸+inset感知渲染+乱码二次修复
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
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 {
|
setContent {
|
||||||
Mag160cTheme {
|
Mag160cTheme {
|
||||||
AppRoot()
|
AppRoot()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -66,9 +67,13 @@ fun AppRoot() {
|
|||||||
|
|
||||||
// ---- overlay navigation ----
|
// ---- overlay navigation ----
|
||||||
if (isLandscape) {
|
if (isLandscape) {
|
||||||
|
// rail overlays the left letterbox margin: no bottom inset needed
|
||||||
|
UiInsets.navPx = 0
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.align(Alignment.CenterStart),
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.onSizeChanged { },
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
|
modifier = Modifier.padding(vertical = 28.dp, horizontal = 2.dp),
|
||||||
@@ -100,7 +105,9 @@ fun AppRoot() {
|
|||||||
} else {
|
} else {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.onSizeChanged { UiInsets.navPx = it.height },
|
||||||
) {
|
) {
|
||||||
NavigationBar(modifier = Modifier.fillMaxWidth()) {
|
NavigationBar(modifier = Modifier.fillMaxWidth()) {
|
||||||
TABS.forEachIndexed { i, t ->
|
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.
|
* Software canvas renderer for the live IR stream.
|
||||||
*
|
*
|
||||||
* The image always fills the display area as much as possible:
|
* The image CONTENT stays fixed in the world while the phone rotates: the
|
||||||
* portrait -> the 320x240 frame is rotated 90 deg (drawn 240x320, fills
|
* draw rotation tracks the display rotation (LiveViewModel.drawRotationDeg).
|
||||||
* the screen width)
|
* Available area = full canvas minus the control-bar (top) and nav (bottom)
|
||||||
* landscape -> native 4:3, fills the screen height
|
* insets reported via [LiveViewModel.uiTopPx]/[uiBottomPx]. OSD icons/text
|
||||||
* OSD icons/text stay upright in the current screen orientation.
|
* stay upright in the current screen orientation.
|
||||||
*/
|
*/
|
||||||
class LiveRenderer(
|
class LiveRenderer(
|
||||||
private val surfaceView: SurfaceView,
|
private val surfaceView: SurfaceView,
|
||||||
@@ -25,8 +25,6 @@ class LiveRenderer(
|
|||||||
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 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
|
||||||
@@ -39,7 +37,6 @@ class LiveRenderer(
|
|||||||
setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
|
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)
|
||||||
@@ -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) {
|
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 ?: return
|
||||||
|
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
||||||
|
|
||||||
val frame = vm.latestFrame
|
// available area (minus UI overlays)
|
||||||
if (frame == null) return
|
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)
|
val d = vm.drawRotationDeg()
|
||||||
if (rotationNeeded) {
|
val swap = d == 90 || d == 270
|
||||||
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
// fit the DISPLAYED (rotated) image into the available area
|
||||||
bitmap.getPixels(px, 0, 320, 0, 0, 320, 240)
|
var dstW: Float
|
||||||
val rpx = IntArray(240 * 320)
|
var dstH: Float
|
||||||
for (y in 0 until 240) {
|
if (swap) {
|
||||||
for (x in 0 until 320) {
|
dstW = availW
|
||||||
// 90 deg CW: dst(x',y') = src(y, 239-x) -> with sizes: dst(240x320)
|
dstH = availW * 4f / 3f
|
||||||
// dst index: x' = 239 - src_y, y' = src_x
|
if (dstH > availH) {
|
||||||
val srcIdx = y * 320 + x
|
dstH = availH
|
||||||
val dstX = 239 - y
|
dstW = availH * 3f / 4f
|
||||||
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 {
|
} else {
|
||||||
bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
|
dstH = availH
|
||||||
val ar = 4f / 3f
|
dstW = availH * 4f / 3f
|
||||||
val viewRatio = w / h
|
if (dstW > availW) {
|
||||||
val dstW: Float
|
dstW = availW
|
||||||
val dstH: Float
|
dstH = availW * 3f / 4f
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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)
|
drawOsd(canvas, vm.state.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
|
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
|
||||||
if (sx < 0 || sy < 0 || tempC == null) return
|
if (sx < 0 || sy < 0 || tempC == null) return
|
||||||
val p = sensorToScreen(sx, sy)
|
val p = vm.probeToScreen(sx, sy)
|
||||||
val cx = p[0]
|
val cx = p[0]
|
||||||
val cy = p[1]
|
val cy = p[1]
|
||||||
val dotR = 4.5f * density
|
val dotR = 4.5f * density
|
||||||
// bigger ring + dot marker
|
|
||||||
markerPaint.style = Paint.Style.FILL
|
markerPaint.style = Paint.Style.FILL
|
||||||
canvas.drawCircle(cx, cy, dotR, markerPaint)
|
canvas.drawCircle(cx, cy, dotR, markerPaint)
|
||||||
markerPaint.style = Paint.Style.STROKE
|
markerPaint.style = Paint.Style.STROKE
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package com.mag160c.thermal.ui.live
|
|||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
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.fillMaxWidth
|
||||||
@@ -29,6 +27,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.R
|
||||||
import com.mag160c.thermal.core.Palettes
|
import com.mag160c.thermal.core.Palettes
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
|
import android.view.Surface
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable single-branch layout for the live view: the SurfaceView is created
|
* Stable single-branch live view: one SurfaceView for the whole screen,
|
||||||
* once and survives orientation changes. The control bar is an overlay
|
* a control TOP bar (always visible) and overlays anchored by orientation.
|
||||||
* (bottom row in portrait, right column in landscape).
|
* Image content stays world-fixed across rotations (renderer handles the
|
||||||
|
* draw rotation from the display rotation).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||||
@@ -51,14 +52,21 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
vm.connect()
|
vm.connect()
|
||||||
|
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
|
||||||
while (true) {
|
while (true) {
|
||||||
kotlinx.coroutines.delay(500)
|
kotlinx.coroutines.delay(500)
|
||||||
|
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
|
||||||
vm.refreshTemps()
|
vm.refreshTemps()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLandscape = androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
|
// keep the renderer in sync with the physical display rotation
|
||||||
android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
val rotation = remember(context) {
|
||||||
|
context.display?.rotation ?: Surface.ROTATION_0
|
||||||
|
}
|
||||||
|
LaunchedEffect(rotation) {
|
||||||
|
vm.uiRotation = rotation
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidSurface(vm)
|
AndroidSurface(vm)
|
||||||
@@ -82,91 +90,61 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLandscape) {
|
// ---- control TOP bar (always visible, both orientations) ----
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.align(Alignment.CenterEnd),
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { vm.uiTopPx = it.height },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = { vm.triggerFfc() }) {
|
IconButton(onClick = { vm.triggerFfc() }) {
|
||||||
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
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) }) {
|
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
|
||||||
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
|
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
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 }) {
|
IconButton(onClick = { showPalette = true }) {
|
||||||
Icon(painterResource(R.drawable.ic_palette), "调色板")
|
Icon(painterResource(R.drawable.ic_palette), "调色板")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
IconButton(onClick = { vm.capturePhoto(context) }) {
|
IconButton(onClick = { vm.capturePhoto(context) }) {
|
||||||
Icon(painterResource(R.drawable.ic_camera), "拍照")
|
Icon(painterResource(R.drawable.ic_camera), "拍照")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
IconButton(onClick = { vm.toggleRecording(context) }) {
|
IconButton(onClick = { vm.toggleRecording(context) }) {
|
||||||
Icon(painterResource(R.drawable.ic_record), "录像")
|
Icon(painterResource(R.drawable.ic_record), "录像")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = Palettes.NAMES[state.paletteIndex],
|
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. */
|
/** Compose host for the SurfaceView renderer. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AndroidSurface(vm: LiveViewModel) {
|
private fun AndroidSurface(vm: LiveViewModel) {
|
||||||
AndroidView(factory = { ctx ->
|
AndroidView(
|
||||||
SurfaceView(ctx).also { sv ->
|
factory = { ctx ->
|
||||||
val renderer = LiveRenderer(sv, vm)
|
SurfaceView(ctx).also { sv ->
|
||||||
renderer.attach()
|
val renderer = LiveRenderer(sv, vm)
|
||||||
}
|
renderer.attach()
|
||||||
}, modifier = Modifier.fillMaxSize())
|
}
|
||||||
|
},
|
||||||
|
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
|
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 {
|
private val sessionListener = object : IrSession.Listener {
|
||||||
override fun onStateChanged(state: IrSession.State, message: String?) {
|
override fun onStateChanged(state: IrSession.State, message: String?) {
|
||||||
_state.value = _state.value.copy(
|
_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
|
* 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
|
* tapping near it. Rotation-aware: the image draw rotation follows the
|
||||||
* deg CW (same mapping as LiveRenderer).
|
* display rotation (same mapping as LiveRenderer), insets carve the
|
||||||
|
* available area.
|
||||||
*/
|
*/
|
||||||
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 isPortrait = viewH > viewW
|
val top = uiTopPx.toFloat()
|
||||||
val dstW: Float
|
val bottom = viewH - uiBottomPx.toFloat()
|
||||||
val dstH: Float
|
if (screenY < top || screenY > bottom) return
|
||||||
val left: Float
|
val d = drawRotationDeg()
|
||||||
val top: Float
|
val swap = d == 90 || d == 270
|
||||||
if (isPortrait) {
|
// fit rect of the DISPLAYED (rotated) image inside the avail rect
|
||||||
dstW = viewW
|
val availW = viewW
|
||||||
dstH = viewW * 320f / 240f
|
val availH = bottom - top
|
||||||
left = 0f
|
var dstW: Float
|
||||||
top = (viewH - dstH) / 2f
|
var dstH: Float
|
||||||
|
if (swap) {
|
||||||
|
dstW = availW
|
||||||
|
dstH = availW * 4f / 3f
|
||||||
|
if (dstH > availH) {
|
||||||
|
dstH = availH
|
||||||
|
dstW = availH * 3f / 4f
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val ar = 4f / 3f
|
dstH = availH
|
||||||
val viewRatio = viewW / viewH
|
dstW = availH * 4f / 3f
|
||||||
if (viewRatio > ar) {
|
if (dstW > availW) {
|
||||||
dstH = viewH
|
dstW = availW
|
||||||
dstW = viewH * ar
|
dstH = availW * 3f / 4f
|
||||||
} else {
|
|
||||||
dstW = viewW
|
|
||||||
dstH = viewW / ar
|
|
||||||
}
|
}
|
||||||
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 fx = (screenX - left) / dstW
|
||||||
val fy = (screenY - top) / dstH
|
val fy = (screenY - top2) / dstH
|
||||||
val sx: Float
|
val sx: Float
|
||||||
val sy: Float
|
val sy: Float
|
||||||
if (isPortrait) {
|
when (d) {
|
||||||
// fx = 1 - sy/120, fy = sx/160 (inverse of the rotated draw mapping)
|
0 -> { sx = fx * 160f; sy = fy * 120f }
|
||||||
sy = (1f - fx) * 120f
|
90 -> { sy = (1f - fx) * 120f; sx = fy * 160f }
|
||||||
sx = fy * 160f
|
180 -> { sx = (1f - fx) * 160f; sy = (1f - fy) * 120f }
|
||||||
} else {
|
else -> { sx = (1f - fy) * 160f; sy = fx * 120f }
|
||||||
sx = fx * 160f
|
|
||||||
sy = fy * 120f
|
|
||||||
}
|
}
|
||||||
// near an existing probe (compare in screen space)? delete it instead
|
// 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 ->
|
||||||
val pxs: Float
|
val scr = probeToScreen(p.x, p.y)
|
||||||
val pys: Float
|
val dx = screenX - scr[0]
|
||||||
if (isPortrait) {
|
val dy = screenY - scr[1]
|
||||||
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
|
dx * dx + dy * dy < thr * thr
|
||||||
}
|
}
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
@@ -304,6 +310,54 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
refreshTemps()
|
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. */
|
/** Toggle MP4 recording of the live stream. */
|
||||||
fun toggleRecording(context: android.content.Context) {
|
fun toggleRecording(context: android.content.Context) {
|
||||||
val rec = recorder
|
val rec = recorder
|
||||||
|
|||||||
Binary file not shown.
@@ -94,12 +94,29 @@
|
|||||||
白的根因)
|
白的根因)
|
||||||
- [x] 模拟器实测:竖屏/横屏截图确认全部生效(analysis/preview/)
|
- [x] 模拟器实测:竖屏/横屏截图确认全部生效(analysis/preview/)
|
||||||
|
|
||||||
|
## 用户反馈修复 第三轮(2026-09-07)
|
||||||
|
|
||||||
|
- [x] **布局重构**:控制项(FFC/变倍/追踪/调色板/拍照/录像/调色板名)移到**顶部横栏**,
|
||||||
|
横竖屏都显示;底栏导航(实时/相册/分析/设置)保持原位置(竖屏底部栏/横屏左侧栏)
|
||||||
|
- [x] **图像内容世界固定**:绘制角度 = (90 - 显示器rotation×90) mod 360,
|
||||||
|
四向旋转(竖屏/左横/右横/倒竖屏)图像内容都不随手机转,只转图标文字
|
||||||
|
(修复"向右横屏图像颠倒"问题)
|
||||||
|
- [x] **状态栏沉浸**:insetsController 隐藏状态栏(下滑临时显示),底栏导航仍可用
|
||||||
|
- [x] **inset感知渲染**:渲染器 viewport = 全屏减顶栏/底栏inset(AppRoot横屏时
|
||||||
|
导航栏宽度不再误算为底部inset)
|
||||||
|
- [x] 顶栏通过 onSizeChanged 上报 vm.uiTopPx;导航 overlay 高度经 UiInsets 单例
|
||||||
|
同步 vm.uiBottomPx
|
||||||
|
- [x] **(重要教训)python 裸写文件会用 GBK**:所有 python 编辑必须显式
|
||||||
|
encoding='utf-8';本次 LiveScreen.kt 双重乱码已重写恢复,dex 验证 CJK 正常
|
||||||
|
- [x] 模拟器验证:顶栏/左导航/图像/色标/标记布局正确,中文正常,状态栏隐藏生效
|
||||||
|
|
||||||
## 待办
|
## 待办
|
||||||
|
|
||||||
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
|
- 真机USB实测(温度绝对值标定、FFC/录像/MDT保存端到端)
|
||||||
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
||||||
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
||||||
- 厂商12调色板精确提取(需运行时抓取)
|
- 厂商12调色板精确提取(需运行时抓取)
|
||||||
|
- 预览图:analysis/preview/preview_landscape.png、preview_right.png(本轮)
|
||||||
|
|
||||||
## 里程碑日志
|
## 里程碑日志
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user