Добавляем WatchProgressStore (watchprogress.json) и его тесты
This commit is contained in:
@@ -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<String, WatchProgress>)
|
||||
|
||||
/**
|
||||
* Прогресс просмотра по элементам: `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<String, WatchProgress>()
|
||||
|
||||
private val _flow = MutableStateFlow<Map<String, WatchProgress>>(emptyMap())
|
||||
/** Снимки прогресса по itemId — для карточек каталога. */
|
||||
val flow: StateFlow<Map<String, WatchProgress>> = _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}") }
|
||||
}
|
||||
}
|
||||
@@ -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<WatchProgressStore, File> {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user