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(