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 serverName: StateFlow<String?> = runtime.serverName
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
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 instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.steipete.clawdis.node.chat.ChatController
|
import com.steipete.clawdis.node.chat.ChatController
|
||||||
import com.steipete.clawdis.node.chat.ChatMessage
|
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.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
class NodeRuntime(context: Context) {
|
class NodeRuntime(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -88,6 +90,13 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _statusText = MutableStateFlow("Offline")
|
private val _statusText = MutableStateFlow("Offline")
|
||||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
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)
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
@@ -603,14 +612,33 @@ class NodeRuntime(context: Context) {
|
|||||||
BridgeSession.InvokeResult.ok(res)
|
BridgeSession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdisCameraCommand.Snap.rawValue -> {
|
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)
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
}
|
}
|
||||||
ClawdisCameraCommand.Clip.rawValue -> {
|
ClawdisCameraCommand.Clip.rawValue -> {
|
||||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||||
if (includeAudio) externalAudioCaptureActive.value = true
|
if (includeAudio) externalAudioCaptureActive.value = true
|
||||||
try {
|
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)
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
} finally {
|
} finally {
|
||||||
if (includeAudio) externalAudioCaptureActive.value = false
|
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 {
|
private suspend fun ensureA2uiReady(): Boolean {
|
||||||
try {
|
try {
|
||||||
val already = canvas.eval(a2uiReadyCheckJS)
|
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 context = LocalContext.current
|
||||||
val serverName by viewModel.serverName.collectAsState()
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||||
|
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||||
|
|
||||||
val bridgeState =
|
val bridgeState =
|
||||||
remember(serverName, statusText) {
|
remember(serverName, statusText) {
|
||||||
@@ -78,6 +80,11 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
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.
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||||
StatusPill(
|
StatusPill(
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class NodeAppModel {
|
final class NodeAppModel {
|
||||||
|
enum CameraHUDKind {
|
||||||
|
case photo
|
||||||
|
case recording
|
||||||
|
case success
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
var isBackgrounded: Bool = false
|
var isBackgrounded: Bool = false
|
||||||
let screen = ScreenController()
|
let screen = ScreenController()
|
||||||
let camera = CameraController()
|
let camera = CameraController()
|
||||||
@@ -18,10 +25,15 @@ final class NodeAppModel {
|
|||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||||
let voiceWake = VoiceWakeManager()
|
let voiceWake = VoiceWakeManager()
|
||||||
|
|
||||||
var bridgeSession: BridgeSession { self.bridge }
|
var bridgeSession: BridgeSession { self.bridge }
|
||||||
|
|
||||||
|
var cameraHUDText: String?
|
||||||
|
var cameraHUDKind: CameraHUDKind?
|
||||||
|
var cameraFlashNonce: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.voiceWake.configure { [weak self] cmd in
|
self.voiceWake.configure { [weak self] cmd in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -453,6 +465,8 @@ final class NodeAppModel {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||||
|
|
||||||
case ClawdisCameraCommand.snap.rawValue:
|
case ClawdisCameraCommand.snap.rawValue:
|
||||||
|
self.showCameraHUD(text: "Taking photo…", kind: .photo)
|
||||||
|
self.triggerCameraFlash()
|
||||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||||
ClawdisCameraSnapParams()
|
ClawdisCameraSnapParams()
|
||||||
let res = try await self.camera.snap(params: params)
|
let res = try await self.camera.snap(params: params)
|
||||||
@@ -468,6 +482,7 @@ final class NodeAppModel {
|
|||||||
base64: res.base64,
|
base64: res.base64,
|
||||||
width: res.width,
|
width: res.width,
|
||||||
height: res.height))
|
height: res.height))
|
||||||
|
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
case ClawdisCameraCommand.clip.rawValue:
|
case ClawdisCameraCommand.clip.rawValue:
|
||||||
@@ -477,6 +492,7 @@ final class NodeAppModel {
|
|||||||
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
|
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
|
||||||
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
|
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
|
||||||
|
|
||||||
|
self.showCameraHUD(text: "Recording…", kind: .recording)
|
||||||
let res = try await self.camera.clip(params: params)
|
let res = try await self.camera.clip(params: params)
|
||||||
|
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
@@ -490,6 +506,7 @@ final class NodeAppModel {
|
|||||||
base64: res.base64,
|
base64: res.base64,
|
||||||
durationMs: res.durationMs,
|
durationMs: res.durationMs,
|
||||||
hasAudio: res.hasAudio))
|
hasAudio: res.hasAudio))
|
||||||
|
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -499,6 +516,10 @@ final class NodeAppModel {
|
|||||||
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if command.hasPrefix("camera.") {
|
||||||
|
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
|
self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2)
|
||||||
|
}
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -530,4 +551,26 @@ final class NodeAppModel {
|
|||||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func triggerCameraFlash() {
|
||||||
|
self.cameraFlashNonce &+= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||||
|
self.cameraHUDDismissTask?.cancel()
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||||
|
self.cameraHUDText = text
|
||||||
|
self.cameraHUDKind = kind
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let autoHideSeconds else { return }
|
||||||
|
self.cameraHUDDismissTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000))
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) {
|
||||||
|
self.cameraHUDText = nil
|
||||||
|
self.cameraHUDKind = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ struct RootCanvas: View {
|
|||||||
bridgeStatus: self.bridgeStatus,
|
bridgeStatus: self.bridgeStatus,
|
||||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||||
voiceWakeToastText: self.voiceWakeToastText,
|
voiceWakeToastText: self.voiceWakeToastText,
|
||||||
|
cameraHUDText: self.appModel.cameraHUDText,
|
||||||
|
cameraHUDKind: self.appModel.cameraHUDKind,
|
||||||
openChat: {
|
openChat: {
|
||||||
self.presentedSheet = .chat
|
self.presentedSheet = .chat
|
||||||
},
|
},
|
||||||
@@ -38,6 +40,10 @@ struct RootCanvas: View {
|
|||||||
self.presentedSheet = .settings
|
self.presentedSheet = .settings
|
||||||
})
|
})
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
|
|
||||||
|
if self.appModel.cameraFlashNonce != 0 {
|
||||||
|
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: self.$presentedSheet) { sheet in
|
.sheet(item: self.$presentedSheet) { sheet in
|
||||||
switch sheet {
|
switch sheet {
|
||||||
@@ -103,6 +109,8 @@ private struct CanvasContent: View {
|
|||||||
var bridgeStatus: StatusPill.BridgeState
|
var bridgeStatus: StatusPill.BridgeState
|
||||||
var voiceWakeEnabled: Bool
|
var voiceWakeEnabled: Bool
|
||||||
var voiceWakeToastText: String?
|
var voiceWakeToastText: String?
|
||||||
|
var cameraHUDText: String?
|
||||||
|
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||||
var openChat: () -> Void
|
var openChat: () -> Void
|
||||||
var openSettings: () -> Void
|
var openSettings: () -> Void
|
||||||
|
|
||||||
@@ -147,6 +155,32 @@ private struct CanvasContent: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||||
|
CameraCaptureToast(
|
||||||
|
text: cameraHUDText,
|
||||||
|
kind: self.mapCameraKind(cameraHUDKind),
|
||||||
|
brighten: self.brightenButtons)
|
||||||
|
.padding(SwiftUI.Edge.Set.leading, 10)
|
||||||
|
.safeAreaPadding(SwiftUI.Edge.Set.top, 106)
|
||||||
|
.transition(
|
||||||
|
AnyTransition.move(edge: SwiftUI.Edge.top)
|
||||||
|
.combined(with: AnyTransition.opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapCameraKind(_ kind: NodeAppModel.CameraHUDKind) -> CameraCaptureToast.Kind {
|
||||||
|
switch kind {
|
||||||
|
case .photo:
|
||||||
|
.photo
|
||||||
|
case .recording:
|
||||||
|
.recording
|
||||||
|
case .success:
|
||||||
|
.success
|
||||||
|
case .error:
|
||||||
|
.error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,3 +221,85 @@ private struct OverlayButton: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct CameraFlashOverlay: View {
|
||||||
|
var nonce: Int
|
||||||
|
|
||||||
|
@State private var opacity: CGFloat = 0
|
||||||
|
@State private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.white
|
||||||
|
.opacity(self.opacity)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.onChange(of: self.nonce) { _, _ in
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = Task { @MainActor in
|
||||||
|
withAnimation(.easeOut(duration: 0.08)) {
|
||||||
|
self.opacity = 0.85
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 110_000_000)
|
||||||
|
withAnimation(.easeOut(duration: 0.32)) {
|
||||||
|
self.opacity = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CameraCaptureToast: View {
|
||||||
|
enum Kind {
|
||||||
|
case photo
|
||||||
|
case recording
|
||||||
|
case success
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
var text: String
|
||||||
|
var kind: Kind
|
||||||
|
var brighten: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
self.icon
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text(self.text)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Camera")
|
||||||
|
.accessibilityValue(self.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var icon: some View {
|
||||||
|
switch self.kind {
|
||||||
|
case .photo:
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
case .recording:
|
||||||
|
Image(systemName: "record.circle.fill")
|
||||||
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(.red, .primary)
|
||||||
|
case .success:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
case .error:
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user