From 8072d0c95af701444ab827d28dffc581a9500590 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 21:35:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(app-phone):=20=D1=82=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=B1=D0=B0=D1=80-=D1=82=D0=B0=D0=B9=D0=BC=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20=D0=B2=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=D0=B5=20(seek?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BB=D1=8E=D0=B1=D1=83=D1=8E=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=86=D0=B8=D1=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MediaCommand.SEEK (абсолютный seek на seekMs, поле в протоколе уже было) - MovieController (очки): SEEK → player.seekTo(seekMs.clamp(0,duration)); чистая seekTargetMs - GlassesScreen (пульт): material3 Slider под статус-карточкой (виден при durationMs>0), локальный стейт драга — время под слайдером следует за пальцем (sliderValue ?: positionMs), после отпускания цель держится до подтверждения позиции от очков (±2.5с, таймаут 10с), звук: SEEK → audio.seekTo(seekMs) - Тесты 70/0 phone, 37/0 glasses (MovieControllerSeekTest, сериализация SEEK) --- .../binom/viewmate/glasses/MovieController.kt | 13 ++++++ .../glasses/MovieControllerSeekTest.kt | 42 +++++++++++++++++ .../binom/viewmate/phone/ui/GlassesScreen.kt | 46 ++++++++++++++++++- .../pw/binom/viewmate/core/GlassesMode.kt | 1 + .../core/protocol/HostToGlassesTest.kt | 11 +++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerSeekTest.kt diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt index a111a78..7c450ec 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MovieController.kt @@ -13,6 +13,14 @@ import pw.binom.viewmate.core.MediaCommand import pw.binom.viewmate.core.protocol.PlayVideo import java.io.File +/** + * Абсолютная цель seek-а: [seekMs] зажимается в [0, durationMs]. + * durationMs ≤ 0 (ещё не известно / нет контента) → зажимаем только снизу. + * Чистая функция — покрыта unit-тестами без Android. + */ +internal fun seekTargetMs(seekMs: Long, durationMs: Long): Long = + seekMs.coerceIn(0L, durationMs.coerceAtLeast(0L)) + /** * Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer, * выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo. @@ -81,6 +89,11 @@ class MovieController(private val context: Context) { player.seekTo(target) } + MediaCommand.SEEK -> { + val target = seekTargetMs(seekMs, player.duration) + player.seekTo(target) + } + // Звук — на телефоне, переключение аудио-дорожек очкам не нужно. MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> { log("glasses", "MediaCommand $command игнорируется (звук на телефоне)") diff --git a/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerSeekTest.kt b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerSeekTest.kt new file mode 100644 index 0000000..ecf1f62 --- /dev/null +++ b/app-glasses/src/test/kotlin/pw/binom/viewmate/glasses/MovieControllerSeekTest.kt @@ -0,0 +1,42 @@ +package pw.binom.viewmate.glasses + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Абсолютный SEEK в MovieController: цель зажимается в [0, duration], + * как при командовании через MediaCommand.SEEK (см. seekTargetMs). + * Чистая логика тестируется без Android-плеера. + */ +class MovieControllerSeekTest { + + @Test + fun seekMsInsideDurationPassesThrough() { + assertEquals(123_456L, seekTargetMs(seekMs = 123_456, durationMs = 600_000)) + } + + @Test + fun negativeSeekClampsToZero() { + assertEquals(0L, seekTargetMs(seekMs = -1, durationMs = 600_000)) + assertEquals(0L, seekTargetMs(seekMs = -123_456, durationMs = 600_000)) + } + + @Test + fun seekBeyondDurationClampsToDuration() { + assertEquals(600_000L, seekTargetMs(seekMs = 600_001, durationMs = 600_000)) + assertEquals(600_000L, seekTargetMs(seekMs = 9_999_999, durationMs = 600_000)) + } + + @Test + fun unknownDurationResolvesToZero() { + // duration ≤ 0 (ещё не известно) — coerceIn(0, 0) даёт 0 + assertEquals(0L, seekTargetMs(seekMs = -1, durationMs = 0)) + assertEquals(0L, seekTargetMs(seekMs = 123_456, durationMs = 0)) + } + + @Test + fun exactBoundsAreAllowed() { + assertEquals(0L, seekTargetMs(seekMs = 0, durationMs = 600_000)) + assertEquals(600_000L, seekTargetMs(seekMs = 600_000, durationMs = 600_000)) + } +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt index 9ec0cd2..8bd91ae 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/GlassesScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import pw.binom.viewmate.core.GlassesMode import pw.binom.viewmate.core.MediaCommand @@ -59,9 +61,13 @@ fun GlassesScreen( val playing by app.state.playingFlow.collectAsState() val mode by app.state.modeFlow.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() + val durationMs = currentItem?.RunTimeTicks?.div(10_000) var showDownloads by remember { mutableStateOf(false) } + // трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK + var sliderValue by remember { mutableStateOf(null) } + val actions = app.actions // активное скачивание на очки: первое с незавершённым файлом @@ -81,6 +87,7 @@ fun GlassesScreen( MediaCommand.PAUSE -> audio.pause() MediaCommand.SEEK_FORWARD -> audio.seekTo(audio.currentPositionMs() + seekMs) MediaCommand.SEEK_BACKWARD -> audio.seekTo((audio.currentPositionMs() - seekMs).coerceAtLeast(0L)) + MediaCommand.SEEK -> audio.seekTo(seekMs) MediaCommand.CLOSE -> audio.stop() else -> Unit } @@ -106,11 +113,48 @@ fun GlassesScreen( currentItemName = currentItem?.Name, positionMs = positionMs, audioPositionMs = audioPos, - durationMs = currentItem?.RunTimeTicks?.div(10_000), + durationMs = durationMs, playing = playing, onClick = onOpenInfo, ) + // трекбар: перетаскивание на любую позицию фильма (абсолютный SEEK) + if (durationMs != null && durationMs > 0) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), + ) { + Slider( + value = sliderValue ?: (positionMs ?: 0L).toFloat().coerceIn(0f, durationMs.toFloat()), + onValueChange = { sliderValue = it }, + valueRange = 0f..durationMs.toFloat(), + onValueChangeFinished = { + sliderValue?.let { sendCommand(MediaCommand.SEEK, it.toLong()) } + }, + ) + Text( + text = "${msToClock((sliderValue ?: positionMs ?: 0L).toLong())} / ${msToClock(durationMs)}", + color = PhoneMuted, + fontSize = 11.sp, + modifier = Modifier.padding(start = 8.dp), + ) + + // очки прислали позицию ≈ цели — seek подтверждён, ползунок снова «живой» + LaunchedEffect(positionMs) { + val sv = sliderValue ?: return@LaunchedEffect + val real = positionMs ?: 0L + if (kotlin.math.abs(real - sv) < 2500) sliderValue = null + } + // страховка от зависания (seek не прошёл/очки отключились) — сброс к реальной позиции через 10с + LaunchedEffect(sliderValue) { + val sv = sliderValue ?: return@LaunchedEffect + delay(10_000) + sliderValue = null + } + } + } + // активная загрузка контента на очки if (activeGlassesItem != null) { Text( diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt index 27fedf8..171d18a 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/GlassesMode.kt @@ -46,6 +46,7 @@ enum class MediaCommand { TOGGLE, SEEK_FORWARD, SEEK_BACKWARD, + SEEK, NEXT_AUDIO, PREV_AUDIO, CLOSE, diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt index d1206bc..ce463ea 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -64,6 +64,17 @@ class HostToGlassesTest { assertEquals(0, decoded.seekMs) } + @Test + fun seekCommandRoundTrip() { + val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK, seekMs = 5_400_000) + val text = json.encodeToString(HostToGlasses.serializer(), msg) + assertEquals("""{"type":"media_command","command":"SEEK","seekMs":5400000}""", text) + val decoded = json.decodeFromString(HostToGlasses.serializer(), text) + assertIs(decoded) + assertEquals(MediaCommand.SEEK, decoded.command) + assertEquals(5_400_000, decoded.seekMs) + } + @Test fun playVideoRoundTrip() { val msg: HostToGlasses = PlayVideo(