diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt index 443b83f..ba59f4c 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/HostConnection.kt @@ -103,6 +103,7 @@ class HostConnection( itemId = snapshot.itemId, audioIndex = snapshot.audioIndex, sentTs = System.currentTimeMillis(), + durationMs = snapshot.durationMs, ), ) } @@ -248,9 +249,9 @@ class HostConnection( fun sendPlaybackPositionNow() { if (transport == null) return scope.launch { - val (positionMs, playing, itemId, audioIndex) = + val (positionMs, playing, itemId, audioIndex, durationMs) = withContext(Dispatchers.Main) { movieController.playbackPosition() } - sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis())) + sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex, sentTs = System.currentTimeMillis(), durationMs = durationMs)) } } 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 7a7fd49..e8d1e11 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 @@ -70,6 +70,8 @@ internal data class PlaybackSnapshot( val playing: Boolean, val itemId: String?, val audioIndex: Int, + /** Длительность видео, мс; -1 = неизвестно (плеера нет / ещё не prepared). */ + val durationMs: Long = -1L, ) /** @@ -239,10 +241,10 @@ class MovieController(private val context: Context) { } } - /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex). Только с главного потока. */ + /** Снимок для PlaybackPosition (позиция, playing, itemId, audioIndex, длительность). Только с главного потока. */ internal fun playbackPosition(): PlaybackSnapshot { val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex) - return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex) + return PlaybackSnapshot(player.currentPosition, player.playWhenReady, contentId.itemId, contentId.audioIndex, player.duration) } /** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */ diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index 9d702a7..96fa402 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -97,7 +97,7 @@ class GlassesHub( * с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после * рестарта телефона (см. PhoneApp.autostartAudio). */ - var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long) -> Unit)? = null + var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int, sentTs: Long, durationMs: Long) -> Unit)? = null /** * Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор @@ -195,13 +195,13 @@ class GlassesHub( this.state.playing = msg.playing val state = if (msg.playing) "играет" else "пауза" log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}") - onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs) + onPlaybackPosition?.invoke(msg.positionMs, msg.playing, msg.itemId, msg.audioIndex, msg.sentTs, msg.durationMs) } is GlassesOff -> { log("glasses", "очки выключились → пауза (${msg.reason})") this.state.playing = false - onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L) + onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0, 0L, -1L) } is LogBatchMsg -> { diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index e766342..e816a42 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -90,6 +90,9 @@ class PhoneApp : Application() { /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } + /** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */ + val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) } + /** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */ lateinit var logCollector: BatchingLogCollector private set @@ -501,11 +504,15 @@ class PhoneApp : Application() { // каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера // + автоподхват звука (очки играют после рестарта телефона, audioSync не активен) - server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs -> + // + прогресс просмотра (resume + индикация в каталоге) + server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs -> lastPositionAt.set(System.currentTimeMillis()) lastWasPlaying.set(playing) audioSync.sync(positionMs, playing, sentTs) scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) } + if (itemId != null) { + scope.launch(Dispatchers.IO) { watchProgress.upsert(itemId, positionMs, durationMs) } + } } // Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt index 80e8bb5..ec561e1 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneConfig.kt @@ -22,8 +22,13 @@ object PhoneConfig { */ const val USE_BLUETOOTH = true - /** Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). */ - const val AUDIO_SILENCE_PAUSE_MS = 5_000L + /** + * Watchdog: пауза звука, если очки молчат дольше этого времени (экран погас, радио спит). + * 10 с = два пропущенных пинга: 5-секундные пинги очков джиттят (интервалы 3.8–6.3 с — + * SPP-запись / main-поток очков задерживаются), и порог 5 с ложно ставил звук на паузу + * при каждом позднем пинге (слышимый провал 200–800 мс, «звук возобновился с того же места»). + */ + const val AUDIO_SILENCE_PAUSE_MS = 10_000L /** Период проверки watchdog. */ const val WATCHDOG_TICK_MS = 2_000L diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt index 3e81b24..90b8bf3 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/CatalogScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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 @@ -45,6 +46,7 @@ import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.phone.PhoneApp +import pw.binom.viewmate.phone.WatchProgress /** Вкладки каталога: Фильмы / Сериалы / Папки. */ enum class CatalogTab { MOVIES, SERIES, FOLDERS } @@ -252,6 +254,8 @@ fun CatalogScreen( // DownloadProgress (видео очки качают сами, звук — телефон). val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState() val glassesDownloads by app.server.hub.glassesDownloads.collectAsState() + // «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия + val watched by app.watchProgress.flow.collectAsState() // подтянуть список, когда появился пользователь Jellyfin LaunchedEffect(userId) { @@ -280,6 +284,7 @@ fun CatalogScreen( ItemCard( item = item, posterUrl = posterUrlOrNull(app, item), + progress = watched[item.Id], badge = mirrorBadge( itemId = item.Id, job = state.mirrors[item.Id], @@ -381,9 +386,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) { private fun ItemCard( item: JellyfinItem, posterUrl: String?, + progress: WatchProgress?, badge: MirrorBadge, onClick: () -> Unit, ) { + // «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера + val percent = progress?.watchedPercent() + val watchedLabel = progress?.takeIf { it.positionMs > 0L } + ?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) } Column( modifier = Modifier .clip(RoundedCornerShape(12.dp)) @@ -401,6 +411,24 @@ private fun ItemCard( .padding(bottom = 6.dp) .clip(RoundedCornerShape(8.dp)), ) + if (percent != null && percent > 0) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + .background(PhoneLine), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(percent / 100f) + .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + .background(PhoneAccent), + ) + } + } Box( modifier = Modifier .padding(2.dp) @@ -417,13 +445,26 @@ private fun ItemCard( ) } } - Text( - text = item.Name ?: item.Id, - color = PhoneTextDim, - fontSize = 11.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + Row( modifier = Modifier.padding(horizontal = 2.dp), - ) + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.Name ?: item.Id, + color = PhoneTextDim, + fontSize = 11.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (watchedLabel != null) { + Text( + text = watchedLabel, + color = PhoneMuted, + fontSize = 9.sp, + modifier = Modifier.padding(start = 4.dp, bottom = 1.dp), + ) + } + } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index a47b75e..129600c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -178,13 +178,24 @@ fun DetailsScreen( } // PlayVideo — только локальный путь на очках, никаких S3-URL очкам val localPath = "file://$mediaDir/${item.Id}/video.mkv" + // «Продолжить с того места»: сохранённая позиция (null — не смотрели / досмотрели) + val resumeMs = if (alreadyPlaying) null else app.watchProgress.resumePosition(item.Id) + if (resumeMs != null) { + log("watch", "возобновление с сохранённой позиции: ${resumeMs} мс (${item.Name})") + } val result: String if (alreadyPlaying) { result = "включил (видео уже играет на очках)" log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук") } else { result = try { - actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk) + actions!!.watch( + item.Id, + effectiveAudioIndex, + localVideoPath = localPath, + phoneAudioReady = audioOk, + startPositionMs = resumeMs ?: 0L, + ) } catch (e: Exception) { log("watch", "watch упал: ${e.message}") snackbar.showSnackbar("watch упал: ${e.message}") @@ -210,7 +221,7 @@ fun DetailsScreen( snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}") } else { log("watch", "audio source: ${audio.source} (${audio.reason})") - val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else 0L + val startPositionMs = if (alreadyPlaying) (app.server.hub.positionMs.value ?: 0L) else (resumeMs ?: 0L) app.audioSync.play(audio.source, startPositionMs) } } else { diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt index 0761368..71efcea 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -74,6 +74,8 @@ data class PlaybackPosition( val audioIndex: Int = 0, /** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */ val sentTs: Long = 0L, + /** Длительность видео на очках, мс; -1 = неизвестно (стара прошивка / ещё не prepared). */ + val durationMs: Long = -1L, ) : GlassesToHost /** Очки выключились / экран погас. */ diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt index 817b7be..60510d3 100644 --- a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt @@ -125,6 +125,7 @@ class GlassesToHostTest { playing = true, itemId = "movie-1", audioIndex = 2, + durationMs = 7_200_000, ) val text = json.encodeToString(GlassesToHost.serializer(), msg) assertTrue("\"itemId\":\"movie-1\"" in text) @@ -135,6 +136,7 @@ class GlassesToHostTest { assertTrue(decoded.playing) assertEquals("movie-1", decoded.itemId) assertEquals(2, decoded.audioIndex) + assertEquals(7_200_000, decoded.durationMs) } @Test @@ -147,6 +149,7 @@ class GlassesToHostTest { assertTrue(!decoded.playing) assertNull(decoded.itemId) assertEquals(0, decoded.audioIndex) + assertEquals(-1L, decoded.durationMs) } @Test