Straw phase G: recent watches + search history (SharedPreferences-lite)

New: HistoryStore — JSON-encoded SharedPreferences for two lists. Watches
keyed by videoId (max 50, FIFO), searches deduped case-insensitively (max
20). Surfaced as StateFlow so screens recompose live.

Wiring:
- StrawApp.onCreate: History.init(this)
- VideoDetailViewModel.load: recordWatch after StreamInfo resolves
- SearchViewModel.submit: recordSearch on each query

UI:
- StrawHome: redesigned. Tight inline header, full-width Search button,
  "Recently watched" LazyColumn below. Tap a recent row → VideoDetail.
- SearchScreen: when query is blank, show recent searches as
  AssistChip row. Tap a chip → onQueryChange + submit (instant search).

Day-4 graduates to Room when there's a real query pattern that SP can't
serve (date ranges, full-text search). For now the JSON-blob approach
ships in 30 minutes vs an hour of Room+KSP plumbing.
This commit is contained in:
Kayos 2026-05-23 19:47:29 -07:00
parent f3b78b4530
commit b3a0972909
7 changed files with 262 additions and 22 deletions

View file

@ -57,6 +57,9 @@ class StrawActivity : ComponentActivity() {
when (val s = nav.current) {
is Screen.Home -> StrawHome(
onOpenSearch = { nav.push(Screen.Search) },
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
)
is Screen.Search -> SearchScreen(
onOpenVideo = { url, title ->

View file

@ -6,6 +6,7 @@
package com.sulkta.straw
import android.app.Application
import com.sulkta.straw.data.History
import com.sulkta.straw.extractor.NewPipeDownloader
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.localization.ContentCountry
@ -19,5 +20,6 @@ class StrawApp : Application() {
Localization("en", "US"),
ContentCountry("US"),
)
History.init(this)
}
}

View file

@ -5,43 +5,127 @@
package com.sulkta.straw
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.sulkta.straw.data.History
import com.sulkta.straw.data.WatchHistoryItem
@Composable
fun StrawHome(onOpenSearch: () -> Unit) {
fun StrawHome(
onOpenSearch: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
) {
val watches by History.get().watches.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
) {
Text(
text = "straw",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
)
// Header
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "v0.1.0 — Sulkta-Coop",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(48.dp))
Button(onClick = onOpenSearch) {
Text("Search YouTube")
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "straw",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "v0.1.0",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onOpenSearch,
modifier = Modifier.fillMaxWidth(),
) { Text("Search YouTube") }
Spacer(modifier = Modifier.height(20.dp))
if (watches.isEmpty()) {
Text(
text = "Recently watched videos appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = "Recently watched",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(watches) { w ->
RecentRow(w) { onOpenVideo(w.url, w.title) }
HorizontalDivider()
}
}
}
}
}
@Composable
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = item.thumbnail,
contentDescription = null,
modifier = Modifier
.width(120.dp)
.height(68.dp)
.clip(RoundedCornerShape(6.dp)),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = item.uploader,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-backed recent watches + recent search store. Day-3.
* Day-4 graduates to Room when there's a real query pattern (date ranges,
* full-text search, etc.) that SharedPreferences can't serve.
*/
package com.sulkta.straw.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class WatchHistoryItem(
val url: String,
val videoId: String,
val title: String,
val uploader: String,
val thumbnail: String?,
val watchedAt: Long,
)
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
class HistoryStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _watches = MutableStateFlow(loadWatches())
val watches: StateFlow<List<WatchHistoryItem>> = _watches.asStateFlow()
private val _searches = MutableStateFlow(loadSearches())
val searches: StateFlow<List<String>> = _searches.asStateFlow()
fun recordWatch(item: WatchHistoryItem) {
val now = item.copy(watchedAt = System.currentTimeMillis())
val without = _watches.value.filterNot { it.videoId == item.videoId }
val next = (listOf(now) + without).take(MAX_WATCHES)
_watches.value = next
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
}
fun recordSearch(query: String) {
val q = query.trim()
if (q.isEmpty()) return
val without = _searches.value.filterNot { it.equals(q, ignoreCase = true) }
val next = (listOf(q) + without).take(MAX_SEARCHES)
_searches.value = next
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
}
fun clearWatches() {
_watches.value = emptyList()
sp.edit().remove(KEY_WATCHES).apply()
}
fun clearSearches() {
_searches.value = emptyList()
sp.edit().remove(KEY_SEARCHES).apply()
}
private fun loadWatches(): List<WatchHistoryItem> = runCatching {
val s = sp.getString(KEY_WATCHES, null) ?: return emptyList()
json.decodeFromString<List<WatchHistoryItem>>(s)
}.getOrDefault(emptyList())
private fun loadSearches(): List<String> = runCatching {
val s = sp.getString(KEY_SEARCHES, null) ?: return emptyList()
json.decodeFromString<List<String>>(s)
}.getOrDefault(emptyList())
}
/** App-wide singleton; created in StrawApp.onCreate. */
object History {
@Volatile private var instance: HistoryStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = HistoryStore(context.applicationContext)
}
}
}
fun get(): HistoryStore = instance
?: error("HistoryStore not initialized — call History.init(context)")
}

View file

@ -7,6 +7,8 @@ package com.sulkta.straw.feature.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History
import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import kotlinx.coroutines.Dispatchers
@ -48,6 +50,23 @@ class VideoDetailViewModel : ViewModel() {
try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id
val thumb = info.thumbnails?.firstOrNull()?.url
val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: ""
runCatching {
History.get().recordWatch(
WatchHistoryItem(
url = streamUrl,
videoId = videoId,
title = title,
uploader = uploader,
thumbnail = thumb,
watchedAt = 0L,
),
)
}
val ryd = withContext(Dispatchers.IO) {
runCatching { RydClient.fetch(videoId) }.getOrNull()
}
@ -55,11 +74,11 @@ class VideoDetailViewModel : ViewModel() {
loading = false,
detail = VideoDetail(
id = videoId,
title = info.name ?: "(no title)",
uploader = info.uploaderName ?: "",
title = title,
uploader = uploader,
viewCount = info.viewCount,
description = info.description?.content ?: "",
thumbnail = info.thumbnails?.firstOrNull()?.url,
thumbnail = thumb,
ryd = ryd,
),
streamInfo = info,

View file

@ -36,9 +36,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.AssistChip
import androidx.compose.runtime.collectAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.data.History
@Composable
fun SearchScreen(
@ -46,6 +50,7 @@ fun SearchScreen(
vm: SearchViewModel = viewModel(),
) {
val state by vm.ui.collectAsStateWithLifecycle()
val recentSearches by History.get().searches.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
@ -76,6 +81,33 @@ fun SearchScreen(
)
}
state.results.isEmpty() && state.query.isBlank() -> {
if (recentSearches.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) { Text("Type a query and hit search.") }
} else {
Text(
"Recent searches",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
recentSearches.forEach { q ->
AssistChip(
onClick = {
vm.onQueryChange(q)
vm.submit()
},
label = { Text(q) },
)
}
}
}
}
state.results.isEmpty() && state.query.isNotBlank() -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,

View file

@ -7,6 +7,7 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -45,6 +46,7 @@ class SearchViewModel : ViewModel() {
fun submit() {
val q = _ui.value.query.trim()
if (q.isEmpty()) return
runCatching { History.get().recordSearch(q) }
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch {
try {