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
0252f6c63f
commit
0343959c95
7 changed files with 262 additions and 22 deletions
|
|
@ -57,6 +57,9 @@ class StrawActivity : ComponentActivity() {
|
||||||
when (val s = nav.current) {
|
when (val s = nav.current) {
|
||||||
is Screen.Home -> StrawHome(
|
is Screen.Home -> StrawHome(
|
||||||
onOpenSearch = { nav.push(Screen.Search) },
|
onOpenSearch = { nav.push(Screen.Search) },
|
||||||
|
onOpenVideo = { url, title ->
|
||||||
|
nav.push(Screen.VideoDetail(url, title))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is Screen.Search -> SearchScreen(
|
is Screen.Search -> SearchScreen(
|
||||||
onOpenVideo = { url, title ->
|
onOpenVideo = { url, title ->
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
package com.sulkta.straw
|
package com.sulkta.straw
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.extractor.NewPipeDownloader
|
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
import org.schabi.newpipe.extractor.localization.ContentCountry
|
import org.schabi.newpipe.extractor.localization.ContentCountry
|
||||||
|
|
@ -19,5 +20,6 @@ class StrawApp : Application() {
|
||||||
Localization("en", "US"),
|
Localization("en", "US"),
|
||||||
ContentCountry("US"),
|
ContentCountry("US"),
|
||||||
)
|
)
|
||||||
|
History.init(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,43 +5,127 @@
|
||||||
|
|
||||||
package com.sulkta.straw
|
package com.sulkta.straw
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Button
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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 androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.data.WatchHistoryItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StrawHome(onOpenSearch: () -> Unit) {
|
fun StrawHome(
|
||||||
|
onOpenSearch: () -> Unit,
|
||||||
|
onOpenVideo: (url: String, title: String) -> Unit,
|
||||||
|
) {
|
||||||
|
val watches by History.get().watches.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
// Header
|
||||||
text = "straw",
|
|
||||||
style = MaterialTheme.typography.displayLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
text = "v0.1.0 — Sulkta",
|
Text(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = "straw",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.displayMedium,
|
||||||
)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
Spacer(modifier = Modifier.height(48.dp))
|
)
|
||||||
Button(onClick = onOpenSearch) {
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Text("Search YouTube")
|
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
|
||||||
|
* 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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.RydClient
|
||||||
import com.sulkta.straw.net.RydVotes
|
import com.sulkta.straw.net.RydVotes
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -48,6 +50,23 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
try {
|
try {
|
||||||
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
|
||||||
val videoId = info.id
|
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) {
|
val ryd = withContext(Dispatchers.IO) {
|
||||||
runCatching { RydClient.fetch(videoId) }.getOrNull()
|
runCatching { RydClient.fetch(videoId) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
@ -55,11 +74,11 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
loading = false,
|
loading = false,
|
||||||
detail = VideoDetail(
|
detail = VideoDetail(
|
||||||
id = videoId,
|
id = videoId,
|
||||||
title = info.name ?: "(no title)",
|
title = title,
|
||||||
uploader = info.uploaderName ?: "",
|
uploader = uploader,
|
||||||
viewCount = info.viewCount,
|
viewCount = info.viewCount,
|
||||||
description = info.description?.content ?: "",
|
description = info.description?.content ?: "",
|
||||||
thumbnail = info.thumbnails?.firstOrNull()?.url,
|
thumbnail = thumb,
|
||||||
ryd = ryd,
|
ryd = ryd,
|
||||||
),
|
),
|
||||||
streamInfo = info,
|
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.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.data.History
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
|
|
@ -46,6 +50,7 @@ fun SearchScreen(
|
||||||
vm: SearchViewModel = viewModel(),
|
vm: SearchViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
|
val recentSearches by History.get().searches.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
OutlinedTextField(
|
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(
|
state.results.isEmpty() && state.query.isNotBlank() -> Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ package com.sulkta.straw.feature.search
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.sulkta.straw.data.History
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -45,6 +46,7 @@ class SearchViewModel : ViewModel() {
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val q = _ui.value.query.trim()
|
val q = _ui.value.query.trim()
|
||||||
if (q.isEmpty()) return
|
if (q.isEmpty()) return
|
||||||
|
runCatching { History.get().recordSearch(q) }
|
||||||
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
|
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue