diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WatchProgressStore.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WatchProgressStore.kt new file mode 100644 index 0000000..120ae49 --- /dev/null +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/WatchProgressStore.kt @@ -0,0 +1,116 @@ +package pw.binom.viewmate.phone + +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Прогресс просмотра элемента: как далеко досмотрели и длительность видео. */ +@Serializable +data class WatchProgress( + val positionMs: Long, + /** Длительность видео, мс; -1 = неизвестно (старые очки не сообщали durationMs). */ + val durationMs: Long = -1L, +) { + /** Процент просмотренного (0..100); null — длительность неизвестна. */ + fun watchedPercent(): Int? = + if (durationMs <= 0L) null else ((positionMs.toDouble() / durationMs) * 100.0).toInt().coerceIn(0, 100) +} + +@Serializable +internal data class WatchProgressFile(val entries: Map) + +/** + * Прогресс просмотра по элементам: `filesDir/watchprogress.json`. + * + * Заполняется из PlaybackPosition очков (раз в 5с, только при продвижении вперёд), + * используется в двух местах: (1) возобновление просмотра с того же места по «▶ Смотреть» + * и (2) индикация просмотренного на карточках каталога. + * Ключ — itemId (не зависит от дорожки звука — позиция общая). + * Конструктор принимает директорию (в проде — filesDir) для JVM-тестируемости. + */ +class WatchProgressStore(dir: File) { + + companion object { + /** Понять «новое начало»: просмотрено меньше 15 с — возобновляем с начала. */ + const val MIN_RESUME_MS = 15_000L + + /** 98% и больше — элемент досмотрен: в следующий раз начинаем сначала. */ + const val FINISHED_RATIO = 0.98 + } + + private val json = Json { ignoreUnknownKeys = true; prettyPrint = true } + private val file = File(dir, "watchprogress.json") + private val lock = Any() + private val entries = LinkedHashMap() + + private val _flow = MutableStateFlow>(emptyMap()) + /** Снимки прогресса по itemId — для карточек каталога. */ + val flow: StateFlow> = _flow.asStateFlow() + + init { + load() + _flow.value = entries.toMap() + } + + /** + * Записать позицию элемента. На диск пишем только при продвижении вперёд + * или когда впервые известна длительность; дубликаты (пауза) — без записи. + * Позиция дальше длительности — мусор ExoPlayer (гашение экрана): игнор. + */ + fun upsert(itemId: String, positionMs: Long, durationMs: Long = -1L) { + if (positionMs < 0L) return + synchronized(lock) { + val old = entries[itemId] ?: WatchProgress(0L) + val knownDuration = if (durationMs > 0L) durationMs else old.durationMs + if (knownDuration > 0L && positionMs > knownDuration) { + log("progress", "упсерт отклонён: позиция $positionMs мс > длительность $knownDuration мс ($itemId)") + return + } + val advances = positionMs > old.positionMs || + (durationMs > 0L && durationMs != old.durationMs) + if (!advances) return + entries[itemId] = WatchProgress(positionMs, if (durationMs > 0L) durationMs else old.durationMs) + persist() + _flow.value = entries.toMap() + } + } + + /** + * Позиция для возобновления просмотра: null = начинать с начала + * (не смотрели, смотрели меньше [MIN_RESUME_MS] или досмотрели до [FINISHED_RATIO]). + */ + fun resumePosition(itemId: String): Long? { + val e = synchronized(lock) { entries[itemId] } ?: return null + return when { + e.positionMs < MIN_RESUME_MS -> null + e.durationMs > 0L && e.positionMs >= e.durationMs * FINISHED_RATIO -> null + else -> e.positionMs + } + } + + /** Процент просмотренного (0..100); null — элемент не смотрели или длительность неизвестна. */ + fun watchedPercent(itemId: String): Int? { + val e = synchronized(lock) { entries[itemId] } ?: return null + return e.watchedPercent() + } + + private fun load() { + val text = runCatching { file.readText() }.getOrNull() ?: return + val parsed = runCatching { json.decodeFromString(WatchProgressFile.serializer(), text).entries }.getOrNull() + if (parsed != null) { + entries.putAll(parsed) + } else { + log("progress", "watchprogress.json не прочитан — начинаю пустым") + } + } + + private fun persist() { + val result = runCatching { json.encodeToString(WatchProgressFile.serializer(), WatchProgressFile(entries.toMap())) } + result.onSuccess { text -> + runCatching { file.writeText(text) }.onFailure { log("progress", "запись watchprogress.json: ${it.message}") } + }.onFailure { log("progress", "сериализация watchprogress.json: ${it.message}") } + } +} diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/WatchProgressStoreTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/WatchProgressStoreTest.kt new file mode 100644 index 0000000..1b1a712 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/WatchProgressStoreTest.kt @@ -0,0 +1,95 @@ +package pw.binom.viewmate.phone + +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class WatchProgressStoreTest { + + private fun tempDir(name: String): File = createTempDirectory(name).toFile() + + private fun newStore(): Pair { + val dir = tempDir("watch-progress") + return WatchProgressStore(dir) to dir + } + + @Test + fun `upsert and resume position`() { + val (store, _) = newStore() + store.upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L) + + assertEquals(3_600_000L, store.resumePosition("item-1")) + assertEquals(50, store.watchedPercent("item-1")) + assertNull(store.resumePosition("other")) + } + + @Test + fun `resume returns null for short or finished viewing`() { + val (store, _) = newStore() + store.upsert("short", positionMs = 10_000L, durationMs = 7_200_000L) + assertNull(store.resumePosition("short")) + + val finishedMs = (7_200_000L * WatchProgressStore.FINISHED_RATIO).toLong() + 1000L + store.upsert("finished", positionMs = finishedMs, durationMs = 7_200_000L) + assertNull(store.resumePosition("finished")) + + val almost = 7_200_000L * 0.90 + store.upsert("almost", positionMs = almost.toLong(), durationMs = 7_200_000L) + assertNotNull(store.resumePosition("almost")) + } + + @Test + fun `backward and duplicate positions are not written`() { + val (store, dir) = newStore() + store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L) + val writtenAfterAdvance = File(dir, "watchprogress.json").readText() + + store.upsert("item-1", positionMs = 55_000L, durationMs = 7_200_000L) + store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L) + assertEquals(writtenAfterAdvance, File(dir, "watchprogress.json").readText()) + } + + @Test + fun `position beyond duration is rejected`() { + val (store, dir) = newStore() + store.upsert("item-1", positionMs = 60_000L, durationMs = 7_200_000L) + val before = File(dir, "watchprogress.json").readText() + + store.upsert("item-1", positionMs = 99_000_000L, durationMs = 7_200_000L) + assertEquals(before, File(dir, "watchprogress.json").readText()) + assertEquals(60_000L, store.resumePosition("item-1")) + } + + @Test + fun `duration discovered later is persisted`() { + val (store, dir) = newStore() + store.upsert("item-1", positionMs = 60_000L) + assertNull(store.watchedPercent("item-1")) + + store.upsert("item-1", positionMs = 60_000L, durationMs = 120_000L) + assertEquals(50, store.watchedPercent("item-1")) + assertEquals("watchprogress.json", File(dir, "watchprogress.json").name) + } + + @Test + fun `progress survives reload`() { + val dir = tempDir("watch-progress-reload") + WatchProgressStore(dir).upsert("item-1", positionMs = 3_600_000L, durationMs = 7_200_000L) + + val reloaded = WatchProgressStore(dir) + assertEquals(3_600_000L, reloaded.resumePosition("item-1")) + assertEquals(50, reloaded.watchedPercent("item-1")) + assertEquals(mapOf("item-1" to WatchProgress(3_600_000L, 7_200_000L)), reloaded.flow.value) + } + + @Test + fun `unknown duration gives percent null but resume works`() { + val (store, _) = newStore() + store.upsert("item-1", positionMs = 3_600_000L) + assertNull(store.watchedPercent("item-1")) + assertEquals(3_600_000L, store.resumePosition("item-1")) + } +}