android: camera-style controls - 4-item top bar (ffc/zoom/trace/palette), shutter row (gallery/photo/record) above nav

This commit is contained in:
ZXCLI
2026-09-07 19:22:49 +08:00
parent fabea4c664
commit 7437ce5e85
8 changed files with 198 additions and 52 deletions
@@ -59,7 +59,7 @@ fun AppRoot() {
Box(modifier = Modifier.fillMaxSize()) {
when (tab) {
0 -> LiveScreen()
0 -> LiveScreen(onOpenGallery = { tab = 1 })
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
GalleryScreen()
}
@@ -1,24 +1,32 @@
package com.mag160c.thermal.ui.live
import android.view.SurfaceView
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -31,41 +39,47 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
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.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.core.Palettes
import android.view.SurfaceView
import com.mag160c.thermal.ui.DeviceOrientation
import com.mag160c.thermal.ui.UiInsets
/**
* Stable single-branch live view: one SurfaceView for the whole screen, a
* control TOP bar (always visible) and the bottom navigation overlay.
* The activity is portrait-locked, so the whole composition (top bar /
* image region / bottom bar) is glued to the phone's portrait frame and
* never moves or rotates, however the phone is physically held — the on-
* screen area always matches the thermal lens direction. The bars' icons
* and text are pre-rotated by the physical grip angle (DeviceOrientation)
* so they stay readable in any grip.
* Stable single-branch live view, camera-app style:
* - control TOP bar (glued to the portrait top edge): FFC / zoom / max-temp
* trace / palette, each with a tiny label, pre-rotated by the grip angle;
* - camera shutter row above the bottom navigation: gallery shortcut, big
* photo shutter, record/stop;
* - the image region stays glued to the phone's portrait frame (matches the
* lens direction) and never moves; OSD text compensates the grip angle.
*/
@Composable
fun LiveScreen(vm: LiveViewModel = viewModel()) {
fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {}) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val phi by com.mag160c.thermal.ui.DeviceOrientation.deg.collectAsState()
val phi by DeviceOrientation.deg.collectAsState()
val density = LocalDensity.current.density
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
vm.connect()
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
while (true) {
kotlinx.coroutines.delay(500)
vm.uiBottomPx = com.mag160c.thermal.ui.UiInsets.navPx
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
vm.refreshTemps()
}
}
@@ -101,7 +115,6 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
// cutout / status-bar safe area (punch-hole phones)
.windowInsetsPadding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Top),
@@ -111,49 +124,154 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.triggerFfc() }) {
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.triggerFfc() }.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_ffc), "FFC 快门校正")
Text("FFC", style = MaterialTheme.typography.labelSmall)
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, 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).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable {
vm.setZoom(vm.state.value.zoom % 4 + 1)
}.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_zoom), "数码变倍")
Text("${state.zoom}×", style = MaterialTheme.typography.labelSmall)
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, 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).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { vm.toggleMaxTrace() }.padding(4.dp),
) {
val on = state.maxTraceOn
Icon(
painterResource(R.drawable.ic_target), "最高温追踪",
tint = if (on) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
if (on) "追踪·开" else "追踪",
style = MaterialTheme.typography.labelSmall,
color = if (on) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { showPalette = true }) {
Box(
modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.clickable { showPalette = true }.padding(4.dp),
) {
Icon(painterResource(R.drawable.ic_palette), "调色板")
Text(
Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelSmall,
)
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.capturePhoto(context) }) {
Icon(painterResource(R.drawable.ic_camera), "拍照")
}
}
// ---- camera shutter row above the bottom navigation (live tab) ----
val recording = state.status == "recording"
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(y = -(navPx / density).dp)
.onSizeChanged {
shutterPx = it.height
vm.uiBottomPx = navPx + shutterPx
}
.fillMaxWidth()
.padding(vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(52.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(52.dp)
.background(Color(0x59FFFFFF), CircleShape)
.clickable { onOpenGallery() },
contentAlignment = Alignment.Center,
) {
Icon(painterResource(R.drawable.ic_gallery), null, tint = Color.White)
}
Text(
"相册", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(74.dp)
.border(4.dp, Color.White, CircleShape)
.padding(6.dp)
.background(Color.White, CircleShape)
.clickable { vm.capturePhoto(context) },
)
Text(
"拍照", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
) {
Box(
modifier = Modifier
.size(52.dp)
.border(3.dp, Color(0xFFFF5252), CircleShape)
.clickable { vm.toggleRecording(context) },
contentAlignment = Alignment.Center,
) {
if (recording) {
Box(
modifier = Modifier
.size(20.dp)
.background(Color(0xFFFF5252), RoundedCornerShape(4.dp)),
)
} else {
Box(
modifier = Modifier
.size(24.dp)
.background(Color(0xFFFF5252), CircleShape),
)
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
IconButton(onClick = { vm.toggleRecording(context) }) {
Icon(painterResource(R.drawable.ic_record), "录像")
}
}
Box(modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, contentAlignment = Alignment.Center) {
Text(
text = Palettes.NAMES[state.paletteIndex],
style = MaterialTheme.typography.labelMedium,
)
}
Text(
if (recording) "停止" else "录像", color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -0,0 +1,8 @@
<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="M5,12a7,7 0 1,0 14,0a7,7 0 1,0 -14,0z" android:strokeColor="#FF000000" android:strokeWidth="2.5" android:fillColor="#00000000" />
<path android:pathData="M12,1.5 L12,5.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M12,18.5 L12,22.5" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M1.5,12 L5.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M18.5,12 L22.5,12" android:strokeColor="#FF000000" android:strokeWidth="2.5" />
<path android:pathData="M10.9,12a1.1,1.1 0 1,0 2.2,0a1.1,1.1 0 1,0 -2.2,0z" android:fillColor="#FF000000" />
</vector>