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
41b3999ad3
commit
7c90f70a0a
7 changed files with 150 additions and 25 deletions
|
|
@ -9,6 +9,33 @@ const val STRAW_SDK_TARGET = 35
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// 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. (a tester, 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:
|
// 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
|
// * Hide-shorts no longer drains a whole channel/search in one burst. The
|
||||||
// infinite-scroll trigger is computed from the FILTERED list while loadMore()
|
// 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
|
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
|
||||||
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 89
|
const val STRAW_VERSION_CODE = 90
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-CW"
|
const val STRAW_VERSION_NAME = "0.1.0-CX"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
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
|
/// Vote counts from the Return-YouTube-Dislike API. Kotlin maps this onto
|
||||||
/// its own `net.RydVotes` data class (the detail-screen overlay model).
|
/// 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)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct RydVotes {
|
pub struct RydVotes {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub likes: i64,
|
pub likes: i64,
|
||||||
pub dislikes: i64,
|
pub dislikes: i64,
|
||||||
pub rating: f64,
|
|
||||||
pub view_count: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RydVotesWire {
|
struct RydVotesWire {
|
||||||
// `id` is required (no default) to match the Kotlin original, whose
|
// `id` is required (no default) to match the Kotlin original, whose
|
||||||
// non-nullable `id: String` made a response missing `id` fail to parse
|
// non-nullable `id: String` made a response missing `id` fail to parse
|
||||||
// and return null. likes/dislikes/rating/viewCount keep defaults — the
|
// and return null. likes/dislikes keep defaults — the Kotlin data class
|
||||||
// Kotlin data class defaulted those.
|
// defaulted those. The JSON's `rating`/`viewCount` are simply not read
|
||||||
|
// (serde ignores unknown fields here) — see RydVotes above.
|
||||||
id: String,
|
id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
likes: i64,
|
likes: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
dislikes: i64,
|
dislikes: i64,
|
||||||
#[serde(default)]
|
|
||||||
rating: f64,
|
|
||||||
#[serde(default)]
|
|
||||||
#[serde(rename = "viewCount")]
|
|
||||||
view_count: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET https://returnyoutubedislikeapi.com/votes?videoId=<id>
|
/// 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,
|
id: wire.id,
|
||||||
likes: wire.likes,
|
likes: wire.likes,
|
||||||
dislikes: wire.dislikes,
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.flow.updateAndGet
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
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.
|
// could land out of order. The write reads the live map so disk converges.
|
||||||
private val writer = PrefsWriter(sp)
|
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()
|
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
|
* Return a fresh enrichment for this videoId, or null when missing
|
||||||
* or aged out per Settings.cacheTtl. Forever-TTL never expires.
|
* 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) {
|
fun put(videoId: String, viewCount: Long, durationSeconds: Long) {
|
||||||
|
if (!hydrated) { writer.serial { put(videoId, viewCount, durationSeconds) }; return }
|
||||||
if (videoId.isBlank()) return
|
if (videoId.isBlank()) return
|
||||||
// Don't write all-zero entries — that's failure not data, and
|
// Don't write all-zero entries — that's failure not data, and
|
||||||
// would waste a slot the cap could spend on a real hit.
|
// would waste a slot the cap could spend on a real hit.
|
||||||
|
|
@ -107,8 +133,11 @@ class EnrichmentStore(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
if (!hydrated) { writer.serial { clear() }; return }
|
||||||
|
val before = _entries.value
|
||||||
_entries.updateAndGet { emptyMap() }
|
_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 {
|
private fun load(): Map<String, Enrichment> = runCatching {
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,28 @@ class PrefsWriter(private val sp: SharedPreferences) {
|
||||||
editor.apply()
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.flow.updateAndGet
|
import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
@ -62,9 +63,39 @@ class ResumePositionsStore(context: Context) {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val writer = PrefsWriter(sp)
|
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()
|
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 {
|
private fun maxResumes(): Int {
|
||||||
val cap = Settings.get().resumePositionsCap.value.value
|
val cap = Settings.get().resumePositionsCap.value.value
|
||||||
return cap.coerceAtMost(MAX_RESUMES_HARD)
|
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.
|
* REMOVED so a finished video doesn't auto-resume to its credits.
|
||||||
*/
|
*/
|
||||||
fun record(videoId: String, positionMs: Long, durationMs: Long) {
|
fun record(videoId: String, positionMs: Long, durationMs: Long) {
|
||||||
|
if (!hydrated) { writer.serial { record(videoId, positionMs, durationMs) }; return }
|
||||||
if (videoId.isBlank()) return
|
if (videoId.isBlank()) return
|
||||||
if (durationMs <= 0L) return
|
if (durationMs <= 0L) return
|
||||||
if (positionMs < MIN_POSITION_MS) 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? {
|
fun get(videoId: String): ResumePosition? {
|
||||||
if (videoId.isBlank()) return null
|
if (videoId.isBlank()) return null
|
||||||
return _positions.value[videoId]
|
return _positions.value[videoId]
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearOne(videoId: String) {
|
fun clearOne(videoId: String) {
|
||||||
|
if (!hydrated) { writer.serial { clearOne(videoId) }; return }
|
||||||
if (videoId.isBlank()) return
|
if (videoId.isBlank()) return
|
||||||
val before = _positions.value
|
val before = _positions.value
|
||||||
val next = _positions.updateAndGet { current ->
|
val next = _positions.updateAndGet { current ->
|
||||||
|
|
@ -156,6 +197,7 @@ class ResumePositionsStore(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearAll() {
|
fun clearAll() {
|
||||||
|
if (!hydrated) { writer.serial { clearAll() }; return }
|
||||||
val before = _positions.value
|
val before = _positions.value
|
||||||
_positions.updateAndGet { emptyMap() }
|
_positions.updateAndGet { emptyMap() }
|
||||||
if (before.isNotEmpty()) {
|
if (before.isNotEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -143,9 +143,16 @@ fun VideoDetailBody(
|
||||||
// now recycle and defer their image decode + 2 progress-overlay flow
|
// now recycle and defer their image decode + 2 progress-overlay flow
|
||||||
// collectors until scrolled into view, instead of mounting ~40 rows
|
// collectors until scrolled into view, instead of mounting ~40 rows
|
||||||
// (~40 decodes + ~80 collectors) eagerly on every video open.
|
// (~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 {
|
when {
|
||||||
state.loading -> item {
|
state.loading -> item {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,19 @@ data class RydVotes(
|
||||||
val id: String,
|
val id: String,
|
||||||
val likes: Long = 0,
|
val likes: Long = 0,
|
||||||
val dislikes: Long = 0,
|
val dislikes: Long = 0,
|
||||||
val rating: Double = 0.0,
|
|
||||||
val viewCount: Long = 0,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
object RydClient {
|
object RydClient {
|
||||||
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
/** Suspends on the Rust async runtime; call from a coroutine. Returns
|
||||||
* null on any failure (transport / non-2xx / bad JSON), same contract
|
* 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? =
|
suspend fun fetch(videoId: String): RydVotes? =
|
||||||
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
|
uniffi.strawcore.fetchRydVotes(videoId)?.let { v ->
|
||||||
RydVotes(
|
RydVotes(
|
||||||
id = v.id,
|
id = v.id,
|
||||||
likes = v.likes,
|
likes = v.likes,
|
||||||
dislikes = v.dislikes,
|
dislikes = v.dislikes,
|
||||||
rating = v.rating,
|
|
||||||
viewCount = v.viewCount,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue