Straw phase K: subscriptions store + Subscribe button + Home section

New SubscriptionsStore (SharedPrefs-lite, same pattern as History/Settings).
Holds Set<ChannelRef> { url, name, avatar }.

ChannelScreen header now has a "Subscribe / Subscribed" button on the
right. Button toggles membership; UI updates immediately via StateFlow.

StrawHome: new "Subscriptions" section above "Recently watched", a
LazyRow of subscribed channels (avatar + name, 80dp chips). Tap a
subscription chip → opens that channel.

StrawApp.onCreate: Subscriptions.init(this).

Day-4 ideas: auto-aggregate latest videos from subs on Home (no more
"hit search to see videos"), sub-feed-only screen, channel notifications.
This commit is contained in:
Kayos 2026-05-23 20:02:52 -07:00
parent 06e6ec64e3
commit 01496c647a
5 changed files with 152 additions and 1 deletions

View file

@ -63,6 +63,9 @@ class StrawActivity : ComponentActivity() {
onOpenVideo = { url, title ->
nav.push(Screen.VideoDetail(url, title))
},
onOpenChannel = { url, name ->
nav.push(Screen.Channel(url, name))
},
)
is Screen.Settings -> SettingsScreen()
is Screen.Search -> SearchScreen(

View file

@ -8,6 +8,7 @@ package com.sulkta.straw
import android.app.Application
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.extractor.NewPipeDownloader
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.localization.ContentCountry
@ -23,5 +24,6 @@ class StrawApp : Application() {
)
History.init(this)
Settings.init(this)
Subscriptions.init(this)
}
}

View file

@ -17,7 +17,9 @@ 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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
@ -37,7 +39,9 @@ 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.ChannelRef
import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem
@Composable
@ -45,8 +49,10 @@ fun StrawHome(
onOpenSearch: () -> Unit,
onOpenSettings: () -> Unit,
onOpenVideo: (url: String, title: String) -> Unit,
onOpenChannel: (channelUrl: String, name: String) -> Unit,
) {
val watches by History.get().watches.collectAsState()
val subs by Subscriptions.get().subs.collectAsState()
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
@ -81,6 +87,19 @@ fun StrawHome(
Spacer(modifier = Modifier.height(20.dp))
if (subs.isNotEmpty()) {
Text(
"Subscriptions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
items(subs) { ch -> SubChip(ch, onOpenChannel) }
}
Spacer(modifier = Modifier.height(20.dp))
}
if (watches.isEmpty()) {
Text(
text = "Recently watched videos appear here.",
@ -104,6 +123,32 @@ fun StrawHome(
}
}
@Composable
private fun SubChip(
ch: ChannelRef,
onOpenChannel: (url: String, name: String) -> Unit,
) {
Column(
modifier = Modifier
.width(80.dp)
.clickable { onOpenChannel(ch.url, ch.name) },
horizontalAlignment = Alignment.CenterHorizontally,
) {
AsyncImage(
model = ch.avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = ch.name,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
Row(

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* SharedPreferences-lite subscription list. Day-4 graduates to Room when
* we want background feed fetching for new uploads.
*/
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 ChannelRef(
val url: String,
val name: String,
val avatar: String? = null,
)
private const val PREFS = "straw_subs"
private const val KEY = "subs_v1"
class SubscriptionsStore(context: Context) {
private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val _subs = MutableStateFlow(load())
val subs: StateFlow<List<ChannelRef>> = _subs.asStateFlow()
fun isSubscribed(channelUrl: String): Boolean =
_subs.value.any { it.url == channelUrl }
fun toggle(ref: ChannelRef) {
val cur = _subs.value
val next = if (cur.any { it.url == ref.url }) {
cur.filterNot { it.url == ref.url }
} else {
cur + ref
}
_subs.value = next
persist(next)
}
fun clear() {
_subs.value = emptyList()
sp.edit().remove(KEY).apply()
}
private fun persist(list: List<ChannelRef>) {
sp.edit().putString(KEY, json.encodeToString(list)).apply()
}
private fun load(): List<ChannelRef> = runCatching {
val s = sp.getString(KEY, null) ?: return emptyList()
json.decodeFromString<List<ChannelRef>>(s)
}.getOrDefault(emptyList())
}
object Subscriptions {
@Volatile private var instance: SubscriptionsStore? = null
fun init(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) instance = SubscriptionsStore(context.applicationContext)
}
}
}
fun get(): SubscriptionsStore = instance
?: error("SubscriptionsStore not initialized — call Subscriptions.init(context)")
}

View file

@ -22,9 +22,13 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -35,9 +39,12 @@ 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.runtime.collectAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
@Composable
@ -49,6 +56,8 @@ fun ChannelScreen(
) {
val state by vm.ui.collectAsStateWithLifecycle()
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
val subs by Subscriptions.get().subs.collectAsState()
val subscribed = subs.any { it.url == channelUrl }
when {
state.loading -> Box(
@ -86,7 +95,7 @@ fun ChannelScreen(
modifier = Modifier.size(56.dp).clip(CircleShape),
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.name.ifBlank { initialName },
style = MaterialTheme.typography.titleLarge,
@ -100,6 +109,20 @@ fun ChannelScreen(
)
}
}
val onClick = {
Subscriptions.get().toggle(
ChannelRef(
url = channelUrl,
name = state.name.ifBlank { initialName },
avatar = state.avatar,
),
)
}
if (subscribed) {
OutlinedButton(onClick = onClick) { Text("Subscribed") }
} else {
Button(onClick = onClick) { Text("Subscribe") }
}
}
HorizontalDivider()
}