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
@@ -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 {