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,