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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -41,6 +51,9 @@ import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.viewmate.glasses.gestures.actionName import pw.binom.viewmate.glasses.gestures.actionName
import pw.binom.viewmate.glasses.gestures.templeActionToCommand 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 import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
class MainActivity : BaseEventActivity() { class MainActivity : BaseEventActivity() {
@@ -245,8 +258,21 @@ fun ConnectionScreen() {
val status by connection.status.collectAsState() val status by connection.status.collectAsState()
val messages by connection.messages.collectAsState() val messages by connection.messages.collectAsState()
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( Column(
modifier = Modifier.fillMaxSize().background(Color.Black).padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium) Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
@@ -259,4 +285,31 @@ fun ConnectionScreen() {
} }
} }
} }
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 package pw.binom.viewmate.glasses.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.opengl.GLES20 import android.opengl.GLES20
import android.text.TextPaint
import android.view.Surface import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize import com.google.android.exoplayer2.video.VideoSize
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.roundToInt import kotlin.math.roundToInt
import pw.binom.viewmate.glasses.log 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.EglCore
import pw.binom.viewmate.glasses.render.FullFrameRect 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.Texture2dProgram
import pw.binom.viewmate.glasses.render.WindowSurface import pw.binom.viewmate.glasses.render.WindowSurface
@@ -64,7 +70,10 @@ fun BinocularVideoPlayer(
* Всё создаётся на главном потоке (SurfaceHolder.Callback и OnFrameAvailableListener), * Всё создаётся на главном потоке (SurfaceHolder.Callback и OnFrameAvailableListener),
* поэтому защита от гонок — null-проверки: кадр может прийти между созданиями выводов. * поэтому защита от гонок — 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 eglCore: EglCore? = null
private var fullFrameBlit: FullFrameRect? = null private var fullFrameBlit: FullFrameRect? = null
@@ -75,6 +84,15 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
private val outputs = mutableListOf<Output>() 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-вьюпорта. // Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта.
private var videoWidth = 0 private var videoWidth = 0
private var videoHeight = 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) GLES20.glViewport(rect.x0, rect.y0, rect.w, rect.h)
} }
blit.drawFrame(textureId, transformMatrix) blit.drawFrame(textureId, transformMatrix)
drawOverlay(viewWidth, viewHeight)
windowSurface.swapBuffers() 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() { fun release() {
if (released) return if (released) return
released = true released = true
@@ -196,6 +339,18 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
videoSurfaceTexture?.release() videoSurfaceTexture?.release()
videoSurfaceTexture = null 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.forEach { it.surface.release() }
outputs.clear() outputs.clear()
@@ -205,6 +360,14 @@ class EglVideoRenderer(private val player: SimpleExoPlayer) {
eglCore?.release() eglCore?.release()
eglCore = null 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): левый-нижний угол и размеры. */ /** Прямоугольник вписывания видео в поверхность (letterbox): левый-нижний угол и размеры. */
@@ -20,6 +20,7 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.video.VideoSize import com.google.android.exoplayer2.video.VideoSize
import pw.binom.viewmate.glasses.log import pw.binom.viewmate.glasses.log
import pw.binom.viewmate.glasses.status.StatusPanelProvider
/** /**
* Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular]. * Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular].
@@ -46,7 +47,21 @@ fun GlassesMovieScreen(
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый // Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView. // глаз из 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) { DisposableEffect(player) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
@@ -0,0 +1,52 @@
package pw.binom.viewmate.glasses.status
import android.os.BatteryManager
import kotlin.test.Test
import kotlin.test.assertEquals
class StatusPanelTest {
@Test
fun chargingIsGreen() {
val color = chargeColorArgb(
plugged = true,
status = BatteryManager.BATTERY_STATUS_CHARGING,
)
assertEquals(CHARGE_COLOR_CHARGING, color)
}
@Test
fun notPluggedIsWhiteRegardlessOfStatus() {
for (status in intArrayOf(
BatteryManager.BATTERY_STATUS_CHARGING,
BatteryManager.BATTERY_STATUS_DISCHARGING,
BatteryManager.BATTERY_STATUS_NOT_CHARGING,
BatteryManager.BATTERY_STATUS_FULL,
BatteryManager.BATTERY_STATUS_UNKNOWN,
)) {
assertEquals(
CHARGE_COLOR_DISCHARGING,
chargeColorArgb(plugged = false, status = status),
"status=$status, plugged=false → белый",
)
}
}
@Test
fun pluggedNotChargingIsRed() {
val color = chargeColorArgb(
plugged = true,
status = BatteryManager.BATTERY_STATUS_NOT_CHARGING,
)
assertEquals(CHARGE_COLOR_PLUGGED_NOT_CHARGING, color)
}
@Test
fun pluggedFullIsGreen() {
val color = chargeColorArgb(
plugged = true,
status = BatteryManager.BATTERY_STATUS_FULL,
)
assertEquals(CHARGE_COLOR_CHARGING, color)
}
}