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:
parent
f3b78b4530
commit
b3a0972909
7 changed files with 262 additions and 22 deletions
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue