diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index d1c34cdeb..ad7693187 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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" diff --git a/rust/strawcore/src/net.rs b/rust/strawcore/src/net.rs index 95ef78646..b95a13424 100644 --- a/rust/strawcore/src/net.rs +++ b/rust/strawcore/src/net.rs @@ -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= @@ -143,8 +144,6 @@ pub async fn fetch_ryd_votes(video_id: String) -> Option { id: wire.id, likes: wire.likes, dislikes: wire.dislikes, - rating: wire.rating, - view_count: wire.view_count, }) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt index 6d564642d..a4360f69b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/EnrichmentStore.kt @@ -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>(emptyMap()) val entries: StateFlow> = _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 = runCatching { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/PrefsWriter.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/PrefsWriter.kt index d4f6f21ab..469931b57 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/PrefsWriter.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/PrefsWriter.kt @@ -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() } + } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt index 81bb83916..f1c431217 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/ResumePositionsStore.kt @@ -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>(emptyMap()) val positions: StateFlow> = _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()) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt index b98a067b9..7a7bb79d8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailBody.kt @@ -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( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 0af9811be..4a0a581af 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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, ) } }