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:
parent
93bf86f534
commit
1e89c0739a
7 changed files with 150 additions and 25 deletions
|
|
@ -9,6 +9,33 @@ const val STRAW_SDK_TARGET = 35
|
|||
|
||||
// Sulkta fork — Straw
|
||||
//
|
||||
// vc=90 / 0.1.0-CX — cold-start hydration + status-bar bleed fix + RYD FFI slim:
|
||||
// * ResumePositionsStore + EnrichmentStore no longer JSON-decode on the main
|
||||
// thread in Application.onCreate. Resume was the heaviest cold-start cost in
|
||||
// the app — at its 100k-entry cap the blob is multi-MB and the decode runs
|
||||
// ~50-100 ms on a low-end device, all on Main before the first frame. Both
|
||||
// now seed an empty StateFlow and hydrate from disk on the store's own
|
||||
// PrefsWriter single-thread dispatcher. Mutations that arrive before the
|
||||
// hydrate finishes DEFER themselves onto that same FIFO dispatcher (a
|
||||
// `hydrated` flag gate) so they re-run after the load 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 (sub-ms) and rendered directly, so
|
||||
// seeding them empty would flash an empty list every cold start. (audit 2.1)
|
||||
// * Fixed the video page leaking details/related rows into the status-bar
|
||||
// strip above the player when scrolled. The detail body fills the screen
|
||||
// from y=0 (under the status bar) and the player only starts at the
|
||||
// status-bar inset, so a leading Spacer-as-item let rows scroll up into the
|
||||
// uncovered strip over the clock/signal. topPadding is now a layout inset on
|
||||
// the LazyColumn itself, so the scroll viewport begins + clips at the
|
||||
// player's bottom edge — rows can't enter the strip. (Cobb, on-device.)
|
||||
// * RYD: dropped the dead `rating` + `viewCount` fields from the strawcore
|
||||
// `RydVotes` FFI Record + the Kotlin shim. Only likes/dislikes were ever
|
||||
// rendered; RYD's rating just restates the like/dislike ratio and its
|
||||
// viewCount is a stale aggregate that conflicts with the real YouTube count
|
||||
// the detail screen already shows — surfacing either would be redundant or
|
||||
// misleading, so they no longer cross the FFI. (audit #2 L-9)
|
||||
//
|
||||
// vc=89 / 0.1.0-CW — pagination-burst fix + finish the SP-write serialization:
|
||||
// * Hide-shorts no longer drains a whole channel/search in one burst. The
|
||||
// infinite-scroll trigger is computed from the FILTERED list while loadMore()
|
||||
|
|
@ -285,6 +312,6 @@ const val STRAW_SDK_TARGET = 35
|
|||
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||
// NewPipeExtractor in the runtime path.
|
||||
const val STRAW_VERSION_CODE = 89
|
||||
const val STRAW_VERSION_NAME = "0.1.0-CW"
|
||||
const val STRAW_VERSION_CODE = 90
|
||||
const val STRAW_VERSION_NAME = "0.1.0-CX"
|
||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||
|
|
|
|||
|
|
@ -91,31 +91,32 @@ pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Opt
|
|||
|
||||
/// Vote counts from the Return-YouTube-Dislike API. Kotlin maps this onto
|
||||
/// its own `net.RydVotes` data class (the detail-screen overlay model).
|
||||
///
|
||||
/// We carry only the fields the UI actually renders (likes/dislikes). RYD's
|
||||
/// own `rating` (a 0-5 restatement of the like/dislike ratio) and `viewCount`
|
||||
/// (RYD's stale aggregate — the real YouTube count from `videoDetails` is what
|
||||
/// the detail screen already shows) are intentionally NOT surfaced: rendering
|
||||
/// either would be redundant or actively misleading, so they don't cross the
|
||||
/// FFI (audit #2 L-9 — slimmed the dead carry-through).
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct RydVotes {
|
||||
pub id: String,
|
||||
pub likes: i64,
|
||||
pub dislikes: i64,
|
||||
pub rating: f64,
|
||||
pub view_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RydVotesWire {
|
||||
// `id` is required (no default) to match the Kotlin original, whose
|
||||
// non-nullable `id: String` made a response missing `id` fail to parse
|
||||
// and return null. likes/dislikes/rating/viewCount keep defaults — the
|
||||
// Kotlin data class defaulted those.
|
||||
// and return null. likes/dislikes keep defaults — the Kotlin data class
|
||||
// defaulted those. The JSON's `rating`/`viewCount` are simply not read
|
||||
// (serde ignores unknown fields here) — see RydVotes above.
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
likes: i64,
|
||||
#[serde(default)]
|
||||
dislikes: i64,
|
||||
#[serde(default)]
|
||||
rating: f64,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "viewCount")]
|
||||
view_count: i64,
|
||||
}
|
||||
|
||||
/// GET https://returnyoutubedislikeapi.com/votes?videoId=<id>
|
||||
|
|
@ -143,8 +144,6 @@ pub async fn fetch_ryd_votes(video_id: String) -> Option<RydVotes> {
|
|||
id: wire.id,
|
||||
likes: wire.likes,
|
||||
dislikes: wire.dislikes,
|
||||
rating: wire.rating,
|
||||
view_count: wire.view_count,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue