feat(app-glasses): панель статуса — время + заряд (цвет по статусу)

Поверх видео (GL-оверлей в EglVideoRenderer: bitmap-текстура, правый
верхний угол, обе поверхности) и композ-панель на экране соединения.
Цвета (из удачной панели pair-view StatusComponent): CHARGING/FULL —
зелёный, DISCHARGING — белый, NOT_CHARGING — красный.
Данные: StatusPanelProvider (sticky ACTION_BATTERY_CHANGED + тик 1с,
HH:mm). Тесты 4 (chargeColorArgb). Железо: «00:02 94%» + зелёный
квадрат (очки на зарядке), скрин подтверждён.
This commit is contained in:
Hermes Agent
2026-08-20 00:03:23 +03:00
parent 2b6fdb7148
commit 3f858c4a1a
5 changed files with 392 additions and 15 deletions
@@ -17,19 +17,29 @@ import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -41,6 +51,9 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch
import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand
import pw.binom.viewmate.glasses.status.CHARGE_COLOR_DISCHARGING
import pw.binom.viewmate.glasses.status.StatusPanelData
import pw.binom.viewmate.glasses.status.StatusPanelProvider
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
class MainActivity : BaseEventActivity() {
@@ -245,18 +258,58 @@ fun ConnectionScreen() {
val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState()
Column(
modifier = Modifier.fillMaxSize().background(Color.Black).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
Text(if (connected) "подключено к телефону" else "нет соединения")
Text(status, style = MaterialTheme.typography.bodyMedium)
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(messages) { msg ->
Text(msg, style = MaterialTheme.typography.bodySmall)
val context = LocalContext.current
var statusData by remember {
mutableStateOf(StatusPanelData("--:--", 0, CHARGE_COLOR_DISCHARGING))
}
val statusProvider = remember(context) {
StatusPanelProvider(context.applicationContext) { statusData = it }
}
DisposableEffect(statusProvider) {
statusProvider.start()
onDispose { statusProvider.stop() }
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
Text(if (connected) "подключено к телефону" else "нет соединения")
Text(status, style = MaterialTheme.typography.bodyMedium)
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(messages) { msg ->
Text(msg, style = MaterialTheme.typography.bodySmall)
}
}
}
StatusPanelOverlay(
data = statusData,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
}
/** Маленькая панель статуса (время + цветной квадрат + заряд) в правом верхнем углу. */
@Composable
private fun StatusPanelOverlay(data: StatusPanelData, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0x80000000.toInt()))
.padding(horizontal = 6.dp, vertical = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(12.dp)
.background(Color(data.chargeColorArgb))
)
Text(data.time, color = Color.White, style = MaterialTheme.typography.bodySmall)
Text("${data.percent}%", color = Color.White, style = MaterialTheme.typography.bodySmall)
}
}
@@ -0,0 +1,94 @@
package pw.binom.viewmate.glasses.status
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Данные панели статуса: время + заряд + цвет индикатора. */
data class StatusPanelData(
val time: String, // "23:51"
val percent: Int, // 0..100
val chargeColorArgb: Int, // цвет индикатора
)
const val CHARGE_COLOR_CHARGING = 0xFF4CAF50.toInt() // зелёный — идёт заряд
const val CHARGE_COLOR_DISCHARGING = 0xFFFFFFFF.toInt() // белый — разряд
const val CHARGE_COLOR_PLUGGED_NOT_CHARGING = 0xFFE53935.toInt() // красный — подключен, но не заряжается
/**
* Цвет индикатора: не подключен=белый, CHARGING=зелёный,
* подключен-не-заряжается (NOT_CHARGING/FULL)=красный.
*/
fun chargeColorArgb(plugged: Boolean, status: Int): Int = when {
!plugged -> CHARGE_COLOR_DISCHARGING
status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL -> CHARGE_COLOR_CHARGING
else -> CHARGE_COLOR_PLUGGED_NOT_CHARGING
}
/** Провайдер: обновляет раз в секунду (время) + батарея из ACTION_BATTERY_CHANGED. */
class StatusPanelProvider(
private val context: Context,
private val onUpdate: (StatusPanelData) -> Unit,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
private var percent = 0
private var plugged = false
private var status = BatteryManager.BATTERY_STATUS_UNKNOWN
private var running = false
fun start() {
if (running) return
running = true
readBattery()
scope.launch {
while (isActive && running) {
emit()
delay(1_000)
readBattery()
}
}
}
fun stop() {
if (!running) return
running = false
scope.coroutineContext.cancel()
}
/** Sticky ACTION_BATTERY_CHANGED: level/scale/status/plugged. */
private fun readBattery() {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
status = intent.getIntExtra(
BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN
)
plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
percent = if (scale > 0 && level >= 0) {
(level * 100f / scale).roundToInt().coerceIn(0, 100)
} else {
level.coerceIn(0, 100)
}
}
private fun emit() {
val time = timeFormat.format(Date())
onUpdate(StatusPanelData(time, percent, chargeColorArgb(plugged, status)))
}
}
@@ -1,24 +1,30 @@
package pw.binom.viewmate.glasses.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.SurfaceTexture
import android.opengl.GLES20
import android.text.TextPaint
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.roundToInt
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.render.Drawable2d
import pw.binom.viewmate.glasses.render.EglCore
import pw.binom.viewmate.glasses.render.FullFrameRect
import pw.binom.viewmate.glasses.render.GlUtil
import pw.binom.viewmate.glasses.render.Texture2dProgram
import pw.binom.viewmate.glasses.render.WindowSurface
@@ -64,7 +70,10 @@ fun BinocularVideoPlayer(
* Всё создаётся на главном потоке (SurfaceHolder.Callback и OnFrameAvailableListener),
* поэтому защита от гонок — null-проверки: кадр может прийти между созданиями выводов.
*/
class EglVideoRenderer(private val player: SimpleExoPlayer) {
class EglVideoRenderer(
private val player: SimpleExoPlayer,
private val density: Float = 1f,
) {
private var eglCore: EglCore? = null
private var fullFrameBlit: FullFrameRect? = null
@@ -75,6 +84,15 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
private val outputs = mutableListOf<Output>()
// Overlay-панель статуса: рисуется в правом верхнем углу поверх видео.
private var overlayText: String? = null
private var overlayColorArgb: Int = 0
private var overlayBitmap: Bitmap? = null
private var overlayProgram: Texture2dProgram? = null
private var overlayDrawable: Drawable2d? = null
private var overlayTextureId = 0
private var overlayNeedsUpload = false
// Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта.
private var videoWidth = 0
private var videoHeight = 0
@@ -171,9 +189,134 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
GLES20.glViewport(rect.x0, rect.y0, rect.w, rect.h)
}
blit.drawFrame(textureId, transformMatrix)
drawOverlay(viewWidth, viewHeight)
windowSurface.swapBuffers()
}
/**
* Задаёт текст и цвет индикатора overlay-панели (обычно из StatusPanelProvider,
* вызывается на главном потоке). При неизменных значениях — no-op.
*/
fun setOverlay(text: String?, colorArgb: Int) {
if (text == overlayText && colorArgb == overlayColorArgb) return
overlayText = text
overlayColorArgb = colorArgb
overlayBitmap?.recycle()
overlayBitmap = text?.let { buildOverlayBitmap(it, colorArgb) }
overlayNeedsUpload = true
}
/** Компактная панель ~110×34px: полупрозрачный чёрный фон, цветной квадрат слева, текст справа. */
private fun buildOverlayBitmap(text: String, colorArgb: Int): Bitmap {
val panelHeight = OVERLAY_PANEL_HEIGHT
val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFFFFFFF.toInt()
textSize = OVERLAY_TEXT_SIZE_SP * density
isAntiAlias = true
}
val textWidth = kotlin.math.ceil(textPaint.measureText(text).toDouble()).toInt()
val panelWidth = OVERLAY_PADDING + OVERLAY_COLOR_SQUARE + OVERLAY_PADDING + textWidth + OVERLAY_PADDING
val bitmap = Bitmap.createBitmap(panelWidth, panelHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bgPaint = Paint().apply { color = 0x80000000.toInt() }
canvas.drawRect(0f, 0f, panelWidth.toFloat(), panelHeight.toFloat(), bgPaint)
val squareTop = (panelHeight - OVERLAY_COLOR_SQUARE) / 2f
val squarePaint = Paint().apply { color = colorArgb }
canvas.drawRect(
OVERLAY_PADDING.toFloat(), squareTop,
(OVERLAY_PADDING + OVERLAY_COLOR_SQUARE).toFloat(), squareTop + OVERLAY_COLOR_SQUARE,
squarePaint,
)
val baseline = panelHeight / 2f - (textPaint.ascent() + textPaint.descent()) / 2f
canvas.drawText(
text,
(OVERLAY_PADDING + OVERLAY_COLOR_SQUARE + OVERLAY_PADDING).toFloat(),
baseline,
textPaint,
)
return bitmap
}
/** Рисует overlay-панель в правом верхнем углу (после блита видео, перед swapBuffers). */
private fun drawOverlay(viewWidth: Int, viewHeight: Int) {
val bitmap = overlayBitmap ?: return
if (overlayProgram == null) {
overlayProgram = Texture2dProgram(Texture2dProgram.ProgramType.TEXTURE_2D)
overlayDrawable = Drawable2d(Drawable2d.Prefab.RECTANGLE)
}
if (overlayTextureId == 0) {
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
overlayTextureId = textures[0]
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, overlayTextureId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
overlayNeedsUpload = true
}
if (overlayNeedsUpload) {
val w = bitmap.width
val h = bitmap.height
val buffer = ByteBuffer.allocateDirect(w * h * 4).order(ByteOrder.nativeOrder())
bitmap.copyPixelsToBuffer(buffer)
buffer.position(0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, overlayTextureId)
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
w, h, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer,
)
overlayNeedsUpload = false
}
GLES20.glViewport(0, 0, viewWidth, viewHeight)
GLES20.glEnable(GLES20.GL_BLEND)
GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA)
val program = overlayProgram!!
val drawable = overlayDrawable!!
program.draw(
mvpMatrix = overlayMvp(
viewWidth, viewHeight, bitmap.width, bitmap.height, OVERLAY_MARGIN,
),
vertexBuffer = drawable.mVertexArray,
firstVertex = 0,
vertexCount = drawable.mVertexCount,
coordsPerVertex = drawable.mCoordsPerVertex,
vertexStride = drawable.mVertexStride,
texMatrix = GlUtil.IDENTITY_MATRIX,
texBuffer = drawable.mTexCoordArray,
textureId = overlayTextureId,
texStride = drawable.mTexCoordStride,
)
GLES20.glDisable(GLES20.GL_BLEND)
}
/**
* MVP для квада панели: прямоугольник RECTANGLE (центр в 0, размер 1×1)
* смещаем/масштабируем так, чтобы панель w×h оказалась в правом верхнем
* углу вьюпорта с отступом [margin].
*/
private fun overlayMvp(viewWidth: Int, viewHeight: Int, w: Int, h: Int, margin: Int): FloatArray {
val left = viewWidth - w - margin
val top = viewHeight - h - margin
val cx = left + w / 2f
val cy = top + h / 2f
val tx = 2f * cx / viewWidth - 1f
val ty = 1f - 2f * cy / viewHeight
val sx = 2f * w / viewWidth
val sy = 2f * h / viewHeight
return floatArrayOf(
sx, 0f, 0f, 0f,
0f, sy, 0f, 0f,
0f, 0f, 1f, 0f,
tx, ty, 0f, 1f,
)
}
fun release() {
if (released) return
released = true
@@ -196,6 +339,18 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
videoSurfaceTexture?.release()
videoSurfaceTexture = null
// Overlay-текстура/программа живут в GL-контексте — удаляем, пока он ещё текущий.
outputs.firstOrNull()?.let { it.surface.makeCurrent() }
if (overlayTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(overlayTextureId), 0)
overlayTextureId = 0
}
overlayProgram?.release()
overlayProgram = null
overlayDrawable = null
overlayBitmap?.recycle()
overlayBitmap = null
outputs.forEach { it.surface.release() }
outputs.clear()
@@ -205,6 +360,14 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
eglCore?.release()
eglCore = null
}
companion object {
private const val OVERLAY_PADDING = 6
private const val OVERLAY_COLOR_SQUARE = 12
private const val OVERLAY_PANEL_HEIGHT = 34
private const val OVERLAY_TEXT_SIZE_SP = 18f
private const val OVERLAY_MARGIN = 8
}
}
/** Прямоугольник вписывания видео в поверхность (letterbox): левый-нижний угол и размеры. */
@@ -20,6 +20,7 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize
import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.status.StatusPanelProvider
/**
* Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular].
@@ -46,7 +47,21 @@ fun GlassesMovieScreen(
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView.
val renderer = remember { EglVideoRenderer(player) }
val renderer = remember {
EglVideoRenderer(player, density = context.resources.displayMetrics.density)
}
// Панель статуса (время + заряд) рисуется в GL-рендерере поверх видео —
// на обе поверхности, т.к. рендерер общий.
val statusProvider = remember {
StatusPanelProvider(context.applicationContext) { data ->
renderer.setOverlay("${data.time} ${data.percent}%", data.chargeColorArgb)
}
}
DisposableEffect(Unit) {
statusProvider.start()
onDispose { statusProvider.stop() }
}
DisposableEffect(player) {
val listener = object : Player.Listener {