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:
parent
b3a0972909
commit
ce3ba9afa2
8 changed files with 198 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue