Сохраняем прогресс просмотра и возобновляем с позиции
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** Пауза при потере связи, если плеер играл. Только с главного потока. Возвращает, играл ли. */
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
// Индикатор аудио-синхронизации в статус-панели очков: |звук − видео| в мс
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
/** Очки выключились / экран погас. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user