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:
parent
db941169e7
commit
9f01a0ae1b
5 changed files with 152 additions and 1 deletions
|
|
@ -63,6 +63,9 @@ class StrawActivity : ComponentActivity() {
|
||||||
onOpenVideo = { url, title ->
|
onOpenVideo = { url, title ->
|
||||||
nav.push(Screen.VideoDetail(url, title))
|
nav.push(Screen.VideoDetail(url, title))
|
||||||
},
|
},
|
||||||
|
onOpenChannel = { url, name ->
|
||||||
|
nav.push(Screen.Channel(url, name))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
is Screen.Settings -> SettingsScreen()
|
is Screen.Settings -> SettingsScreen()
|
||||||
is Screen.Search -> SearchScreen(
|
is Screen.Search -> SearchScreen(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ package com.sulkta.straw
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
import com.sulkta.straw.data.Settings
|
import com.sulkta.straw.data.Settings
|
||||||
|
import com.sulkta.straw.data.Subscriptions
|
||||||
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
|
||||||
|
|
@ -23,5 +24,6 @@ class StrawApp : Application() {
|
||||||
)
|
)
|
||||||
History.init(this)
|
History.init(this)
|
||||||
Settings.init(this)
|
Settings.init(this)
|
||||||
|
Subscriptions.init(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.HorizontalDivider
|
||||||
|
|
@ -37,7 +39,9 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
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 coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.data.ChannelRef
|
||||||
import com.sulkta.straw.data.History
|
import com.sulkta.straw.data.History
|
||||||
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.data.WatchHistoryItem
|
import com.sulkta.straw.data.WatchHistoryItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -45,8 +49,10 @@ fun StrawHome(
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenVideo: (url: String, title: String) -> Unit,
|
onOpenVideo: (url: String, title: String) -> Unit,
|
||||||
|
onOpenChannel: (channelUrl: String, name: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val watches by History.get().watches.collectAsState()
|
val watches by History.get().watches.collectAsState()
|
||||||
|
val subs by Subscriptions.get().subs.collectAsState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
|
modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp),
|
||||||
|
|
@ -81,6 +87,19 @@ fun StrawHome(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
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()) {
|
if (watches.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Recently watched videos appear here.",
|
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
|
@Composable
|
||||||
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
|
private fun RecentRow(item: WatchHistoryItem, onClick: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Sulkta
|
||||||
|
* 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)")
|
||||||
|
}
|
||||||
|
|
@ -22,9 +22,13 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.font.FontWeight
|
||||||
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.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.ChannelRef
|
||||||
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -49,6 +56,8 @@ fun ChannelScreen(
|
||||||
) {
|
) {
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
LaunchedEffect(channelUrl) { vm.load(channelUrl) }
|
||||||
|
val subs by Subscriptions.get().subs.collectAsState()
|
||||||
|
val subscribed = subs.any { it.url == channelUrl }
|
||||||
|
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(
|
state.loading -> Box(
|
||||||
|
|
@ -86,7 +95,7 @@ fun ChannelScreen(
|
||||||
modifier = Modifier.size(56.dp).clip(CircleShape),
|
modifier = Modifier.size(56.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
Column {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = state.name.ifBlank { initialName },
|
text = state.name.ifBlank { initialName },
|
||||||
style = MaterialTheme.typography.titleLarge,
|
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()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue