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
@@ -17,29 +17,22 @@ 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.fillMaxHeight
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.layout.width
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
@@ -51,10 +44,11 @@ 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.status.StatusPanelCompose
import pw.binom.viewmate.glasses.ui.Binocular
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
import pw.binom.viewmate.glasses.ui.px
class MainActivity : BaseEventActivity() {
private var resumeAfterScreenOn = false
@@ -258,21 +252,13 @@ fun ConnectionScreen() {
val status by connection.status.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)) {
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
Binocular(scale = false) {
val screenWidthPx = BinocularScreenSize.current.width
Box(modifier = Modifier.width(screenWidthPx.px).fillMaxHeight().background(Color.Black)) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
@@ -286,30 +272,9 @@ fun ConnectionScreen() {
}
}
StatusPanelOverlay(
data = statusData,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.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,48 @@
package pw.binom.viewmate.glasses.status
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
/**
* Композ-панель статуса (время + заряд). Вызывается внутри [Binocular] content,
* который рендерится дважды (левый/правый глаз), поэтому панель автоматически
* дублируется и бинокулярно сливается в одну.
*/
@Composable
fun StatusPanelCompose(modifier: Modifier = Modifier) {
var data by remember { mutableStateOf<StatusPanelData?>(null) }
val context = LocalContext.current
DisposableEffect(Unit) {
val provider = StatusPanelProvider(context) { data = it }
provider.start()
onDispose { provider.stop() }
}
val d = data ?: return
Row(
modifier = modifier
.background(Color(0x99000000)) // полупрозрачный чёрный
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(d.time, color = Color.White, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.width(6.dp))
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
Text("${d.percent}%", color = Color(d.chargeColorArgb), style = MaterialTheme.typography.bodyMedium)
}
}
@@ -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,6 +91,10 @@ fun GlassesMovieScreen(
Side.RIGHT -> Arrangement.Start
}
// ВАЖНО: явная ширина половины (как у Row ниже) — иначе fillMaxSize
// растянет контент на весь 1280, и панель окажется по центру экрана
// (половина в каждом глазу) вместо центра каждой половины.
Box(modifier = Modifier.width((screenWidthPx).px).fillMaxHeight()) {
Row(
modifier = Modifier
.background(Color.Black)
@@ -109,6 +107,13 @@ fun GlassesMovieScreen(
modifier = Modifier.size(playerWidth.px, playerHeight.px),
)
}
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
// своей половины, бинокулярно сливается в одну по центру.
StatusPanelCompose(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
)
}
}
}