Добавляем 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