diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 6327701bc..bca8be0bc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -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( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 32c430312..6d8a1defc 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -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) } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index b5dbe885a..e76e91943 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -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( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt new file mode 100644 index 000000000..a0a93c0ae --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SubscriptionsStore.kt @@ -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> = _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) { + sp.edit().putString(KEY, json.encodeToString(list)).apply() + } + + private fun load(): List = runCatching { + val s = sp.getString(KEY, null) ?: return emptyList() + json.decodeFromString>(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)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 97b0660f3..18a256dc2 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -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() }