Straw phase H: Settings screen + SponsorBlock category toggles

New: SettingsStore (SharedPreferences-lite, same pattern as HistoryStore)
exposes a Set<SbCategory> 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.
This commit is contained in:
Kayos 2026-05-23 19:51:43 -07:00
parent b3a0972909
commit ce3ba9afa2
8 changed files with 198 additions and 5 deletions

View file

@ -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")

View file

@ -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
}

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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<Set<SbCategory>> = _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<SbCategory> {
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)")
}

View file

@ -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<PlayerUiState> = _ui.asStateFlow()
fun resolve(streamUrl: String, sbCategories: List<String> = 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

View file

@ -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() })
}
}