fix(app-glasses): панель статуса — по центру в оба глаза, процент цветной

Binocular-контент растягивался на весь 1280 (fillMaxSize внутри
PositionedBox) — панель оказывалась по центру экрана, половина в каждом
глазу. Теперь явная ширина половины (как у видео-Row) — панель по центру
каждой половины, бинокулярно сливается в одну. ConnectionScreen тоже
обёрнут в Binocular. Квадратик-индикатор убран — процент окрашивается
цветом статуса (зелёный/белый/красный). Скрин: обе половины идентичны.
This commit is contained in:
Hermes Agent
2026-08-20 00:24:12 +03:00
parent 3f858c4a1a
commit 9f20093bbf
4 changed files with 104 additions and 249 deletions
@@ -1,11 +1,7 @@
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
@@ -17,14 +13,10 @@ 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
@@ -72,7 +64,6 @@ fun BinocularVideoPlayer(
*/
class EglVideoRenderer(
private val player: SimpleExoPlayer,
private val density: Float = 1f,
) {
private var eglCore: EglCore? = null
@@ -84,15 +75,6 @@ class EglVideoRenderer(
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
@@ -189,134 +171,9 @@ class EglVideoRenderer(
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
@@ -339,18 +196,6 @@ class EglVideoRenderer(
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()
@@ -360,14 +205,6 @@ class EglVideoRenderer(
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): левый-нижний угол и размеры. */
@@ -4,7 +4,11 @@ import android.app.Activity
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
@@ -13,14 +17,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
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
import pw.binom.viewmate.glasses.status.StatusPanelCompose
/**
* Экран кино: один плеер, вывод — дважды (левый/правый глаз) через [Binocular].
@@ -48,19 +54,7 @@ fun GlassesMovieScreen(
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
// глаз из Binocular) регистрируют в нём свои SurfaceView.
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() }
EglVideoRenderer(player)
}
DisposableEffect(player) {
@@ -97,16 +91,27 @@ fun GlassesMovieScreen(
Side.RIGHT -> Arrangement.Start
}
Row(
modifier = Modifier
.background(Color.Black)
.width((screenWidthPx).px),
horizontalArrangement = horizontalArrangement,
) {
BinocularVideoPlayer(
player = player,
renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px),
// ВАЖНО: явная ширина половины (как у Row ниже) — иначе fillMaxSize
// растянет контент на весь 1280, и панель окажется по центру экрана
// (половина в каждом глазу) вместо центра каждой половины.
Box(modifier = Modifier.width((screenWidthPx).px).fillMaxHeight()) {
Row(
modifier = Modifier
.background(Color.Black)
.width((screenWidthPx).px),
horizontalArrangement = horizontalArrangement,
) {
BinocularVideoPlayer(
player = player,
renderer = renderer,
modifier = Modifier.size(playerWidth.px, playerHeight.px),
)
}
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
// своей половины, бинокулярно сливается в одну по центру.
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
)
}
}