diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt index eefa502..f2fef9a 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt @@ -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,58 +252,29 @@ 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)) { - 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) + // Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой + // половины (явная ширина половины, иначе 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(start = 24.dp, end = 24.dp, top = 56.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) + } } } + + StatusPanelCompose( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp), + ) } - - 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) } } diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt new file mode 100644 index 0000000..f9792f0 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/status/StatusPanelCompose.kt @@ -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(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) + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt index 4617e7d..d85430e 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/BinocularVideoPlayer.kt @@ -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() - // 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): левый-нижний угол и размеры. */ diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt index 560f8e7..e359204 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/ui/GlassesMovieScreen.kt @@ -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), ) } }