vc=59: per-store cache caps + TTL + Clear all caches
User-facing cache controls Cobb specifically asked for. Each SharedPreferences-backed store now reads its cap from Settings instead of a hardcoded constant: - History watches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 50) - History searches: 50 / 200 / 1000 / 10k / Unlimited (was fixed 20) - Resume positions: same options (was fixed 500) - Search results cache: same options (was fixed 30 queries) Each store also enforces a hard ceiling (100k for History + Resume, 5k for SearchCache) so Unlimited doesn't OOM SP on a hostile import. New global Cache TTL: 1 day / 7 days / 30 days / 1 year / Forever. Drops subs feed + search cache entries older than the cutoff on every read. Defaults to 30 days. Settings UI — new 'Cache & history limits' section inside the existing Local cache block with one chip-row per cap + the TTL chip-row + a 'Clear all caches' button that nukes FeedCache, SearchCache, ResumePositions, History.watches, History.searches on one tap.
This commit is contained in:
parent
7fff36c5e3
commit
2e75938f4e
7 changed files with 296 additions and 29 deletions
|
|
@ -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 = 58
|
||||
const val STRAW_VERSION_NAME = "0.1.0-BR"
|
||||
const val STRAW_VERSION_CODE = 59
|
||||
const val STRAW_VERSION_NAME = "0.1.0-BS"
|
||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||
|
|
|
|||
|
|
@ -38,10 +38,19 @@ class FeedCacheStore(context: Context) {
|
|||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** Snapshot of the disk cache. Returns empty map if nothing saved. */
|
||||
/**
|
||||
* Snapshot of the disk cache, filtered by the user-configured TTL.
|
||||
* Returns empty map if nothing saved or everything expired. vc=59 —
|
||||
* Settings.cacheTtl.isForever short-circuits the filter; finite TTLs
|
||||
* drop entries whose fetchedAt is older than (now - ttl).
|
||||
*/
|
||||
fun load(): Map<String, FeedCacheEntry> = runCatching {
|
||||
val s = sp.getString(KEY, null) ?: return emptyMap()
|
||||
json.decodeFromString<Map<String, FeedCacheEntry>>(s)
|
||||
val raw = json.decodeFromString<Map<String, FeedCacheEntry>>(s)
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return raw
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
raw.filterValues { it.fetchedAt >= cutoff }
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
/** Atomic write. Caller is responsible for diffing if needed. */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* Recent watches + recent searches backed by SharedPreferences JSON
|
||||
* blobs. Capped to MAX_WATCHES / MAX_SEARCHES. Graduates to Room when
|
||||
* blobs. Capped to maxWatches() / maxSearches(). Graduates to Room when
|
||||
* a real query pattern (date ranges, full-text search) shows up.
|
||||
*/
|
||||
|
||||
|
|
@ -31,13 +31,30 @@ data class WatchHistoryItem(
|
|||
private const val PREFS = "straw_history"
|
||||
private const val KEY_WATCHES = "watches_v1"
|
||||
private const val KEY_SEARCHES = "searches_v1"
|
||||
private const val MAX_WATCHES = 50
|
||||
private const val MAX_SEARCHES = 20
|
||||
|
||||
/**
|
||||
* Pre-vc=59 hard limits. Still used as the absolute upper bound when
|
||||
* Settings.historyWatchesCap is CacheCap.Unlimited — we don't want to
|
||||
* allow truly-uncapped growth that could OOM SP on a hostile import.
|
||||
* Any user-picked cap above this is silently floored to MAX_*_HARD.
|
||||
*/
|
||||
private const val maxWatches()_HARD = 100_000
|
||||
private const val maxSearches()_HARD = 100_000
|
||||
|
||||
class HistoryStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun maxWatches(): Int {
|
||||
val cap = Settings.get().historyWatchesCap.value.value
|
||||
return cap.coerceAtMost(maxWatches()_HARD)
|
||||
}
|
||||
|
||||
private fun maxSearches(): Int {
|
||||
val cap = Settings.get().historySearchesCap.value.value
|
||||
return cap.coerceAtMost(maxSearches()_HARD)
|
||||
}
|
||||
|
||||
private val _watches = MutableStateFlow(loadWatches())
|
||||
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
|
||||
|
||||
|
|
@ -51,7 +68,7 @@ class HistoryStore(context: Context) {
|
|||
// is exactly the bug updateAndGet avoids.
|
||||
val next = _watches.updateAndGet { current ->
|
||||
val without = current.filterNot { it.videoId == item.videoId }
|
||||
(listOf(now) + without).take(MAX_WATCHES)
|
||||
(listOf(now) + without).take(maxWatches())
|
||||
}
|
||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
|
@ -63,7 +80,7 @@ class HistoryStore(context: Context) {
|
|||
*
|
||||
* Walks input newest-first (input is fed oldest-first), filters
|
||||
* blanks + already-seen videoIds, prepends to current, then takes
|
||||
* MAX_WATCHES. Imports WIN over older current entries when the
|
||||
* maxWatches(). Imports WIN over older current entries when the
|
||||
* store is at the cap — the vc=37 first cut silently discarded
|
||||
* the whole import in that case (round-3 audit HIGH-1).
|
||||
*
|
||||
|
|
@ -76,7 +93,7 @@ class HistoryStore(context: Context) {
|
|||
* store on this call (counts new videoIds; duplicates of
|
||||
* already-recorded entries don't count). Round-4 audit HIGH-7 —
|
||||
* SettingsImport previously reported `size_after - size_before`
|
||||
* which lies when the store was at MAX_WATCHES (post-state can
|
||||
* which lies when the store was at maxWatches() (post-state can
|
||||
* be 50 = pre-state even when 20 imports landed and 20 older
|
||||
* locals were truncated to make room).
|
||||
*/
|
||||
|
|
@ -92,11 +109,11 @@ class HistoryStore(context: Context) {
|
|||
val seen = HashSet<String>(current.size + items.size)
|
||||
current.forEach { seen.add(it.videoId) }
|
||||
// Build the import list newest-first. Capped at
|
||||
// MAX_WATCHES on its own so we don't over-allocate
|
||||
// maxWatches() on its own so we don't over-allocate
|
||||
// even on a 50k-row hostile export.
|
||||
val fresh = ArrayList<WatchHistoryItem>(MAX_WATCHES)
|
||||
val fresh = ArrayList<WatchHistoryItem>(maxWatches())
|
||||
val it = items.listIterator(items.size)
|
||||
while (it.hasPrevious() && fresh.size < MAX_WATCHES) {
|
||||
while (it.hasPrevious() && fresh.size < maxWatches()) {
|
||||
val item = it.previous()
|
||||
if (item.videoId.isBlank()) continue
|
||||
if (!seen.add(item.videoId)) continue
|
||||
|
|
@ -105,8 +122,8 @@ class HistoryStore(context: Context) {
|
|||
}
|
||||
if (fresh.isEmpty()) return@updateAndGet current
|
||||
// Combine + cap. take() truncates older `current` entries
|
||||
// when we'd exceed MAX_WATCHES, so imports always land.
|
||||
(fresh + current).take(MAX_WATCHES)
|
||||
// when we'd exceed maxWatches(), so imports always land.
|
||||
(fresh + current).take(maxWatches())
|
||||
}
|
||||
if (next !== before) {
|
||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||
|
|
@ -133,9 +150,9 @@ class HistoryStore(context: Context) {
|
|||
counter.set(0)
|
||||
val seen = HashSet<String>(current.size + queries.size)
|
||||
current.forEach { seen.add(it.lowercase()) }
|
||||
val fresh = ArrayList<String>(MAX_SEARCHES)
|
||||
val fresh = ArrayList<String>(maxSearches())
|
||||
val it = queries.listIterator(queries.size)
|
||||
while (it.hasPrevious() && fresh.size < MAX_SEARCHES) {
|
||||
while (it.hasPrevious() && fresh.size < maxSearches()) {
|
||||
val q = it.previous().trim()
|
||||
if (q.isEmpty()) continue
|
||||
if (!seen.add(q.lowercase())) continue
|
||||
|
|
@ -143,7 +160,7 @@ class HistoryStore(context: Context) {
|
|||
counter.incrementAndGet()
|
||||
}
|
||||
if (fresh.isEmpty()) return@updateAndGet current
|
||||
(fresh + current).take(MAX_SEARCHES)
|
||||
(fresh + current).take(maxSearches())
|
||||
}
|
||||
if (next !== before) {
|
||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||
|
|
@ -156,7 +173,7 @@ class HistoryStore(context: Context) {
|
|||
if (q.isEmpty()) return
|
||||
val next = _searches.updateAndGet { current ->
|
||||
val without = current.filterNot { it.equals(q, ignoreCase = true) }
|
||||
(listOf(q) + without).take(MAX_SEARCHES)
|
||||
(listOf(q) + without).take(maxSearches())
|
||||
}
|
||||
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
* on player teardown, keyed by videoId so resume works across stream
|
||||
* URL rotations (googlevideo URLs rotate per session).
|
||||
*
|
||||
* SharedPreferences-lite, single JSON blob, capped at MAX_RESUMES with
|
||||
* SharedPreferences-lite, single JSON blob, capped at maxResumes() with
|
||||
* oldest-eviction. Same shape as HistoryStore — graduates to Room if a
|
||||
* real query pattern shows up.
|
||||
*/
|
||||
|
|
@ -34,8 +34,14 @@ data class ResumePosition(
|
|||
private const val PREFS = "straw_resume_positions"
|
||||
private const val KEY_POSITIONS = "positions_v1"
|
||||
|
||||
/** Cap on retained per-video resume entries — prune oldest on overflow. */
|
||||
private const val MAX_RESUMES = 500
|
||||
/**
|
||||
* Pre-vc=59 hard cap. Now a ceiling rather than a fixed value: the
|
||||
* user-picked cap from Settings.resumePositionsCap is silently floored
|
||||
* to this so even "Unlimited" doesn't OOM SP. Bigger ceiling here
|
||||
* than HistoryStore because resume entries are tiny (~50 bytes each)
|
||||
* vs WatchHistoryItem's ~250 bytes.
|
||||
*/
|
||||
private const val maxResumes()_HARD = 100_000
|
||||
|
||||
/**
|
||||
* Skip writes for trivial positions — auto-resuming from 0:03 is more
|
||||
|
|
@ -58,6 +64,11 @@ class ResumePositionsStore(context: Context) {
|
|||
private val _positions = MutableStateFlow(load())
|
||||
val positions: StateFlow<Map<String, ResumePosition>> = _positions.asStateFlow()
|
||||
|
||||
private fun maxResumes(): Int {
|
||||
val cap = Settings.get().resumePositionsCap.value.value
|
||||
return cap.coerceAtMost(maxResumes()_HARD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record (or update) the scrub-point for a video. Skipped silently
|
||||
* when:
|
||||
|
|
@ -84,13 +95,13 @@ class ResumePositionsStore(context: Context) {
|
|||
val before = _positions.value
|
||||
val next = _positions.updateAndGet { current ->
|
||||
val withEntry = current + (videoId to entry)
|
||||
if (withEntry.size > MAX_RESUMES) {
|
||||
if (withEntry.size > maxResumes()) {
|
||||
// Drop oldest by lastWatchedAt — newcomers always land
|
||||
// because the entry we just added is by definition the
|
||||
// freshest. take(MAX_RESUMES) of the sorted-desc list.
|
||||
// freshest. take(maxResumes()) of the sorted-desc list.
|
||||
withEntry.entries
|
||||
.sortedByDescending { it.value.lastWatchedAt }
|
||||
.take(MAX_RESUMES)
|
||||
.take(maxResumes())
|
||||
.associate { it.key to it.value }
|
||||
} else {
|
||||
withEntry
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ data class SearchCacheEntry(
|
|||
|
||||
private const val PREFS = "straw_search_cache"
|
||||
private const val KEY = "search_v1"
|
||||
private const val MAX_QUERIES = 30
|
||||
private const val maxQueries()_HARD = 5000
|
||||
private const val MAX_ITEMS_PER_QUERY = 20
|
||||
|
||||
class SearchCacheStore(context: Context) {
|
||||
|
|
@ -51,13 +51,29 @@ class SearchCacheStore(context: Context) {
|
|||
private val _entries = MutableStateFlow(loadFromDisk())
|
||||
val entries: StateFlow<List<SearchCacheEntry>> = _entries.asStateFlow()
|
||||
|
||||
private fun maxQueries(): Int =
|
||||
Settings.get().searchCacheCap.value.value.coerceAtMost(maxQueries()_HARD)
|
||||
|
||||
/**
|
||||
* Filter out entries older than the configured TTL. Called on every
|
||||
* read path so stale data never surfaces. Forever (ttl.isForever)
|
||||
* is a no-op. Returns a fresh list — caller decides whether to
|
||||
* persist the trim.
|
||||
*/
|
||||
private fun filterByTtl(items: List<SearchCacheEntry>): List<SearchCacheEntry> {
|
||||
val ttl = Settings.get().cacheTtl.value
|
||||
if (ttl.isForever) return items
|
||||
val cutoff = System.currentTimeMillis() - ttl.ms
|
||||
return items.filter { it.fetchedAt >= cutoff }
|
||||
}
|
||||
|
||||
/** Snapshot of the cache. Used by the reactive search filter. */
|
||||
fun load(): List<SearchCacheEntry> = _entries.value
|
||||
fun load(): List<SearchCacheEntry> = filterByTtl(_entries.value)
|
||||
|
||||
/**
|
||||
* Record a freshly-fetched query result. Idempotent: a re-run of
|
||||
* the same query overwrites the prior entry rather than duplicating.
|
||||
* Oldest entries fall off when MAX_QUERIES is exceeded.
|
||||
* Oldest entries fall off when maxQueries() is exceeded.
|
||||
*
|
||||
* Atomic via updateAndGet — concurrent records don't lose entries.
|
||||
*/
|
||||
|
|
@ -68,7 +84,7 @@ class SearchCacheStore(context: Context) {
|
|||
val now = System.currentTimeMillis()
|
||||
val next = _entries.updateAndGet { current ->
|
||||
val without = current.filterNot { it.query.equals(q, ignoreCase = true) }
|
||||
(listOf(SearchCacheEntry(q, now, capped)) + without).take(MAX_QUERIES)
|
||||
(listOf(SearchCacheEntry(q, now, capped)) + without).take(maxQueries())
|
||||
}
|
||||
sp.edit().putString(KEY, json.encodeToString(next)).apply()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,41 @@ enum class AutoUpdateInterval(val label: String) {
|
|||
H24("Every 24 hours"),
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing cache caps. Each store's hard limit is the cap's value;
|
||||
* `Int.MAX_VALUE` means "unlimited" (the store grows without trimming).
|
||||
* Defaults match the pre-vc=59 hardcoded constants so existing data
|
||||
* keeps the same shape until the user picks something different.
|
||||
*/
|
||||
enum class CacheCap(val label: String, val value: Int) {
|
||||
Tiny("50", 50),
|
||||
Small("200", 200),
|
||||
Medium("1000", 1000),
|
||||
Large("10000", 10000),
|
||||
Unlimited("Unlimited", Int.MAX_VALUE);
|
||||
|
||||
companion object {
|
||||
fun nearest(target: Int): CacheCap =
|
||||
entries.firstOrNull { it.value == target } ?: Unlimited
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL knob for time-decayed caches (subs feed + search results). 0
|
||||
* means "forever" — entries never time out and only fall off via
|
||||
* size cap. Shorter TTLs reclaim disk on devices with tight storage.
|
||||
*/
|
||||
enum class CacheTtl(val label: String, val days: Int) {
|
||||
D1("1 day", 1),
|
||||
D7("7 days", 7),
|
||||
D30("30 days", 30),
|
||||
D365("1 year", 365),
|
||||
Forever("Forever", 0);
|
||||
|
||||
val isForever: Boolean get() = days == 0
|
||||
val ms: Long get() = days.toLong() * 24L * 60L * 60L * 1000L
|
||||
}
|
||||
|
||||
private const val PREFS = "straw_settings"
|
||||
private const val KEY_SB_CATS = "sb_categories_v1"
|
||||
private const val KEY_MAX_RES = "max_resolution_v1"
|
||||
|
|
@ -85,6 +120,11 @@ private const val KEY_LAST_UPDATE_CHECK_MS = "last_update_check_ms_v1"
|
|||
private const val KEY_LATEST_KNOWN_VC = "latest_known_vc_v1"
|
||||
private const val KEY_LATEST_KNOWN_VNAME = "latest_known_vname_v1"
|
||||
private const val KEY_HIDE_SHORTS = "hide_shorts_v1"
|
||||
private const val KEY_CACHE_HISTORY_WATCHES = "cache_history_watches_v1"
|
||||
private const val KEY_CACHE_HISTORY_SEARCHES = "cache_history_searches_v1"
|
||||
private const val KEY_CACHE_RESUME_POSITIONS = "cache_resume_positions_v1"
|
||||
private const val KEY_CACHE_SEARCH = "cache_search_v1"
|
||||
private const val KEY_CACHE_TTL = "cache_ttl_v1"
|
||||
|
||||
class SettingsStore(context: Context) {
|
||||
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
|
@ -198,6 +238,38 @@ class SettingsStore(context: Context) {
|
|||
)
|
||||
val hideShorts: StateFlow<Boolean> = _hideShorts.asStateFlow()
|
||||
|
||||
/**
|
||||
* Per-store cache caps. Each store reads its cap from the matching
|
||||
* StateFlow on every prune cycle so flipping the toggle in Settings
|
||||
* takes effect immediately (next write trims to the new cap; reads
|
||||
* are unbounded since they're already in memory).
|
||||
*
|
||||
* Defaults match the pre-vc=59 hardcoded constants so first-launch
|
||||
* behavior is unchanged from prior versions.
|
||||
*/
|
||||
private val _historyWatchesCap = MutableStateFlow(
|
||||
CacheCap.nearest(sp.getInt(KEY_CACHE_HISTORY_WATCHES, 50)),
|
||||
)
|
||||
val historyWatchesCap: StateFlow<CacheCap> = _historyWatchesCap.asStateFlow()
|
||||
|
||||
private val _historySearchesCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_HISTORY_SEARCHES, default = 20),
|
||||
)
|
||||
val historySearchesCap: StateFlow<CacheCap> = _historySearchesCap.asStateFlow()
|
||||
|
||||
private val _resumePositionsCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_RESUME_POSITIONS, default = 500),
|
||||
)
|
||||
val resumePositionsCap: StateFlow<CacheCap> = _resumePositionsCap.asStateFlow()
|
||||
|
||||
private val _searchCacheCap = MutableStateFlow(
|
||||
loadCap(KEY_CACHE_SEARCH, default = 30),
|
||||
)
|
||||
val searchCacheCap: StateFlow<CacheCap> = _searchCacheCap.asStateFlow()
|
||||
|
||||
private val _cacheTtl = MutableStateFlow(loadCacheTtl())
|
||||
val cacheTtl: StateFlow<CacheTtl> = _cacheTtl.asStateFlow()
|
||||
|
||||
fun toggle(cat: SbCategory) {
|
||||
// Atomic toggle via updateAndGet — see AUD-HIGH note in HistoryStore.
|
||||
val next = _sbCategories.updateAndGet { cur ->
|
||||
|
|
@ -302,6 +374,44 @@ class SettingsStore(context: Context) {
|
|||
sp.edit().putBoolean(KEY_HIDE_SHORTS, hide).apply()
|
||||
}
|
||||
|
||||
fun setHistoryWatchesCap(cap: CacheCap) {
|
||||
if (_historyWatchesCap.value == cap) return
|
||||
_historyWatchesCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_HISTORY_WATCHES, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setHistorySearchesCap(cap: CacheCap) {
|
||||
if (_historySearchesCap.value == cap) return
|
||||
_historySearchesCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_HISTORY_SEARCHES, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setResumePositionsCap(cap: CacheCap) {
|
||||
if (_resumePositionsCap.value == cap) return
|
||||
_resumePositionsCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_RESUME_POSITIONS, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setSearchCacheCap(cap: CacheCap) {
|
||||
if (_searchCacheCap.value == cap) return
|
||||
_searchCacheCap.value = cap
|
||||
sp.edit().putInt(KEY_CACHE_SEARCH, cap.value).apply()
|
||||
}
|
||||
|
||||
fun setCacheTtl(ttl: CacheTtl) {
|
||||
if (_cacheTtl.value == ttl) return
|
||||
_cacheTtl.value = ttl
|
||||
sp.edit().putString(KEY_CACHE_TTL, ttl.name).apply()
|
||||
}
|
||||
|
||||
private fun loadCap(key: String, default: Int): CacheCap =
|
||||
CacheCap.nearest(sp.getInt(key, default))
|
||||
|
||||
private fun loadCacheTtl(): CacheTtl {
|
||||
val name = sp.getString(KEY_CACHE_TTL, null) ?: return CacheTtl.D30
|
||||
return CacheTtl.entries.firstOrNull { it.name == name } ?: CacheTtl.D30
|
||||
}
|
||||
|
||||
private fun loadCategories(): Set<SbCategory> {
|
||||
val raw = sp.getStringSet(KEY_SB_CATS, null)
|
||||
return if (raw == null) {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
import androidx.compose.material3.FilterChip
|
||||
import com.sulkta.straw.BuildConfig
|
||||
import com.sulkta.straw.data.AutoUpdateInterval
|
||||
import com.sulkta.straw.data.CacheCap
|
||||
import com.sulkta.straw.data.CacheTtl
|
||||
import com.sulkta.straw.data.FeedCache
|
||||
import com.sulkta.straw.data.Resume
|
||||
import com.sulkta.straw.feature.update.UpdateScheduler
|
||||
import com.sulkta.straw.feature.update.runUpdateCheck
|
||||
import com.sulkta.straw.util.formatRelativeSince
|
||||
|
|
@ -529,6 +532,65 @@ fun SettingsScreen() {
|
|||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Cache & history limits",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
"Pick how much to keep. Unlimited = no auto-pruning. Old " +
|
||||
"entries beyond a TTL are dropped on read.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CacheCapRow(
|
||||
label = "Watch history",
|
||||
selected = store.historyWatchesCap.collectAsState().value,
|
||||
onPick = { store.setHistoryWatchesCap(it) },
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Search history",
|
||||
selected = store.historySearchesCap.collectAsState().value,
|
||||
onPick = { store.setHistorySearchesCap(it) },
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Resume positions",
|
||||
selected = store.resumePositionsCap.collectAsState().value,
|
||||
onPick = { store.setResumePositionsCap(it) },
|
||||
)
|
||||
CacheCapRow(
|
||||
label = "Search results cache",
|
||||
selected = store.searchCacheCap.collectAsState().value,
|
||||
onPick = { store.setSearchCacheCap(it) },
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Cache TTL",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Drop subs feed + search cache entries older than this.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val ttl by store.cacheTtl.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CacheTtl.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = ttl == opt,
|
||||
onClick = { store.setCacheTtl(opt) },
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
"History",
|
||||
|
|
@ -544,6 +606,20 @@ fun SettingsScreen() {
|
|||
Text("Clear searches")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { FeedCache.get().clear() }
|
||||
runCatching { SearchCache.get().clear() }
|
||||
runCatching { Resume.get().clearAll() }
|
||||
runCatching { History.get().clearWatches() }
|
||||
runCatching { History.get().clearSearches() }
|
||||
}
|
||||
}
|
||||
},
|
||||
) { Text("Clear all caches") }
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Text(
|
||||
|
|
@ -665,3 +741,31 @@ private fun CategoryRow(
|
|||
Switch(checked = enabled, onCheckedChange = { onToggle() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip-group row for picking a CacheCap. Label on the left,
|
||||
* 5 chips on the right. Used four times in the Cache section so the
|
||||
* shape is consolidated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun CacheCapRow(
|
||||
label: String,
|
||||
selected: CacheCap,
|
||||
onPick: (CacheCap) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
CacheCap.entries.forEach { opt ->
|
||||
FilterChip(
|
||||
selected = selected == opt,
|
||||
onClick = { onPick(opt) },
|
||||
label = { Text(opt.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue