feat(app-phone): трекбар-таймлайн в пульте (seek на любую позицию)
- 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)
This commit is contained in:
@@ -13,6 +13,14 @@ import pw.binom.viewmate.core.MediaCommand
|
|||||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||||
import java.io.File
|
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,
|
* Держатель плеера фильма: создаёт/освобождает SimpleExoPlayer,
|
||||||
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
* выполняет команды из WS-протокола. Сам плеер создаётся только по PlayVideo.
|
||||||
@@ -81,6 +89,11 @@ class MovieController(private val context: Context) {
|
|||||||
player.seekTo(target)
|
player.seekTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaCommand.SEEK -> {
|
||||||
|
val target = seekTargetMs(seekMs, player.duration)
|
||||||
|
player.seekTo(target)
|
||||||
|
}
|
||||||
|
|
||||||
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
|
// Звук — на телефоне, переключение аудио-дорожек очкам не нужно.
|
||||||
MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> {
|
MediaCommand.NEXT_AUDIO, MediaCommand.PREV_AUDIO -> {
|
||||||
log("glasses", "MediaCommand $command игнорируется (звук на телефоне)")
|
log("glasses", "MediaCommand $command игнорируется (звук на телефоне)")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.viewmate.core.GlassesMode
|
import pw.binom.viewmate.core.GlassesMode
|
||||||
import pw.binom.viewmate.core.MediaCommand
|
import pw.binom.viewmate.core.MediaCommand
|
||||||
@@ -59,9 +61,13 @@ fun GlassesScreen(
|
|||||||
val playing by app.state.playingFlow.collectAsState()
|
val playing by app.state.playingFlow.collectAsState()
|
||||||
val mode by app.state.modeFlow.collectAsState()
|
val mode by app.state.modeFlow.collectAsState()
|
||||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||||
|
val durationMs = currentItem?.RunTimeTicks?.div(10_000)
|
||||||
|
|
||||||
var showDownloads by remember { mutableStateOf(false) }
|
var showDownloads by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// трекбар: пока тянем — слайдер следует за пальцем; отпустили — абсолютный SEEK
|
||||||
|
var sliderValue by remember { mutableStateOf<Float?>(null) }
|
||||||
|
|
||||||
val actions = app.actions
|
val actions = app.actions
|
||||||
|
|
||||||
// активное скачивание на очки: первое с незавершённым файлом
|
// активное скачивание на очки: первое с незавершённым файлом
|
||||||
@@ -81,6 +87,7 @@ fun GlassesScreen(
|
|||||||
MediaCommand.PAUSE -> audio.pause()
|
MediaCommand.PAUSE -> audio.pause()
|
||||||
MediaCommand.SEEK_FORWARD -> audio.seekTo(audio.currentPositionMs() + seekMs)
|
MediaCommand.SEEK_FORWARD -> audio.seekTo(audio.currentPositionMs() + seekMs)
|
||||||
MediaCommand.SEEK_BACKWARD -> audio.seekTo((audio.currentPositionMs() - seekMs).coerceAtLeast(0L))
|
MediaCommand.SEEK_BACKWARD -> audio.seekTo((audio.currentPositionMs() - seekMs).coerceAtLeast(0L))
|
||||||
|
MediaCommand.SEEK -> audio.seekTo(seekMs)
|
||||||
MediaCommand.CLOSE -> audio.stop()
|
MediaCommand.CLOSE -> audio.stop()
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
@@ -106,11 +113,48 @@ fun GlassesScreen(
|
|||||||
currentItemName = currentItem?.Name,
|
currentItemName = currentItem?.Name,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
audioPositionMs = audioPos,
|
audioPositionMs = audioPos,
|
||||||
durationMs = currentItem?.RunTimeTicks?.div(10_000),
|
durationMs = durationMs,
|
||||||
playing = playing,
|
playing = playing,
|
||||||
onClick = onOpenInfo,
|
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) {
|
if (activeGlassesItem != null) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ enum class MediaCommand {
|
|||||||
TOGGLE,
|
TOGGLE,
|
||||||
SEEK_FORWARD,
|
SEEK_FORWARD,
|
||||||
SEEK_BACKWARD,
|
SEEK_BACKWARD,
|
||||||
|
SEEK,
|
||||||
NEXT_AUDIO,
|
NEXT_AUDIO,
|
||||||
PREV_AUDIO,
|
PREV_AUDIO,
|
||||||
CLOSE,
|
CLOSE,
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ class HostToGlassesTest {
|
|||||||
assertEquals(0, decoded.seekMs)
|
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<MediaCommandMsg>(decoded)
|
||||||
|
assertEquals(MediaCommand.SEEK, decoded.command)
|
||||||
|
assertEquals(5_400_000, decoded.seekMs)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun playVideoRoundTrip() {
|
fun playVideoRoundTrip() {
|
||||||
val msg: HostToGlasses = PlayVideo(
|
val msg: HostToGlasses = PlayVideo(
|
||||||
|
|||||||
Reference in New Issue
Block a user