From 798e18068330d771f3f69cb99543ef7f1c570e1c Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Mon, 7 Sep 2026 12:43:35 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E9=A1=B6=E6=A0=8F=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=A1=B9+=E5=9B=BE=E5=83=8F=E4=B8=96=E7=95=8C=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E6=97=8B=E8=BD=AC+=E7=8A=B6=E6=80=81=E6=A0=8F?= =?UTF-8?q?=E6=B2=89=E6=B5=B8+inset=E6=84=9F=E7=9F=A5=E6=B8=B2=E6=9F=93+?= =?UTF-8?q?=E4=B9=B1=E7=A0=81=E4=BA=8C=E6=AC=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analysis/preview/preview_landscape.png | 4 +- analysis/preview/preview_live.png | 4 +- analysis/preview/preview_right.png | 3 + analysis/preview/preview_upsidedown.png | 3 + .../com/mag160c/thermal/MainActivity.kt | 6 + .../kotlin/com/mag160c/thermal/ui/AppRoot.kt | 11 +- .../kotlin/com/mag160c/thermal/ui/UiInsets.kt | 8 + .../mag160c/thermal/ui/live/LiveRenderer.kt | 119 +++++++-------- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 128 ++++++++-------- .../mag160c/thermal/ui/live/LiveViewModel.kt | 138 ++++++++++++------ build-artifacts/mag160c-app-debug.apk | 4 +- docs/android_app/session_state.md | 17 +++ 12 files changed, 256 insertions(+), 189 deletions(-) create mode 100644 analysis/preview/preview_right.png create mode 100644 analysis/preview/preview_upsidedown.png create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt diff --git a/analysis/preview/preview_landscape.png b/analysis/preview/preview_landscape.png index 531bcab..663c7eb 100644 --- a/analysis/preview/preview_landscape.png +++ b/analysis/preview/preview_landscape.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9907dd1e8e9390434163ebd7bef5ad67a6f475afb9342cf93b61a10ce1d66ce1 -size 807556 +oid sha256:edf0034437dd82b7ab2de3a05f730b2852b290119170760924ba17c82392298c +size 662155 diff --git a/analysis/preview/preview_live.png b/analysis/preview/preview_live.png index 531bcab..995f7a8 100644 --- a/analysis/preview/preview_live.png +++ b/analysis/preview/preview_live.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9907dd1e8e9390434163ebd7bef5ad67a6f475afb9342cf93b61a10ce1d66ce1 -size 807556 +oid sha256:69cebf163b16fbd1240ee3542b0f37e253313025af454064a59c7f2340fb02c7 +size 558648 diff --git a/analysis/preview/preview_right.png b/analysis/preview/preview_right.png new file mode 100644 index 0000000..dc06ca4 --- /dev/null +++ b/analysis/preview/preview_right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9808eb7b6825d793203119ac56d8ffa1a4bfe444f72dda115b1d1e7847625f2c +size 528466 diff --git a/analysis/preview/preview_upsidedown.png b/analysis/preview/preview_upsidedown.png new file mode 100644 index 0000000..4f420f5 --- /dev/null +++ b/analysis/preview/preview_upsidedown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083212f0a9134a37ccec68d7d05e069d5910bcdaaefa7527ac6579ee07324b57 +size 589375 diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt index 4cb4369..22303e8 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/MainActivity.kt @@ -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() 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 9be98f5..742e7d4 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 @@ -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 -> diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt new file mode 100644 index 0000000..1ea7c10 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/UiInsets.kt @@ -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 +} 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 7293568..1acfafd 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 @@ -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 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 ac42f98..3f46f6d 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 @@ -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 + }, + ) } 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 88528a2..66846d1 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 @@ -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 diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index d23e567..b4d6601 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08ef06842a8a7a9a9d626ca1b578de6ae3f76ebeb585253216da7f2c71429a41 -size 11829966 +oid sha256:538b162be037aaef17b18b8953037d12792b3aad781b3bed5e3d26024309ab84 +size 11806159 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index c0ef464..5af694a 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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(本轮) ## 里程碑日志