From 69560889ae2b4399fb75607ceef7d013af2dfbb5 Mon Sep 17 00:00:00 2001 From: Kayos Date: Mon, 25 May 2026 12:50:13 -0700 Subject: [PATCH] vc=33: persistent feed cache + strawcore avatar fix (via strawcore-core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-avatar issue and the sluggish-load issue both addressed. strawcore-core (Sulkta-Coop/strawcore @ 7c71511) — separate repo, already committed + pushed: Channel parser was only pulling avatar from the legacy c4TabbedHeaderRenderer branch. The newer pageHeaderRenderer (most channels with a 2024+ refreshed header — including WTYP) was returning empty avatars. Added a deep-nested ViewModel walk for pageHeaderRenderer, plus a metadata.channelMetadataRenderer .avatar.thumbnails[] backfill. WTYP and other re-headered channels should now show their icon. Persistent feed cache (data/FeedCacheStore.kt): New SharedPreferences-backed JSON store. ~225 KB at the upper bound (30 subs * 30 items * ~250 bytes), well within SP's comfort zone. Survives process death. SubscriptionFeedViewModel: Hydrates from FeedCacheStore on init. The Subs tab now paints cached items in one frame on cold start, then refreshes stale channels in the background. Persists the cache after each successful refresh via Dispatchers.IO. Per-channel TTL bumped 10min → 30min (disk cache amortizes the cost; stale-from-disk + background-refresh feels like instant). Per-channel timeout 15s → 10s (slow channel rides cached value instead of stalling the batch). Parallelism 8 → 12 (less network-bound now that UI doesn't wait for first byte). StreamItem now @Serializable so the cache can encode it. FeedCache.init wired into StrawApp.onCreate alongside the other SharedPreferences-backed stores. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../feature/feed/SubscriptionFeedViewModel.kt | 62 ++++++++++++++++--- .../straw/feature/search/SearchViewModel.kt | 1 + 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e6a729b1a..9319ebcd7 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // 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 = 32 -const val STRAW_VERSION_NAME = "0.1.0-AR" +const val STRAW_VERSION_CODE = 33 +const val STRAW_VERSION_NAME = "0.1.0-AS" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index ca36407c9..95be0a44e 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -6,6 +6,7 @@ package com.sulkta.straw import android.app.Application +import com.sulkta.straw.data.FeedCache import com.sulkta.straw.data.History import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Settings @@ -23,5 +24,6 @@ class StrawApp : Application() { Settings.init(this) Subscriptions.init(this) Playlists.init(this) + FeedCache.init(this) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index bb6ce845d..03eb764f7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -19,9 +19,12 @@ package com.sulkta.straw.feature.feed import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.ChannelRef +import com.sulkta.straw.data.FeedCache +import com.sulkta.straw.data.FeedCacheEntry import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.strawLogW +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -33,6 +36,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap @@ -47,18 +51,52 @@ class SubscriptionFeedViewModel : ViewModel() { private val _ui = MutableStateFlow(SubscriptionFeedUiState()) val ui: StateFlow = _ui.asStateFlow() - /** Per-channel cache: each entry refreshes independently. */ - private data class ChannelCacheEntry(val fetchedAt: Long, val items: List) - private val channelCache = ConcurrentHashMap() + /** + * Per-channel cache: each entry refreshes independently. Hydrated + * from disk on init via FeedCacheStore so cold app starts can show + * the last successful fetch instantly. ConcurrentHashMap because + * fetchChannelInto writes concurrently from the per-channel + * coroutines; mergeFromCache and refreshIfStale read. + */ + private val channelCache = ConcurrentHashMap() /** Per-channel TTL — Refresh just re-fetches stale entries. */ - private val perChannelTtlMs = 10L * 60 * 1000 + private val perChannelTtlMs = 30L * 60 * 1000 - /** Per-channel fetch timeout — slowest channel can't stall the batch. */ - private val perChannelTimeoutMs = 15_000L + init { + // Hydrate from disk and immediately render the cached items so + // the Subs tab paints in one frame instead of after the network + // round-trip. The refresh that follows replaces stale entries + // in-place — items animate to their new positions via LazyColumn + // key stability (URLs are stable across fetches). + val saved = FeedCache.get().load() + if (saved.isNotEmpty()) { + channelCache.putAll(saved) + val channels = Subscriptions.get().subs.value + if (channels.isNotEmpty()) { + _ui.value = _ui.value.copy( + items = mergeFromCache(channels), + lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L, + ) + } + } + } - /** Cap parallel network fetches even with 100+ subs. */ - private val parallelism = 8 + /** + * Per-channel fetch timeout. 10s instead of 15s — a channel that + * hasn't responded in 10s is likely a transient network hiccup or a + * dead channel handle; better to drop it from the batch and ride + * the disk-cache stale value than block the whole feed. + */ + private val perChannelTimeoutMs = 10_000L + + /** + * Parallel network fetches. 12 instead of 8 — with the disk cache + * now buffering UI from network latency, the dominant cost is + * end-to-end batch completion, which is bottle-necked by the + * slowest network round-trip in each parallel group. + */ + private val parallelism = 12 /** * Videos pulled per channel. Bumped from 5 → 30 so "show me @@ -108,6 +146,12 @@ class SubscriptionFeedViewModel : ViewModel() { lastFetchedAt = System.currentTimeMillis(), ) } + // Persist what we just freshened. Off the main thread — + // JSON encode on 30 subs * 30 items is small but not + // free, and SharedPreferences.apply is async anyway. + withContext(Dispatchers.IO) { + runCatching { FeedCache.get().save(channelCache.toMap()) } + } } catch (t: Throwable) { _ui.update { it.copy( @@ -157,7 +201,7 @@ class SubscriptionFeedViewModel : ViewModel() { // leaves any prior cache entry intact, so a glitchy channel // doesn't blank your feed for that channel. if (outcome.isNotEmpty()) { - channelCache[ch.url] = ChannelCacheEntry(System.currentTimeMillis(), outcome) + channelCache[ch.url] = FeedCacheEntry(System.currentTimeMillis(), outcome) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 40ff14603..0b3cc78b3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -20,6 +20,7 @@ data class SearchUiState( val error: String? = null, ) +@kotlinx.serialization.Serializable data class StreamItem( val url: String, val title: String,