From ce3ba9afa262d4cba9dafe9af27e26a3be155ba6 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 23 May 2026 19:51:43 -0700 Subject: [PATCH] Straw phase H: Settings screen + SponsorBlock category toggles New: SettingsStore (SharedPreferences-lite, same pattern as HistoryStore) exposes a Set StateFlow. 7 SponsorBlock categories surfaced as toggle rows in a new Settings screen: sponsor (on by default), selfpromo, intro, outro, interaction (reminders), music_offtopic (talking in music videos), filler. Wiring: - StrawApp.onCreate: Settings.init(this) - StrawHome: new Settings gear icon next to "straw v0.1.0" header. Tap routes to Screen.Settings. - PlayerViewModel.resolve: reads Settings.get().sbCategories on resolve. Empty set = SponsorBlock skip disabled (no API call). Material-icons-core dep added (1.7.5) for the Icons.Filled.Settings glyph. Day-4 ideas: theme override, default audio-only playback, preferred quality, clear history buttons. --- strawApp/build.gradle.kts | 1 + .../src/main/kotlin/com/sulkta/straw/Nav.kt | 1 + .../kotlin/com/sulkta/straw/StrawActivity.kt | 3 + .../main/kotlin/com/sulkta/straw/StrawApp.kt | 2 + .../main/kotlin/com/sulkta/straw/StrawHome.kt | 14 ++- .../com/sulkta/straw/data/SettingsStore.kt | 70 +++++++++++++ .../straw/feature/player/PlayerViewModel.kt | 14 ++- .../straw/feature/settings/SettingsScreen.kt | 98 +++++++++++++++++++ 8 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 68d40114b..cf423bb28 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.ui) + implementation("androidx.compose.material:material-icons-core:1.7.5") // Lifecycle + ViewModel for Compose implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt index d5e61ab88..0afd64042 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/Nav.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.remember sealed interface Screen { data object Home : Screen data object Search : Screen + data object Settings : Screen data class VideoDetail(val streamUrl: String, val title: String) : Screen data class Player(val streamUrl: String, val title: String) : Screen } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt index 7b4bb7950..3d1fc394a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier import com.sulkta.straw.feature.detail.VideoDetailScreen import com.sulkta.straw.feature.player.PlayerScreen import com.sulkta.straw.feature.search.SearchScreen +import com.sulkta.straw.feature.settings.SettingsScreen private val YT_HOSTS = setOf("youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be") private val YT_URL_RE = Regex("https?://(?:www\\.|m\\.)?(?:youtube\\.com/[^\\s]+|youtu\\.be/[A-Za-z0-9_-]+)") @@ -57,10 +58,12 @@ class StrawActivity : ComponentActivity() { when (val s = nav.current) { is Screen.Home -> StrawHome( onOpenSearch = { nav.push(Screen.Search) }, + onOpenSettings = { nav.push(Screen.Settings) }, onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(url, title)) }, ) + is Screen.Settings -> SettingsScreen() is Screen.Search -> SearchScreen( onOpenVideo = { url, title -> nav.push(Screen.VideoDetail(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 5d58bae7b..32c430312 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -7,6 +7,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.extractor.NewPipeDownloader import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.localization.ContentCountry @@ -21,5 +22,6 @@ class StrawApp : Application() { ContentCountry("US"), ) History.init(this) + Settings.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 92be5b230..b5dbe885a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -21,8 +21,12 @@ 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.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,6 +43,7 @@ import com.sulkta.straw.data.WatchHistoryItem @Composable fun StrawHome( onOpenSearch: () -> Unit, + onOpenSettings: () -> Unit, onOpenVideo: (url: String, title: String) -> Unit, ) { val watches by History.get().watches.collectAsState() @@ -48,7 +53,10 @@ fun StrawHome( ) { // Header Spacer(modifier = Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { Text( text = "straw", style = MaterialTheme.typography.displayMedium, @@ -59,7 +67,11 @@ fun StrawHome( text = "v0.1.0", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), ) + IconButton(onClick = onOpenSettings) { + Icon(Icons.Filled.Settings, contentDescription = "Settings") + } } Spacer(modifier = Modifier.height(16.dp)) Button( diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt new file mode 100644 index 000000000..8d9d421ab --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/data/SettingsStore.kt @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Sulkta Straw — user settings (SharedPreferences-lite). + * Day-3 starts with SponsorBlock category selection. Other settings + * (theme override, default player quality, watch-as-audio default) land + * here later. + */ + +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 + +/** SponsorBlock category keys — must match server-side IDs. */ +enum class SbCategory(val key: String, val label: String, val help: String) { + Sponsor("sponsor", "Sponsor", "Paid promotion / ad reads — skipped by default."), + SelfPromo("selfpromo", "Self-promo", "\"Like and subscribe\", merch plugs."), + Intro("intro", "Intro", "Channel logos / theme music."), + Outro("outro", "Outro", "End cards, credits."), + Interaction("interaction", "Reminder", "\"Don't forget to subscribe\" mid-roll."), + MusicOfftopic("music_offtopic", "Non-music in music vid", "Talking sections in a music video."), + Filler("filler", "Filler / tangent", "Pulled out of the flow — opt-in only."), +} + +private const val PREFS = "straw_settings" +private const val KEY_SB_CATS = "sb_categories_v1" + +class SettingsStore(context: Context) { + private val sp: SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private val _sbCategories = MutableStateFlow(loadCategories()) + val sbCategories: StateFlow> = _sbCategories.asStateFlow() + + fun toggle(cat: SbCategory) { + val cur = _sbCategories.value + val next = if (cat in cur) cur - cat else cur + cat + _sbCategories.value = next + sp.edit().putStringSet(KEY_SB_CATS, next.map { it.key }.toSet()).apply() + } + + private fun loadCategories(): Set { + val raw = sp.getStringSet(KEY_SB_CATS, null) + return if (raw == null) { + // Default: sponsor only. + setOf(SbCategory.Sponsor) + } else { + raw.mapNotNull { key -> SbCategory.entries.firstOrNull { it.key == key } }.toSet() + } + } +} + +object Settings { + @Volatile private var instance: SettingsStore? = null + + fun init(context: Context) { + if (instance == null) { + synchronized(this) { + if (instance == null) instance = SettingsStore(context.applicationContext) + } + } + } + + fun get(): SettingsStore = instance + ?: error("SettingsStore not initialized — call Settings.init(context)") +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt index c7360ca11..847bfbe6a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -7,6 +7,7 @@ package com.sulkta.straw.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sulkta.straw.data.Settings import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SponsorBlockClient import kotlinx.coroutines.Dispatchers @@ -42,15 +43,20 @@ class PlayerViewModel : ViewModel() { private val _ui = MutableStateFlow(PlayerUiState()) val ui: StateFlow = _ui.asStateFlow() - fun resolve(streamUrl: String, sbCategories: List = listOf("sponsor")) { + fun resolve(streamUrl: String) { _ui.value = PlayerUiState(loading = true) viewModelScope.launch { try { val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } val videoId = info.id - val segments = withContext(Dispatchers.IO) { - runCatching { SponsorBlockClient.fetch(videoId, sbCategories) } - .getOrDefault(emptyList()) + val sbCategories = Settings.get().sbCategories.value.map { it.key } + val segments = if (sbCategories.isEmpty()) { + emptyList() + } else { + withContext(Dispatchers.IO) { + runCatching { SponsorBlockClient.fetch(videoId, sbCategories) } + .getOrDefault(emptyList()) + } } val combined = info.videoStreams diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt new file mode 100644 index 000000000..764414c61 --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/settings/SettingsScreen.kt @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.sulkta.straw.feature.settings + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sulkta.straw.data.SbCategory +import com.sulkta.straw.data.Settings + +@Composable +fun SettingsScreen() { + val store = Settings.get() + val cats by store.sbCategories.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text( + "Settings", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(24.dp)) + + Text( + "SponsorBlock — auto-skip categories", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Segments tagged by SponsorBlock are skipped during playback. Toggle which categories you want.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(12.dp)) + + SbCategory.entries.forEach { cat -> + CategoryRow( + cat = cat, + enabled = cat in cats, + onToggle = { store.toggle(cat) }, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun CategoryRow( + cat: SbCategory, + enabled: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(cat.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(2.dp)) + Text( + cat.help, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = enabled, onCheckedChange = { onToggle() }) + } +}