vc=90: cold-start store hydration off-Main + video-page status-bar fix + RYD FFI slim

Resume + Enrichment stores no longer JSON-decode on the main thread in Application.onCreate (Resume is the heaviest cold-start cost: ~50-100ms decode at its 100k-entry cap). Both seed an empty StateFlow and hydrate off-thread on their PrefsWriter single-thread dispatcher. Mutations that arrive before the hydrate finishes defer onto that FIFO dispatcher (a hydrated gate) so they re-run on fully-loaded state -- correct for adds AND clears; a naive loaded+live merge would resurrect a clear that landed on the empty seed. Subscriptions/Playlists/History/Settings stay eager (tiny + rendered directly -> would flash empty). (audit 2.1)

Video page: details/related rows no longer leak into the status-bar strip above the player when scrolled. topPadding is now a layout inset on the detail LazyColumn, so the scroll viewport begins+clips at the player's bottom edge instead of letting rows scroll up over the clock/signal.

RYD: dropped the dead rating + view_count fields from the strawcore RydVotes FFI Record + Kotlin shim -- only likes/dislikes are rendered; RYD's rating restates the like/dislike ratio and its viewCount is a stale aggregate conflicting with the real YouTube count already shown. (audit #2 L-9)

Adversarially reviewed (Opus): clear-resurrection closed, no defer-loop, FFI slim has no readers. Headless compileDebugKotlin green.
This commit is contained in:
Sulkta 2026-06-22 05:10:48 -07:00
parent 41b3999ad3
commit 7c90f70a0a
7 changed files with 150 additions and 25 deletions

View file

@ -23,6 +23,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -52,9 +53,33 @@ class EnrichmentStore(context: Context) {
// could land out of order. The write reads the live map so disk converges.
private val writer = PrefsWriter(sp)
private val _entries = MutableStateFlow(load())
// Seed empty + hydrate off the main thread (cold-start: this store can hold
// up to MAX_ENRICHMENTS=5000 entries, ~250 KB to JSON-decode in onCreate).
// Same clobber-safe pattern as ResumePositionsStore — merge loaded + live on
// the shared PrefsWriter FIFO dispatcher so a put() during the hydrate window
// survives and disk converges. No flash risk: this is overlay data the feed
// merges in on IO after the rows already paint, not a primary list.
private val _entries = MutableStateFlow<Map<String, Enrichment>>(emptyMap())
val entries: StateFlow<Map<String, Enrichment>> = _entries.asStateFlow()
// See ResumePositionsStore: put()/clear() that arrive before the hydrate
// finishes defer themselves onto the write dispatcher so they re-run on
// fully-loaded state (a clear on the empty seed would otherwise be undone).
@Volatile private var hydrated = false
init {
writer.serial {
try {
val loaded = load()
if (loaded.isNotEmpty()) _entries.update { live -> loaded + live }
} finally {
// See ResumePositionsStore: flip the gate even on a throw so a
// failed hydrate can't strand every mutation in a self-redispatch loop.
hydrated = true
}
}
}
/**
* Return a fresh enrichment for this videoId, or null when missing
* or aged out per Settings.cacheTtl. Forever-TTL never expires.
@ -69,6 +94,7 @@ class EnrichmentStore(context: Context) {
}
fun put(videoId: String, viewCount: Long, durationSeconds: Long) {
if (!hydrated) { writer.serial { put(videoId, viewCount, durationSeconds) }; return }
if (videoId.isBlank()) return
// Don't write all-zero entries — that's failure not data, and
// would waste a slot the cap could spend on a real hit.
@ -107,8 +133,11 @@ class EnrichmentStore(context: Context) {
}
fun clear() {
if (!hydrated) { writer.serial { clear() }; return }
val before = _entries.value
_entries.updateAndGet { emptyMap() }
writer.write { putString(KEY, json.encodeToString(_entries.value)) }
// Skip the SP write when already empty (matches ResumePositionsStore.clearAll).
if (before.isNotEmpty()) writer.write { putString(KEY, json.encodeToString(_entries.value)) }
}
private fun load(): Map<String, Enrichment> = runCatching {

View file

@ -44,4 +44,28 @@ class PrefsWriter(private val sp: SharedPreferences) {
editor.apply()
}
}
/**
* Run [block] on the SAME single-thread dispatcher as write(), FIFO with
* it. Two uses, both relying on that FIFO ordering:
*
* 1. HYDRATE a store's StateFlow from disk OFF the main thread at
* construction the JSON decode otherwise blocks Application.onCreate
* (ResumePositionsStore can hold up to 100k entries; its own comment
* pegs encode/decode at ~50-100 ms on a low-end device). Enqueue it
* from the store constructor, before the singleton is published, so it
* is FIFO-first.
* 2. DEFER a mutation that arrived before hydration finished. A mutation
* run on the empty seed would diverge from disk: an add is recoverable
* by merging disk under it, but a CLEAR is NOT `loaded + emptyLive`
* resurrects the just-cleared data (the empty live map carries no trace
* of the removal). So instead of merging-and-hoping, every mutation
* checks a `hydrated` flag and, if false, re-enqueues ITSELF here. The
* deferred copy runs after the FIFO-earlier hydrate, on fully-loaded
* state, so adds and clears are both correct. (The dispatcher is one
* worker; the deferred mutation provably observes `hydrated == true`.)
*/
fun serial(block: () -> Unit) {
StrawApp.globalScope.launch(dispatcher) { block() }
}
}

View file

@ -20,6 +20,7 @@ import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -62,9 +63,39 @@ class ResumePositionsStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val writer = PrefsWriter(sp)
private val _positions = MutableStateFlow(load())
// Seed empty + hydrate off the main thread. The synchronous decode this
// store used to do in its constructor (MutableStateFlow(load())) is the
// heaviest cold-start cost in the app: at MAX_RESUMES_HARD the blob is
// up to ~5 MB and the encode/decode runs ~50-100 ms (see record()'s note).
// We MERGE loaded + live so a record() that lands during the hydrate
// window survives, and because the hydrate shares the PrefsWriter FIFO
// dispatcher (enqueued before this store is published) it always runs
// before any write's persist — disk converges to the full set, no clobber.
private val _positions = MutableStateFlow<Map<String, ResumePosition>>(emptyMap())
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
// Flipped true at the end of the hydrate. Mutations that arrive before then
// defer themselves onto the write dispatcher (see record/clearOne/clearAll)
// so they re-run AFTER the hydrate on fully-loaded state — a clear on the
// empty seed would otherwise be silently undone when the merge restored
// the on-disk data the user just wiped.
@Volatile private var hydrated = false
init {
writer.serial {
try {
val loaded = load()
if (loaded.isNotEmpty()) _positions.update { live -> loaded + live }
} finally {
// Flip the gate even if load/merge throws. load() is runCatching-
// guarded today so it can't, but a stuck-false `hydrated` would turn
// every later mutation into an unbounded self-redispatch storm —
// keep that impossible locally, not by action-at-a-distance.
hydrated = true
}
}
}
private fun maxResumes(): Int {
val cap = Settings.get().resumePositionsCap.value.value
return cap.coerceAtMost(MAX_RESUMES_HARD)
@ -81,6 +112,7 @@ class ResumePositionsStore(context: Context) {
* REMOVED so a finished video doesn't auto-resume to its credits.
*/
fun record(videoId: String, positionMs: Long, durationMs: Long) {
if (!hydrated) { writer.serial { record(videoId, positionMs, durationMs) }; return }
if (videoId.isBlank()) return
if (durationMs <= 0L) return
if (positionMs < MIN_POSITION_MS) return
@ -138,13 +170,22 @@ class ResumePositionsStore(context: Context) {
}
}
/** Returns null when the video has no recorded position. */
/**
* Returns null when the video has no recorded position. Reads are NOT
* gated on hydration (unlike writes): in the brief cold-start window before
* the off-thread hydrate lands, this returns null even for a video that has
* a saved position on disk auto-resume then starts at 0 for that one open.
* Benign (the stored position is intact and resumes on the next open), and
* near-impossible in practice: playback only starts after stream extraction
* (a network round-trip), by which point the local-disk hydrate has run.
*/
fun get(videoId: String): ResumePosition? {
if (videoId.isBlank()) return null
return _positions.value[videoId]
}
fun clearOne(videoId: String) {
if (!hydrated) { writer.serial { clearOne(videoId) }; return }
if (videoId.isBlank()) return
val before = _positions.value
val next = _positions.updateAndGet { current ->
@ -156,6 +197,7 @@ class ResumePositionsStore(context: Context) {
}
fun clearAll() {
if (!hydrated) { writer.serial { clearAll() }; return }
val before = _positions.value
_positions.updateAndGet { emptyMap() }
if (before.isNotEmpty()) {

View file

@ -143,9 +143,16 @@ fun VideoDetailBody(
// now recycle and defer their image decode + 2 progress-overlay flow
// collectors until scrolled into view, instead of mounting ~40 rows
// (~40 decodes + ~80 collectors) eagerly on every video open.
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item { Spacer(modifier = Modifier.height(topPadding)) }
//
// topPadding is applied as a layout inset on the LazyColumn itself, NOT a
// leading Spacer item. A Spacer-as-item scrolls away, letting rows slide up
// through the full-height body into the status-bar strip ABOVE the player —
// visible over the clock/signal, since the player (opaque) only starts at
// the status-bar inset and nothing occludes that strip. As padding, the
// scroll viewport begins at the player's bottom edge and clips there, so
// rows can never enter the status strip. (The player is opaque black, so
// the old "scroll behind the player" was invisible anyway — no feel change.)
LazyColumn(modifier = Modifier.fillMaxWidth().padding(top = topPadding)) {
when {
state.loading -> item {
Box(

View file

@ -17,22 +17,19 @@ data class RydVotes(
val id: String,
val likes: Long = 0,
val dislikes: Long = 0,
val rating: Double = 0.0,
val viewCount: Long = 0,
)
object RydClient {
/** Suspends on the Rust async runtime; call from a coroutine. Returns
* null on any failure (transport / non-2xx / bad JSON), same contract
* the old blocking client had. */
* the old blocking client had. Only likes/dislikes are carried RYD's
* rating/viewCount were dead, redundant overlay data (audit #2 L-9). */
suspend fun fetch(videoId: String): RydVotes? =
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
RydVotes(
id = v.id,
likes = v.likes,
dislikes = v.dislikes,
rating = v.rating,
viewCount = v.viewCount,
)
}
}