Сохраняем прогресс просмотра и возобновляем с позиции
This commit is contained in:
@@ -103,6 +103,7 @@ class HostConnection(
|
|||||||
itemId = snapshot.itemId,
|
itemId = snapshot.itemId,
|
||||||
audioIndex = snapshot.audioIndex,
|
audioIndex = snapshot.audioIndex,
|
||||||
sentTs = System.currentTimeMillis(),
|
sentTs = System.currentTimeMillis(),
|
||||||
|
durationMs = snapshot.durationMs,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -248,9 +249,9 @@ class HostConnection(
|
|||||||
fun sendPlaybackPositionNow() {
|
fun sendPlaybackPositionNow() {
|
||||||
if (transport == null) return
|
if (transport == null) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val (positionMs, playing, itemId, audioIndex) =
|
val (positionMs, playing, itemId, audioIndex, durationMs) =
|
||||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
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 playing: Boolean,
|
||||||
val itemId: String?,
|
val itemId: String?,
|
||||||
val audioIndex: Int,
|
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 {
|
internal fun playbackPosition(): PlaybackSnapshot {
|
||||||
val player = _player ?: return PlaybackSnapshot(0L, false, contentId.itemId, contentId.audioIndex)
|
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] — для автоподхвата звука после
|
* с playing=false). [itemId]+[audioIndex] — для автоподхвата звука после
|
||||||
* рестарта телефона (см. PhoneApp.autostartAudio).
|
* рестарта телефона (см. 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
|
this.state.playing = msg.playing
|
||||||
val state = if (msg.playing) "играет" else "пауза"
|
val state = if (msg.playing) "играет" else "пауза"
|
||||||
log("glasses", "позиция: ${msg.positionMs} мс, $state, item=${msg.itemId} idx=${msg.audioIndex}")
|
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 -> {
|
is GlassesOff -> {
|
||||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||||
this.state.playing = false
|
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 -> {
|
is LogBatchMsg -> {
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ class PhoneApp : Application() {
|
|||||||
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
|
||||||
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
|
||||||
|
|
||||||
|
/** Прогресс просмотра по элементам (filesDir/watchprogress.json): возобновление + индикация в каталоге. */
|
||||||
|
val watchProgress: WatchProgressStore by lazy { WatchProgressStore(filesDir) }
|
||||||
|
|
||||||
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
|
/** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
|
||||||
lateinit var logCollector: BatchingLogCollector
|
lateinit var logCollector: BatchingLogCollector
|
||||||
private set
|
private set
|
||||||
@@ -501,11 +504,15 @@ class PhoneApp : Application() {
|
|||||||
|
|
||||||
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
|
||||||
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
|
// + автоподхват звука (очки играют после рестарта телефона, audioSync не активен)
|
||||||
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs ->
|
// + прогресс просмотра (resume + индикация в каталоге)
|
||||||
|
server.hub.onPlaybackPosition = { positionMs, playing, itemId, audioIndex, sentTs, durationMs ->
|
||||||
lastPositionAt.set(System.currentTimeMillis())
|
lastPositionAt.set(System.currentTimeMillis())
|
||||||
lastWasPlaying.set(playing)
|
lastWasPlaying.set(playing)
|
||||||
audioSync.sync(positionMs, playing, sentTs)
|
audioSync.sync(positionMs, playing, sentTs)
|
||||||
scope.launch { autostartAudio(itemId, audioIndex, positionMs, playing) }
|
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
|
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. */
|
/** Период проверки watchdog. */
|
||||||
const val WATCHDOG_TICK_MS = 2_000L
|
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
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.media.MirrorJob
|
||||||
import pw.binom.viewmate.core.protocol.DownloadPhase
|
import pw.binom.viewmate.core.protocol.DownloadPhase
|
||||||
import pw.binom.viewmate.phone.PhoneApp
|
import pw.binom.viewmate.phone.PhoneApp
|
||||||
|
import pw.binom.viewmate.phone.WatchProgress
|
||||||
|
|
||||||
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
/** Вкладки каталога: Фильмы / Сериалы / Папки. */
|
||||||
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
enum class CatalogTab { MOVIES, SERIES, FOLDERS }
|
||||||
@@ -252,6 +254,8 @@ fun CatalogScreen(
|
|||||||
// DownloadProgress (видео очки качают сами, звук — телефон).
|
// DownloadProgress (видео очки качают сами, звук — телефон).
|
||||||
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
val glassesDownloadedIds by app.server.hub.downloadedItemIds.collectAsState()
|
||||||
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
val glassesDownloads by app.server.hub.glassesDownloads.collectAsState()
|
||||||
|
// «Просмотрено» по элементам (watchprogress.json): тонкий бар на плашке + %/время у названия
|
||||||
|
val watched by app.watchProgress.flow.collectAsState()
|
||||||
|
|
||||||
// подтянуть список, когда появился пользователь Jellyfin
|
// подтянуть список, когда появился пользователь Jellyfin
|
||||||
LaunchedEffect(userId) {
|
LaunchedEffect(userId) {
|
||||||
@@ -280,6 +284,7 @@ fun CatalogScreen(
|
|||||||
ItemCard(
|
ItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
posterUrl = posterUrlOrNull(app, item),
|
posterUrl = posterUrlOrNull(app, item),
|
||||||
|
progress = watched[item.Id],
|
||||||
badge = mirrorBadge(
|
badge = mirrorBadge(
|
||||||
itemId = item.Id,
|
itemId = item.Id,
|
||||||
job = state.mirrors[item.Id],
|
job = state.mirrors[item.Id],
|
||||||
@@ -381,9 +386,14 @@ private fun tabLabel(tab: CatalogTab): String = when (tab) {
|
|||||||
private fun ItemCard(
|
private fun ItemCard(
|
||||||
item: JellyfinItem,
|
item: JellyfinItem,
|
||||||
posterUrl: String?,
|
posterUrl: String?,
|
||||||
|
progress: WatchProgress?,
|
||||||
badge: MirrorBadge,
|
badge: MirrorBadge,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// «Просмотрено»: % — когда известна длительность, иначе время; бар — снизу постера
|
||||||
|
val percent = progress?.watchedPercent()
|
||||||
|
val watchedLabel = progress?.takeIf { it.positionMs > 0L }
|
||||||
|
?.let { p -> percent?.let { "$it%" } ?: msToClock(p.positionMs) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
@@ -401,6 +411,24 @@ private fun ItemCard(
|
|||||||
.padding(bottom = 6.dp)
|
.padding(bottom = 6.dp)
|
||||||
.clip(RoundedCornerShape(8.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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(2.dp)
|
.padding(2.dp)
|
||||||
@@ -417,13 +445,26 @@ private fun ItemCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Row(
|
||||||
text = item.Name ?: item.Id,
|
|
||||||
color = PhoneTextDim,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(horizontal = 2.dp),
|
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 очкам
|
// PlayVideo — только локальный путь на очках, никаких S3-URL очкам
|
||||||
val localPath = "file://$mediaDir/${item.Id}/video.mkv"
|
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
|
val result: String
|
||||||
if (alreadyPlaying) {
|
if (alreadyPlaying) {
|
||||||
result = "включил (видео уже играет на очках)"
|
result = "включил (видео уже играет на очках)"
|
||||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||||
} else {
|
} else {
|
||||||
result = try {
|
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) {
|
} catch (e: Exception) {
|
||||||
log("watch", "watch упал: ${e.message}")
|
log("watch", "watch упал: ${e.message}")
|
||||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||||
@@ -210,7 +221,7 @@ fun DetailsScreen(
|
|||||||
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
snackbar.showSnackbar("нет аудио-дорожки: ${audio.reason}")
|
||||||
} else {
|
} else {
|
||||||
log("watch", "audio source: ${audio.source} (${audio.reason})")
|
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)
|
app.audioSync.play(audio.source, startPositionMs)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ data class PlaybackPosition(
|
|||||||
val audioIndex: Int = 0,
|
val audioIndex: Int = 0,
|
||||||
/** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */
|
/** Время отправки на часах очков (epoch ms). Телефон мерит латентность линка = now − sentTs. 0 = не замерено. */
|
||||||
val sentTs: Long = 0L,
|
val sentTs: Long = 0L,
|
||||||
|
/** Длительность видео на очках, мс; -1 = неизвестно (стара прошивка / ещё не prepared). */
|
||||||
|
val durationMs: Long = -1L,
|
||||||
) : GlassesToHost
|
) : GlassesToHost
|
||||||
|
|
||||||
/** Очки выключились / экран погас. */
|
/** Очки выключились / экран погас. */
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ class GlassesToHostTest {
|
|||||||
playing = true,
|
playing = true,
|
||||||
itemId = "movie-1",
|
itemId = "movie-1",
|
||||||
audioIndex = 2,
|
audioIndex = 2,
|
||||||
|
durationMs = 7_200_000,
|
||||||
)
|
)
|
||||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||||
assertTrue("\"itemId\":\"movie-1\"" in text)
|
assertTrue("\"itemId\":\"movie-1\"" in text)
|
||||||
@@ -135,6 +136,7 @@ class GlassesToHostTest {
|
|||||||
assertTrue(decoded.playing)
|
assertTrue(decoded.playing)
|
||||||
assertEquals("movie-1", decoded.itemId)
|
assertEquals("movie-1", decoded.itemId)
|
||||||
assertEquals(2, decoded.audioIndex)
|
assertEquals(2, decoded.audioIndex)
|
||||||
|
assertEquals(7_200_000, decoded.durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -147,6 +149,7 @@ class GlassesToHostTest {
|
|||||||
assertTrue(!decoded.playing)
|
assertTrue(!decoded.playing)
|
||||||
assertNull(decoded.itemId)
|
assertNull(decoded.itemId)
|
||||||
assertEquals(0, decoded.audioIndex)
|
assertEquals(0, decoded.audioIndex)
|
||||||
|
assertEquals(-1L, decoded.durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user