feat(node): show camera capture HUD
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package com.steipete.clawdis.node
|
||||
|
||||
enum class CameraHudKind {
|
||||
Photo,
|
||||
Recording,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class CameraHudState(
|
||||
val token: Long,
|
||||
val kind: CameraHudKind,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
|
||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||
|
||||
val instanceId: StateFlow<String> = runtime.instanceId
|
||||
val displayName: StateFlow<String> = runtime.displayName
|
||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.steipete.clawdis.node.chat.ChatController
|
||||
import com.steipete.clawdis.node.chat.ChatMessage
|
||||
@@ -41,6 +42,7 @@ import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class NodeRuntime(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
@@ -88,6 +90,13 @@ class NodeRuntime(context: Context) {
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val cameraHudSeq = AtomicLong(0)
|
||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||
|
||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||
|
||||
private val _serverName = MutableStateFlow<String?>(null)
|
||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||
|
||||
@@ -603,14 +612,33 @@ class NodeRuntime(context: Context) {
|
||||
BridgeSession.InvokeResult.ok(res)
|
||||
}
|
||||
ClawdisCameraCommand.Snap.rawValue -> {
|
||||
val res = camera.snap(paramsJson)
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camera.snap(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
ClawdisCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
val res = camera.clip(paramsJson)
|
||||
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
|
||||
val res =
|
||||
try {
|
||||
camera.clip(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
@@ -624,6 +652,35 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
}
|
||||
|
||||
private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) {
|
||||
val token = cameraHudSeq.incrementAndGet()
|
||||
_cameraHud.value = CameraHudState(token = token, kind = kind, message = message)
|
||||
|
||||
if (autoHideMs != null && autoHideMs > 0) {
|
||||
scope.launch {
|
||||
delay(autoHideMs)
|
||||
if (_cameraHud.value?.token == token) _cameraHud.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
// Preserve full string for callers/logging, but keep the returned message human-friendly.
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.steipete.clawdis.node.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||
import androidx.compose.material.icons.filled.PhotoCamera
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
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.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.steipete.clawdis.node.CameraHudKind
|
||||
import com.steipete.clawdis.node.CameraHudState
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun CameraHudOverlay(
|
||||
hud: CameraHudState?,
|
||||
flashToken: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
CameraFlash(token = flashToken)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = hud != null,
|
||||
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
|
||||
) {
|
||||
if (hud != null) {
|
||||
Toast(hud = hud)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CameraFlash(token: Long) {
|
||||
var alpha by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(token) {
|
||||
if (token == 0L) return@LaunchedEffect
|
||||
alpha = 0.85f
|
||||
delay(110)
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(alpha)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Toast(hud: CameraHudState) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (hud.kind) {
|
||||
CameraHudKind.Photo -> {
|
||||
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||
Spacer(Modifier.size(10.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
CameraHudKind.Recording -> {
|
||||
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
|
||||
}
|
||||
CameraHudKind.Success -> {
|
||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||
}
|
||||
CameraHudKind.Error -> {
|
||||
Icon(Icons.Default.Error, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
text = hud.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||
|
||||
val bridgeState =
|
||||
remember(serverName, statusText) {
|
||||
@@ -78,6 +80,11 @@ fun RootScreen(viewModel: MainViewModel) {
|
||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||
StatusPill(
|
||||
|
||||
Reference in New Issue
Block a user