diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 376b3f0f4..7b4bb7950 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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 -> diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 2cc81045c..5d58bae7b 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.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) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 8c926cdc8..92be5b230 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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, + ) } } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt new file mode 100644 index 000000000..5482162dc --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/HistoryStore.kt @@ -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> = _watches.asStateFlow() + + private val _searches = MutableStateFlow(loadSearches()) + val searches: StateFlow> = _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 = runCatching { + val s = sp.getString(KEY_WATCHES, null) ?: return emptyList() + json.decodeFromString>(s) + }.getOrDefault(emptyList()) + + private fun loadSearches(): List = runCatching { + val s = sp.getString(KEY_SEARCHES, null) ?: return emptyList() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 689241f84..9c12d13c6 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index b3839ecd3..d72371aea 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -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, 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 e0ab94edb..cb0c5fa0a 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 @@ -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 {