fix(app-glasses): scale на паузе — принудительная перерисовка кадра
На паузе ExoPlayer не шлёт кадры — поверхность меняла размер/позицию, но кадр оставался старым (часть видео не видно). requestRedraw(): updateTexImage + drawFrame для всех outputs — вызывается при смене scale (LaunchedEffect) и в surfaceChanged. Подтверждено на железе.
This commit is contained in:
@@ -3,6 +3,7 @@ package pw.binom.viewmate.glasses
|
||||
import android.app.Application
|
||||
import com.rayneo.arsdk.android.MercurySDK
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/** Конфигурация очков: адрес WS-сервера телефона. Креды не нужны — очки просто экран. */
|
||||
object GlassesConfig {
|
||||
@@ -27,6 +28,9 @@ class GlassesApp : Application() {
|
||||
lateinit var downloadManager: GlassesDownloadManager
|
||||
private set
|
||||
|
||||
/** Масштаб экрана очков (0.5..1.0); задаётся пультом телефона. */
|
||||
val scale = MutableStateFlow(1f)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
@@ -30,6 +30,7 @@ import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.SetScale
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
|
||||
@@ -221,6 +222,11 @@ class HostConnection(
|
||||
|
||||
is SetMode -> addMessage("SetMode: ${msg.mode}")
|
||||
|
||||
is SetScale -> {
|
||||
GlassesApp.instance.scale.value = msg.scale.coerceIn(0.5f, 1f)
|
||||
log("app", "scale → ${msg.scale}")
|
||||
}
|
||||
|
||||
is PlayVideo -> {
|
||||
addMessage("получено видео: «${msg.title}» — ${msg.videoUrl}")
|
||||
reconnectResume.reset()
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
* дублируется и бинокулярно сливается в одну.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPanelCompose(modifier: Modifier = Modifier) {
|
||||
fun StatusPanelCompose(modifier: Modifier = Modifier, scale: Float = 1f) {
|
||||
var data by remember { mutableStateOf<StatusPanelData?>(null) }
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
@@ -34,15 +34,17 @@ fun StatusPanelCompose(modifier: Modifier = Modifier) {
|
||||
onDispose { provider.stop() }
|
||||
}
|
||||
val d = data ?: return
|
||||
val baseStyle = MaterialTheme.typography.bodyMedium
|
||||
val style = baseStyle.copy(fontSize = baseStyle.fontSize * scale)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.background(Color(0x99000000)) // полупрозрачный чёрный
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
.padding(horizontal = 10.dp * scale, vertical = 4.dp * scale),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(d.time, color = Color.White, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(d.time, color = Color.White, style = style)
|
||||
Spacer(Modifier.width(6.dp * scale))
|
||||
// Процент — цветом статуса заряда (зелёный/белый/красный), без квадратика.
|
||||
Text("${d.percent}%", color = Color(d.chargeColorArgb), style = MaterialTheme.typography.bodyMedium)
|
||||
Text("${d.percent}%", color = Color(d.chargeColorArgb), style = style)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package pw.binom.viewmate.glasses.ui
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.GLES20
|
||||
import android.os.Looper
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -73,6 +74,9 @@ class EglVideoRenderer(
|
||||
private val transformMatrix = FloatArray(16)
|
||||
private var playerSurface: Surface? = null
|
||||
|
||||
// Поток, где создан GL-контекст/привязка EGL (attach из surfaceCreated — main).
|
||||
private var renderThread: Thread? = null
|
||||
|
||||
private val outputs = mutableListOf<Output>()
|
||||
|
||||
// Размер видео (0,0 пока неизвестен) — нужен для letterbox-вьюпорта.
|
||||
@@ -127,6 +131,7 @@ class EglVideoRenderer(
|
||||
it.width = width
|
||||
it.height = height
|
||||
}
|
||||
requestRedraw()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
@@ -141,6 +146,7 @@ class EglVideoRenderer(
|
||||
|
||||
private fun attach(holder: SurfaceHolder) {
|
||||
if (released) return
|
||||
renderThread = Thread.currentThread()
|
||||
val egl = eglCore ?: EglCore().also { eglCore = it }
|
||||
val ws = WindowSurface(egl, holder.surface, false)
|
||||
outputs.add(Output(holder, ws))
|
||||
@@ -160,6 +166,33 @@ class EglVideoRenderer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Перерисовать последний кадр (для смены масштаба на паузе). */
|
||||
fun requestRedraw() {
|
||||
val t = renderThread ?: return
|
||||
if (Thread.currentThread() == t) redrawNow() else {
|
||||
// Колбэки SurfaceTexture приходят на поток создания — постим туда.
|
||||
// attach всегда на main — используем main looper.
|
||||
android.os.Handler(Looper.getMainLooper()).post { redrawNow() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun redrawNow() {
|
||||
if (released) return
|
||||
val st = videoSurfaceTexture ?: return
|
||||
val blit = fullFrameBlit ?: return
|
||||
try {
|
||||
st.updateTexImage()
|
||||
st.getTransformMatrix(transformMatrix)
|
||||
outputs.forEach { output ->
|
||||
if (output.width > 0 && output.height > 0) {
|
||||
drawFrame(blit, output.surface, output.width, output.height)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// GL-контекст может быть не готов — следующий кадр перерисует.
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawFrame(blit: FullFrameRect, windowSurface: WindowSurface, viewWidth: Int, viewHeight: Int) {
|
||||
windowSurface.makeCurrent()
|
||||
val rect = letterboxRect(videoWidth, videoHeight, viewWidth, viewHeight)
|
||||
|
||||
@@ -6,13 +6,17 @@ 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -25,6 +29,7 @@ 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.GlassesApp
|
||||
import pw.binom.viewmate.glasses.log
|
||||
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
||||
|
||||
@@ -51,12 +56,19 @@ fun GlassesMovieScreen(
|
||||
var videoWidth by remember { mutableIntStateOf(640) }
|
||||
var videoHeight by remember { mutableIntStateOf(480) }
|
||||
|
||||
val scale by GlassesApp.instance.scale.collectAsState()
|
||||
|
||||
// Один рендерер на экран: оба экземпляра BinocularVideoPlayer (левый/правый
|
||||
// глаз из Binocular) регистрируют в нём свои SurfaceView.
|
||||
val renderer = remember {
|
||||
EglVideoRenderer(player)
|
||||
}
|
||||
|
||||
// Смена масштаба на паузе — принудительно перерисовать последний кадр.
|
||||
LaunchedEffect(scale) {
|
||||
renderer.requestRedraw()
|
||||
}
|
||||
|
||||
DisposableEffect(player) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
@@ -77,46 +89,63 @@ fun GlassesMovieScreen(
|
||||
Binocular(scale = false) {
|
||||
val screenWidthPx = BinocularScreenSize.current.width
|
||||
val screenHeightPx = BinocularScreenSize.current.height
|
||||
val side = BinocularScreenSide.current
|
||||
|
||||
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
|
||||
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
|
||||
val containerW = (screenWidthPx * scale).toInt()
|
||||
val containerH = (screenHeightPx * scale).toInt()
|
||||
|
||||
val (playerWidth, playerHeight) = calculateVideoSizeFull(
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
maxWidth = screenWidthPx, // padding = 0
|
||||
maxHeight = screenHeightPx - 25,
|
||||
maxWidth = containerW,
|
||||
maxHeight = containerH - 25,
|
||||
)
|
||||
|
||||
// «Нос к носу»: окно каждого глаза прижато к центру экрана.
|
||||
val horizontalArrangement = when (BinocularScreenSide.current) {
|
||||
Side.LEFT -> Arrangement.End
|
||||
Side.RIGHT -> Arrangement.Start
|
||||
}
|
||||
|
||||
// ВАЖНО: явная ширина половины (как у Row ниже) — иначе fillMaxSize
|
||||
// растянет контент на весь 1280, и панель окажется по центру экрана
|
||||
// (половина в каждом глазу) вместо центра каждой половины.
|
||||
Box(modifier = Modifier.width((screenWidthPx).px).fillMaxHeight()) {
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
.width((screenWidthPx).px),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
.width(containerW.px)
|
||||
.height(containerH.px)
|
||||
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.Black),
|
||||
horizontalArrangement = if (side == Side.LEFT) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
if (SCALE_DEBUG_SQUARE) {
|
||||
// Тестовый режим: большой красный квадрат вместо видео — быстрая
|
||||
// проверка масштаба/позиций скрином без запуска видео.
|
||||
Box(Modifier.fillMaxSize().background(Color.Red))
|
||||
} else {
|
||||
BinocularVideoPlayer(
|
||||
player = player,
|
||||
renderer = renderer,
|
||||
modifier = Modifier.size(playerWidth.px, playerHeight.px),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Панель по центру верхней letterbox-полосы: в каждом глазу — в центре
|
||||
// своей половины, бинокулярно сливается в одну по центру.
|
||||
StatusPanelCompose(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
scale = scale,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение контейнера к центру: левый глаз прижат правым краем к центру,
|
||||
* правый — от центра (offset 0: его левый край уже у центра).
|
||||
*/
|
||||
fun scaleOffsetX(side: Side, screenWidthPx: Int, scale: Float): Int =
|
||||
if (side == Side.LEFT) (screenWidthPx * (1f - scale)).toInt() else 0
|
||||
|
||||
/** Отладочный режим: красный квадрат вместо видео (проверка масштаба/позиций скрином). Перед финалом — false. */
|
||||
private const val SCALE_DEBUG_SQUARE = false
|
||||
|
||||
/** Fit с сохранением пропорций: вариант по ширине/по высоте, берём помещающийся. */
|
||||
fun calculateVideoSizeFull(
|
||||
videoWidth: Int, videoHeight: Int,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import pw.binom.viewmate.glasses.ui.Side
|
||||
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
||||
|
||||
/**
|
||||
* scaleOffsetX — смещение контейнера глаза к центру (к носу):
|
||||
* левый прижат правым краем к центру, правый — offset 0 (левый край уже у центра).
|
||||
* Чистая функция — тестируется без Android.
|
||||
*/
|
||||
class ScaleOffsetTest {
|
||||
|
||||
@Test
|
||||
fun leftFullScreenNoOffset() {
|
||||
assertEquals(0, scaleOffsetX(Side.LEFT, 640, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftScale07Offset() {
|
||||
assertEquals(192, scaleOffsetX(Side.LEFT, 640, 0.7f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leftScale05Offset() {
|
||||
assertEquals(320, scaleOffsetX(Side.LEFT, 640, 0.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rightNeverOffset() {
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 1f))
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.7f))
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 640, 0.5f))
|
||||
assertEquals(0, scaleOffsetX(Side.RIGHT, 480, 0.5f))
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@ fun GlassesScreen(
|
||||
// трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK
|
||||
var sliderValue by remember { mutableStateOf<Float?>(null) }
|
||||
|
||||
// масштаб экрана очков (0.5..1): слайдер пишет в PhoneState, в очки — при отпускании
|
||||
var scale by remember { mutableStateOf(app.state.scale) }
|
||||
|
||||
val actions = app.actions
|
||||
|
||||
// активное скачивание на очки: первое с незавершённым файлом
|
||||
@@ -155,6 +158,31 @@ fun GlassesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// масштаб экрана очков: весь экран уменьшается (0.5..1), картина прижимается к носу
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Масштаб: ${"%.2f".format(scale)}",
|
||||
color = PhoneMuted,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
Slider(
|
||||
value = scale,
|
||||
onValueChange = { scale = it },
|
||||
onValueChangeFinished = {
|
||||
app.state.scale = scale
|
||||
val a = actions
|
||||
if (a != null) scope.launch { a.applyScale(scale) }
|
||||
},
|
||||
valueRange = 0.5f..1f,
|
||||
steps = 9, // шаг 0.05
|
||||
)
|
||||
}
|
||||
|
||||
// активная загрузка контента на очки
|
||||
if (activeGlassesItem != null) {
|
||||
Text(
|
||||
|
||||
@@ -16,6 +16,7 @@ import pw.binom.viewmate.core.protocol.DownloadFiles
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.SetScale
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
|
||||
/**
|
||||
@@ -102,6 +103,8 @@ class PhoneActions(
|
||||
|
||||
val title = item.Name ?: itemId
|
||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
|
||||
// очки не помнят scale между запусками — при старте просмотра шлём текущий
|
||||
if (state.scale != 1f) sender.send(SetScale(state.scale))
|
||||
state.mode = GlassesMode.MOVIE
|
||||
state.currentItem = item
|
||||
state.currentAudioIndex = audioIndex
|
||||
@@ -253,5 +256,12 @@ class PhoneActions(
|
||||
return "команда медиа: $cmd${if (seekMs != 0L) " ($seekMs мс)" else ""}"
|
||||
}
|
||||
|
||||
/** Масштаб экрана очков: SetScale (0.5..1.0). */
|
||||
suspend fun applyScale(scale: Float): String {
|
||||
val clamped = scale.coerceIn(0.5f, 1f)
|
||||
sender.send(SetScale(clamped))
|
||||
return "масштаб → $clamped"
|
||||
}
|
||||
|
||||
private fun ticksToMinutes(ticks: Long): Long = ticks / 10_000_000 / 60
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ class PhoneState {
|
||||
log("state", "режим → $value")
|
||||
}
|
||||
|
||||
private val _scale = MutableStateFlow(1f)
|
||||
val scaleFlow: StateFlow<Float> = _scale.asStateFlow()
|
||||
/** Масштаб экрана очков (0.5..1.0); слайдер пульта пишет сюда же. */
|
||||
var scale: Float
|
||||
get() = _scale.value
|
||||
set(value) {
|
||||
_scale.value = value.coerceIn(0.5f, 1f)
|
||||
log("state", "масштаб → $value")
|
||||
}
|
||||
|
||||
private val _currentItem = MutableStateFlow<JellyfinItem?>(null)
|
||||
val currentItemFlow: StateFlow<JellyfinItem?> = _currentItem.asStateFlow()
|
||||
var currentItem: JellyfinItem?
|
||||
|
||||
@@ -75,3 +75,8 @@ data class ShowText(val text: String, val sessionId: String? = null) : HostToGla
|
||||
@Serializable
|
||||
@SerialName("chat_session")
|
||||
data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlasses
|
||||
|
||||
/** Масштаб экрана очков (0.5..1.0); 1.0 — на весь экран. */
|
||||
@Serializable
|
||||
@SerialName("set_scale")
|
||||
data class SetScale(val scale: Float) : HostToGlasses
|
||||
|
||||
@@ -45,6 +45,16 @@ class HostToGlassesTest {
|
||||
assertEquals(GlassesMode.MOVIE, decoded.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setScaleRoundTrip() {
|
||||
val msg: HostToGlasses = SetScale(scale = 0.7f)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
assertEquals("""{"type":"set_scale","scale":0.7}""", text)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<SetScale>(decoded)
|
||||
assertEquals(0.7f, decoded.scale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaCommandRoundTrip() {
|
||||
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)
|
||||
|
||||
Reference in New Issue
Block a user