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?) {
|
||||
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
|
||||
if (frame == null) return
|
||||
|
||||
// build displayed bitmap (rotated in portrait)
|
||||
if (rotationNeeded) {
|
||||
val frame = vm.latestFrame ?: return
|
||||
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]
|
||||
|
||||
// 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
|
||||
|
||||
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,53 +90,18 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||
)
|
||||
}
|
||||
|
||||
if (isLandscape) {
|
||||
// ---- control TOP bar (always visible, both orientations) ----
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
IconButton(onClick = { vm.triggerFfc() }) {
|
||||
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
|
||||
}
|
||||
IconButton(onClick = { vm.setZoom(vm.state.value.zoom % 4 + 1) }) {
|
||||
Icon(painterResource(R.drawable.ic_zoom), "数码变倍 x${state.zoom}")
|
||||
}
|
||||
Text(
|
||||
text = if (state.maxTraceOn) "追踪·开" else "追踪",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (state.maxTraceOn) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(8.dp),
|
||||
)
|
||||
IconButton(onClick = { showPalette = true }) {
|
||||
Icon(painterResource(R.drawable.ic_palette), "调色板")
|
||||
}
|
||||
IconButton(onClick = { vm.capturePhoto(context) }) {
|
||||
Icon(painterResource(R.drawable.ic_camera), "拍照")
|
||||
}
|
||||
IconButton(onClick = { vm.toggleRecording(context) }) {
|
||||
Icon(painterResource(R.drawable.ic_record), "录像")
|
||||
}
|
||||
Text(
|
||||
text = Palettes.NAMES[state.paletteIndex],
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { vm.uiTopPx = it.height },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
@@ -165,6 +138,11 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
|
||||
Icon(painterResource(R.drawable.ic_record), "录像")
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = Palettes.NAMES[state.paletteIndex],
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
SurfaceView(ctx).also { sv ->
|
||||
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
|
||||
|
||||
/** 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
|
||||
} else {
|
||||
val ar = 4f / 3f
|
||||
val viewRatio = viewW / viewH
|
||||
if (viewRatio > ar) {
|
||||
dstH = viewH
|
||||
dstW = viewH * ar
|
||||
} else {
|
||||
dstW = viewW
|
||||
dstH = viewW / ar
|
||||
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
|
||||
}
|
||||
left = (viewW - dstW) / 2f
|
||||
top = (viewH - dstH) / 2f
|
||||
} else {
|
||||
dstH = availH
|
||||
dstW = availH * 4f / 3f
|
||||
if (dstW > availW) {
|
||||
dstW = availW
|
||||
dstH = availW * 3f / 4f
|
||||
}
|
||||
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
|
||||
|
||||
Binary file not shown.
@@ -94,12 +94,29 @@
|
||||
白的根因)
|
||||
- [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保存端到端)
|
||||
- 网络互连远程预览(用户需求:UDP自动发现+手动IP)
|
||||
- 离线MDT温度解码(ConvertResponse2Temperature+标定参数,待真机文件对照)
|
||||
- 厂商12调色板精确提取(需运行时抓取)
|
||||
- 预览图:analysis/preview/preview_landscape.png、preview_right.png(本轮)
|
||||
|
||||
## 里程碑日志
|
||||
|
||||
|
||||
Reference in New Issue
Block a user