android: visible-light PIP overlay (camera2, draggable, 3 sizes)

This commit is contained in:
ZXCLI
2026-09-11 00:31:00 +08:00
parent c34940e6fd
commit f8b3200464
8 changed files with 418 additions and 4 deletions
+3
View File
@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<!-- Visible-light PIP overlay (Phase E): optional, requested at runtime -->
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -1,9 +1,13 @@
package com.mag160c.thermal.ui.live
import android.view.SurfaceView
import android.view.TextureView
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -31,6 +35,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -39,6 +44,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
@@ -48,6 +54,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
@@ -72,6 +79,18 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
// visible-light PIP (Phase E)
var pipEngine by remember { mutableStateOf<PipCameraEngine?>(null) }
var viewportSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) }
val cameraPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
vm.togglePip()
} else {
com.mag160c.thermal.media.DebugLog.log("pip", "camera permission denied by user")
}
}
LaunchedEffect(Unit) {
vm.connect()
@@ -84,7 +103,26 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
}
}
Box(modifier = Modifier.fillMaxSize()) {
// The PIP overlay owns the camera: it releases on any of these exits —
// PIP switched off (overlay leaves composition), live screen left, or the
// activity going to the background (ON_STOP).
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) {
pipEngine?.release()
pipEngine = null
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { viewportSize = it },
) {
AndroidSurface(vm)
Box(
modifier = Modifier
@@ -194,9 +232,54 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
)
}
}
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable {
if (state.pipOn) {
vm.togglePip()
} else if (ContextCompat.checkSelfPermission(
context, android.Manifest.permission.CAMERA,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
) {
vm.togglePip()
} else {
cameraPermission.launch(android.Manifest.permission.CAMERA)
}
}
.padding(4.dp),
) {
val pipOn = state.pipOn
Icon(
painterResource(R.drawable.ic_pip), "画中画",
tint = if (pipOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (pipOn) "画中画·开" else "画中画",
style = MaterialTheme.typography.labelSmall,
color = if (pipOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// ---- visible-light PIP overlay (before the shutter row so the row draws on top) ----
if (state.pipOn) {
PipOverlay(
state = state,
vm = vm,
viewport = viewportSize,
onEngine = { pipEngine = it },
)
}
// ---- camera shutter row above the bottom navigation (live tab) ----
val recording = state.status == "recording"
Row(
@@ -289,6 +372,78 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
}
}
/**
* Draggable visible-light PIP overlay (Phase E).
*
* Position is expressed relative to the thermal viewport (the same rect the
* renderer draws into): left = viewport.left + xf * free width, with the
* color-bar footprint kept clear at the right edge. Drag updates the stored
* fractions; tap cycles 96/128/160 dp; double tap closes.
*/
@Composable
private fun PipOverlay(
state: LiveViewModel.LiveState,
vm: LiveViewModel,
viewport: androidx.compose.ui.unit.IntSize,
onEngine: (PipCameraEngine) -> Unit,
) {
val density = LocalDensity.current
val context = LocalContext.current
val engine = remember { PipCameraEngine(context.applicationContext) }
LaunchedEffect(Unit) { onEngine(engine) }
// switching PIP off / leaving the screen removes this overlay: release here
DisposableEffect(engine) {
onDispose { engine.release() }
}
val wDp = LiveViewModel.PIP_WIDTHS_DP[state.pipSizeIndex.coerceIn(0, 2)]
val hDp = wDp * 3 / 4
with(density) {
val top = vm.uiTopPx.toFloat()
val bottom = (viewport.height - vm.uiBottomPx).toFloat().coerceAtLeast(top + 1f)
val freeW = (viewport.width - wDp * density.density).coerceAtLeast(1f)
val freeH = (bottom - top - hDp * density.density).coerceAtLeast(1f)
// xf = 1 parks the PIP at the right edge minus the color-bar margin
val rightMargin = LiveViewModel.PIP_RIGHT_MARGIN_DP * density.density
val left = vm.state.value.pipXf * (freeW - rightMargin)
val topPx = top + vm.state.value.pipYf * freeH
Box(
modifier = Modifier
.offset(x = (left / density.density).dp, y = (topPx / density.density).dp)
.size(wDp.dp, hDp.dp)
.border(2.dp, Color.White, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.pointerInput(state.pipSizeIndex) {
detectDragGestures { change, drag ->
change.consume()
val s = vm.state.value
val nx = (s.pipXf + drag.x / (freeW - rightMargin).coerceAtLeast(1f)).coerceIn(0f, 1f)
val ny = (s.pipYf + drag.y / freeH).coerceIn(0f, 1f)
vm.setPipPos(nx, ny)
}
}
.pointerInput(state.pipSizeIndex) {
detectTapGestures(
onTap = { vm.cyclePipSize() },
onDoubleTap = { vm.togglePip() },
)
},
) {
AndroidView(
factory = { ctx ->
TextureView(ctx).also { tv ->
tv.surfaceTextureListener = engine
engine.attach(tv)
}
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
private fun statusText(state: LiveViewModel.LiveState): String = when (state.status) {
"no_device" -> "未检测到热像仪,请插入MAG160C"
"no_permission" -> "USB权限未授予"
@@ -27,6 +27,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
val identity: IrSession.CameraIdentity? = null,
val maxTraceOn: Boolean = true,
val probes: List<ProbePoint> = emptyList(),
/** Visible-light PIP overlay (Phase E). */
val pipOn: Boolean = false,
/** 0/1/2 -> 96/128/160 dp wide. */
val pipSizeIndex: Int = 1,
/** PIP top-left X as a fraction of the free space (0..1). */
val pipXf: Float = 1f,
/** PIP top-left Y as a fraction of the free space below the top bar (0..1). */
val pipYf: Float = 0f,
)
private val _state = MutableStateFlow(LiveState())
@@ -39,6 +47,14 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private val session = IrSession(app)
companion object {
/** PIP overlay width in dp for size index 0/1/2. */
val PIP_WIDTHS_DP = intArrayOf(96, 128, 160)
/** Color-bar footprint kept clear at the right edge (20dp bar + 2x12dp). */
const val PIP_RIGHT_MARGIN_DP = 32
}
private var usbReceiver: android.content.BroadcastReceiver? = null
private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null
@@ -200,6 +216,25 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
}
// ---- visible-light PIP (Phase E) ----
/** PIP width options in dp (matches [PIP_WIDTHS_DP]). */
fun togglePip() {
_state.value = _state.value.copy(pipOn = !_state.value.pipOn)
}
fun cyclePipSize() {
_state.value = _state.value.copy(pipSizeIndex = (_state.value.pipSizeIndex + 1) % 3)
}
/** Drag position as fractions of the free space (clamped to 0..1). */
fun setPipPos(xf: Float, yf: Float) {
_state.value = _state.value.copy(
pipXf = xf.coerceIn(0f, 1f),
pipYf = yf.coerceIn(0f, 1f),
)
}
/** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */
fun capturePhoto(context: android.content.Context) {
val frame = latestFrame ?: return
@@ -0,0 +1,195 @@
package com.mag160c.thermal.ui.live
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import android.view.TextureView
import com.mag160c.thermal.media.DebugLog
/**
* Minimal Camera2 preview engine for the visible-light PIP overlay (Phase E).
*
* Design rules:
* - open the first BACK-facing camera, preview-only (TEMPLATE_PREVIEW), no
* capture requests, no recording, no image reader;
* - every failure path logs under tag "pip" and tears the camera down —
* a camera problem must never crash or block the thermal live view;
* - [release] is idempotent and safe to call from any state.
*/
class PipCameraEngine(private val context: Context) : TextureView.SurfaceTextureListener {
private var textureView: TextureView? = null
private var camera: CameraDevice? = null
private var session: CameraCaptureSession? = null
private var thread: HandlerThread? = null
private var handler: Handler? = null
private var surface: Surface? = null
@Volatile
private var released = false
/** True once a repeating preview request has been submitted. */
@Volatile
var previewing = false
private set
/** Attach to a TextureView and start the preview as soon as it has a surface. */
fun attach(view: TextureView) {
try {
textureView = view
view.surfaceTextureListener = this
if (view.isAvailable) onSurfaceTextureAvailable(view.surfaceTexture!!, view.width, view.height)
} catch (e: Exception) {
DebugLog.log("pip", "attach failed: $e")
}
}
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
try {
startThread()
surface = Surface(st)
openCamera()
} catch (e: Exception) {
DebugLog.log("pip", "surface available handling failed: $e")
release()
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) = Unit
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
release()
return true
}
override fun onSurfaceTextureUpdated(st: SurfaceTexture) = Unit
private fun startThread() {
if (thread == null) {
val t = HandlerThread("pip-camera")
t.start()
thread = t
handler = Handler(t.looper)
}
}
private fun openCamera() {
val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
val id = try {
manager.cameraIdList.firstOrNull { cid ->
val facing = manager.getCameraCharacteristics(cid)
.get(CameraCharacteristics.LENS_FACING)
facing == CameraCharacteristics.LENS_FACING_BACK
} ?: manager.cameraIdList.firstOrNull()
} catch (e: Exception) {
DebugLog.log("pip", "no camera listed: $e")
null
}
if (id == null) {
DebugLog.log("pip", "no camera available")
return
}
if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
DebugLog.log("pip", "camera permission not granted")
return
}
try {
manager.openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(device: CameraDevice) {
camera = device
DebugLog.log("pip", "camera opened id=$id")
createSession(device)
}
override fun onDisconnected(device: CameraDevice) {
DebugLog.log("pip", "camera disconnected")
release()
}
override fun onError(device: CameraDevice, error: Int) {
DebugLog.log("pip", "camera error=$error")
release()
}
}, handler)
} catch (e: Exception) {
DebugLog.log("pip", "openCamera failed: $e")
release()
}
}
private fun createSession(device: CameraDevice) {
val target = surface ?: run {
DebugLog.log("pip", "no surface for session")
return
}
try {
@Suppress("DEPRECATION")
device.createCaptureSession(listOf(target), object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
session = s
startPreview(device, s, target)
}
override fun onConfigureFailed(s: CameraCaptureSession) {
DebugLog.log("pip", "capture session configure failed")
release()
}
}, handler)
} catch (e: Exception) {
DebugLog.log("pip", "createCaptureSession failed: $e")
release()
}
}
private fun startPreview(device: CameraDevice, s: CameraCaptureSession, target: Surface) {
try {
val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
addTarget(target)
set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO)
}
s.setRepeatingRequest(request.build(), null, handler)
previewing = true
DebugLog.log("pip", "repeating preview started")
} catch (e: Exception) {
DebugLog.log("pip", "startPreview failed: $e")
release()
}
}
/** Idempotent teardown: session -> camera -> surface -> thread. */
fun release() {
if (released && camera == null && session == null) return
released = true
previewing = false
try {
session?.close()
} catch (e: Exception) {
DebugLog.log("pip", "session close failed: $e")
}
session = null
try {
camera?.close()
} catch (e: Exception) {
DebugLog.log("pip", "camera close failed: $e")
}
camera = null
try {
surface?.release()
} catch (_: Exception) {
}
surface = null
thread?.quitSafely()
thread = null
handler = null
DebugLog.log("pip", "released")
}
}
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M13,13 h6 v6 h-6 z" android:fillColor="#FF000000" />
</vector>